Compare commits
15 Commits
4c979285c6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 12be225242 | |||
| 1b50511a4f | |||
| d6e1797f08 | |||
| ee5cec6de4 | |||
|
|
b12e55a776 | ||
|
|
10f9c0061a | ||
| 5878f7d9f4 | |||
| 40de992516 | |||
| 47c0863bab | |||
| 97bbb3f306 | |||
| d3d41b20d7 | |||
| f6a43302d2 | |||
| c435ab15cf | |||
| 9b053e302b | |||
| 509487f155 |
0
.cursor/changelog/.changelog-ack
Normal file
0
.cursor/changelog/.changelog-ack
Normal file
1250
.cursor/changelog/changelog-full.md
Normal file
1250
.cursor/changelog/changelog-full.md
Normal file
File diff suppressed because it is too large
Load Diff
54
.cursor/changelog/changelog-headlines.md
Normal file
54
.cursor/changelog/changelog-headlines.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Dev Changelog — Headlines
|
||||||
|
|
||||||
|
最近 ~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-0810] 对话输入栏支持剪贴板粘贴图片为参考图:ChatInput根容器onPasteCapture提取image/*文件并走startUpload,placeholder/title提示
|
||||||
|
- [CL-20260416-0745] Gemini原生生图:延长读写超时+对RemoteProtocolError等可重试错误自动重试(默认3次指数退避),环境变量可微调VECTORENGINE_GEMINI_*_TIMEOUT
|
||||||
|
- [CL-20260416-0720] 修复拖拽参考图到ChatInput区域后覆盖层卡住不消失:ChatInput的stopPropagation阻止了main.onDrop,新增onFileDrop回调通知父组件清除拖拽状态
|
||||||
|
- [CL-20260416-0700] 新增GeminiNativeImageProvider:对接Gemini原生generateContent接口,支持多图参考(最多14张),Gemini恢复supports_ref_image=True
|
||||||
|
- [CL-20260416-0620] 修复拖拽上传重复(stopPropagation)+Gemini改回不支持参考图(向量引擎不支持Gemini走images/edits)+images.edit移除不支持的quality参数
|
||||||
|
- [CL-20260416-0600] 多张参考图全链路支持:前端多图上传/拖拽/沿用+后端API/AgentLoop/Provider全部改为list[str],OpenAI走images.edit端点(最多16张),Replicate取首张兼容
|
||||||
|
- [CL-20260416-0510] 生图模型注册表新增Gemini 3.1 Flash Image(gemini-3.1-flash-image-preview),走向量引擎中转复用OpenAIImageProvider
|
||||||
|
- [CL-20260416-0500] 全区域拖拽添加参考图:拖拽区域从底部输入栏扩展到整个对话区域,拖入时全屏覆盖层提示,ChatInput改forwardRef暴露uploadFile
|
||||||
|
- [CL-20260416-0430] 接入GPT Image 1.5生图模型:新增OpenAIImageProvider走向量引擎中转,IMAGE_MODELS注册表新增gpt-image-1.5
|
||||||
|
- [CL-20260416-0400] Session级参考图自动沿用:发送后记住参考图供后续消息复用+UI提示条+可清除,后端对缺参考图的风格迁移模型返回友好错误
|
||||||
|
- [CL-20260416-0300] System Prompt新增禁止LLM在回复中嵌入Markdown图片链接(sandbox:等),图片展示由image_result SSE事件处理
|
||||||
|
- [CL-20260416-0245] 修复三点菜单被右侧对话区遮挡:从fixed右侧弹出改为inline下方展开+模型列表折叠式内联,全部在侧栏内完成
|
||||||
|
- [CL-20260416-0230] LLM注册表扩充至7个模型(+gpt-5.4/claude-sonnet-4-6/claude-opus-4-6/gemini-3.1-pro-preview/glm-4.7),默认改为gpt-5.4
|
||||||
|
- [CL-20260416-0200] 接入向量引擎中转API+对话级LLM模型切换:LLM_MODELS注册表(gpt-4o-mini/gpt-4o/deepseek-chat),provider分发(vectorengine/deepseek),前端三点菜单模型选择,Session级llmModel
|
||||||
|
- [CL-20260416-0030] 青绿山水风格改造:色彩体系替换为千里江山图石青石绿色调,背景改双层漂移云雾,glow-border 柔化为云烟缭绕,新增 fog-scroll 雾气边缘,11 个组件硬编码颜色统一替换
|
||||||
|
- [CL-20260415-2230] 光影流动边框改为 hover 触发:常态微弱静态发光,hover 时旋转动画通过 opacity 500ms 平滑淡入/淡出,无跳变
|
||||||
|
- [CL-20260415-2330] 用户个人信息+记忆查看面板:右上角菜单弹窗,展示 Mem0 记忆列表(按时间分组,只读),后端新增 /api/memory/list 端点
|
||||||
|
- [CL-20260415-2200] 全站 UI 风格改造为 Cyberpunk Dark Neon:主色改为青绿霓虹,新增环境光晕/毛玻璃/霓虹发光边框,13 个组件重构 + 视觉风格文档
|
||||||
|
- [CL-20260414-2350] 修复登录后 useApp must be used within AppProvider:AppProvider 未初始化时区分已认证/未认证,避免子组件拿到 null context
|
||||||
|
- [CL-20260414-2340] 用 pwdlib (Argon2id) 替换 passlib (bcrypt):彻底解决兼容性问题,新密码 Argon2id,旧 bcrypt 哈希兼容验证
|
||||||
|
- [CL-20260414-2330] 修复 passlib + bcrypt 5.x 兼容性:锁定 bcrypt>=4.0.1,<4.1,解决后端启动 AttributeError + ValueError
|
||||||
|
- [CL-20260414-2300] 用户系统:管理员邀请制 + JWT 认证 + SQLite 用户表 + Mem0/localStorage 按用户隔离,所有 API 鉴权,前端登录页 + AuthGuard
|
||||||
|
- [CL-20260414-2230] 对话区域新增"回到底部"浮动按钮:不在底部时显示向下箭头,点击平滑滚到最新消息
|
||||||
|
- [CL-20260414-2220] 修复长对话切换回来滚动位置上移:scroll 事件实时保存替代 effect 延迟保存,避免 DOM 变更后 scrollTop 被浏览器 clamp
|
||||||
|
- [CL-20260414-2210] 修复会话切换时滚动位置跳动:拆 effect 解决时序竞争 + isSwitching 抑制 smooth scroll 干扰 + instant 同步赋值
|
||||||
|
- [CL-20260414-2200] 修复多会话滚动位置共享 bug:per-session 独立记录滚动位置,切换会话时保存/恢复,无记录时滚到底部
|
||||||
|
- [CL-20260413-2320] 集成 Mem0 记忆系统:Mem0 OSS + DeepSeek 事实提取 + Ollama 本地 embedding + 滑动窗口,一次性解决上下文管理和跨会话长期记忆
|
||||||
|
- [CL-20260413-1130] 修复 SDXL 模型 404 错误:与 Kolors 同因,Replicate 非 Flux 官方模型需补全版本 hash
|
||||||
|
- [CL-20260413-1100] 修复切换模型后 LLM 仍声称使用旧模型名:System Prompt 动态注入当前模型名,阻止 LLM 从对话历史幻觉旧模型
|
||||||
|
- [CL-20260413-1030] Agent Loop 假生成检测 + 自动重试:DeepSeek 用文字模拟生图时自动注入纠正消息强制调用工具
|
||||||
|
- [CL-20260413-1000] 助手消息气泡添加复制文本按钮:hover 显示、点击复制 + 对勾反馈
|
||||||
|
- [CL-20260413-0930] System Prompt 加入"禁止模拟工具调用"约束,防止 DeepSeek 用文字模拟生图而不调用工具
|
||||||
|
- [CL-20260413-0900] 非 vision + 参考图场景:不再绝对禁止风格词,改为"不自行猜测但保留用户明确指定的风格"
|
||||||
|
- [CL-20260413-0800] 修复 InstantStyle ReadTimeout + 多次生成丢失参考图:wait 改 False 走纯轮询 + 不再清除 ref_image_url
|
||||||
105
.cursor/changelog/changelog-recent.md
Normal file
105
.cursor/changelog/changelog-recent.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Dev Changelog — Recent
|
||||||
|
|
||||||
|
最近 ~10 次改动的摘要记录,按时间倒序排列。
|
||||||
|
当 Agent 检测到当前任务与近期改动相关时自动读取。
|
||||||
|
|
||||||
|
### [CL-20260420-2237] 2026-04-20 — 前端一次性铺开文档中心 / 3D / 动作三大功能 UI(占位 + Phase 2 徽章)
|
||||||
|
- **tags**: 前端, UI, 文档中心, 3D, 动作, 命令面板, 工作台, 资源库, 占位, react-markdown, model-viewer
|
||||||
|
- **affected_files**: types.ts, placeholder-store.ts, app-context.tsx, layout.tsx, top-nav.tsx, app/docs/page.tsx, components/docs/*, task-type-tabs.tsx, workbench/params-3d.tsx, workbench/params-motion.tsx, app/page.tsx, task-card.tsx, detail/model-3d-panel.tsx, detail/motion-panel.tsx, app/gallery/page.tsx, motion-presets.ts
|
||||||
|
- **summary**: 按用户要求一次性把"文档管理中心(类似 Cursor)/ 3D 模型生成 / 动作生成"的界面与交互全部用占位实现落地。文档中心:`/docs` 三栏(DocTree+Tabs+Editor+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
|
||||||
|
- **tags**: 后端, 生图, prompt, SDXL, Flux, Replicate, image_gen, Agent工具
|
||||||
|
- **affected_files**: image_prompt_strategy.py, image_gen.py, tools.py
|
||||||
|
- **summary**: 新增 `image_prompt_strategy`:按注册表 id 为 7 个生图模型应用策略(OpenAI/Gemini 自然语言规范化;BFL Flux 指南对齐、剥 SD 式 Negative 段;SDXL 默认负向词 + `---NEGATIVE---`/`|||NEG|||` 拆分;IP-Adapter 侧重内容描述)。`generate_images` 统一调用后下发;Replicate 非 Flux 路径写入 `negative_prompt`。`GenerateResult` 增加 `effective_prompt`/`negative_prompt`;工具返回与描述补充 SDXL 分隔符约定。
|
||||||
|
|
||||||
|
### [CL-20260416-0810] 2026-04-16 — 对话栏粘贴剪贴板图片为参考图
|
||||||
|
- **tags**: 前端, ChatInput, 剪贴板, 参考图, UX
|
||||||
|
- **affected_files**: chat-input.tsx
|
||||||
|
- **summary**: 在 ChatInput 根容器上使用 onPasteCapture:从 clipboardData.items 收集 kind===file 且 image/* 的项,调用 getAsFile 后复用现有 startUpload。无图片文件时不拦截,纯文本粘贴照常。placeholder 与 textarea title 提示可粘贴图片。
|
||||||
|
|
||||||
|
### [CL-20260416-0745] 2026-04-16 — Gemini 原生生图:超时与断连重试
|
||||||
|
- **tags**: 后端, Gemini, httpx, 超时, 重试, 向量引擎, image_gen
|
||||||
|
- **affected_files**: image_gen.py, .env.example
|
||||||
|
- **summary**: 用户遇到 `Server disconnected without sending a response`(httpx RemoteProtocolError 等)。将 Gemini generateContent 默认 read 超时提高到 600s、write 180s、connect/pool 60s;对 RemoteProtocolError/ConnectError/ReadTimeout 等可恢复错误默认最多重试 3 次(指数退避 1s/2s);失败时错误信息附带调优提示。可通过 VECTORENGINE_GEMINI_* 环境变量覆盖。
|
||||||
|
|
||||||
|
### [CL-20260416-0720] 2026-04-16 — 修复拖拽参考图到 ChatInput 区域后覆盖层卡住不消失
|
||||||
|
- **tags**: bug修复, 拖拽, 覆盖层, ChatInput, stopPropagation, UX
|
||||||
|
- **affected_files**: chat-input.tsx, page.tsx
|
||||||
|
- **summary**: 文件拖拽到 ChatInput 区域时,ChatInput.handleDrop 的 stopPropagation 阻止事件冒泡到 main.onDrop,导致 mainDragging 状态无法重置为 false,拖拽覆盖层("松开以添加参考图")卡住不消失。修复:ChatInput 新增 onFileDrop 回调 prop,在 handleDrop 中调用,page.tsx 传入回调重置 dragCounter 和 mainDragging。
|
||||||
|
|
||||||
|
### [CL-20260416-0700] 2026-04-16 — 新增 GeminiNativeImageProvider:Gemini 原生 generateContent 接口对接
|
||||||
|
- **tags**: 后端, Gemini, Provider, 原生API, generateContent, 多图参考, 架构
|
||||||
|
- **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` 条目。
|
||||||
|
|
||||||
@@ -2,10 +2,201 @@
|
|||||||
|
|
||||||
## Active Items
|
## Active Items
|
||||||
|
|
||||||
(暂无延期方案)
|
### [cloud-deploy-route-b] 路线 B:云服务器正式部署
|
||||||
|
- **status**: deferred
|
||||||
|
- **tags**: deployment, docker, nginx, cloud, production
|
||||||
|
- **recorded**: 2026-04-12
|
||||||
|
- **source_chat**: [发布到手机浏览器的方案讨论](discussion-publish-routes)
|
||||||
|
- **prerequisite**: 路线 A(内网穿透 + 移动端适配)完成后
|
||||||
|
- **related_files**:
|
||||||
|
- art-agent/backend/app/main.py
|
||||||
|
- art-agent/frontend/next.config.ts
|
||||||
|
- art-agent/frontend/.env.local
|
||||||
|
- art-agent/backend/.env
|
||||||
|
- **context**: |
|
||||||
|
买轻量云服务器(2C2G ~50元/月),编写前后端 Dockerfile + docker-compose,
|
||||||
|
配置 Nginx/Caddy 反向代理统一入口,HTTPS 自动签证书,
|
||||||
|
环境变量搬迁(去掉本地代理配置),可选绑定域名。
|
||||||
|
预估工作量 1-2 天。
|
||||||
|
- **chosen_alternative**: 路线 A — 本地运行 + 内网穿透(Cloudflare Tunnel / ngrok)
|
||||||
|
- **deferred_reason**: 当前阶段只需自己和朋友试用,内网穿透足够
|
||||||
|
|
||||||
|
### [style-pipeline-layered] 分层组合风格一致性产线(含便利选取 + LoRA 训练)
|
||||||
|
- **status**: deferred
|
||||||
|
- **tags**: 风格一致性, 风格选取, IP-Adapter, LoRA, ControlNet, 量产, 美术产线, InstantStyle, 风格库, 训练
|
||||||
|
- **recorded**: 2026-04-12
|
||||||
|
- **updated**: 2026-04-16
|
||||||
|
- **source_chat**: [风格一致性技术方案讨论](style-consistency-discussion)
|
||||||
|
- **prerequisite**: EPEEKit 进入美术资源量产阶段(风格方向已确定、需要批量产出同风格素材)
|
||||||
|
- **related_files**:
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- art-agent/backend/app/config.py
|
||||||
|
- art-agent/frontend/src/app/page.tsx
|
||||||
|
- **context**: |
|
||||||
|
分层组合的风格一致性产线:
|
||||||
|
1. 探索期(当前):Prompt + 参考图,快速试错确定风格方向
|
||||||
|
2. 锁定期:IP-Adapter / ControlNet,选 3-5 张精选图作为风格锚点,推理时注入风格
|
||||||
|
3. 量产期:LoRA + ControlNet,用精选图训练 LoRA(20-50 张),批量产出同风格资源
|
||||||
|
4. 贯穿全程:标准化 Prompt 模板 + Negative Prompt 模板,确保品质下限
|
||||||
|
各阶段可叠加使用,不互斥。LoRA 训练需要同风格样本积累到足够数量。
|
||||||
|
|
||||||
|
2026-04-13 调研补充 — 风格迁移模型层级:
|
||||||
|
- Kolors IP-Adapter(当前在用):通用 IP-Adapter,风格迁移弱,内容+风格+构图混合提取
|
||||||
|
- InstantStyle(Replicate 可用):专门分离内容/风格,风格迁移强,但贵且慢
|
||||||
|
- Style IPAdapter for NoobAI-XL(CivitAI):最强画风迁移(线条+着色技法),需 ComfyUI
|
||||||
|
- ICAS 框架(学术前沿 2025.04):IP-Adapter + ControlNet 组合,多主体风格一致性最优
|
||||||
|
锁定期的最佳方案:迁移到 HF Inference Endpoints 后,用 NoobAI-XL + Style IPAdapter + ControlNet
|
||||||
|
|
||||||
|
2026-04-16 补充 — 便利的风格选取和 LoRA 训练 UI:
|
||||||
|
- 风格库 UI:预设风格卡片(缩略图+标签+描述),一键选取即注入对应 Prompt 模板/参考图/LoRA
|
||||||
|
- LoRA 训练流程集成:在 Agent 内上传训练素材 → 触发云端 LoRA 训练 → 训练完成后自动入库为新风格
|
||||||
|
- 风格组合:支持多风格叠加(如"水墨 + 赛博朋克"),前端可视化混合权重
|
||||||
|
- 风格管理:用户自建/收藏风格,按项目/标签分类,团队共享
|
||||||
|
- **chosen_alternative**: 当前使用 Prompt Engineering + LLM 参考图理解(探索期方案)
|
||||||
|
- **deferred_reason**: 风格方向尚未确定,过早引入 LoRA/ControlNet 是过度优化
|
||||||
|
|
||||||
|
### [doc-gen-management] 文档生成和管理系统
|
||||||
|
- **status**: deferred
|
||||||
|
- **tags**: 文档生成, 项目大纲, 游戏风格文档, 美术规范, GDD, 模板, 导入
|
||||||
|
- **recorded**: 2026-04-16
|
||||||
|
- **source_chat**: [接下来要做的三件事](next-features-planning-0416)
|
||||||
|
- **prerequisite**: 基础对话生图能力稳定后
|
||||||
|
- **related_files**:
|
||||||
|
- art-agent/frontend/src/app/page.tsx
|
||||||
|
- art-agent/backend/app/agent/loop.py
|
||||||
|
- art-agent/backend/app/agent/tools.py
|
||||||
|
- **context**: |
|
||||||
|
文档驱动的美术资源生产流程:
|
||||||
|
1. 项目大纲导入:支持上传/粘贴项目大纲(GDD、策划案等),Agent 解析结构
|
||||||
|
2. 游戏风格文档生成:基于大纲 + 对话交互,自动生成美术风格指南
|
||||||
|
(色彩体系、质感定义、元素规范、参考图收集等)
|
||||||
|
3. 文档模板:预设多种文档模板(美术风格指南、UI 规范、角色设定集、场景规范等)
|
||||||
|
4. 文档管理:按项目组织,版本历史,团队共享,与生图任务关联
|
||||||
|
5. 文档 → 生图联动:风格文档中的规范自动注入生图 Prompt,确保产出一致性
|
||||||
|
- **chosen_alternative**: 当前无文档管理能力,美术规范靠口头约定或外部文档
|
||||||
|
- **deferred_reason**: 当前阶段聚焦核心生图能力,文档系统是生图能力稳定后的自然延伸
|
||||||
|
|
||||||
|
### [asset-spec-presets] 资产规格快速选取系统
|
||||||
|
- **status**: deferred
|
||||||
|
- **tags**: 资产规格, icon, UI资产, 3D资产, 尺寸, 比例, 安全区, 底色, 预设, 美术标准
|
||||||
|
- **recorded**: 2026-04-16
|
||||||
|
- **source_chat**: [接下来要做的三件事](next-features-planning-0416)
|
||||||
|
- **prerequisite**: 基础生图能力稳定后
|
||||||
|
- **related_files**:
|
||||||
|
- art-agent/backend/app/agent/tools.py
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- art-agent/frontend/src/app/page.tsx
|
||||||
|
- **context**: |
|
||||||
|
资产规格预设系统——选取资产类型即自动套用全部技术标准:
|
||||||
|
1. 资产类型库:Icon / UI按钮 / 头像框 / 背景图 / 角色立绘 / 场景原画 / 3D贴图 等
|
||||||
|
2. 每种类型包含完整规格定义:
|
||||||
|
- 尺寸(输出分辨率、DPI)
|
||||||
|
- 比例(1:1 / 16:9 / 自定义)
|
||||||
|
- 安全区(内容区 vs 出血区)
|
||||||
|
- 底色/透明度要求
|
||||||
|
- 文件格式(PNG / SVG / PSD 等)
|
||||||
|
- 风格约束(该类资产的通用风格要求,如 icon 需要简洁辨识度高)
|
||||||
|
- 导出规格(多尺寸自动导出,如 @1x @2x @3x)
|
||||||
|
3. 项目级自定义:允许在预设基础上调整,保存为项目专属规格
|
||||||
|
4. 3D 资产扩展:未来覆盖 3D 模型的面数/贴图分辨率/UV规范等
|
||||||
|
5. 一键应用:选取资产类型后,尺寸/比例/后处理参数自动注入生图调用
|
||||||
|
6. 游戏全品类覆盖:目标是游戏中所有美术资产类型都有对应的规格预设
|
||||||
|
- **chosen_alternative**: 当前生图参数完全由用户在对话中手动指定或由 LLM 推断
|
||||||
|
- **deferred_reason**: 当前阶段聚焦核心生图和风格探索,规格系统是进入量产阶段的必备基础设施
|
||||||
|
|
||||||
|
### [hf-inference-endpoints] 迁移到 Hugging Face + Inference Endpoints
|
||||||
|
- **status**: deferred
|
||||||
|
- **tags**: 基础设施, Hugging Face, Inference Endpoints, ComfyUI, 自定义模型, 风格迁移
|
||||||
|
- **recorded**: 2026-04-13
|
||||||
|
- **source_chat**: [Kolors 风格迁移排查与模型调研](kolors-style-investigation)
|
||||||
|
- **prerequisite**: 探索期结束、确定要用的模型组合后
|
||||||
|
- **related_files**:
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- art-agent/backend/app/config.py
|
||||||
|
- **context**: |
|
||||||
|
将生图基础设施从 Replicate API 迁移到 Hugging Face Inference Endpoints:
|
||||||
|
1. 可部署任意 ComfyUI 工作流(NoobAI-XL + Style IPAdapter + ControlNet 等任意组合)
|
||||||
|
2. 不受 Replicate 模型作者暴露的参数限制,完全控制推理流程
|
||||||
|
3. CivitAI 上的任何 LoRA、IP-Adapter 权重都能直接加载
|
||||||
|
4. 按 GPU 时长计费,批量生成时远比 Replicate 按次付费便宜
|
||||||
|
5. 是 style-pipeline-layered 延期方案中"锁定期 → 量产期"的基础设施
|
||||||
|
背景:当前在 Replicate 上可用的风格迁移模型有限——
|
||||||
|
- Kolors IP-Adapter:风格迁移弱(混合提取内容+风格+构图)
|
||||||
|
- InstantStyle(jyoung105/instant-style):风格迁移强但贵($0.12/次)且慢(~128s)
|
||||||
|
- Style IPAdapter for NoobAI-XL:最强画风迁移,但需 ComfyUI 环境,Replicate 上无法使用
|
||||||
|
迁移到 HF Inference Endpoints 后可自由组合以上所有方案。
|
||||||
|
- **chosen_alternative**: 当前使用 Replicate API(Kolors IP-Adapter + Flux + SDXL)
|
||||||
|
- **deferred_reason**: 探索期用 Replicate 足够快速迭代,迁移工作量较大(需搭建自定义推理服务)
|
||||||
|
|
||||||
|
### [vision-model-asset-library] 专用 Vision 模型用于资源库功能
|
||||||
|
- **status**: deferred
|
||||||
|
- **tags**: 资源库, vision, CLIP, BLIP, 图片搜索, 自动标签, embedding
|
||||||
|
- **recorded**: 2026-04-12
|
||||||
|
- **source_chat**: [参考图风格解析方案讨论](vision-model-discussion)
|
||||||
|
- **prerequisite**: 资源库功能进入增强阶段(基础 Gallery 已完成)
|
||||||
|
- **related_files**:
|
||||||
|
- art-agent/frontend/src/app/gallery/page.tsx
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- **context**: |
|
||||||
|
引入专用 Vision 模型(CLIP / BLIP 等)为资源库提供智能化能力:
|
||||||
|
1. 自动标签:生成的图片自动分类(UI/角色/场景/图标等)
|
||||||
|
2. 以图搜图:上传一张图,从资源库中找风格最相似的
|
||||||
|
3. 语义搜索:用自然语言搜索图片(如"蓝色水晶质感的按钮")
|
||||||
|
4. 聚类分组:自动将图片按视觉相似性分组
|
||||||
|
可通过 Replicate 调用或本地部署,成本极低。
|
||||||
|
- **chosen_alternative**: 当前资源库使用手动标签 + Prompt 文本搜索
|
||||||
|
- **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
|
||||||
|
|
||||||
(暂无已完成或已废弃的方案)
|
### [context-window-management] 对话上下文管理(滑动窗口)
|
||||||
|
- **status**: completed
|
||||||
|
- **completed_date**: 2026-04-13
|
||||||
|
- **completed_by**: [CL-20260413-2320] Mem0 记忆系统集成
|
||||||
|
- **notes**: 原计划自建 tiktoken + 滑动窗口 + 摘要。实际方案:Mem0 的事实提取替代了"对话摘要",滑动窗口在 loop.py 中实现(MAX_RECENT_TURNS=20)
|
||||||
|
|
||||||
|
### [mem0-long-term-memory] 引入 Mem0 作为长期记忆层
|
||||||
|
- **status**: completed
|
||||||
|
- **completed_date**: 2026-04-13
|
||||||
|
- **completed_by**: [CL-20260413-2320] Mem0 记忆系统集成
|
||||||
|
- **notes**: Mem0 OSS 自部署(DeepSeek 事实提取 + Ollama nomic-embed-text embedding + Qdrant 本地向量库),与上下文管理一并实施
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
|
反复出现的顽固问题追踪记录,按时间倒序排列。
|
||||||
|
|
||||||
|
<!-- 新条目追加在此行下方 -->
|
||||||
18
.cursor/hooks.json
Normal file
18
.cursor/hooks.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"hooks": {
|
||||||
|
"sessionStart": [
|
||||||
|
{
|
||||||
|
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/run-hook.ps1 session-init",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stop": [
|
||||||
|
{
|
||||||
|
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/run-hook.ps1 check-changelog",
|
||||||
|
"timeout": 10,
|
||||||
|
"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`。
|
||||||
105
.cursor/hooks/check-changelog.ps1
Normal file
105
.cursor/hooks/check-changelog.ps1
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Changelog sync guard — 统一 stop hook
|
||||||
|
#
|
||||||
|
# 用确定性逻辑检查:
|
||||||
|
# 1. 源文件是否比 changelog 更新(mtime 比较)
|
||||||
|
# 2. stdin 中的 agent 上下文是否显示有源文件编辑操作
|
||||||
|
#
|
||||||
|
# 静默条件(不触发提醒):
|
||||||
|
# 1. 环境变量 CURSOR_SKIP_CHANGELOG 被设置(sessionStart 在非 agent 模式设置)
|
||||||
|
# 2. stdin JSON 中 composer_mode 不是 "agent"(如 debug/ask/edit 模式)
|
||||||
|
# 3. changelog 文件不存在
|
||||||
|
# 4. .changelog-ack 标记文件存在且足够新(本会话已确认过 changelog 状态)
|
||||||
|
# 5. 没有源文件比 changelog 更新
|
||||||
|
# 6. stdin 上下文中没有文件编辑操作的证据(防止跨会话残留 mtime 误触发)
|
||||||
|
|
||||||
|
$input = [Console]::In.ReadToEnd()
|
||||||
|
|
||||||
|
# === 豁免检查 1:环境变量跳过标志 ===
|
||||||
|
if ($env:CURSOR_SKIP_CHANGELOG) {
|
||||||
|
Write-Output '{}'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# === 豁免检查 2:从 stdin 解析 composer_mode ===
|
||||||
|
try {
|
||||||
|
$data = $input | ConvertFrom-Json
|
||||||
|
$mode = $data.composer_mode
|
||||||
|
if ($mode -and $mode -ne "agent") {
|
||||||
|
Write-Output '{}'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
# JSON 解析失败,继续后续检查
|
||||||
|
}
|
||||||
|
|
||||||
|
# === 豁免检查 3:从 stdin 文本匹配 debug 上下文关键词 ===
|
||||||
|
# stop hook 的 $ARGUMENTS 可能包含对话/工具上下文,检测 debug 相关信号
|
||||||
|
if ($input -match '"mode"\s*:\s*"debug"' -or
|
||||||
|
$input -match 'debug[\s_-]?mode' -or
|
||||||
|
$input -match 'Debug Mode') {
|
||||||
|
Write-Output '{}'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$changelog = ".cursor\changelog\changelog-headlines.md"
|
||||||
|
$srcDir = "art-agent"
|
||||||
|
|
||||||
|
# === 豁免检查 4:changelog 文件不存在 ===
|
||||||
|
if (-not (Test-Path $changelog)) {
|
||||||
|
Write-Output '{}'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$clMtime = (Get-Item $changelog).LastWriteTime
|
||||||
|
|
||||||
|
# === 豁免检查 5:ack 标记文件足够新 ===
|
||||||
|
$ackFile = ".cursor\changelog\.changelog-ack"
|
||||||
|
if ((Test-Path $ackFile) -and (Get-Item $ackFile).LastWriteTime -ge $clMtime) {
|
||||||
|
Write-Output '{}'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# === 豁免检查 6:stdin 中无文件编辑证据 ===
|
||||||
|
# 防止跨会话残留 mtime 差异导致误触发:如果 ack 文件不存在(或过旧),
|
||||||
|
# 但 stdin 上下文中也没有任何文件写入/编辑操作的痕迹,说明本次会话
|
||||||
|
# 没有进行代码改动,不应触发提醒。
|
||||||
|
$hasEditEvidence = (
|
||||||
|
$input -match 'StrReplace|Write\s*tool|edit_file|file_write|write_to_file' -or
|
||||||
|
$input -match 'Created file|Modified file|Wrote contents' -or
|
||||||
|
$input -match '"tool"\s*:\s*"(str_replace|write|edit)"'
|
||||||
|
)
|
||||||
|
if (-not $hasEditEvidence) {
|
||||||
|
Write-Output '{}'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# === 核心检查:是否有源文件比 changelog 更新 ===
|
||||||
|
$extensions = @("*.py", "*.tsx", "*.ts", "*.css")
|
||||||
|
$excludeDirs = @("node_modules", ".next", "__pycache__", "venv")
|
||||||
|
|
||||||
|
$newerFile = $null
|
||||||
|
foreach ($ext in $extensions) {
|
||||||
|
$files = Get-ChildItem -Path $srcDir -Filter $ext -Recurse -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object {
|
||||||
|
$skip = $false
|
||||||
|
foreach ($ex in $excludeDirs) {
|
||||||
|
if ($_.FullName -like "*\$ex\*") { $skip = $true; break }
|
||||||
|
}
|
||||||
|
-not $skip -and $_.LastWriteTime -gt $clMtime
|
||||||
|
} |
|
||||||
|
Select-Object -First 1
|
||||||
|
|
||||||
|
if ($files) {
|
||||||
|
$newerFile = $files.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($newerFile) {
|
||||||
|
$msg = "[Hook] Source file updated (e.g. $newerFile) but changelog not synced. Run dev-changelog Skill operation A NOW to write all three changelog layers."
|
||||||
|
$json = '{"followup_message":"' + $msg.Replace('"','\"') + '"}'
|
||||||
|
Write-Output $json
|
||||||
|
} else {
|
||||||
|
Write-Output '{}'
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
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 '{}'
|
||||||
|
}
|
||||||
|
}
|
||||||
23
.cursor/hooks/session-init.ps1
Normal file
23
.cursor/hooks/session-init.ps1
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# session-init.ps1 — 会话启动时检测 composer_mode,非 agent 模式设置跳过标志
|
||||||
|
#
|
||||||
|
# sessionStart input 包含 composer_mode 字段("agent" / "ask" / "edit" / "debug" 等)
|
||||||
|
# 通过 env 输出的环境变量会传递给同会话内所有后续 hook
|
||||||
|
|
||||||
|
$input = [Console]::In.ReadToEnd()
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = $input | ConvertFrom-Json
|
||||||
|
$mode = $data.composer_mode
|
||||||
|
} catch {
|
||||||
|
$mode = $null
|
||||||
|
}
|
||||||
|
|
||||||
|
Remove-Item ".cursor\changelog\.changelog-ack" -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if ($mode -and $mode -ne "agent") {
|
||||||
|
$json = '{"env":{"CURSOR_SKIP_CHANGELOG":"1","CURSOR_COMPOSER_MODE":"' + $mode + '"}}'
|
||||||
|
Write-Output $json
|
||||||
|
} else {
|
||||||
|
Write-Output '{}'
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
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"
|
||||||
|
}
|
||||||
34
.cursor/pitfalls/pitfalls.md
Normal file
34
.cursor/pitfalls/pitfalls.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Pitfall Journal
|
||||||
|
|
||||||
|
开发过程中踩过的坑,按时间倒序排列。
|
||||||
|
Agent 进入 Debug mode 或遇到运行时错误时自动检索匹配。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [PF-20260420-1600] firtoz/trellis Replicate 部署版 schema 与 README 及官方 API 文档不一致
|
||||||
|
- **症状**: (1) 按 Replicate README example 用 `input={"image": uri}` 传单数 image → `422 Input validation failed: input: images is required`。改成 `images: [uri]` 后模型能跑,产出 model_file/combined_video/gaussian_ply/no_background_images。(2) 尽管 `generate_color=True` + `generate_normal=True` 都开,输出字典里 `color_video` 和 `normal_video` 字段**恒为 null**,只有 `combined_video` 这个"color 和 normal 左右并排的 mp4"才真正可用。
|
||||||
|
- **根因**: `firtoz/trellis:e8f6c45206993f297372f5436b90350817bd9b4a0d52d2a76df50c1c8afa2b3c` 这个 Replicate 部署版的实际输入/输出 schema 与 firtoz 在 Replicate README 里贴的 example、以及 microsoft/TRELLIS 官方仓库的 Python API 都不一样——输入字段是 `images`(数组)而不是 `image`(字符串),输出字段虽然声明了 `color_video` 和 `normal_video` 但该部署版恒给 null,只填 `combined_video`。很可能是部署者在包装层做了合并(省带宽/对象存储开销)但没同步 README。
|
||||||
|
- **解法**: (1) 输入字段固定传 `images: [uri]` 数组,即使只有 1 张图;(2) Phase 2 抽帧的数据源从 `combined_video` 切——每一帧的左半 = color、右半 = normal,用 PIL crop 即可;(3) 不要相信 Replicate README 上的 Python example,永远以 **该版本 ID 的 OpenAPI schema** 为准(虽然 firtoz/trellis 这个 README 也看不到精确 schema,所以只能实测)。
|
||||||
|
- **防御**: 接 Replicate 新模型前先用 1 美元额度做一次最小输入测试,打印 `output` 的原始 dict 结构 + 每个 key 的值类型;不要假设 README 的 example 与实际部署版本一致。`mesh_generator.py` 里已经做了"把 raw_output_keys 和每个字段的 None 警告都打印"的防御日志。
|
||||||
|
- **关联**: Replicate, firtoz/trellis, Mesh Pipeline, view_transform, Trellis, 3D 重建, schema, OpenAPI, mesh_generator.py
|
||||||
|
|
||||||
|
### [PF-20260420-1501] Windows uvicorn --reload 缺 watchfiles 退化为轮询,代码改动不被热重载
|
||||||
|
- **症状**: 代码已修好(git diff / Read 看源文件都是新版),但后端运行时行为仍是旧代码。具体表现:CL-20260420-1212 修好 Zero123++ 切图后(`cols=2/rows=3` + 自动纠错),前端再触发一次转视角,切出的 view 图仍是 213×480(= 640/3 × 960/2,旧的 `cols=3,rows=2` 逻辑),而独立用当前代码调用 `_split_grid_image` 能正确切出 320×320。进程启动时间 11:55,改动时间 12:12,观察时间 15:00,期间未 reload 过任何改动。
|
||||||
|
- **根因**: uvicorn `--reload` 在未安装 `watchfiles` 或 `watchdog` 时 fallback 到 `StatReload`(基于轮询)。Windows 文件系统事件语义本就弱,加上深层子目录(`app/services/`)+ 新建文件(`view_transform.py` 在仓库里是新增 untracked 文件),几个因素叠加让 StatReload 漏通知/响应极慢,表现为"启动后改什么代码都不会重载"。venv 里 `watchfiles` 和 `watchdog` 双双 MISSING 是核心证据。
|
||||||
|
- **解法**: (1) `pip install watchfiles==0.24.0`(uvicorn 官方首选 reloader);(2) 写入 `requirements.txt` 和 `requirements-lock.txt`,避免其他设备重装环境时再次踩坑;(3) **kill 当前 uvicorn 进程重启一次**——新进程才会用上 WatchFiles reloader,并同步加载最新代码。仅装依赖不重启不会生效,因为当前进程已经加载了 StatReload。
|
||||||
|
- **防御**: Windows 上只要 uvicorn 带 `--reload`,`watchfiles` 就是硬依赖,不是可选项。启动后首屏日志如果看到 `WatchFiles detected changes` 或 `Started reloader process [xxx] using WatchFiles` 才算正常;如果只有 `Started reloader process [xxx] using StatReload` 就是退化状态。可以额外传 `--reload-dir app` 显式指定监控范围更保险。任何"改了代码不生效"的诡异现象,第一时间先确认进程是否已经重载(看日志里的文件变更提醒,或对比进程启动时间 vs 文件 mtime),再排查代码逻辑。
|
||||||
|
- **关联**: uvicorn, --reload, watchfiles, StatReload, FastAPI, Windows, 热重载, requirements.txt, view_transform.py, Zero123++
|
||||||
|
|
||||||
|
### [PF-20260420-1212] Zero123++ 输出 grid 布局被文档措辞"3×2"误导
|
||||||
|
- **症状**: Zero123++ 视角变换后切出的 6 张图严重错位,每张子图呈窄长条(宽:高 约 1:2),画面里上下拼着两个教堂,边缘大量留白。拼接图原图视觉正常。
|
||||||
|
- **根因**: Zero123++ 官方 README 写 "output is a 3×2 grid of 6 views",但 `jd7h/zero123plusplus` 这个 Replicate 部署版本实际输出的是 **2 列 × 3 行**(高图,如 640×960),而 config.py 里按"3 列 × 2 行"配置了 `grid_layout`。切图时 `cell_w = 640//3 = 213, cell_h = 960//2 = 480`,每个切片横跨了真实 grid 中两行的一部分,于是每张图都拼着两个视角的局部。
|
||||||
|
- **解法**: (1) `config.py` 的 `VIEW_TRANSFORM_MODELS["zero123plus"].grid_layout` 改为 `{"cols": 2, "rows": 3}`;(2) `view_transform._split_grid_image` 加入"按实际图像长宽比 vs 配置 ratio 自动纠正"——假定每个单视角为正方形,真实 aspect ratio 应等于 `cols/rows`,若配置 ratio 与实际差异 >20% 就自动交换 cols/rows 并打 warning。
|
||||||
|
- **防御**: 任何涉及"grid 拼接图切分"的场景,不要完全相信模型文档的"A×B"措辞(A 是列还是行常有歧义),必须用**实际图像长宽比**做健壮性检查;最稳妥的做法是让 split 函数本身能根据单视角正方形假设自动推断布局,不信任硬编码。更激进的方案是直接用 PIL 探测内容块边界(但对有微量白边的模型不稳定)。
|
||||||
|
- **关联**: Zero123++, Replicate, jd7h/zero123plusplus, grid 切图, PIL, view_transform.py, config.py, Novel View Synthesis
|
||||||
|
|
||||||
|
### [PF-20260412-1530] Python load_dotenv 默认不覆盖已有环境变量 + 模块级变量在 import 时固化
|
||||||
|
- **症状**: 修改 .env 切换 API 供应商后,后端仍报旧供应商的错误(如 DeepSeek 报 "Model Not Exist",实际发送的模型名是 OpenAI 的 gpt-4o-mini)
|
||||||
|
- **根因**: 两层叠加:(1) `load_dotenv()` 默认 `override=False`,如果进程/系统中已有同名环境变量,.env 中的新值不会生效;(2) `config.py` 使用模块级常量 `LLM_MODEL = os.getenv(...)` 在 import 时就求值固化,而 `main.py` 的 import 链在 `load_dotenv()` 之前就触发了 config.py 的加载
|
||||||
|
- **解法**: (1) `load_dotenv(override=True)` 并提到所有业务 import 之前;(2) config.py 改为函数式 `get_llm_model()`,运行时才读取
|
||||||
|
- **防御**: 任何 Python 项目使用 dotenv 时,始终 `override=True` + 放在文件最顶部(仅在 `import os` 和 `from dotenv import load_dotenv` 之后);配置值用函数或属性包装,不要用模块级常量
|
||||||
|
- **关联**: Python, dotenv, FastAPI, uvicorn, 环境变量, 配置管理, OpenAI SDK
|
||||||
26
.cursor/profile/project-profile-log.md
Normal file
26
.cursor/profile/project-profile-log.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Project Profile Log
|
||||||
|
|
||||||
|
详细记录每次项目画像更新的完整上下文,按时间正序追加。
|
||||||
|
|
||||||
|
## 记录
|
||||||
|
|
||||||
|
### 2026-04-11 — 初始化项目画像
|
||||||
|
- **分类**: 项目定位
|
||||||
|
- **精简版**: 面向游戏美术团队的 AI Agent 工具,通过对话驱动产出美术资源
|
||||||
|
- **原始上下文**: "我希望做一个agent工具方便我们团队的美术更简易便捷地主要通过对话的形式去产出美术资源"
|
||||||
|
- **来源对话**: 初始化会话
|
||||||
|
- **操作**: 新增
|
||||||
|
|
||||||
|
### 2026-04-11 — 初始化项目画像
|
||||||
|
- **分类**: 设计约定
|
||||||
|
- **精简版**: 目标用户是美术人员,交互设计需优先考虑非技术用户的友好度
|
||||||
|
- **原始上下文**: "方便我们团队的美术更简易便捷地"
|
||||||
|
- **来源对话**: 初始化会话
|
||||||
|
- **操作**: 新增
|
||||||
|
|
||||||
|
### 2026-04-11 — 初始化项目画像
|
||||||
|
- **分类**: 产品方向
|
||||||
|
- **精简版**: 当前阶段:平面资源、UI 类资源、交互设计、美术风格;远期:场景、3D 资源
|
||||||
|
- **原始上下文**: "目前可能主要考虑产出平面资源和UI类资源、交互、美术风格等,以后也考虑要产出场景甚至3D资源"
|
||||||
|
- **来源对话**: 初始化会话
|
||||||
|
- **操作**: 新增
|
||||||
13
.cursor/profile/project-profile.md
Normal file
13
.cursor/profile/project-profile.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Project Profile
|
||||||
|
|
||||||
|
## 项目定位
|
||||||
|
- 面向游戏美术团队的 AI Agent 工具,通过对话驱动产出美术资源
|
||||||
|
|
||||||
|
## 技术栈与架构
|
||||||
|
|
||||||
|
## 设计约定
|
||||||
|
- 目标用户是美术人员,交互设计需优先考虑非技术用户的友好度
|
||||||
|
|
||||||
|
## 产品方向
|
||||||
|
- 当前阶段:平面资源、UI 类资源、交互设计、美术风格
|
||||||
|
- 远期规划:场景资源、3D 资源生成
|
||||||
@@ -25,8 +25,12 @@ Agent回复时使用简体中文。
|
|||||||
|
|
||||||
| 位置 | 用途 | 示例 |
|
| 位置 | 用途 | 示例 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `~/.cursor/skills/` | 跨项目通用 Skill,全局可用 | epee-orchestrator、deferred-decisions |
|
| `.cursor/skills/` | **所有 Skill 的主存储位置**(含通用和项目专属) | epee-orchestrator、deferred-decisions、项目特定 Skill |
|
||||||
| `.cursor/skills/` | 当前项目专属 Skill | 项目特定的代码生成、配置管理等 |
|
| `~/.cursor/skills/` | 跨项目通用 Skill 的全局副本(可选,方便其他项目复用) | epee-orchestrator、deferred-decisions |
|
||||||
|
|
||||||
|
> **重要**:无论 Skill 是通用还是项目专属,都**必须**在项目的 `.cursor/skills/` 下保留一份,
|
||||||
|
> 以确保能被 Git 管理和版本控制。全局目录 `~/.cursor/skills/` 仅作为跨项目共享的便利副本,
|
||||||
|
> 不作为唯一存储位置。
|
||||||
|
|
||||||
### Agent 创建 Rule 或 Skill 时必须遵守
|
### Agent 创建 Rule 或 Skill 时必须遵守
|
||||||
|
|
||||||
@@ -35,5 +39,5 @@ Agent回复时使用简体中文。
|
|||||||
3. 确认后放入对应目录:
|
3. 确认后放入对应目录:
|
||||||
- 通用 Rule → `.cursor/rules/common/`
|
- 通用 Rule → `.cursor/rules/common/`
|
||||||
- 项目专属 Rule → `.cursor/rules/project/`
|
- 项目专属 Rule → `.cursor/rules/project/`
|
||||||
- 通用 Skill → `~/.cursor/skills/`
|
- **所有 Skill(含通用)→ `.cursor/skills/`**(必须,确保 Git 可管理)
|
||||||
- 项目专属 Skill → `.cursor/skills/`
|
- 通用 Skill 额外同步 → `~/.cursor/skills/`(可选,方便其他项目使用)
|
||||||
|
|||||||
125
.cursor/rules/common/changelog-recall.mdc
Normal file
125
.cursor/rules/common/changelog-recall.mdc
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
description: 每次会话开始时注入开发日志概要(L3),并在检测到任务与近期改动相关时自动读取中期记录(L2)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开发日志上下文注入
|
||||||
|
|
||||||
|
每次会话处理用户第一个任务前,执行以下操作:
|
||||||
|
|
||||||
|
1. 读取 `.cursor/changelog/changelog-headlines.md`(不存在则跳过)
|
||||||
|
2. 如文件存在且有实质内容(不仅是模板头部),将全部条目作为背景知识注入上下文
|
||||||
|
3. 这些信息帮助 Agent 快速建立位置感:项目进展到什么阶段、最近的工作重心在哪个模块
|
||||||
|
4. 在后续回复中自然参考,不显式提及"根据开发日志"
|
||||||
|
|
||||||
|
## 主动定位辅助
|
||||||
|
|
||||||
|
L3 概要的核心价值之一是帮助 Agent 在**冷启动**(新会话、无上下文)时理解用户意图。
|
||||||
|
当用户的请求缺少具体文件名或模块名时,Agent 应主动利用 L3 进行推断:
|
||||||
|
|
||||||
|
### 典型场景
|
||||||
|
|
||||||
|
1. **隐式延续**:用户说"继续做昨天那个"、"把那个功能完善一下"
|
||||||
|
→ 从 L3 中找到最近的相关条目,上溯到 L2 获取具体文件列表
|
||||||
|
2. **模糊指代**:用户说"那个组件有 bug"、"之前改的那个接口"
|
||||||
|
→ 用 L3 中的关键词匹配用户描述,定位到具体改动
|
||||||
|
3. **上下文补全**:用户直接提出一个任务,没有背景说明
|
||||||
|
→ 用 L3 判断该任务是否与近期某个改动有关联(如同一模块、同一功能线)
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 解析用户请求,识别是否存在隐式引用或模糊指代
|
||||||
|
2. 在 L3 概要中查找语义最匹配的 1-3 条记录
|
||||||
|
3. 提取匹配条目的锚点 ID,上溯到 L2 获取 affected_files 和 tags
|
||||||
|
4. 如有必要,继续上溯到 L1 获取完整的决策背景
|
||||||
|
5. 将定位到的文件/模块作为任务的起点,开始执行
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 L3 中没有匹配到任何相关记录,正常处理即可——不是所有任务都与近期改动有关。
|
||||||
|
|
||||||
|
## L2 自动触发
|
||||||
|
|
||||||
|
Agent 开始处理一个新任务时,判断是否需要读取近期详细记录:
|
||||||
|
|
||||||
|
1. 从当前任务中提取涉及的文件路径和语义关键词
|
||||||
|
2. 与 L3 概要中的内容做快速比对——如果近期有相关模块/文件的改动记录
|
||||||
|
3. 命中时,读取 `.cursor/changelog/changelog-recent.md`,将相关条目纳入上下文
|
||||||
|
4. 匹配策略:
|
||||||
|
- 硬匹配:当前任务涉及的文件出现在 L2 条目的 `affected_files` 中
|
||||||
|
- 软匹配:当前任务的语义关键词与条目的 `tags` 有交集
|
||||||
|
- 任一命中即触发读取
|
||||||
|
|
||||||
|
### 注意
|
||||||
|
|
||||||
|
- L3 注入是低成本操作(~50 句话),每次会话都执行
|
||||||
|
- L2 读取按需触发,只在检测到关联时才读取
|
||||||
|
- 开发日志是事实性记录,直接使用即可,不像画像那样需要"自然融入"的措辞考量
|
||||||
|
- 记录的写入和管理由 `dev-changelog` Skill 负责,本 Rule 只负责读取和注入
|
||||||
|
|
||||||
|
## 逐级上溯
|
||||||
|
|
||||||
|
当 L3 中某条记录的一句话描述**语义模糊**(无法判断具体范围或与当前任务的关系),
|
||||||
|
按以下步骤精准上溯,**禁止全文读取 L1**:
|
||||||
|
|
||||||
|
1. 提取该条目的锚点 ID(`CL-xxx`)
|
||||||
|
2. 用 Grep 在 `changelog-recent.md`(L2)中搜索该 ID → 找到则读取该条目
|
||||||
|
3. 如 L2 中未找到或仍有歧义 → 用 Grep 在 `changelog-full.md`(L1)中搜索该 ID,
|
||||||
|
获取行号后用 Read 工具读取该行号 ±20 行范围
|
||||||
|
4. 一次上溯通常只涉及 1-3 条记录,不批量上溯
|
||||||
|
|
||||||
|
## 任务完成 Checklist(强制)
|
||||||
|
|
||||||
|
Agent 在即将输出最终回复前,**必须**逐项检查以下清单。
|
||||||
|
这是硬性要求,不是建议——**跳过任何一项都视为执行错误**。
|
||||||
|
|
||||||
|
### 前置项(每次回复前无条件执行)
|
||||||
|
|
||||||
|
**A. 画像信号扫描(短路版)**
|
||||||
|
|
||||||
|
目的:以最小 token 成本维持 `profile-memory` Skill 的被动检测通路。
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
|
||||||
|
1. **快判(词表匹配)**:扫描本次对话中**用户消息**,检查是否**明显**出现以下偏好/决策触发词族之一:
|
||||||
|
|
||||||
|
- 个人偏好信号:`我喜欢` / `我倾向` / `我不喜欢` / `我讨厌` / `以后都` / `以后别` /
|
||||||
|
`别再` / `不要再` / `我的风格` / `我的偏好` / `太花哨` / `minimal` /
|
||||||
|
`简洁` / `精炼` / `啰嗦`
|
||||||
|
- 项目决策信号:`这个项目是` / `目标用户是` / `技术栈` / `架构上` /
|
||||||
|
`产品方向` / `定位是`
|
||||||
|
|
||||||
|
2. **分支**:
|
||||||
|
- 无匹配 → **直接跳过**,进入下一 checklist 项(thinking 应在 50 token 内结束)
|
||||||
|
- 有匹配 → 读取 `.cursor/skills/profile-memory/SKILL.md` 并执行操作 A 的确认流程
|
||||||
|
|
||||||
|
3. **约束**:本项不做全量语义判断——漏记隐式信号是可接受的权衡。
|
||||||
|
用户如需记录隐式偏好可显式说"把这条记入画像"。
|
||||||
|
|
||||||
|
### 变更检查项(仅当本次有代码/配置文件改动时执行)
|
||||||
|
|
||||||
|
1. **本次是否有代码/配置文件改动?**(不含 `.cursor/` 下的基础设施文件)
|
||||||
|
- 否 → 跳过本组后续项,直接进入"收尾项"
|
||||||
|
- 是 → 继续
|
||||||
|
|
||||||
|
2. **是否已写入三层开发日志?**
|
||||||
|
- 否 → **立即**读取 `dev-changelog` Skill 并执行操作 A,写入 L1 + L2 + L3 后再输出回复
|
||||||
|
- 是 → 继续
|
||||||
|
|
||||||
|
3. **是否修改了任何 Skill?**(包括 SKILL.md 内容变更)
|
||||||
|
- 否 → 跳过
|
||||||
|
- 是 → 检查是否已同步 `epee-orchestrator` 的 `registry.md`,未同步则立即执行
|
||||||
|
|
||||||
|
4. **本次是否有 Skill 使用异常?**(遗漏、误触、产物与用户预期偏差)
|
||||||
|
- 否 → 跳过
|
||||||
|
- 是 → 执行 `epee-orchestrator.mdc` 中的自迭代确认流程
|
||||||
|
|
||||||
|
### 收尾项(每次回复前无条件执行)
|
||||||
|
|
||||||
|
**Z. 静默 Hook ack**
|
||||||
|
|
||||||
|
- 用 Write 工具创建空文件 `.cursor/changelog/.changelog-ack`
|
||||||
|
- 这会更新 ack 文件的 mtime,阻止 `check-changelog.ps1` hook 在后续回复中重复提醒
|
||||||
|
|
||||||
|
**执行原则**:先完成 checklist 中的所有写入动作,再输出面向用户的回复文本。
|
||||||
|
回复末尾如果执行了变更检查项第 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 定义
|
||||||
@@ -28,6 +28,9 @@ Agent **必须**执行以下操作:
|
|||||||
2. 更新或新增对应 Skill 的条目(格式参见 registry.md 中的条目结构)
|
2. 更新或新增对应 Skill 的条目(格式参见 registry.md 中的条目结构)
|
||||||
3. 确保条目中的能力描述和触发场景与 Skill 实际内容一致
|
3. 确保条目中的能力描述和触发场景与 Skill 实际内容一致
|
||||||
|
|
||||||
|
> **注意**:此项已纳入 `changelog-recall.mdc` 的"任务完成 Checklist"第 3 项。
|
||||||
|
> 如果 Agent 在 checklist 阶段发现遗漏,必须立即补执行。
|
||||||
|
|
||||||
### Skill 自迭代(强制)
|
### Skill 自迭代(强制)
|
||||||
|
|
||||||
每个 SKILL.md 必须包含一个"自迭代日志"章节,用于记录使用该 Skill 过程中发现的经验教训。
|
每个 SKILL.md 必须包含一个"自迭代日志"章节,用于记录使用该 Skill 过程中发现的经验教训。
|
||||||
@@ -38,6 +41,9 @@ Agent **必须**执行以下操作:
|
|||||||
2. 用户需要反复补充同类信息
|
2. 用户需要反复补充同类信息
|
||||||
3. 生成产物与用户预期存在系统性偏差
|
3. 生成产物与用户预期存在系统性偏差
|
||||||
|
|
||||||
|
> **注意**:此项已纳入 `changelog-recall.mdc` 的"任务完成 Checklist"第 4 项。
|
||||||
|
> Agent 不应等到"下次使用 Skill 时"才想起自迭代——当次就应检查。
|
||||||
|
|
||||||
**流程**:
|
**流程**:
|
||||||
|
|
||||||
1. 识别问题根因,归纳为一条简明的检查项
|
1. 识别问题根因,归纳为一条简明的检查项
|
||||||
|
|||||||
31
.cursor/rules/common/pitfall-recall.mdc
Normal file
31
.cursor/rules/common/pitfall-recall.mdc
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
## 踩坑经验自动检索
|
||||||
|
|
||||||
|
### 被动检测触发
|
||||||
|
|
||||||
|
Agent 在以下场景中,应自动读取 `.cursor/pitfalls/pitfalls.md` 并进行匹配检索:
|
||||||
|
|
||||||
|
1. **进入 Debug mode**:读取全部条目,将当前错误症状与已有记录比对
|
||||||
|
2. **遇到运行时错误**:提取错误信息关键词,在"症状"字段中检索匹配
|
||||||
|
3. **同一问题第二次出现**:如果当前会话中某个错误已出现过一次且未解决,强制检索
|
||||||
|
|
||||||
|
### 匹配策略
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 提取当前问题的信号:错误信息关键词、涉及文件/模块、技术栈
|
||||||
|
2. 在 pitfalls.md 中匹配:
|
||||||
|
- 硬匹配:错误关键词出现在条目的"症状"中
|
||||||
|
- 软匹配:模块/技术栈出现在条目的"关联"中
|
||||||
|
3. 命中时在分析开头提示:
|
||||||
|
> 注意:之前遇到过类似问题 [PF-xxx]:[标题]。根因是 [xxx],先排查这个方向。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 写入提醒
|
||||||
|
|
||||||
|
Agent 在完成涉及 debug/修复的任务后,应读取 `pitfall-journal` Skill 并执行其"操作 A:写入记录"流程。
|
||||||
|
判断标准:问题的根因是否"非显而易见"——如果只看代码逻辑觉得应该没问题,但实际运行时才暴露,就值得记录。
|
||||||
|
|
||||||
|
### 注意
|
||||||
|
|
||||||
|
- 检索结果是**辅助参考**,不是确定性答案——匹配到不代表根因一定相同
|
||||||
|
- pitfalls.md 不存在时跳过,不报错
|
||||||
|
- 每次会话中对同一条 pitfall 最多提醒一次
|
||||||
46
.cursor/rules/common/profile-recall.mdc
Normal file
46
.cursor/rules/common/profile-recall.mdc
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
description: 每次会话开始时读取用户画像和项目画像,将精简 Profile 注入上下文以指导 Agent 行为
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## 画像上下文注入
|
||||||
|
|
||||||
|
每次会话处理用户第一个任务前,执行以下操作:
|
||||||
|
|
||||||
|
1. 读取 `~/.cursor/profile/user-profile.md`(不存在记为 user_missing)
|
||||||
|
2. 读取 `.cursor/profile/project-profile.md`(不存在记为 project_missing)
|
||||||
|
3. **冷启动检测**:如果 user_missing 或 project_missing 为真,在回复开头简要提醒:
|
||||||
|
> 画像系统尚未初始化(缺少:user-profile / project-profile)。
|
||||||
|
> 如需启用画像功能,请说"初始化画像",我会引导你完成。
|
||||||
|
- 每次会话最多提醒一次,不重复打扰
|
||||||
|
- 如用户回应"初始化画像",读取 `profile-memory` Skill 并按其模板创建文件,
|
||||||
|
然后引导用户填写基本信息
|
||||||
|
4. 如文件存在且有实质内容(非空模板),将其内容作为背景知识纳入考量
|
||||||
|
5. 在后续回复中,Agent 应自然地参考画像信息,无需显式引用
|
||||||
|
|
||||||
|
### 注意
|
||||||
|
|
||||||
|
- 画像信息是背景参考,不是硬性约束——当用户当前指令与画像冲突时,以当前指令为准
|
||||||
|
- 不要在回复中提及"根据你的画像"之类的措辞,自然融入即可
|
||||||
|
- 画像的记录和管理由 `profile-memory` Skill 负责,本 Rule 只负责读取和注入
|
||||||
|
|
||||||
|
### 逐级上溯
|
||||||
|
|
||||||
|
当精简版 Profile 中某条记录**语义模糊**(无法判断偏好的具体适用场景),
|
||||||
|
按以下步骤精准查找详细 Log,**禁止全文读取 Log 文件**:
|
||||||
|
|
||||||
|
1. 提取该条目的锚点 ID(HTML 注释中的 `PF-xxx`)
|
||||||
|
2. 用 Grep 在对应的 Log 文件中搜索该 ID,获取行号
|
||||||
|
3. 用 Read 工具读取该行号 ±15 行范围,获取原始上下文和来源信息
|
||||||
|
4. 一次上溯通常只涉及 1-2 条记录,不批量上溯
|
||||||
|
|
||||||
|
## 被动检测触发点
|
||||||
|
|
||||||
|
被动检测不在本 Rule 执行,而是由 `changelog-recall.mdc` 的
|
||||||
|
**任务完成 Checklist 前置项 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 版本并存),用户可随时手动修改
|
||||||
18
.cursor/rules/project/project-launcher.mdc
Normal file
18
.cursor/rules/project/project-launcher.mdc
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
description: 当用户要求启动项目、运行项目、打开前端/后端服务时触发
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目启动快捷指令
|
||||||
|
|
||||||
|
当用户表达以下意图时,读取并执行 `project-launcher` Skill:
|
||||||
|
|
||||||
|
**触发关键词**:启动项目、运行项目、跑起来、打开前端和后端、启动服务、start、launch、run dev、启动前端、启动后端
|
||||||
|
|
||||||
|
**执行方式**:
|
||||||
|
|
||||||
|
1. 读取 `.cursor/skills/project-launcher/SKILL.md`
|
||||||
|
2. 按 Skill 中的流程,在**独立可见的终端窗口**中启动前端和/或后端
|
||||||
|
3. 必须使用 `Start-Process`(PowerShell)或 `start cmd`(CMD)打开新窗口
|
||||||
|
4. **不要**在 Cursor 内置终端中后台运行——用户需要看到窗口并能手动关闭
|
||||||
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 条目。
|
||||||
151
.cursor/skills/deferred-decisions/SKILL.md
Normal file
151
.cursor/skills/deferred-decisions/SKILL.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
name: deferred-decisions
|
||||||
|
description: >-
|
||||||
|
记录开发中被延期的技术方案/决策,并在关联任务出现时主动提醒用户。
|
||||||
|
当对话中出现"以后再做"、"先不做"、"defer"、"延期方案"、"备选方案"、
|
||||||
|
"递进方案"等语义时触发。也用于浏览、管理已有的 deferred items。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deferred Decisions Skill
|
||||||
|
|
||||||
|
追踪开发中被延期的技术方案,在合适时机主动提醒用户。
|
||||||
|
|
||||||
|
## 存储
|
||||||
|
|
||||||
|
**数据文件**: `.cursor/deferred/registry.md`
|
||||||
|
|
||||||
|
该文件是 Agent 的结构化记忆,不是面向人类的文档。每个 deferred item 是一个 H3 级标题块。
|
||||||
|
|
||||||
|
## 操作 A:记录延期方案
|
||||||
|
|
||||||
|
### 触发识别
|
||||||
|
|
||||||
|
当对话中出现以下模式时,Agent 应主动提议记录:
|
||||||
|
- 讨论了多种方案,选择了一种,明确说其余"以后再做"
|
||||||
|
- 实现了基础版本,提到了可递进升级的高级版本
|
||||||
|
- 发现了可以改进的点,但当前优先级不够
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 从对话中提炼:
|
||||||
|
- 选择了什么方案(chosen_alternative)
|
||||||
|
- 延期了什么(标题 + context)
|
||||||
|
- 为什么延期(deferred_reason)
|
||||||
|
- 什么时候可以做(prerequisite)
|
||||||
|
- 涉及哪些领域(tags)和文件(related_files)
|
||||||
|
2. 生成 slug 形式的唯一 ID(如 ecosystem-lod-switching)
|
||||||
|
3. 读取 .cursor/deferred/registry.md(不存在则用模板创建)
|
||||||
|
4. 在 "## Active Items" 区域末尾、"---" 分隔线之前追加新条目
|
||||||
|
5. 向用户确认已记录,展示条目摘要
|
||||||
|
```
|
||||||
|
|
||||||
|
### 条目模板
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### [item-slug] 简短标题
|
||||||
|
- **status**: deferred
|
||||||
|
- **tags**: tag1, tag2, tag3
|
||||||
|
- **recorded**: YYYY-MM-DD
|
||||||
|
- **source_chat**: [简短描述](chat-uuid)
|
||||||
|
- **prerequisite**: 实施前提条件(可选,无则写 "无")
|
||||||
|
- **related_files**:
|
||||||
|
- path/to/file1
|
||||||
|
- path/to/file2
|
||||||
|
- **context**: |
|
||||||
|
延期方案的具体内容,2-5 行描述。
|
||||||
|
包含方案的要点和实施思路。
|
||||||
|
- **chosen_alternative**: 当时选择的方案简述
|
||||||
|
- **deferred_reason**: 延期的原因
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| ID(方括号内) | 是 | slug 形式,全局唯一 |
|
||||||
|
| status | 是 | `deferred` / `reminded` / `in_progress` / `done` / `cancelled` |
|
||||||
|
| tags | 是 | 逗号分隔的领域标签,用于关联匹配 |
|
||||||
|
| recorded | 是 | 记录日期 |
|
||||||
|
| source_chat | 否 | 来源对话标识 |
|
||||||
|
| prerequisite | 否 | 实施前提条件 |
|
||||||
|
| related_files | 否 | 关联代码文件路径 |
|
||||||
|
| context | 是 | 延期方案的具体内容 |
|
||||||
|
| chosen_alternative | 是 | 当时选择了什么 |
|
||||||
|
| deferred_reason | 是 | 延期原因 |
|
||||||
|
|
||||||
|
## 操作 B:关联提醒
|
||||||
|
|
||||||
|
### 触发条件
|
||||||
|
|
||||||
|
由 `.cursor/rules/common/deferred-recall.mdc` 触发,或当用户任务涉及已有 deferred item 的领域时自动触发。
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 读取 .cursor/deferred/registry.md
|
||||||
|
2. 提取当前任务的关键词和涉及文件
|
||||||
|
3. 匹配 status=deferred 的 items:
|
||||||
|
- tags 与当前任务关键词有交集
|
||||||
|
- related_files 与当前任务涉及文件有重叠
|
||||||
|
- prerequisite 描述的条件可能已满足
|
||||||
|
4. 如匹配到,在回复开头简要提醒:
|
||||||
|
"提醒:你之前有一个延期方案 [item-title] 与当前任务相关。要一并处理吗?"
|
||||||
|
5. 如用户同意,将该 item 的 status 改为 in_progress
|
||||||
|
```
|
||||||
|
|
||||||
|
### 提醒原则
|
||||||
|
|
||||||
|
- 每个 item 在同一会话中最多提醒一次
|
||||||
|
- 只提醒 status=deferred 的 items(reminded/in_progress 不重复提醒)
|
||||||
|
- 提醒应简洁,不打断用户的主线任务
|
||||||
|
|
||||||
|
## 操作 C:状态管理
|
||||||
|
|
||||||
|
| 用户动作 | 状态变更 | 额外操作 |
|
||||||
|
|---|---|---|
|
||||||
|
| "开始做 X" | deferred → in_progress | 无 |
|
||||||
|
| "X 完成了" | in_progress → done | 移到 "Completed / Cancelled Items" 区域 |
|
||||||
|
| "X 不需要了" | any → cancelled | 移到 "Completed / Cancelled Items" 区域 |
|
||||||
|
| Agent 提醒后用户确认 | deferred → in_progress | 无 |
|
||||||
|
|
||||||
|
### 移动条目
|
||||||
|
|
||||||
|
将条目从 "Active Items" 剪切到 "Completed / Cancelled Items" 区域时,保留完整内容,仅修改 status。
|
||||||
|
|
||||||
|
## 操作 D:浏览/回顾
|
||||||
|
|
||||||
|
当用户问"有哪些延期方案"、"deferred list"、"待办方案"等时:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 读取 registry.md
|
||||||
|
2. 列出所有 status=deferred 的 items 摘要表:
|
||||||
|
| ID | 标题 | Tags | 记录日期 | 前提条件 |
|
||||||
|
3. 如用户要求按 tag 过滤,只展示匹配项
|
||||||
|
```
|
||||||
|
|
||||||
|
## Registry 文件模板
|
||||||
|
|
||||||
|
当 `.cursor/deferred/registry.md` 不存在时,用此模板创建:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Deferred Decisions Registry
|
||||||
|
|
||||||
|
## Active Items
|
||||||
|
|
||||||
|
(暂无延期方案)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completed / Cancelled Items
|
||||||
|
|
||||||
|
(暂无已完成或已废弃的方案)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自迭代日志
|
||||||
|
|
||||||
|
本节记录使用本 Skill 过程中发现的必要检查项。
|
||||||
|
|
||||||
|
### 已知必要检查
|
||||||
|
|
||||||
|
(暂无)
|
||||||
259
.cursor/skills/dev-changelog/SKILL.md
Normal file
259
.cursor/skills/dev-changelog/SKILL.md
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
---
|
||||||
|
name: dev-changelog
|
||||||
|
description: >-
|
||||||
|
三层开发进程记录系统。在 Agent 完成代码改动后自动记录,提供从一句话概要到完整日志的
|
||||||
|
多级上下文,帮助 Agent 在跨会话场景下保持对项目开发进展的感知。
|
||||||
|
当用户提到"开发日志"、"changelog"、"最近改了什么"、"回顾改动"等时触发。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dev Changelog
|
||||||
|
|
||||||
|
三层开发进程记录系统,解决 Agent 跨会话的上下文断裂问题。
|
||||||
|
|
||||||
|
## 三层架构
|
||||||
|
|
||||||
|
| 层级 | 文件 | 信息密度 | 条目数量 | 注入方式 |
|
||||||
|
|------|------|---------|---------|---------|
|
||||||
|
| L1 完整版 | `changelog-full.md` | 高(5-15 行/条) | 无上限,只追加 | 用户手动唤醒 |
|
||||||
|
| L2 中期版 | `changelog-recent.md` | 中(3-5 行/条) | 滚动窗口 ~20 条 | 检测到关联时自动读取 |
|
||||||
|
| L3 概要版 | `changelog-headlines.md` | 低(1 行/条) | 滚动窗口 ~50 条 | 每次会话自动注入 |
|
||||||
|
|
||||||
|
所有数据文件存放在 `.cursor/changelog/` 目录下。
|
||||||
|
|
||||||
|
## 锚点 ID 机制
|
||||||
|
|
||||||
|
每条记录在写入时生成一个**锚点 ID**,格式为 `CL-YYYYMMDD-HHMM`(如 `CL-20260412-1430`)。
|
||||||
|
同一分钟内有多条时追加字母后缀(`CL-20260412-1430a`、`CL-20260412-1430b`)。
|
||||||
|
|
||||||
|
锚点 ID 在三层文件中保持一致,用于跨层精准定位:
|
||||||
|
- L3 一句话条目以 `[CL-xxx]` 开头
|
||||||
|
- L2 摘要条目的 H3 标题包含 `[CL-xxx]`
|
||||||
|
- L1 完整条目的 H3 标题包含 `[CL-xxx]`
|
||||||
|
|
||||||
|
这使得从 L3 → L2 → L1 的逐级查找可以通过 Grep 精准定位,无需全文读取。
|
||||||
|
|
||||||
|
## 逐级上溯机制
|
||||||
|
|
||||||
|
当 Agent 在使用 L3 概要作为上下文时,如果某条记录的一句话描述**语义模糊**
|
||||||
|
(如无法判断改动的具体范围、与当前任务的关系不明确),执行以下逐级查找:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 从 L3 条目中提取锚点 ID(如 CL-20260412-1430)
|
||||||
|
2. 在 changelog-recent.md(L2)中 Grep 该 ID
|
||||||
|
- 找到 → 读取该条目的 3-5 行摘要,通常足以消除歧义
|
||||||
|
- 未找到(已滚出 L2 窗口)→ 进入步骤 3
|
||||||
|
3. 在 changelog-full.md(L1)中 Grep 该 ID
|
||||||
|
- 找到 → 用 Read 工具读取该 ID 所在行号 ±20 行范围(精准读取,不读全文)
|
||||||
|
- 未找到 → 放弃上溯,该条目上下文不可用
|
||||||
|
```
|
||||||
|
|
||||||
|
### 上溯原则
|
||||||
|
|
||||||
|
- **按需触发**:只有在 L3 信息不足以支撑当前任务判断时才上溯,不预防性地批量读取
|
||||||
|
- **精准读取**:对 L1 的访问必须通过 Grep 定位行号 + Read 局部读取,禁止全文读取
|
||||||
|
- **最小化**:一次上溯通常只涉及 1-3 条记录,不批量上溯
|
||||||
|
|
||||||
|
## 操作 A:记录写入(核心流程)
|
||||||
|
|
||||||
|
### 触发条件
|
||||||
|
|
||||||
|
Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后,自动触发。
|
||||||
|
|
||||||
|
以下情况**不触发**:
|
||||||
|
- 纯对话讨论、方案设计、问题解答(无文件改动)
|
||||||
|
- 只读操作(查看文件、搜索代码)
|
||||||
|
- 只改动了 `.cursor/` 目录下的基础设施文件(如画像、延期方案、changelog 自身)
|
||||||
|
|
||||||
|
### 写入流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 生成锚点 ID:CL-YYYYMMDD-HHMM(检查是否与已有 ID 冲突,冲突则追加字母后缀)
|
||||||
|
|
||||||
|
2. 从刚完成的任务中提取以下信息:
|
||||||
|
- 做了什么(what):一句话概括
|
||||||
|
- 为什么这样做(why):动机和背景
|
||||||
|
- 改了哪里(where):受影响的文件/模块列表
|
||||||
|
- 关键决策(decisions):如果有方案选择,记录选了什么、放弃了什么
|
||||||
|
- 注意事项(notes):后续可能受影响的地方、已知限制等
|
||||||
|
|
||||||
|
3. 生成三层内容(共享同一个锚点 ID):
|
||||||
|
- L1 完整条目(包含以上全部信息)
|
||||||
|
- L2 摘要条目(what + why + where,3-5 行)
|
||||||
|
- L3 一句话(what,不超过 80 字)
|
||||||
|
|
||||||
|
4. 写入三个文件(按以下顺序):
|
||||||
|
a. 读取 changelog-full.md,在 "## 记录" 下方追加 L1 条目
|
||||||
|
b. 读取 changelog-recent.md,在顶部插入 L2 条目,如超过 20 条则移除最旧的(与下方「L2 窗口」一致)
|
||||||
|
c. 读取 changelog-headlines.md,在顶部插入 L3 条目,如超过 50 条则移除最旧的
|
||||||
|
|
||||||
|
5. 在回复末尾附一行提示:"[已记录到开发日志]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 静默写入原则
|
||||||
|
|
||||||
|
- **不需要用户确认**——Agent 自己做的改动,对"做了什么"的认知是一手的
|
||||||
|
- 用户如果觉得记录不准确,可通过操作 D 修改或删除
|
||||||
|
- 回滚操作也要记录("回退了 XX 改动"),真实反映开发过程
|
||||||
|
|
||||||
|
## 操作 B:L2 自动触发读取
|
||||||
|
|
||||||
|
### 触发条件
|
||||||
|
|
||||||
|
由 `changelog-recall.mdc` Rule 调度。当 Agent 开始处理一个新任务时,判断该任务是否
|
||||||
|
与近期改动相关。
|
||||||
|
|
||||||
|
### 匹配策略(文件 + 标签双匹配)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 从当前任务中提取:
|
||||||
|
- 涉及的文件路径
|
||||||
|
- 语义关键词(模块名、功能领域等)
|
||||||
|
|
||||||
|
2. 读取 changelog-recent.md,逐条检查:
|
||||||
|
- 硬匹配:当前任务涉及的文件出现在条目的 affected_files 中
|
||||||
|
- 软匹配:当前任务的语义关键词与条目的 tags 有交集
|
||||||
|
|
||||||
|
3. 任一匹配命中 → 将匹配到的 L2 条目作为上下文纳入考量
|
||||||
|
4. 在回复中自然融入,不显式提及"根据开发日志"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 操作 C:L1 手动检索
|
||||||
|
|
||||||
|
### 触发条件
|
||||||
|
|
||||||
|
用户主动要求回顾完整改动记录时触发。典型话语:
|
||||||
|
- "回顾一下最近的改动"
|
||||||
|
- "XX 模块之前改过什么"
|
||||||
|
- "查看开发日志"
|
||||||
|
- "changelog"
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 读取 changelog-full.md
|
||||||
|
2. 根据用户需求过滤:
|
||||||
|
- 按时间范围
|
||||||
|
- 按模块/文件
|
||||||
|
- 按 tags
|
||||||
|
3. 展示匹配的条目摘要表,用户可以进一步查看某条的完整内容
|
||||||
|
```
|
||||||
|
|
||||||
|
## 操作 D:记录管理
|
||||||
|
|
||||||
|
用户可以对已有记录进行管理:
|
||||||
|
|
||||||
|
| 操作 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 删除 | 从三层文件中同步移除对应条目 |
|
||||||
|
| 修改 | 修改某条记录的描述(三层同步更新) |
|
||||||
|
| 清理 | 手动触发 L1 的归档(如按月分文件,暂不实现,作为演进方向) |
|
||||||
|
|
||||||
|
## 条目格式
|
||||||
|
|
||||||
|
### L1 完整条目
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### [CL-20260412-1430] YYYY-MM-DD HH:MM — 一句话标题
|
||||||
|
- **tags**: tag1, tag2, tag3
|
||||||
|
- **affected_files**:
|
||||||
|
- path/to/file1
|
||||||
|
- path/to/file2
|
||||||
|
- **what**: 做了什么的简要描述
|
||||||
|
- **why**: 动机和背景
|
||||||
|
- **decisions**: 选择了 A 方案(放弃了 B 因为 xxx)
|
||||||
|
- **notes**: 后续注意事项
|
||||||
|
- **source_chat**: [对话简述](chat-uuid)
|
||||||
|
```
|
||||||
|
|
||||||
|
### L2 摘要条目
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### [CL-20260412-1430] YYYY-MM-DD — 一句话标题
|
||||||
|
- **tags**: tag1, tag2, tag3
|
||||||
|
- **affected_files**: file1, file2
|
||||||
|
- **summary**: 做了什么 + 为什么(3-5 行)
|
||||||
|
```
|
||||||
|
|
||||||
|
### L3 一句话条目
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- [CL-20260412-1430] 一句话描述改动内容(不超过 80 字)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 滚动窗口维护
|
||||||
|
|
||||||
|
### L2 窗口(~20 条)
|
||||||
|
|
||||||
|
```
|
||||||
|
写入新条目后,检查总条目数:
|
||||||
|
- <= 20 条:不做处理
|
||||||
|
- > 20 条:移除文件底部(最旧的)条目,直到恰好 20 条
|
||||||
|
```
|
||||||
|
|
||||||
|
### L3 窗口(~50 条)
|
||||||
|
|
||||||
|
```
|
||||||
|
写入新条目后,检查总行数(排除文件头部的标题和说明):
|
||||||
|
- <= 50 条:不做处理
|
||||||
|
- > 50 条:移除文件底部(最旧的)条目,直到恰好 50 条
|
||||||
|
```
|
||||||
|
|
||||||
|
被移除的条目不需要额外归档——L1 完整版保留了所有历史。
|
||||||
|
|
||||||
|
## 数据文件模板
|
||||||
|
|
||||||
|
首次写入时,如对应文件不存在,按以下模板创建。
|
||||||
|
|
||||||
|
### 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-recall` Rule | 下游消费者 | 每次会话注入 L3,自动触发 L2 读取 |
|
||||||
|
| `deferred-decisions` Skill | 互补 | deferred 记"没做什么",changelog 记"做了什么" |
|
||||||
|
| `profile-memory` Skill | 结构对称 | profile 是"是什么",changelog 是"做了什么" |
|
||||||
|
| `epee-orchestrator` | 注册 | 在 registry.md 中注册本 Skill |
|
||||||
|
|
||||||
|
## 自迭代日志
|
||||||
|
|
||||||
|
本节记录使用本 Skill 过程中发现的必要检查项。
|
||||||
|
|
||||||
|
### 已知必要检查
|
||||||
|
|
||||||
|
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 的一行条目不受本条约束(它就是单行,天然无此风险)。
|
||||||
125
.cursor/skills/epee-orchestrator/SKILL.md
Normal file
125
.cursor/skills/epee-orchestrator/SKILL.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
name: epee-orchestrator
|
||||||
|
description: >-
|
||||||
|
EPEE Skill Orchestrator:元层调度系统。检测任务是否可由已有 Skill 处理并分流,
|
||||||
|
或发现 Skill 缺口并引导创建新 Skill。当 Agent 遇到手动配置密集、重复模式明确、
|
||||||
|
手工指引过长的任务时触发。也用于 Skill Registry 的维护和同步。
|
||||||
|
---
|
||||||
|
|
||||||
|
# EPEE Skill Orchestrator
|
||||||
|
|
||||||
|
元层调度系统,负责 Skill 分流、缺口发现和 Registry 维护。
|
||||||
|
|
||||||
|
## 操作 A:Skill 分流(匹配已有 Skill)
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 读取 registry.md 获取所有已注册 Skill 的摘要
|
||||||
|
2. 将当前任务特征与每个 Skill 的"触发场景"关键词匹配
|
||||||
|
3. 如果匹配到:
|
||||||
|
a. 告知用户"此任务可以通过 [Skill名] 高效完成"
|
||||||
|
b. 简述该 Skill 的能力及与当前任务的契合点
|
||||||
|
c. 用户确认后,读取并激活对应 Skill 的 SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 匹配规则
|
||||||
|
|
||||||
|
- 优先匹配触发场景关键词与当前任务描述的交集
|
||||||
|
- 如有多个 Skill 匹配,按相关度排序推荐,由用户选择
|
||||||
|
- `deferred-decisions` 等标记为"基础设施"类型的 Skill 不参与任务分流匹配,
|
||||||
|
仅作为 Orchestrator 的下游工具使用
|
||||||
|
|
||||||
|
## 操作 B:Skill 缺口发现 + 创建引导
|
||||||
|
|
||||||
|
### 触发条件
|
||||||
|
|
||||||
|
操作 A 未找到匹配的 Skill,且当前任务满足"Skill 创建价值判断标准"中至少 2 条。
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 向用户提出建议:
|
||||||
|
"这类任务可以通过创建一个 [建议 Skill 名] 来自动化"
|
||||||
|
2. 给出 1-3 句方案概要:
|
||||||
|
- 这个 Skill 会做什么
|
||||||
|
- 核心工作机制(如 DSL 生成、YAML 直写、MCP 调用等)
|
||||||
|
- 预估能节省的重复劳动
|
||||||
|
3. 询问用户选择:
|
||||||
|
a) "继续讨论并创建" → 进入创建流程
|
||||||
|
b) "以后再说" → 进入延期记录流程
|
||||||
|
c) "不需要" → 结束,正常执行当前任务
|
||||||
|
```
|
||||||
|
|
||||||
|
### a) 创建流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 委托给 create-skill Skill(路径: ~/.cursor/skills-cursor/create-skill/SKILL.md)
|
||||||
|
2. 将以下上下文传递给 create-skill 流程:
|
||||||
|
- 触发创建的原始任务描述
|
||||||
|
- Orchestrator 的方案概要
|
||||||
|
- 建议的 Skill 名称
|
||||||
|
3. 按 create-skill 的标准流程完成 Discovery → Design → Implementation → Verification
|
||||||
|
4. 创建完成后,要求用户对新 Skill 进行实际测试
|
||||||
|
5. 测试通过后,执行操作 C 同步 Registry
|
||||||
|
```
|
||||||
|
|
||||||
|
### b) 延期记录流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 读取 deferred-decisions Skill
|
||||||
|
2. 按其"操作 A:记录延期方案"流程,将 Skill 创建建议记录为 deferred item
|
||||||
|
3. tags 中包含 "skill-creation" 和相关领域标签
|
||||||
|
4. context 中记录方案概要,便于未来回忆
|
||||||
|
```
|
||||||
|
|
||||||
|
## 操作 C:Registry 同步
|
||||||
|
|
||||||
|
### 触发条件
|
||||||
|
|
||||||
|
- 新 Skill 被创建后
|
||||||
|
- 已有 Skill 的 SKILL.md 被实质性修改后(如能力范围变化、触发场景变化)
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 读取目标 Skill 的 SKILL.md
|
||||||
|
2. 从 frontmatter 提取 name 和 description
|
||||||
|
3. 从正文提取核心能力和触发场景关键词
|
||||||
|
4. 读取 registry.md
|
||||||
|
5. 新增或更新对应条目,遵循 registry.md 中定义的条目格式
|
||||||
|
6. 写回 registry.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 条目格式
|
||||||
|
|
||||||
|
参见 [registry.md](registry.md) 中的条目结构。
|
||||||
|
|
||||||
|
## Skill 创建价值判断标准
|
||||||
|
|
||||||
|
当操作 A 无匹配时,Agent 使用以下标准评估是否建议创建新 Skill。
|
||||||
|
满足 **2 条及以上** 即认为值得建议:
|
||||||
|
|
||||||
|
1. **频次**:该类任务预计会出现 3 次以上
|
||||||
|
2. **模式明确**:任务有明确的输入/输出模式(输入 X → 产出 Y)
|
||||||
|
3. **步骤繁多**:手动操作步骤 >= 5 步,或涉及 >= 3 个文件的协调修改
|
||||||
|
4. **易错**:存在容易出错的重复性操作(如 GUID 填写、格式对齐等)
|
||||||
|
5. **可自动化**:可以通过生成代码、配置文件、脚本或 MCP 调用来替代手动操作
|
||||||
|
|
||||||
|
不满足标准时,Agent 正常执行任务,不提出创建建议。
|
||||||
|
|
||||||
|
## 与其他系统的协作
|
||||||
|
|
||||||
|
| 系统 | 关系 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `create-skill` Skill | 下游委托 | 操作 B 创建流程的执行者 |
|
||||||
|
| `deferred-decisions` Skill | 下游工具 | 操作 B 延期记录的执行者 |
|
||||||
|
| `deferred-recall` Rule | 协同 | Orchestrator 延期的建议通过 deferred-recall 在未来自动提醒 |
|
||||||
|
|
||||||
|
## 自迭代日志
|
||||||
|
|
||||||
|
本节记录使用本 Skill 过程中发现的必要检查项。
|
||||||
|
|
||||||
|
### 已知必要检查
|
||||||
|
|
||||||
|
1. **收尾动作级联遗漏** — 当 Agent 遗漏了一个收尾动作(如 changelog 写入)后,后续的收尾动作(自迭代、Registry 同步)也会被一并遗漏,因为它们都在同一个"收尾阶段"。`changelog-recall.mdc` 中的 Checklist 化设计可以打断这种级联——每项独立检查,不依赖前一项的执行记忆。
|
||||||
74
.cursor/skills/epee-orchestrator/registry.md
Normal file
74
.cursor/skills/epee-orchestrator/registry.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# EPEE Skill Registry
|
||||||
|
|
||||||
|
> 本文件由 EPEE Skill Orchestrator 维护,记录所有已实现 Skill 的摘要信息。
|
||||||
|
> 每次创建/修改 Skill 后必须同步更新(参见 `epee-orchestrator.mdc` Rule)。
|
||||||
|
|
||||||
|
## 条目格式说明
|
||||||
|
|
||||||
|
每个条目包含以下字段:
|
||||||
|
- **类型**:项目级 / 个人级 / 基础设施
|
||||||
|
- **能力**:1 句话核心能力描述
|
||||||
|
- **触发场景**:逗号分隔的关键词/短语,用于与任务特征匹配
|
||||||
|
- **输出**:该 Skill 的产出物
|
||||||
|
- **路径**:SKILL.md 的路径(统一使用 `.cursor/skills/...`)
|
||||||
|
- **备注**(可选):特殊说明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已注册 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
|
||||||
|
- **类型**: 基础设施
|
||||||
|
- **能力**: 记录和追踪延期的技术方案/决策,在关联任务出现时主动提醒
|
||||||
|
- **触发场景**: "以后再做"、"先不做"、"defer"、延期方案管理、延期回顾
|
||||||
|
- **输出**: .cursor/deferred/registry.md 条目
|
||||||
|
- **路径**: .cursor/skills/deferred-decisions/SKILL.md
|
||||||
|
- **备注**: 不参与任务分流匹配,仅作为 Orchestrator 的下游工具
|
||||||
|
|
||||||
|
### profile-memory
|
||||||
|
- **类型**: 个人级
|
||||||
|
- **能力**: 在对话中被动检测用户个人特质和项目信息,经确认后持久化为精简画像和详细日志
|
||||||
|
- **触发场景**: "画像"、"profile"、"我的偏好"、"查看画像"、"项目信息"、"查看项目画像"、用户主动管理画像;被动检测由 `changelog-recall` checklist 前置项 A 的短路扫描触发(命中偏好/项目决策触发词族时)
|
||||||
|
- **输出**: ~/.cursor/profile/user-profile.md、.cursor/profile/project-profile.md 及对应 log 文件
|
||||||
|
- **路径**: .cursor/skills/profile-memory/SKILL.md
|
||||||
|
- **备注**: 精简 Profile 每次会话由 `profile-recall` Rule 自动注入上下文;被动检测路径由 `changelog-recall` 的强制 checklist 前置项 A 兜底(2026-04-20 升级:原 `profile-recall` 的软性"被动检测提醒"失效率高,改为词表短路扫描挂入 checklist)
|
||||||
|
|
||||||
|
### dev-changelog
|
||||||
|
- **类型**: 基础设施
|
||||||
|
- **能力**: 三层开发进程记录系统,Agent 完成代码改动后自动记录,提供一句话概要到完整日志的多级上下文
|
||||||
|
- **触发场景**: "开发日志"、"changelog"、"最近改了什么"、"回顾改动"、"查看开发记录"、代码改动后自动写入
|
||||||
|
- **输出**: .cursor/changelog/ 下的 changelog-full.md、changelog-recent.md、changelog-headlines.md
|
||||||
|
- **路径**: .cursor/skills/dev-changelog/SKILL.md
|
||||||
|
- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklist);L3 概要每次会话自动注入上下文;stop Hook 双保险兜底;写入后须维护 L3≤50 / L2≤20 滚动窗口,L1/L2 多行条目须遵循"顶部插入 StrReplace 范式"防止旧首条目被截断(见 Skill 自迭代检查项)
|
||||||
|
|
||||||
|
### pitfall-journal
|
||||||
|
- **类型**: 基础设施
|
||||||
|
- **能力**: 记录开发中踩过的坑(非显而易见的问题),在遇到同类问题时自动检索匹配已有经验
|
||||||
|
- **触发场景**: debug 完成后、"踩坑"、"之前遇到过"、"坑"、进入 Debug mode、同类错误反复出现
|
||||||
|
- **输出**: .cursor/pitfalls/pitfalls.md 条目
|
||||||
|
- **路径**: .cursor/skills/pitfall-journal/SKILL.md
|
||||||
|
- **备注**: 与 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
|
||||||
|
- **类型**: 项目级
|
||||||
|
- **能力**: 一键启动 Art Agent 全部服务(Ollama + 后端 FastAPI + 前端 Next.js),自动检测已运行的服务并跳过
|
||||||
|
- **触发场景**: "启动项目"、"运行项目"、"跑起来"、"打开前端和后端"、"启动服务"、"start"、"launch"、"run dev"、"启动前端"、"启动后端"
|
||||||
|
- **输出**: Ollama 后台服务 + 两个独立的终端窗口(前端 Next.js + 后端 FastAPI)
|
||||||
|
- **路径**: .cursor/skills/project-launcher/SKILL.md
|
||||||
|
- **备注**: 项目专属 Skill;启动前必须先读取 `.cursor/local-env.json` 获取 nodejs_path 和 shell;Ollama 必须先于后端启动(Mem0 embedding 依赖);前后端窗口必须可见
|
||||||
122
.cursor/skills/pitfall-journal/SKILL.md
Normal file
122
.cursor/skills/pitfall-journal/SKILL.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
name: pitfall-journal
|
||||||
|
description: >-
|
||||||
|
踩坑经验记录系统。在调试完成或发现非显而易见的坑后记录根因和解决方式,
|
||||||
|
后续遇到同类问题时自动检索匹配,避免重复踩坑。
|
||||||
|
当 debug 完成、问题反复出现、或用户提到"踩坑"、"之前遇到过"、"坑"时触发。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pitfall Journal
|
||||||
|
|
||||||
|
记录开发过程中遇到的"坑"——那些不看代码逻辑觉得应该没问题、但实际运行时才暴露的问题。
|
||||||
|
与 dev-changelog 互补:changelog 记"做了什么",pitfall-journal 记"踩了什么坑、怎么爬出来的"。
|
||||||
|
|
||||||
|
## 数据文件
|
||||||
|
|
||||||
|
所有记录存放在 `.cursor/pitfalls/pitfalls.md`。
|
||||||
|
|
||||||
|
## 条目格式
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### [PF-YYYYMMDD-HHMM] 一句话标题
|
||||||
|
- **症状**: 用户/系统看到的错误表现
|
||||||
|
- **根因**: 技术层面的真正原因
|
||||||
|
- **解法**: 具体怎么修的
|
||||||
|
- **防御**: 以后如何避免(可选,如果有通用性的话)
|
||||||
|
- **关联**: 相关文件、模块、技术栈标签
|
||||||
|
```
|
||||||
|
|
||||||
|
## 操作 A:写入记录
|
||||||
|
|
||||||
|
### 触发条件
|
||||||
|
|
||||||
|
以下任一场景触发:
|
||||||
|
|
||||||
|
1. **调试完成后** — 经历了 debug 过程并找到了非显而易见的根因
|
||||||
|
2. **用户主动提及** — "记录一下这个坑"、"以后别再犯"
|
||||||
|
3. **Agent 识别到经验价值** — 问题涉及框架/库的隐式行为、配置陷阱、环境差异等
|
||||||
|
|
||||||
|
以下情况**不触发**:
|
||||||
|
- 纯拼写错误、简单语法错误
|
||||||
|
- 问题原因一目了然(如变量名打错)
|
||||||
|
- 纯业务逻辑调整(不涉及"坑"的语义)
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 生成条目 ID:PF-YYYYMMDD-HHMM
|
||||||
|
2. 从调试过程中提取:症状、根因、解法
|
||||||
|
3. 归纳防御措施(如果有通用性)
|
||||||
|
4. 读取 pitfalls.md,在顶部追加新条目
|
||||||
|
5. 在回复末尾附:[已记录到踩坑日志]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 静默写入原则
|
||||||
|
|
||||||
|
与 dev-changelog 一致——Agent 自己调试出来的问题,不需要用户确认就可以记录。
|
||||||
|
|
||||||
|
## 操作 B:自动匹配检索
|
||||||
|
|
||||||
|
### 触发条件
|
||||||
|
|
||||||
|
当 Agent 在当前任务中遇到以下情况时,应主动检索 pitfalls.md:
|
||||||
|
|
||||||
|
1. **进入 Debug mode** — 读取 pitfalls.md,扫描是否有与当前错误症状匹配的记录
|
||||||
|
2. **同类错误再现** — 错误信息关键词与已有条目的"症状"匹配
|
||||||
|
3. **涉及已知高危区域** — 当前操作涉及的模块/技术栈在已有条目的"关联"中出现
|
||||||
|
|
||||||
|
### 匹配策略
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 提取当前问题的关键信号:
|
||||||
|
- 错误信息关键词
|
||||||
|
- 涉及的文件/模块
|
||||||
|
- 涉及的技术栈/框架
|
||||||
|
|
||||||
|
2. 在 pitfalls.md 中匹配:
|
||||||
|
- 硬匹配:错误信息关键词出现在条目的"症状"中
|
||||||
|
- 软匹配:涉及的模块/技术栈出现在条目的"关联"中
|
||||||
|
|
||||||
|
3. 命中时,在分析中优先考虑已有经验:
|
||||||
|
> 注意:之前遇到过类似问题 [PF-xxx]:[一句话描述]。
|
||||||
|
> 上次的根因是 [xxx],先排查这个方向。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 操作 C:手动检索
|
||||||
|
|
||||||
|
### 触发条件
|
||||||
|
|
||||||
|
用户主动要求回顾踩坑记录。典型话语:
|
||||||
|
- "之前那个坑是什么来着"
|
||||||
|
- "看看踩坑日志"
|
||||||
|
- "有遇到过类似的问题吗"
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 读取 pitfalls.md
|
||||||
|
2. 根据用户描述匹配相关条目
|
||||||
|
3. 展示匹配结果
|
||||||
|
```
|
||||||
|
|
||||||
|
## 与其他系统的协作
|
||||||
|
|
||||||
|
| 系统 | 关系 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `dev-changelog` | 互补 | changelog 记改动事实,pitfall 记经验教训 |
|
||||||
|
| `pitfall-recall` Rule | 下游消费者 | 进入 Debug mode 或遇到错误时自动触发检索 |
|
||||||
|
| `epee-orchestrator` | 注册 | 在 registry.md 中注册本 Skill |
|
||||||
|
|
||||||
|
## 自迭代日志
|
||||||
|
|
||||||
|
本节记录使用本 Skill 过程中发现的必要检查项。
|
||||||
|
|
||||||
|
### 已知必要检查
|
||||||
|
|
||||||
|
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 过程中发现的必要检查项。
|
||||||
|
|
||||||
|
### 已知必要检查
|
||||||
|
|
||||||
|
(暂无)
|
||||||
271
.cursor/skills/profile-memory/SKILL.md
Normal file
271
.cursor/skills/profile-memory/SKILL.md
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
---
|
||||||
|
name: profile-memory
|
||||||
|
description: >-
|
||||||
|
渐进式用户/项目画像系统。两条触发路径:
|
||||||
|
(1)被动检测(主路径)——由 `changelog-recall` checklist 前置项 A 触发短路扫描,
|
||||||
|
命中偏好/项目决策触发词时执行操作 A(总结 + 用户确认 + 写入)。
|
||||||
|
(2)显式管理——当用户提到"画像"、"profile"、"查看画像"、"我的偏好"、"项目信息"等时
|
||||||
|
执行操作 B(浏览、修改、删除已有条目)。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Profile Memory
|
||||||
|
|
||||||
|
渐进式画像系统,被动收集并持久化用户个人特质与项目信息。
|
||||||
|
|
||||||
|
## 存储结构
|
||||||
|
|
||||||
|
| 文件 | 位置 | 用途 | 注入上下文 |
|
||||||
|
|------|------|------|-----------|
|
||||||
|
| user-profile.md | `~/.cursor/profile/` | 个人画像精简版 | 是(每次会话) |
|
||||||
|
| user-profile-log.md | `~/.cursor/profile/` | 个人画像详细日志 | 否 |
|
||||||
|
| project-profile.md | `.cursor/profile/` | 项目画像精简版 | 是(每次会话) |
|
||||||
|
| project-profile-log.md | `.cursor/profile/` | 项目画像详细日志 | 否 |
|
||||||
|
|
||||||
|
精简版 Profile 是 Agent 每次会话的上下文输入,必须极度精简。
|
||||||
|
详细 Log 保留完整上下文,供用户主动查阅和溯源。
|
||||||
|
|
||||||
|
## 锚点 ID 机制
|
||||||
|
|
||||||
|
每条画像记录在写入时生成一个**锚点 ID**,格式为 `PF-YYYYMMDD-NN`(如 `PF-20260412-01`),
|
||||||
|
其中 NN 为当天的序号。
|
||||||
|
|
||||||
|
锚点 ID 在精简版和 Log 中保持一致:
|
||||||
|
- 精简版条目格式:`- 条目内容 <!-- PF-20260412-01 -->`(HTML 注释,不影响可读性)
|
||||||
|
- Log 条目的 H3 标题:`### [PF-20260412-01] YYYY-MM-DD — 简短标题`
|
||||||
|
|
||||||
|
这使得从精简版到 Log 的查找可以通过 Grep 精准定位,无需全文读取 Log。
|
||||||
|
|
||||||
|
## 逐级上溯机制
|
||||||
|
|
||||||
|
当 Agent 在使用精简版 Profile 作为上下文时,如果某条记录**语义模糊**
|
||||||
|
(如无法判断偏好的具体适用场景、与当前任务的关系不明确),执行以下查找:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 从精简版条目中提取锚点 ID(HTML 注释中的 PF-xxx)
|
||||||
|
2. 在对应的 Log 文件中 Grep 该 ID
|
||||||
|
- 找到 → 用 Read 工具读取该 ID 所在行号 ±15 行范围(精准读取,不读全文)
|
||||||
|
- Log 中包含原始上下文、来源对话等完整信息,通常足以消除歧义
|
||||||
|
```
|
||||||
|
|
||||||
|
### 上溯原则
|
||||||
|
|
||||||
|
- **按需触发**:只有在精简版信息不足以支撑当前判断时才上溯
|
||||||
|
- **精准读取**:通过 Grep 定位行号 + Read 局部读取,禁止全文读取 Log
|
||||||
|
- **最小化**:一次上溯通常只涉及 1-2 条记录
|
||||||
|
|
||||||
|
## 硬上限
|
||||||
|
|
||||||
|
- user-profile.md:不超过 **50 行**
|
||||||
|
- project-profile.md:不超过 **30 行**
|
||||||
|
- 每条信息一行,`- ` 开头,措辞客观中立
|
||||||
|
|
||||||
|
## 操作 A:被动检测 + 对话结束前确认
|
||||||
|
|
||||||
|
### 检测范围
|
||||||
|
|
||||||
|
在正常对话中被动检测以下信号(**不主动询问**):
|
||||||
|
|
||||||
|
**个人特质**:
|
||||||
|
- 审美/设计偏好("太花哨了"、"我喜欢 minimal")
|
||||||
|
- 技术偏好("以后都用 X"、"我不喜欢 class 写法")
|
||||||
|
- 做事风格("先讨论再动手"、"不要自作主张")
|
||||||
|
- 沟通偏好("给我简短的回答"、"多解释一下原理")
|
||||||
|
- 产品理解/思维方式
|
||||||
|
|
||||||
|
**项目信息**:
|
||||||
|
- 项目定位和目标("这个项目是做 X 的")
|
||||||
|
- 技术栈与架构决策
|
||||||
|
- 设计约定和规范
|
||||||
|
- 产品方向和目标用户
|
||||||
|
|
||||||
|
### 检测原则
|
||||||
|
|
||||||
|
- **保守而非激进**:宁可漏记也不误记
|
||||||
|
- 只记录**持久性**的偏好/特质,忽略一次性的临时需求
|
||||||
|
- 区分"个人"和"项目"两个维度
|
||||||
|
|
||||||
|
### 变更分类
|
||||||
|
|
||||||
|
检测到的新信息与已有条目的关系分为三类,处理方式不同:
|
||||||
|
|
||||||
|
| 类型 | 定义 | 示例 | 提示强度 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| **新增** | 全新维度,无已有条目 | 首次提到审美偏好 | 常规 |
|
||||||
|
| **演进** | 已有条目的深化、细化或自然发展 | "偏好 React" → "偏好 React + Next.js 全栈" | 常规,展示前后对比 |
|
||||||
|
| **转向** | 与已有条目方向性矛盾或根本性变化 | "偏好 React" → "想转 Vue";项目方向从 B2C 转 B2B | **加强提醒**,展示前后对比 |
|
||||||
|
|
||||||
|
### 确认流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 在对话过程中将检测到的信息在内部缓存,分类为"个人"或"项目"
|
||||||
|
2. 对每条缓存信息,与已有 Profile 比对,标记变更类型(新增/演进/转向)
|
||||||
|
3. 当用户的主线任务完成后,统一提出,格式如下:
|
||||||
|
|
||||||
|
"本次对话中我注意到以下可记录的画像信息:"
|
||||||
|
|
||||||
|
**个人画像:**
|
||||||
|
- [新增][分类] 条目内容
|
||||||
|
- [演进][分类] 旧:xxx → 新:yyy
|
||||||
|
- [转向][分类] 旧:xxx → 新:yyy ⚠️
|
||||||
|
|
||||||
|
**项目画像:**
|
||||||
|
- (同上格式)
|
||||||
|
|
||||||
|
转向类条目额外标注 ⚠️ 并附一句说明:
|
||||||
|
"⚠️ 以下条目与已有记录存在方向性变化,请特别关注:"
|
||||||
|
|
||||||
|
"是否记录?你可以全部确认、逐条修改或跳过。"
|
||||||
|
|
||||||
|
4. 用户确认后执行写入流程
|
||||||
|
5. 如本次对话未检测到任何画像信息,则不触发此流程
|
||||||
|
```
|
||||||
|
|
||||||
|
### 写入流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 读取对应的精简 Profile 和详细 Log(不存在则用模板创建)
|
||||||
|
2. 为每条新记录生成锚点 ID:PF-YYYYMMDD-NN(检查 Log 中已有 ID 避免冲突)
|
||||||
|
3. 按变更类型执行:
|
||||||
|
- 新增:在对应分类下追加条目,带锚点 ID 注释
|
||||||
|
- 演进:替换对应旧条目为新措辞,沿用旧条目的锚点 ID(或生成新 ID,视变化程度而定)
|
||||||
|
- 转向:替换对应旧条目为新措辞,生成新锚点 ID(用户已在确认流程中审核)
|
||||||
|
4. 写入精简 Profile(条目格式:`- 内容 <!-- PF-xxx -->`)
|
||||||
|
5. 追加详细 Log(标题格式:`### [PF-xxx] YYYY-MM-DD — 简短标题`),其中:
|
||||||
|
- 新增条目:操作记为"新增"
|
||||||
|
- 演进条目:操作记为"更新(旧值 → 新值)"
|
||||||
|
- 转向条目:操作记为"转向(旧值 → 新值)",便于溯源重大变化
|
||||||
|
6. 检查精简 Profile 是否超过硬上限,超过则提醒用户精简
|
||||||
|
```
|
||||||
|
|
||||||
|
## 操作 B:用户主动管理
|
||||||
|
|
||||||
|
当用户说"查看我的画像"、"profile"、"查看项目画像"、"我的偏好"等:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 读取对应的精简 Profile,展示给用户
|
||||||
|
2. 如用户要看详细版或溯源,读取对应 Log 展示
|
||||||
|
3. 用户可要求:
|
||||||
|
- 删除某条记录(同步删除精简版条目,Log 中标记为已删除)
|
||||||
|
- 修改某条记录的措辞
|
||||||
|
- 合并/重组分类
|
||||||
|
- 新增分类
|
||||||
|
```
|
||||||
|
|
||||||
|
## 操作 C:变更比对与冲突处理
|
||||||
|
|
||||||
|
### 比对逻辑
|
||||||
|
|
||||||
|
```
|
||||||
|
对每条新检测到的信息,在同分类下的已有条目中查找语义相近项:
|
||||||
|
1. 无相近条目 → 标记为"新增"
|
||||||
|
2. 有相近条目且方向一致(深化/细化/补充) → 标记为"演进"
|
||||||
|
3. 有相近条目且方向矛盾(替代/转向/否定) → 标记为"转向"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前后对比格式
|
||||||
|
|
||||||
|
演进和转向类条目在确认流程中必须展示前后对比:
|
||||||
|
|
||||||
|
```
|
||||||
|
- [演进][技术偏好] 旧:偏好 React → 新:偏好 React + Next.js 全栈开发
|
||||||
|
- [转向][产品方向] 旧:面向 C 端个人用户 → 新:转向 B 端企业客户 ⚠️
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用户选择
|
||||||
|
|
||||||
|
对每条演进/转向条目,用户可以:
|
||||||
|
- **确认更新**:用新条目替换旧条目
|
||||||
|
- **保留两者**:旧条目不动,新条目作为补充追加(适用于不同细分维度)
|
||||||
|
- **放弃**:不记录本条
|
||||||
|
|
||||||
|
## 数据文件模板
|
||||||
|
|
||||||
|
首次使用时,如对应文件不存在,按以下模板创建。
|
||||||
|
|
||||||
|
### user-profile.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# User Profile
|
||||||
|
|
||||||
|
## 审美与设计
|
||||||
|
|
||||||
|
## 技术偏好
|
||||||
|
|
||||||
|
## 做事风格
|
||||||
|
|
||||||
|
## 沟通偏好
|
||||||
|
|
||||||
|
## 产品理解
|
||||||
|
```
|
||||||
|
|
||||||
|
### user-profile-log.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# User Profile Log
|
||||||
|
|
||||||
|
详细记录每次画像更新的完整上下文,按时间正序追加。
|
||||||
|
|
||||||
|
## 记录
|
||||||
|
```
|
||||||
|
|
||||||
|
### project-profile.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Project Profile
|
||||||
|
|
||||||
|
## 项目定位
|
||||||
|
|
||||||
|
## 技术栈与架构
|
||||||
|
|
||||||
|
## 设计约定
|
||||||
|
|
||||||
|
## 产品方向
|
||||||
|
```
|
||||||
|
|
||||||
|
### project-profile-log.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Project Profile Log
|
||||||
|
|
||||||
|
详细记录每次项目画像更新的完整上下文,按时间正序追加。
|
||||||
|
|
||||||
|
## 记录
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log 条目格式
|
||||||
|
|
||||||
|
每次写入 Log 时,追加以下格式的条目:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### [PF-20260412-01] YYYY-MM-DD — 简短标题
|
||||||
|
- **分类**: 对应的精简 Profile 分类名
|
||||||
|
- **精简版**: 写入精简 Profile 的那一行内容
|
||||||
|
- **原始上下文**: 用户原话或对话中的关键语句
|
||||||
|
- **来源对话**: [对话简述](chat-uuid)
|
||||||
|
- **操作**: 新增 / 演进(旧值 → 新值)/ 转向(旧值 → 新值)/ 删除
|
||||||
|
```
|
||||||
|
|
||||||
|
## 分类扩展
|
||||||
|
|
||||||
|
预设的分类列表可以扩展。当检测到的信息不属于任何现有分类时:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 在确认流程中标注"建议新增分类: [分类名]"
|
||||||
|
2. 用户确认后在精简 Profile 中新增该分类的 H2 标题
|
||||||
|
3. 注意硬上限,新增分类会占用行数
|
||||||
|
```
|
||||||
|
|
||||||
|
## 与其他系统的协作
|
||||||
|
|
||||||
|
| 系统 | 关系 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `profile-recall` Rule | 下游消费者 | 每次会话读取精简 Profile 并注入上下文 |
|
||||||
|
| `epee-orchestrator` | 注册 | 在 registry.md 中注册本 Skill |
|
||||||
|
|
||||||
|
## 自迭代日志
|
||||||
|
|
||||||
|
本节记录使用本 Skill 过程中发现的必要检查项。
|
||||||
|
|
||||||
|
### 已知必要检查
|
||||||
|
|
||||||
|
(暂无)
|
||||||
174
.cursor/skills/project-launcher/SKILL.md
Normal file
174
.cursor/skills/project-launcher/SKILL.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
---
|
||||||
|
name: project-launcher
|
||||||
|
description: >-
|
||||||
|
Art Agent 项目一键启动。当用户说"启动项目"、"运行项目"、"打开前端和后端"等时触发,
|
||||||
|
自动在独立的可见终端窗口中启动前端(Next.js)和后端(FastAPI),方便用户随时查看和关闭。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project Launcher
|
||||||
|
|
||||||
|
一键启动 Art Agent 的前端和后端服务,在**独立可见的终端窗口**中运行,
|
||||||
|
用户可以随时查看日志或手动关闭。
|
||||||
|
|
||||||
|
## 触发条件
|
||||||
|
|
||||||
|
当用户表达以下意图时触发:
|
||||||
|
- "启动项目"、"运行项目"、"跑起来"
|
||||||
|
- "打开前端和后端"、"启动服务"
|
||||||
|
- "start"、"launch"、"run dev"
|
||||||
|
- "启动后端"、"启动前端"(可单独启动其中一个)
|
||||||
|
|
||||||
|
## 项目路径
|
||||||
|
|
||||||
|
| 组件 | 路径 | 启动命令 |
|
||||||
|
|------|------|---------|
|
||||||
|
| Ollama | 系统级服务 | `ollama serve` |
|
||||||
|
| 后端 | `art-agent/backend` | `uvicorn app.main:app --reload --host 0.0.0.0 --port 8000` |
|
||||||
|
| 前端 | `art-agent/frontend` | `npm run dev` |
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
- 后端需要激活 Python 虚拟环境(`art-agent/backend/venv`)
|
||||||
|
- 前端需要 Node.js >= 18
|
||||||
|
- **Ollama 必须在后端之前启动**:Mem0 记忆系统依赖 Ollama 提供本地 embedding 服务(`nomic-embed-text` 模型),端口 `11434`
|
||||||
|
|
||||||
|
## 本地环境配置(local-env.json)
|
||||||
|
|
||||||
|
设备绑定的路径和配置统一存放在 `.cursor/local-env.json`(已 gitignored),不硬编码到 Skill 中。
|
||||||
|
|
||||||
|
**启动前必须先读取该文件**,从中获取 `nodejs_path` 和 `shell` 字段。
|
||||||
|
|
||||||
|
| 字段 | 用途 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `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。
|
||||||
|
|
||||||
|
## 核心操作:启动服务
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 确定项目根目录(workspace 根目录下的 art-agent/)
|
||||||
|
|
||||||
|
2. 读取 .cursor/local-env.json:
|
||||||
|
- 存在 → 解析 nodejs_path 和 shell
|
||||||
|
- 不存在 → 执行自动探测流程(见上方),生成后再读取
|
||||||
|
|
||||||
|
3. 检查并启动 Ollama:
|
||||||
|
- 检测 Ollama 是否已在运行(请求 http://localhost:11434/api/tags)
|
||||||
|
- 未运行 → 启动 Ollama 服务,等待就绪
|
||||||
|
- 已运行 → 跳过
|
||||||
|
|
||||||
|
4. 根据 shell 字段选择对应的启动命令模板:
|
||||||
|
- powershell → 使用 PowerShell 命令
|
||||||
|
- bash/zsh → 使用 macOS/Linux 命令
|
||||||
|
|
||||||
|
5. 启动后端(在独立可见终端窗口中)
|
||||||
|
|
||||||
|
6. 启动前端(在另一个独立可见终端窗口中):
|
||||||
|
- 如果 nodejs_path 不为 null,先注入到 PATH
|
||||||
|
|
||||||
|
7. 确认三个服务正在运行,告知用户访问地址
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ollama 启动(跨平台通用)
|
||||||
|
|
||||||
|
先检测 Ollama 是否已在运行,未运行则启动:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 检测(PowerShell)
|
||||||
|
try {
|
||||||
|
Invoke-WebRequest -Uri "http://localhost:11434/api/tags" -UseBasicParsing -TimeoutSec 3 | Out-Null
|
||||||
|
# 已运行,跳过
|
||||||
|
} catch {
|
||||||
|
# 未运行,启动
|
||||||
|
Start-Process "ollama" -ArgumentList "serve" -WindowStyle Normal
|
||||||
|
# 等待就绪(最多 10 秒)
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Ollama 启动后会常驻后台,不需要独立终端窗口。如果用户系统已将 Ollama 设为开机自启,
|
||||||
|
> 则检测会直接通过,不会重复启动。
|
||||||
|
|
||||||
|
### 启动命令模板
|
||||||
|
|
||||||
|
**关键要求**:必须在**新的、可见的终端窗口**中启动,不能在 Cursor 内置终端后台运行。
|
||||||
|
|
||||||
|
以下模板中的变量说明:
|
||||||
|
- `BACKEND_PATH` / `FRONTEND_PATH`:替换为实际绝对路径
|
||||||
|
- `NODEJS_PATH`:从 `local-env.json` 的 `nodejs_path` 字段读取
|
||||||
|
|
||||||
|
#### shell = "powershell"
|
||||||
|
|
||||||
|
启动后端:
|
||||||
|
```powershell
|
||||||
|
Start-Process powershell -ArgumentList '-NoExit', '-Command', "cd 'BACKEND_PATH'; .\venv\Scripts\Activate.ps1; uvicorn app.main:app --reload --host 0.0.0.0 --port 8000" -WindowStyle Normal
|
||||||
|
```
|
||||||
|
|
||||||
|
启动前端(注入从 local-env.json 读取的 NODEJS_PATH):
|
||||||
|
```powershell
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
#### shell = "bash" / "zsh"(macOS / Linux)
|
||||||
|
|
||||||
|
启动后端:
|
||||||
|
```bash
|
||||||
|
osascript -e 'tell application "Terminal" to do script "cd BACKEND_PATH && source venv/bin/activate && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000"'
|
||||||
|
```
|
||||||
|
|
||||||
|
启动前端(NODEJS_PATH 为 null 时跳过 PATH 注入):
|
||||||
|
```bash
|
||||||
|
osascript -e 'tell application "Terminal" to do script "export PATH=NODEJS_PATH:$PATH && cd FRONTEND_PATH && npm run dev"'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 执行注意事项
|
||||||
|
|
||||||
|
1. **路径拼接**:`BACKEND_PATH` 和 `FRONTEND_PATH` 必须替换为实际的绝对路径
|
||||||
|
2. **venv 存在性检查**:启动后端前先确认 `art-agent/backend/venv` 目录存在,
|
||||||
|
不存在时提醒用户先创建虚拟环境
|
||||||
|
3. **依赖检查**:如果 `node_modules` 不存在,先提醒用户执行 `npm install`
|
||||||
|
4. **窗口标题**:尽量为窗口设置有意义的标题(如 "Art Agent Backend"、"Art Agent Frontend"),
|
||||||
|
方便用户在任务栏中识别
|
||||||
|
5. **不使用 `block_until_ms: 0`**:不要用 Cursor 的后台命令方式,
|
||||||
|
那样窗口不可见,用户无法直接查看和关闭
|
||||||
|
|
||||||
|
## 单独启动
|
||||||
|
|
||||||
|
如果用户只说"启动前端"或"启动后端",只启动对应的服务即可,不需要全部启动。
|
||||||
|
|
||||||
|
## 访问信息
|
||||||
|
|
||||||
|
启动完成后告知用户:
|
||||||
|
- Ollama:http://localhost:11434(Mem0 embedding 服务)
|
||||||
|
- 后端 API:http://localhost:8000
|
||||||
|
- 后端文档:http://localhost:8000/docs
|
||||||
|
- 前端页面:http://localhost:3000
|
||||||
|
|
||||||
|
## 自迭代日志
|
||||||
|
|
||||||
|
本节记录使用本 Skill 过程中发现的必要检查项。
|
||||||
|
|
||||||
|
### 已知必要检查
|
||||||
|
|
||||||
|
1. **先读 local-env.json** — 启动前必须读取 `.cursor/local-env.json` 获取 `nodejs_path` 和 `shell`。文件不存在时执行自动探测并生成。绝不在 Skill 中硬编码设备相关的路径。
|
||||||
|
2. **Node.js PATH 注入** — 如果 `nodejs_path` 不为 null,说明 Node.js 未在系统 PATH 中,启动前端时必须将该路径注入到会话 PATH。
|
||||||
|
3. **Ollama 必须先于后端启动** — Mem0 记忆系统依赖 Ollama 的 `nomic-embed-text` 模型做本地 embedding(端口 11434)。Ollama 未运行时后端能启动但对话会报 502 错误。启动流程必须在后端之前检测并启动 Ollama。
|
||||||
60
.gitignore
vendored
Normal file
60
.gitignore
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# EPEEAIKit .gitignore
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# ─── Python ──────────────────────────────────────────────
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# 虚拟环境
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# 分发 / 构建
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# ─── Node.js / Next.js ──────────────────────────────────
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# ─── 环境变量 / 密钥(保留 .env.example 作为模板)───────
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# ─── 运行时生成物 ────────────────────────────────────────
|
||||||
|
art-agent/backend/uploads/
|
||||||
|
art-agent/backend/generated/
|
||||||
|
art-agent/backend/data/
|
||||||
|
# 本地下载的 cloudflared CLI(体积大,见 README / start-tunnel.ps1)
|
||||||
|
art-agent/cloudflared.exe
|
||||||
|
|
||||||
|
# ─── 工具二进制 ──────────────────────────────────────────
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# ─── 本地环境配置(设备绑定,不入版本控制)──────────────
|
||||||
|
.cursor/local-env.json
|
||||||
|
|
||||||
|
# ─── IDE / 编辑器 ────────────────────────────────────────
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# ─── OS 文件 ─────────────────────────────────────────────
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# ─── 日志 / 调试 ─────────────────────────────────────────
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"AndreaNovelHelper.workspaceDisabled": true
|
|
||||||
}
|
|
||||||
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 |
|
||||||
177
art-agent/README.md
Normal file
177
art-agent/README.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# EPEEKit
|
||||||
|
|
||||||
|
AI 美术资源生成工具集 — 通过对话生成游戏美术资源。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 准备 API Key
|
||||||
|
|
||||||
|
你需要两个 API Key:
|
||||||
|
|
||||||
|
- **OpenAI API Key**(或兼容 API):https://platform.openai.com/api-keys
|
||||||
|
- **Replicate API Token**:https://replicate.com/account/api-tokens
|
||||||
|
|
||||||
|
### 2. 启动后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd art-agent/backend
|
||||||
|
|
||||||
|
# 创建虚拟环境(推荐)
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\activate # Windows
|
||||||
|
# source venv/bin/activate # macOS/Linux
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 配置环境变量
|
||||||
|
copy .env.example .env
|
||||||
|
# 编辑 .env,填入你的 API Key 和模型配置
|
||||||
|
|
||||||
|
# 启动(--host 0.0.0.0 允许局域网/穿透访问)
|
||||||
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 启动前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd art-agent/frontend
|
||||||
|
|
||||||
|
# 安装依赖(需要 Node.js >= 18)
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 启动
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 使用
|
||||||
|
|
||||||
|
打开浏览器访问 http://localhost:3000
|
||||||
|
|
||||||
|
- 输入文字描述你想要的美术资源
|
||||||
|
- 可以上传参考图引导风格
|
||||||
|
- 生成的图片可以点击"保存"按钮下载
|
||||||
|
- 持续对话进行迭代修改
|
||||||
|
|
||||||
|
## 访问地址
|
||||||
|
|
||||||
|
| 场景 | 前端页面 | 后端 API | 后端文档 |
|
||||||
|
|------|---------|---------|---------|
|
||||||
|
| **本机** | http://localhost:3000 | http://localhost:8000 | http://localhost:8000/docs |
|
||||||
|
| **局域网**(同一 WiFi) | `http://<你的IP>:3000` | `http://<你的IP>:8000` | `http://<你的IP>:8000/docs` |
|
||||||
|
| **外网**(Cloudflare Tunnel) | `https://xxx.trycloudflare.com`(每次启动不同) | `https://yyy.trycloudflare.com` | — |
|
||||||
|
|
||||||
|
查看本机局域网 IP:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows
|
||||||
|
Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceAlias -eq 'WLAN' }
|
||||||
|
|
||||||
|
# macOS / Linux
|
||||||
|
ifconfig | grep "inet " | grep -v 127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
所有可配置项都集中在 `backend/.env` 文件中,分为以下几组:
|
||||||
|
|
||||||
|
| 配置组 | 变量 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| **LLM 对话** | `OPENAI_API_KEY` | API 密钥 |
|
||||||
|
| | `OPENAI_BASE_URL` | API 基地址,支持任何 OpenAI 兼容 API |
|
||||||
|
| | `LLM_MODEL` | 模型名称,如 `gpt-4o-mini`、`deepseek-chat` 等 |
|
||||||
|
| | `LLM_MAX_ITERATIONS` | Agent 单轮最大工具调用次数 |
|
||||||
|
| **图像生成** | `REPLICATE_API_TOKEN` | Replicate API Token |
|
||||||
|
| | `IMAGE_MODEL` | 图像生成模型,如 `black-forest-labs/flux-schnell` |
|
||||||
|
| | `IMAGE_ASPECT_RATIO` | 默认宽高比,如 `1:1` |
|
||||||
|
| | `IMAGE_OUTPUT_FORMAT` | 输出格式,如 `png` |
|
||||||
|
| **网络** | `HTTP_PROXY` / `HTTPS_PROXY` | 代理配置 |
|
||||||
|
| **服务** | `PORT` | 后端服务端口 |
|
||||||
|
|
||||||
|
切换 LLM 供应商只需修改 `OPENAI_BASE_URL` + `OPENAI_API_KEY` + `LLM_MODEL` 三个值。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
art-agent/
|
||||||
|
backend/ # Python FastAPI 后端
|
||||||
|
app/
|
||||||
|
main.py # 入口
|
||||||
|
config.py # 集中配置(读取 .env)
|
||||||
|
api/chat.py # 对话 API
|
||||||
|
agent/ # Agent Loop 核心
|
||||||
|
services/ # AI 服务封装
|
||||||
|
frontend/ # Next.js 前端
|
||||||
|
src/
|
||||||
|
app/ # 页面
|
||||||
|
components/ # UI 组件
|
||||||
|
lib/ # 工具函数
|
||||||
|
docs/ # 文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 远程访问(外网穿透)
|
||||||
|
|
||||||
|
通过 Cloudflare Quick Tunnel 免费穿透内网,手机或朋友在任何网络下都能访问,无需买服务器。
|
||||||
|
|
||||||
|
### 安装 cloudflared
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows(任选一种)
|
||||||
|
winget install Cloudflare.cloudflared
|
||||||
|
|
||||||
|
# 或手动下载
|
||||||
|
# https://github.com/cloudflare/cloudflared/releases/latest
|
||||||
|
# 下载 cloudflared-windows-amd64.exe,放到项目目录下即可
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install cloudflared
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
# https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 一键启动穿透
|
||||||
|
|
||||||
|
确保前后端服务已经在运行,然后在另一个终端执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd art-agent
|
||||||
|
.\start-tunnel.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会自动:
|
||||||
|
1. 为后端和前端各创建一条穿透隧道
|
||||||
|
2. 更新 `frontend/.env.local` 中的后端 API 地址为穿透地址
|
||||||
|
3. 打印手机可访问的公网 HTTPS 链接
|
||||||
|
4. 关闭脚本时(Ctrl+C)自动恢复 `.env.local` 为 localhost
|
||||||
|
|
||||||
|
> **重要**:穿透启动后需要**重启前端** (`npm run dev`) 才能读取新的 `.env.local`。
|
||||||
|
|
||||||
|
### 穿透管理
|
||||||
|
|
||||||
|
| 操作 | 方式 |
|
||||||
|
|------|------|
|
||||||
|
| **启动** | 运行 `.\start-tunnel.ps1`,等待打印出两条 `trycloudflare.com` 地址 |
|
||||||
|
| **停止** | 在脚本窗口按 `Ctrl+C`,或在任务管理器中结束 `cloudflared.exe` 进程 |
|
||||||
|
| **查看状态** | 检查日志:`Get-Content $env:TEMP\epeekit-tunnel-backend.log` |
|
||||||
|
| **重启** | 先停止再启动,地址会变(Quick Tunnel 每次分配随机域名) |
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
- Quick Tunnel **免费无需账号**,但地址每次启动都会变化
|
||||||
|
- 如果 cloudflared 连接超时,可能需要配置代理(Clash 等),在运行脚本前设置:
|
||||||
|
```powershell
|
||||||
|
$env:HTTP_PROXY = "http://127.0.0.1:7890"
|
||||||
|
$env:HTTPS_PROXY = "http://127.0.0.1:7890"
|
||||||
|
.\start-tunnel.ps1
|
||||||
|
```
|
||||||
|
- 电脑关机、休眠或代理断开后隧道会自动失效
|
||||||
|
- 停止穿透后,`start-tunnel.ps1` 会自动将 `.env.local` 恢复为 `localhost`;如果手动杀进程,需要自行恢复
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- 前端:Next.js + TypeScript + Tailwind CSS
|
||||||
|
- 后端:Python FastAPI + SSE
|
||||||
|
- AI:可配置 LLM(默认 GPT-4o-mini)+ 可配置图像模型(默认 Replicate Flux)
|
||||||
59
art-agent/backend/.env.example
Normal file
59
art-agent/backend/.env.example
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# ╔═══════════════════════════════════════════════════════════╗
|
||||||
|
# ║ EPEEKit — 集中配置 ║
|
||||||
|
# ║ 切换 LLM 只需改 OPENAI_BASE_URL + OPENAI_API_KEY ║
|
||||||
|
# ║ + LLM_MODEL 三个值 ║
|
||||||
|
# ╚═══════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
# ─── LLM 对话配置 ─────────────────────────────────────────
|
||||||
|
# API 密钥(OpenAI 或兼容 API 的 key)
|
||||||
|
OPENAI_API_KEY=sk-your-key-here
|
||||||
|
|
||||||
|
# API 基地址(留空 = 直连 api.openai.com)
|
||||||
|
# 常见替代值:
|
||||||
|
# OpenRouter: https://openrouter.ai/api/v1
|
||||||
|
# DeepSeek: https://api.deepseek.com/v1
|
||||||
|
# 本地 Ollama: http://localhost:11434/v1
|
||||||
|
# OPENAI_BASE_URL=
|
||||||
|
|
||||||
|
# 对话模型名称
|
||||||
|
# 常见选项: gpt-4o-mini | gpt-4o | deepseek-chat | claude-3-haiku-20240307
|
||||||
|
LLM_MODEL=gpt-4o-mini
|
||||||
|
|
||||||
|
# Agent 单轮最大工具调用次数(防止无限循环)
|
||||||
|
LLM_MAX_ITERATIONS=5
|
||||||
|
|
||||||
|
# ─── 图像生成配置 ─────────────────────────────────────────
|
||||||
|
# Replicate API Token
|
||||||
|
REPLICATE_API_TOKEN=your-replicate-token-here
|
||||||
|
|
||||||
|
# 默认图像生成模型(注册表短 ID,前端未指定时的 fallback)
|
||||||
|
# 可选值: flux-schnell | flux-dev | sdxl
|
||||||
|
# 也兼容完整 Replicate model ID 如 black-forest-labs/flux-schnell
|
||||||
|
IMAGE_MODEL=flux-schnell
|
||||||
|
|
||||||
|
# 默认宽高比
|
||||||
|
IMAGE_ASPECT_RATIO=1:1
|
||||||
|
|
||||||
|
# 输出格式
|
||||||
|
IMAGE_OUTPUT_FORMAT=png
|
||||||
|
|
||||||
|
# Gemini 原生生图(向量引擎 /v1beta/.../generateContent)可选调优(多图参考易超时或断连)
|
||||||
|
# VECTORENGINE_GEMINI_READ_TIMEOUT=600
|
||||||
|
# VECTORENGINE_GEMINI_WRITE_TIMEOUT=180
|
||||||
|
# VECTORENGINE_GEMINI_CONNECT_TIMEOUT=60
|
||||||
|
# VECTORENGINE_GEMINI_POOL_TIMEOUT=60
|
||||||
|
# VECTORENGINE_GEMINI_MAX_RETRIES=3
|
||||||
|
|
||||||
|
# ─── 网络代理(按需配置)─────────────────────────────────
|
||||||
|
# HTTP_PROXY=http://127.0.0.1:7890
|
||||||
|
# HTTPS_PROXY=http://127.0.0.1:7890
|
||||||
|
|
||||||
|
# ─── 用户认证 ─────────────────────────────────────────────
|
||||||
|
# JWT 签名密钥(必填,建议随机生成 32+ 位字符串)
|
||||||
|
JWT_SECRET=your-random-secret-key-here
|
||||||
|
|
||||||
|
# 首次启动时自动创建的管理员密码(留空则随机生成并打印到控制台)
|
||||||
|
# ADMIN_DEFAULT_PASSWORD=changeme123
|
||||||
|
|
||||||
|
# ─── 服务配置 ─────────────────────────────────────────────
|
||||||
|
PORT=8000
|
||||||
0
art-agent/backend/app/__init__.py
Normal file
0
art-agent/backend/app/__init__.py
Normal file
0
art-agent/backend/app/agent/__init__.py
Normal file
0
art-agent/backend/app/agent/__init__.py
Normal file
386
art-agent/backend/app/agent/loop.py
Normal file
386
art-agent/backend/app/agent/loop.py
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
"""Agent Loop 主循环:LLM 对话 -> 工具调用 -> 结果回传 -> 继续。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
from typing import AsyncGenerator, Optional
|
||||||
|
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
from app.agent.tools import TOOL_DEFINITIONS, execute_tool
|
||||||
|
from app.config import (
|
||||||
|
get_llm_model_config,
|
||||||
|
get_llm_max_iterations,
|
||||||
|
get_image_model_config,
|
||||||
|
get_max_recent_turns,
|
||||||
|
)
|
||||||
|
from app.memory import get_memory
|
||||||
|
from app.services.image_gen import to_data_uri
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client(provider: str) -> AsyncOpenAI:
|
||||||
|
"""按 provider 创建对应的 OpenAI 兼容客户端。"""
|
||||||
|
if provider == "vectorengine":
|
||||||
|
return AsyncOpenAI(
|
||||||
|
api_key=os.getenv("VECTORENGINE_API_KEY"),
|
||||||
|
base_url=os.getenv("VECTORENGINE_BASE_URL", "https://api.vectorengine.ai/v1"),
|
||||||
|
)
|
||||||
|
if provider == "deepseek":
|
||||||
|
return AsyncOpenAI(
|
||||||
|
api_key=os.getenv("DEEPSEEK_API_KEY"),
|
||||||
|
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
|
||||||
|
)
|
||||||
|
return AsyncOpenAI()
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """\
|
||||||
|
你是一个专业的游戏美术 AI 助手。你的工作是帮助美术人员通过对话生成游戏美术资源。
|
||||||
|
|
||||||
|
## 你的能力
|
||||||
|
- 根据用户的文字描述生成图片(UI图标、按钮、插画、立绘、概念图等)
|
||||||
|
- 理解用户的审美意图,将中文描述转化为高质量的英文生成 prompt
|
||||||
|
- 根据用户反馈迭代修改(调整颜色、风格、构图等)
|
||||||
|
- 如果用户提供了参考图(支持多张),将参考图的风格元素融入生成 prompt
|
||||||
|
- 理解多张参考图各自的角色(如"图1的主体 + 图2的风格/视角"),并在 prompt 中准确传达
|
||||||
|
- 理解用户在图片上的标注(框选区域 + 文字批注),精准定位需要修改的部分
|
||||||
|
- 将一张图片转换为多个不同视角(使用 transform_view 工具),适合建筑、物体等需要从不同角度查看的场景
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
1. 理解用户需求,必要时追问细节(尺寸、风格、用途等)
|
||||||
|
2. 将需求转化为详细的英文 prompt,调用 generate_image 工具生成图片
|
||||||
|
3. 向用户展示结果并询问反馈
|
||||||
|
4. 根据反馈调整 prompt 并重新生成
|
||||||
|
|
||||||
|
## 标注理解
|
||||||
|
当用户发送带有「图片标注」的消息时,表示用户在之前生成的图片上做了标注。
|
||||||
|
标注格式为:`[区域 (x%, y%) 大小 w%×h%]: 修改意见`
|
||||||
|
- 区域坐标表示标注框在图片上的相对位置
|
||||||
|
- 你需要理解标注区域所指的图片内容,并将修改意见融入新的 prompt
|
||||||
|
- 如果附带了标注截图(参考图),仔细观察红色标注框和文字来理解用户意图
|
||||||
|
- 在调整 prompt 时,保持原图整体风格不变,只针对标注区域做修改
|
||||||
|
|
||||||
|
## 生成 prompt 要求
|
||||||
|
- 必须使用英文
|
||||||
|
- 尽量详细描述:主体内容、颜色方案、光照、构图、材质等
|
||||||
|
- 如果用户要求游戏 UI 元素,添加相关关键词如 "game UI", "icon", "button" 等
|
||||||
|
- 如果你能直接看到参考图(图片内容),可以在 prompt 中描述参考图的风格特征。多张参考图时,理解用户对各图的定位(如"图1做主体参考、图2做风格参考"),将相应特征分别融入 prompt
|
||||||
|
- 如果你无法看到参考图(只收到了文字提示说有参考图),参考图会由生图工具自动处理风格融合。此时你不要自行猜测画风/艺术风格关键词(如 pixel art、watercolor、oil painting 等),把风格交给参考图来决定。但如果用户在消息中明确指定了风格(如"赛博朋克风"、"水彩风"等),应保留并翻译到 prompt 中——尊重用户的主动意图
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
- 用中文和用户交流
|
||||||
|
- 生成图片后简要说明你使用的 prompt 思路
|
||||||
|
- 主动建议迭代方向
|
||||||
|
- **禁止模拟工具调用**:生成图片时必须实际调用 generate_image 工具,绝不能用文字描述"已生成"或假装工具已执行。如果需要生成多张图片,每张都必须单独调用工具
|
||||||
|
- **禁止在回复中嵌入图片链接**:不要在回复文字中使用 Markdown 图片语法(如 `` 或 `sandbox:` 链接)。图片展示由系统自动处理,你只需用文字描述结果即可
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def run_agent_loop(
|
||||||
|
messages: list[dict],
|
||||||
|
ref_image_urls: Optional[list[str]] = None,
|
||||||
|
image_model: Optional[str] = None,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
user_id: str = "default_user",
|
||||||
|
llm_model: Optional[str] = None,
|
||||||
|
) -> AsyncGenerator[dict, None]:
|
||||||
|
"""
|
||||||
|
运行 Agent Loop,以 SSE 事件流形式 yield 结果。
|
||||||
|
|
||||||
|
事件类型:
|
||||||
|
- text_delta: LLM 文字增量
|
||||||
|
- tool_start: 开始执行工具
|
||||||
|
- image_result: 图片生成结果
|
||||||
|
- memory_warning: 记忆存储异常(不中断对话)
|
||||||
|
- done: 完成
|
||||||
|
- error: 错误
|
||||||
|
"""
|
||||||
|
# ── 滑动窗口:截断过早的消息 ──
|
||||||
|
max_turns = get_max_recent_turns()
|
||||||
|
if len(messages) > max_turns:
|
||||||
|
messages = messages[-max_turns:]
|
||||||
|
|
||||||
|
# ── 记忆检索:用最新 user 消息语义检索相关记忆 ──
|
||||||
|
memory_block = ""
|
||||||
|
try:
|
||||||
|
memory = get_memory()
|
||||||
|
last_user_content = ""
|
||||||
|
for m in reversed(messages):
|
||||||
|
if m["role"] == "user":
|
||||||
|
last_user_content = m["content"]
|
||||||
|
break
|
||||||
|
|
||||||
|
if last_user_content:
|
||||||
|
search_kwargs = {"query": last_user_content, "user_id": user_id, "limit": 10}
|
||||||
|
if session_id:
|
||||||
|
search_kwargs["run_id"] = session_id
|
||||||
|
relevant = memory.search(**search_kwargs)
|
||||||
|
|
||||||
|
results = relevant.get("results", []) if isinstance(relevant, dict) else relevant
|
||||||
|
if results:
|
||||||
|
items = "\n".join(f"- {m['memory']}" for m in results if m.get("memory"))
|
||||||
|
if items:
|
||||||
|
memory_block = f"\n\n## 用户记忆(来自历史对话)\n{items}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Mem0 记忆检索失败: %s", e, exc_info=True)
|
||||||
|
yield {"type": "error", "data": {"message": f"记忆系统检索失败: {e}"}}
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── 解析 LLM 模型配置 ──
|
||||||
|
llm_config = get_llm_model_config(llm_model)
|
||||||
|
llm_provider = llm_config["provider"]
|
||||||
|
llm_model_id = llm_config["model_id"]
|
||||||
|
vision_capable = llm_config.get("vision", False)
|
||||||
|
|
||||||
|
# ── 构建 system prompt ──
|
||||||
|
img_model_config = get_image_model_config(image_model)
|
||||||
|
current_model_name = img_model_config.get('name', '未知')
|
||||||
|
current_model_id = img_model_config.get('id', '未知')
|
||||||
|
model_hint = (
|
||||||
|
f"\n\n## 当前生图模型(重要)\n"
|
||||||
|
f"本次对话用户选择的生图模型是 **{current_model_name}**"
|
||||||
|
f"(model_id: {current_model_id})。\n"
|
||||||
|
f"**注意**:对话历史中可能包含之前使用其他模型的记录,忽略那些旧模型名。"
|
||||||
|
f"本次生成使用的是 {current_model_name},在回复中只能使用这个名称。"
|
||||||
|
)
|
||||||
|
api_messages = [{"role": "system", "content": SYSTEM_PROMPT + model_hint + memory_block}]
|
||||||
|
|
||||||
|
effective_refs = ref_image_urls or []
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
if msg["role"] == "user" and effective_refs and msg is messages[-1]:
|
||||||
|
if vision_capable:
|
||||||
|
content_parts: list[dict] = [{"type": "text", "text": msg["content"]}]
|
||||||
|
for ref_url in effective_refs:
|
||||||
|
content_parts.append({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": to_data_uri(ref_url)},
|
||||||
|
})
|
||||||
|
api_messages.append({"role": "user", "content": content_parts})
|
||||||
|
else:
|
||||||
|
n_refs = len(effective_refs)
|
||||||
|
hint = (
|
||||||
|
f"{msg['content']}\n\n"
|
||||||
|
f"【系统提示:用户上传了 {n_refs} 张参考图,已自动传递给图片生成工具。"
|
||||||
|
"生图工具会根据模型能力自动处理参考图的风格融合。"
|
||||||
|
"你无法看到这些参考图,因此在生成 prompt 时:\n"
|
||||||
|
"1. 描述画面内容(主体、构图、光照、材质等)\n"
|
||||||
|
"2. 不要自行猜测画风/艺术风格——但如果用户明确指定了风格,保留到 prompt 中\n"
|
||||||
|
"3. 用户未指定风格时,风格完全由参考图决定】"
|
||||||
|
)
|
||||||
|
api_messages.append({"role": "user", "content": hint})
|
||||||
|
continue
|
||||||
|
api_messages.append({"role": msg["role"], "content": msg["content"]})
|
||||||
|
|
||||||
|
client = _get_client(llm_provider)
|
||||||
|
|
||||||
|
for _ in range(get_llm_max_iterations()):
|
||||||
|
try:
|
||||||
|
response = await client.chat.completions.create(
|
||||||
|
model=llm_model_id,
|
||||||
|
messages=api_messages,
|
||||||
|
tools=TOOL_DEFINITIONS,
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
yield {"type": "error", "data": {"message": str(e)}}
|
||||||
|
return
|
||||||
|
|
||||||
|
collected_text = ""
|
||||||
|
tool_calls_data: dict[int, dict] = {}
|
||||||
|
|
||||||
|
async for chunk in response:
|
||||||
|
delta = chunk.choices[0].delta if chunk.choices else None
|
||||||
|
if not delta:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 文字内容
|
||||||
|
if delta.content:
|
||||||
|
collected_text += delta.content
|
||||||
|
yield {"type": "text_delta", "data": {"text": delta.content}}
|
||||||
|
|
||||||
|
# 工具调用(流式累积)
|
||||||
|
if delta.tool_calls:
|
||||||
|
for tc in delta.tool_calls:
|
||||||
|
idx = tc.index
|
||||||
|
if idx not in tool_calls_data:
|
||||||
|
tool_calls_data[idx] = {
|
||||||
|
"id": "",
|
||||||
|
"name": "",
|
||||||
|
"arguments": "",
|
||||||
|
}
|
||||||
|
if tc.id:
|
||||||
|
tool_calls_data[idx]["id"] = tc.id
|
||||||
|
if tc.function and tc.function.name:
|
||||||
|
tool_calls_data[idx]["name"] = tc.function.name
|
||||||
|
if tc.function and tc.function.arguments:
|
||||||
|
tool_calls_data[idx]["arguments"] += tc.function.arguments
|
||||||
|
|
||||||
|
finish_reason = chunk.choices[0].finish_reason if chunk.choices else None
|
||||||
|
|
||||||
|
# 如果没有工具调用:检测 LLM 是否在用文字模拟生图
|
||||||
|
if not tool_calls_data:
|
||||||
|
if _looks_like_fake_generation(collected_text):
|
||||||
|
# LLM 用文字假装调用了工具,丢弃这段文字,注入纠正消息强制重试
|
||||||
|
yield {
|
||||||
|
"type": "text_delta",
|
||||||
|
"data": {"text": "\n\n[系统:检测到未调用生图工具,正在重试...]\n"},
|
||||||
|
}
|
||||||
|
api_messages.append({"role": "assistant", "content": collected_text})
|
||||||
|
api_messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
"你刚才没有调用 generate_image 工具,只是用文字描述了生成过程。"
|
||||||
|
"请立即调用 generate_image 工具来实际生成图片。"
|
||||||
|
"不要解释,直接调用工具。"
|
||||||
|
f"当前使用的生图模型是 {img_model_config.get('name', '未知')}。"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
# 对话正常结束,异步存储记忆
|
||||||
|
async for evt in _store_and_done(messages, session_id, user_id):
|
||||||
|
yield evt
|
||||||
|
return
|
||||||
|
|
||||||
|
# 将 assistant 消息(含 tool_calls)加入历史
|
||||||
|
assistant_msg: dict = {"role": "assistant"}
|
||||||
|
if collected_text:
|
||||||
|
assistant_msg["content"] = collected_text
|
||||||
|
else:
|
||||||
|
assistant_msg["content"] = None
|
||||||
|
|
||||||
|
assistant_msg["tool_calls"] = []
|
||||||
|
for idx in sorted(tool_calls_data.keys()):
|
||||||
|
tc_data = tool_calls_data[idx]
|
||||||
|
assistant_msg["tool_calls"].append({
|
||||||
|
"id": tc_data["id"],
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": tc_data["name"],
|
||||||
|
"arguments": tc_data["arguments"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
api_messages.append(assistant_msg)
|
||||||
|
|
||||||
|
# 依次执行每个工具调用
|
||||||
|
for idx in sorted(tool_calls_data.keys()):
|
||||||
|
tc_data = tool_calls_data[idx]
|
||||||
|
tool_name = tc_data["name"]
|
||||||
|
|
||||||
|
tool_message = (
|
||||||
|
"正在进行视角变换..." if tool_name == "transform_view"
|
||||||
|
else "正在生成图片..."
|
||||||
|
)
|
||||||
|
yield {
|
||||||
|
"type": "tool_start",
|
||||||
|
"data": {"tool": tool_name, "message": tool_message},
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
arguments = json.loads(tc_data["arguments"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
arguments = {}
|
||||||
|
|
||||||
|
result = await execute_tool(tool_name, arguments, effective_refs or None, image_model)
|
||||||
|
|
||||||
|
used_model = result.get("model_name", "")
|
||||||
|
|
||||||
|
if result.get("errors"):
|
||||||
|
yield {
|
||||||
|
"type": "tool_error",
|
||||||
|
"data": {
|
||||||
|
"tool": tool_name,
|
||||||
|
"errors": result["errors"],
|
||||||
|
"model_name": used_model,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.get("error") and not result.get("success"):
|
||||||
|
yield {
|
||||||
|
"type": "tool_error",
|
||||||
|
"data": {
|
||||||
|
"tool": tool_name,
|
||||||
|
"errors": [result["error"]],
|
||||||
|
"model_name": used_model,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if tool_name == "transform_view" and result.get("images"):
|
||||||
|
# transform_view 返回带视角信息的图片列表
|
||||||
|
image_urls = [v["url"] for v in result["images"]]
|
||||||
|
yield {
|
||||||
|
"type": "image_result",
|
||||||
|
"data": {
|
||||||
|
"images": image_urls,
|
||||||
|
"prompt_used": "",
|
||||||
|
"model_name": used_model,
|
||||||
|
"view_info": result["images"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
elif result.get("images"):
|
||||||
|
yield {
|
||||||
|
"type": "image_result",
|
||||||
|
"data": {
|
||||||
|
"images": result["images"],
|
||||||
|
"prompt_used": result.get("prompt_used", ""),
|
||||||
|
"model_name": used_model,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 工具结果回传给 LLM
|
||||||
|
api_messages.append({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tc_data["id"],
|
||||||
|
"content": json.dumps(result, ensure_ascii=False),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ref_image_url 不清除:消息构建只在循环外执行一次,
|
||||||
|
# 后续工具调用仍需参考图(InstantStyle 等模型必须有 style_image)
|
||||||
|
|
||||||
|
# 迭代次数用尽,存储记忆后结束
|
||||||
|
async for evt in _store_and_done(messages, session_id, user_id):
|
||||||
|
yield evt
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 异步记忆存储 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _store_and_done(
|
||||||
|
messages: list[dict], session_id: Optional[str], user_id: str = "default_user"
|
||||||
|
) -> AsyncGenerator[dict, None]:
|
||||||
|
"""触发 Mem0 异步存储后 yield done。存储失败时 yield warning 但不中断。"""
|
||||||
|
try:
|
||||||
|
memory = get_memory()
|
||||||
|
recent = messages[-4:] if len(messages) >= 4 else messages
|
||||||
|
add_kwargs: dict = {"user_id": user_id}
|
||||||
|
if session_id:
|
||||||
|
add_kwargs["run_id"] = session_id
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
await loop.run_in_executor(None, lambda: memory.add(recent, **add_kwargs))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Mem0 记忆存储失败: %s", e, exc_info=True)
|
||||||
|
yield {
|
||||||
|
"type": "memory_warning",
|
||||||
|
"data": {"message": f"记忆存储失败: {e}"},
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {"type": "done", "data": {}}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 假生成检测 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
_FAKE_GEN_PATTERNS = re.compile(
|
||||||
|
r"已生成|生成完成|开始生成|正在生成|图片已|prompt.*?设计思路",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_fake_generation(text: str) -> bool:
|
||||||
|
"""判断 LLM 的纯文字回复是否在假装已经调用了生图工具。"""
|
||||||
|
if not text or len(text) < 50:
|
||||||
|
return False
|
||||||
|
matches = _FAKE_GEN_PATTERNS.findall(text)
|
||||||
|
return len(matches) >= 2
|
||||||
121
art-agent/backend/app/agent/tools.py
Normal file
121
art-agent/backend/app/agent/tools.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Agent 可调用的工具定义和实现。"""
|
||||||
|
|
||||||
|
from app.services.image_gen import generate_images
|
||||||
|
from app.services.view_transform import transform_view
|
||||||
|
|
||||||
|
# OpenAI Function Calling 格式的工具定义
|
||||||
|
TOOL_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "generate_image",
|
||||||
|
"description": (
|
||||||
|
"根据文字描述生成图片。prompt 必须是英文。"
|
||||||
|
"如果用户提供了参考图,会自动传入 ref_image_url 参数。"
|
||||||
|
"若当前生图模型为 Stable Diffusion XL,可在正提示后单独一行写 ---NEGATIVE--- 再写负向提示;"
|
||||||
|
"不传则服务端会使用该模型的默认负向词。"
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"prompt": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "英文图片描述 prompt,详细描述要生成的图片内容、风格、颜色等",
|
||||||
|
},
|
||||||
|
"num_images": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "生成图片数量,1-4 张",
|
||||||
|
"default": 1,
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"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": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_tool(
|
||||||
|
tool_name: str,
|
||||||
|
arguments: dict,
|
||||||
|
ref_image_urls: list[str] | None = None,
|
||||||
|
image_model: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""执行工具调用,返回结果。"""
|
||||||
|
if tool_name == "generate_image":
|
||||||
|
prompt = arguments["prompt"]
|
||||||
|
num_images = arguments.get("num_images", 1)
|
||||||
|
result = await generate_images(
|
||||||
|
prompt=prompt,
|
||||||
|
num_images=num_images,
|
||||||
|
ref_image_urls=ref_image_urls,
|
||||||
|
model_id=image_model,
|
||||||
|
)
|
||||||
|
valid_urls = [u for u in result.urls if not u.startswith("[")]
|
||||||
|
errors = [u for u in result.urls if u.startswith("[")]
|
||||||
|
return {
|
||||||
|
"success": len(valid_urls) > 0,
|
||||||
|
"images": valid_urls,
|
||||||
|
"errors": errors,
|
||||||
|
"prompt_used": prompt,
|
||||||
|
"effective_prompt": result.effective_prompt,
|
||||||
|
"negative_prompt": result.negative_prompt,
|
||||||
|
"model_name": result.model_name,
|
||||||
|
"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}"}
|
||||||
0
art-agent/backend/app/api/__init__.py
Normal file
0
art-agent/backend/app/api/__init__.py
Normal file
95
art-agent/backend/app/api/admin.py
Normal file
95
art-agent/backend/app/api/admin.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
管理员路由:创建用户 / 用户列表 / 禁用或删除用户。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth import hash_password, require_admin
|
||||||
|
from app.db import User, get_session
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
display_name: str = ""
|
||||||
|
is_admin: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
display_name: str
|
||||||
|
is_admin: bool
|
||||||
|
is_active: bool
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users", response_model=UserOut)
|
||||||
|
def create_user(
|
||||||
|
body: CreateUserRequest,
|
||||||
|
_admin: User = Depends(require_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
existing = session.exec(select(User).where(User.username == body.username)).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="用户名已存在")
|
||||||
|
if len(body.password) < 6:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="密码至少 6 位")
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
username=body.username,
|
||||||
|
hashed_password=hash_password(body.password),
|
||||||
|
display_name=body.display_name or body.username,
|
||||||
|
is_admin=body.is_admin,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return UserOut(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
display_name=user.display_name,
|
||||||
|
is_admin=user.is_admin,
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_model=list[UserOut])
|
||||||
|
def list_users(
|
||||||
|
_admin: User = Depends(require_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
users = session.exec(select(User)).all()
|
||||||
|
return [
|
||||||
|
UserOut(
|
||||||
|
id=u.id,
|
||||||
|
username=u.username,
|
||||||
|
display_name=u.display_name,
|
||||||
|
is_admin=u.is_admin,
|
||||||
|
is_active=u.is_active,
|
||||||
|
created_at=u.created_at.isoformat(),
|
||||||
|
)
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}")
|
||||||
|
def disable_user(
|
||||||
|
user_id: str,
|
||||||
|
_admin: User = Depends(require_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
user = session.exec(select(User).where(User.id == user_id)).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
if user.id == _admin.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能禁用自己")
|
||||||
|
user.is_active = False
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
return {"message": f"用户 {user.username} 已禁用"}
|
||||||
99
art-agent/backend/app/api/auth.py
Normal file
99
art-agent/backend/app/api/auth.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
认证路由:登录 / 刷新令牌 / 修改密码 / 当前用户信息。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth import (
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
decode_token,
|
||||||
|
get_current_user,
|
||||||
|
hash_password,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from app.db import User, get_session
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
user: dict
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
old_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
def _user_dict(u: User) -> dict:
|
||||||
|
return {
|
||||||
|
"id": u.id,
|
||||||
|
"username": u.username,
|
||||||
|
"display_name": u.display_name,
|
||||||
|
"is_admin": u.is_admin,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
def login(body: LoginRequest, session: Session = Depends(get_session)):
|
||||||
|
user = session.exec(select(User).where(User.username == body.username)).first()
|
||||||
|
if not user or not verify_password(body.password, user.hashed_password):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=create_access_token(user.id),
|
||||||
|
refresh_token=create_refresh_token(user.id),
|
||||||
|
user=_user_dict(user),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh")
|
||||||
|
def refresh(body: RefreshRequest, session: Session = Depends(get_session)):
|
||||||
|
user_id = decode_token(body.refresh_token, expected_type="refresh")
|
||||||
|
user = session.exec(select(User).where(User.id == user_id)).first()
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已禁用")
|
||||||
|
return {
|
||||||
|
"access_token": create_access_token(user.id),
|
||||||
|
"token_type": "bearer",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
def change_password(
|
||||||
|
body: ChangePasswordRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
if not verify_password(body.old_password, current_user.hashed_password):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误")
|
||||||
|
if len(body.new_password) < 6:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="新密码至少 6 位")
|
||||||
|
# 重新获取以确保在同一 session 中
|
||||||
|
user = session.exec(select(User).where(User.id == current_user.id)).first()
|
||||||
|
if user:
|
||||||
|
user.hashed_password = hash_password(body.new_password)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
return {"message": "密码已修改"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
def me(current_user: User = Depends(get_current_user)):
|
||||||
|
return _user_dict(current_user)
|
||||||
113
art-agent/backend/app/api/chat.py
Normal file
113
art-agent/backend/app/api/chat.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||||
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
|
from app.agent.loop import run_agent_loop
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.config import (
|
||||||
|
get_image_models_list,
|
||||||
|
get_default_image_model_id,
|
||||||
|
get_llm_models_list,
|
||||||
|
get_default_llm_model_id,
|
||||||
|
)
|
||||||
|
from app.db import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
UPLOADS_DIR = Path(__file__).parent.parent.parent / "uploads"
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_upload(file: UploadFile) -> str:
|
||||||
|
"""保存上传的参考图,返回可访问的 URL 路径。"""
|
||||||
|
ext = Path(file.filename).suffix or ".png"
|
||||||
|
filename = f"{uuid.uuid4().hex}{ext}"
|
||||||
|
filepath = UPLOADS_DIR / filename
|
||||||
|
content = await file.read()
|
||||||
|
filepath.write_bytes(content)
|
||||||
|
return f"/uploads/{filename}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload-ref-image")
|
||||||
|
async def upload_ref_image(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
独立的参考图上传端点。
|
||||||
|
前端选图后立即调用,返回服务端路径,供后续发消息时引用。
|
||||||
|
"""
|
||||||
|
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
url = await _save_upload(file)
|
||||||
|
return {"url": url, "filename": file.filename}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models")
|
||||||
|
async def list_models(current_user: User = Depends(get_current_user)):
|
||||||
|
"""返回可用的图像生成模型列表。"""
|
||||||
|
return {
|
||||||
|
"models": get_image_models_list(),
|
||||||
|
"default": get_default_image_model_id(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/llm-models")
|
||||||
|
async def list_llm_models(current_user: User = Depends(get_current_user)):
|
||||||
|
"""返回可用的 LLM 对话模型列表。"""
|
||||||
|
return {
|
||||||
|
"models": get_llm_models_list(),
|
||||||
|
"default": get_default_llm_model_id(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat")
|
||||||
|
async def chat(
|
||||||
|
messages: str = Form(...),
|
||||||
|
ref_image: Optional[UploadFile] = File(None),
|
||||||
|
ref_image_url: Optional[str] = Form(None),
|
||||||
|
ref_image_urls: Optional[str] = Form(None),
|
||||||
|
image_model: Optional[str] = Form(None),
|
||||||
|
session_id: Optional[str] = Form(None),
|
||||||
|
llm_model: Optional[str] = Form(None),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
主对话端点。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
- messages: JSON 字符串,对话历史 [{role, content}]
|
||||||
|
- ref_image: 可选的参考图文件(兼容旧方式)
|
||||||
|
- ref_image_url: 兼容旧方式,单张参考图服务端路径
|
||||||
|
- ref_image_urls: JSON 数组字符串,多张参考图服务端路径列表
|
||||||
|
- image_model: 可选,指定本次使用的生图模型短 ID
|
||||||
|
- session_id: 可选,前端会话 ID,用于 Mem0 记忆作用域
|
||||||
|
- llm_model: 可选,指定本次使用的 LLM 模型短 ID
|
||||||
|
"""
|
||||||
|
parsed_messages = json.loads(messages)
|
||||||
|
|
||||||
|
resolved_ref_urls: list[str] = []
|
||||||
|
if ref_image_urls:
|
||||||
|
resolved_ref_urls = json.loads(ref_image_urls)
|
||||||
|
elif ref_image_url:
|
||||||
|
resolved_ref_urls = [ref_image_url]
|
||||||
|
elif ref_image and ref_image.filename:
|
||||||
|
resolved_ref_urls = [await _save_upload(ref_image)]
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
async for event in run_agent_loop(
|
||||||
|
parsed_messages,
|
||||||
|
resolved_ref_urls or None,
|
||||||
|
image_model=image_model,
|
||||||
|
session_id=session_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
llm_model=llm_model,
|
||||||
|
):
|
||||||
|
yield {
|
||||||
|
"event": event["type"],
|
||||||
|
"data": json.dumps(event["data"], ensure_ascii=False),
|
||||||
|
}
|
||||||
|
|
||||||
|
return EventSourceResponse(event_generator())
|
||||||
37
art-agent/backend/app/api/memory.py
Normal file
37
art-agent/backend/app/api/memory.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
记忆查询 API:供前端查看当前用户的 Mem0 记忆条目。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.db import User
|
||||||
|
from app.memory import get_memory
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/memory", tags=["memory"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list")
|
||||||
|
async def list_memories(current_user: User = Depends(get_current_user)):
|
||||||
|
"""返回当前用户的所有记忆条目(只读)。"""
|
||||||
|
memory = get_memory()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None, lambda: memory.get_all(user_id=current_user.id, limit=200)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("获取记忆列表失败: %s", e)
|
||||||
|
return {"memories": [], "error": str(e)}
|
||||||
|
|
||||||
|
results = result.get("results", []) if isinstance(result, dict) else result
|
||||||
|
logger.info("Mem0 get_all 返回 %d 条记忆, result_type=%s", len(results), type(result).__name__)
|
||||||
|
if results:
|
||||||
|
sample = results[0]
|
||||||
|
logger.info("记忆样本 keys=%s, memory=%s", list(sample.keys()) if isinstance(sample, dict) else "not-dict", str(sample)[:200])
|
||||||
|
return {"memories": results}
|
||||||
89
art-agent/backend/app/auth.py
Normal file
89
art-agent/backend/app/auth.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
认证工具:密码哈希 + JWT 令牌 + FastAPI 依赖注入。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from pwdlib import PasswordHash
|
||||||
|
from pwdlib.hashers.argon2 import Argon2Hasher
|
||||||
|
from pwdlib.hashers.bcrypt import BcryptHasher
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.db import User, get_session
|
||||||
|
|
||||||
|
# Argon2 优先用于新密码,BcryptHasher 兼容旧版已有哈希
|
||||||
|
pwd_hash = PasswordHash((Argon2Hasher(), BcryptHasher()))
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||||
|
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
||||||
|
|
||||||
|
|
||||||
|
def _get_secret() -> str:
|
||||||
|
secret = os.getenv("JWT_SECRET", "")
|
||||||
|
if not secret:
|
||||||
|
raise RuntimeError("JWT_SECRET 环境变量未设置")
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plain: str) -> str:
|
||||||
|
return pwd_hash.hash(plain)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return pwd_hash.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user_id: str) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
return jwt.encode(
|
||||||
|
{"sub": user_id, "exp": expire, "type": "access"},
|
||||||
|
_get_secret(),
|
||||||
|
algorithm=ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(user_id: str) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||||
|
return jwt.encode(
|
||||||
|
{"sub": user_id, "exp": expire, "type": "refresh"},
|
||||||
|
_get_secret(),
|
||||||
|
algorithm=ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str, expected_type: str = "access") -> str:
|
||||||
|
"""解码 JWT,返回 user_id。无效时抛 HTTPException 401。"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, _get_secret(), algorithms=[ALGORITHM])
|
||||||
|
user_id: str = payload.get("sub", "")
|
||||||
|
token_type: str = payload.get("type", "")
|
||||||
|
if not user_id or token_type != expected_type:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效令牌")
|
||||||
|
return user_id
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="令牌已过期或无效")
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
token: str = Depends(oauth2_scheme),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> User:
|
||||||
|
"""FastAPI 依赖:从 Bearer token 解析当前用户。"""
|
||||||
|
user_id = decode_token(token, "access")
|
||||||
|
user = session.exec(select(User).where(User.id == user_id)).first()
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已禁用")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""FastAPI 依赖:要求当前用户是管理员。"""
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
|
||||||
|
return user
|
||||||
406
art-agent/backend/app/config.py
Normal file
406
art-agent/backend/app/config.py
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
"""
|
||||||
|
EPEEKit 集中配置。
|
||||||
|
所有可调参数从环境变量读取,每次调用实时读取(不缓存),确保 load_dotenv() 后生效。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_max_iterations() -> int:
|
||||||
|
return int(os.getenv("LLM_MAX_ITERATIONS", "5"))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── LLM 模型注册表 ─────────────────────────────────────
|
||||||
|
#
|
||||||
|
# 每个模型的配置说明:
|
||||||
|
# id — 前端/API 使用的短 ID
|
||||||
|
# name — 显示名称
|
||||||
|
# provider — API 提供者(对应 _get_client 的分发键)
|
||||||
|
# model_id — 传给 OpenAI SDK 的 model 参数
|
||||||
|
# description — 前端下拉列表说明文字
|
||||||
|
# vision — 是否支持多模态图片输入
|
||||||
|
|
||||||
|
LLM_MODELS: dict[str, dict[str, Any]] = {
|
||||||
|
"gpt-5.4": {
|
||||||
|
"id": "gpt-5.4",
|
||||||
|
"name": "GPT-5.4",
|
||||||
|
"provider": "vectorengine",
|
||||||
|
"model_id": "gpt-5.4",
|
||||||
|
"description": "知识工作与计算机操控最强,1M 上下文",
|
||||||
|
"vision": True,
|
||||||
|
},
|
||||||
|
"claude-sonnet-4-6": {
|
||||||
|
"id": "claude-sonnet-4-6",
|
||||||
|
"name": "Claude Sonnet 4.6",
|
||||||
|
"provider": "vectorengine",
|
||||||
|
"model_id": "claude-sonnet-4-6",
|
||||||
|
"description": "高性价比编码与日常任务",
|
||||||
|
"vision": True,
|
||||||
|
},
|
||||||
|
"claude-opus-4-6": {
|
||||||
|
"id": "claude-opus-4-6",
|
||||||
|
"name": "Claude Opus 4.6",
|
||||||
|
"provider": "vectorengine",
|
||||||
|
"model_id": "claude-opus-4-6",
|
||||||
|
"description": "编码与专家级推理最强,128K 输出",
|
||||||
|
"vision": True,
|
||||||
|
},
|
||||||
|
"gemini-3.1-pro-preview": {
|
||||||
|
"id": "gemini-3.1-pro-preview",
|
||||||
|
"name": "Gemini 3.1 Pro",
|
||||||
|
"provider": "vectorengine",
|
||||||
|
"model_id": "gemini-3.1-pro-preview",
|
||||||
|
"description": "推理最强、价格最低,2M 上下文",
|
||||||
|
"vision": True,
|
||||||
|
},
|
||||||
|
"glm-4.7": {
|
||||||
|
"id": "glm-4.7",
|
||||||
|
"name": "GLM-4.7",
|
||||||
|
"provider": "vectorengine",
|
||||||
|
"model_id": "glm-4.7",
|
||||||
|
"description": "智谱 AI,中文能力突出,免费额度",
|
||||||
|
"vision": False,
|
||||||
|
},
|
||||||
|
"gpt-4o-mini": {
|
||||||
|
"id": "gpt-4o-mini",
|
||||||
|
"name": "GPT-4o Mini",
|
||||||
|
"provider": "vectorengine",
|
||||||
|
"model_id": "gpt-4o-mini",
|
||||||
|
"description": "轻量快速、高性价比",
|
||||||
|
"vision": True,
|
||||||
|
},
|
||||||
|
"deepseek-chat": {
|
||||||
|
"id": "deepseek-chat",
|
||||||
|
"name": "DeepSeek Chat",
|
||||||
|
"provider": "deepseek",
|
||||||
|
"model_id": "deepseek-chat",
|
||||||
|
"description": "中文对话优化(直连)",
|
||||||
|
"vision": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_llm_model_id() -> str:
|
||||||
|
"""返回 .env 中配置的默认 LLM 短 ID,不在注册表中则回退到 gpt-5.4。"""
|
||||||
|
env_model = os.getenv("LLM_MODEL", "gpt-5.4")
|
||||||
|
if env_model in LLM_MODELS:
|
||||||
|
return env_model
|
||||||
|
return "gpt-5.4"
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_model_config(model_id: str | None = None) -> dict[str, Any]:
|
||||||
|
"""根据短 ID 获取 LLM 模型配置,未指定或不存在则使用默认模型。"""
|
||||||
|
if model_id and model_id in LLM_MODELS:
|
||||||
|
return LLM_MODELS[model_id]
|
||||||
|
return LLM_MODELS[get_default_llm_model_id()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_models_list() -> list[dict]:
|
||||||
|
"""返回前端下拉列表所需的 LLM 模型摘要信息。"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": cfg["id"],
|
||||||
|
"name": cfg["name"],
|
||||||
|
"description": cfg["description"],
|
||||||
|
"vision": cfg.get("vision", False),
|
||||||
|
}
|
||||||
|
for cfg in LLM_MODELS.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 图像模型注册表 ─────────────────────────────────────
|
||||||
|
#
|
||||||
|
# 每个模型的配置说明:
|
||||||
|
# id — 前端/API 使用的短 ID
|
||||||
|
# name — 显示名称
|
||||||
|
# provider — 生成服务提供者(对应 image_gen.py 中的 Provider)
|
||||||
|
# model_id — Replicate 上的完整模型 ID
|
||||||
|
# description — 前端下拉列表中的说明文字
|
||||||
|
# supports_ref_image — 是否原生支持参考图输入(IP-Adapter 等)
|
||||||
|
# ref_image_param — 传给 Replicate 的参考图参数名(模型间可能不同)
|
||||||
|
# num_images_param — 批量生成参数名(Flux 用 num_outputs,Kolors 用 number_of_images)
|
||||||
|
# default_params — 默认推理参数
|
||||||
|
|
||||||
|
IMAGE_MODELS: dict[str, dict[str, Any]] = {
|
||||||
|
"gpt-image-1.5": {
|
||||||
|
"id": "gpt-image-1.5",
|
||||||
|
"name": "GPT Image 1.5",
|
||||||
|
"provider": "openai",
|
||||||
|
"model_id": "gpt-image-1.5",
|
||||||
|
"description": "OpenAI 最强生图,文字渲染与 prompt 理解最佳,支持多图参考",
|
||||||
|
"supports_ref_image": True,
|
||||||
|
"max_ref_images": 16,
|
||||||
|
"default_params": {
|
||||||
|
"size": "1024x1024",
|
||||||
|
"quality": "high",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"gemini-3.1-flash-image": {
|
||||||
|
"id": "gemini-3.1-flash-image",
|
||||||
|
"name": "Gemini 3.1 Flash Image",
|
||||||
|
"provider": "gemini_native",
|
||||||
|
"model_id": "gemini-3.1-flash-image-preview",
|
||||||
|
"description": "Google 原生生图,速度快、价格低,支持多图参考(最多 14 张)",
|
||||||
|
"supports_ref_image": True,
|
||||||
|
"max_ref_images": 14,
|
||||||
|
"default_params": {},
|
||||||
|
},
|
||||||
|
"flux-schnell": {
|
||||||
|
"id": "flux-schnell",
|
||||||
|
"name": "Flux Schnell",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model_id": "black-forest-labs/flux-schnell",
|
||||||
|
"description": "快速生成,适合快速迭代",
|
||||||
|
"supports_ref_image": False,
|
||||||
|
"num_images_param": "num_outputs",
|
||||||
|
"default_params": {
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"output_format": "png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"flux-dev": {
|
||||||
|
"id": "flux-dev",
|
||||||
|
"name": "Flux Dev",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model_id": "black-forest-labs/flux-dev",
|
||||||
|
"description": "高质量生成,细节更好",
|
||||||
|
"supports_ref_image": False,
|
||||||
|
"num_images_param": "num_outputs",
|
||||||
|
"default_params": {
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"output_format": "png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"sdxl": {
|
||||||
|
"id": "sdxl",
|
||||||
|
"name": "Stable Diffusion XL",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model_id": "stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b",
|
||||||
|
"description": "经典 SDXL,支持 negative prompt",
|
||||||
|
"supports_ref_image": False,
|
||||||
|
"num_images_param": "num_outputs",
|
||||||
|
"default_params": {
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024,
|
||||||
|
"num_inference_steps": 50,
|
||||||
|
"guidance_scale": 7.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"instant-style": {
|
||||||
|
"id": "instant-style",
|
||||||
|
"name": "InstantStyle",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model_id": "jyoung105/instant-style:c6f01e12f31cb99f9ee774a78992a71294f630a6f433d9aecfdc33b816fc4baa",
|
||||||
|
"description": "强风格迁移,画风还原度高(较慢)",
|
||||||
|
"supports_ref_image": True,
|
||||||
|
"ref_image_param": "style_image",
|
||||||
|
"num_images_param": "num_outputs",
|
||||||
|
"default_params": {
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024,
|
||||||
|
"num_inference_steps": 30,
|
||||||
|
"guidance_scale": 5,
|
||||||
|
"style_strength": 1.0,
|
||||||
|
"block_mode": "style-only",
|
||||||
|
"adapter_mode": "original",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"kolors-ipadapter": {
|
||||||
|
"id": "kolors-ipadapter",
|
||||||
|
"name": "Kolors IP-Adapter",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model_id": "fofr/kolors-with-ipadapter:5a1a92b2c0f81813225d48ed8e411813da41aa84e7582fb705d1af46eea36eed",
|
||||||
|
"description": "风格参考生成,上传参考图效果最佳",
|
||||||
|
"supports_ref_image": True,
|
||||||
|
"ref_image_param": "image",
|
||||||
|
"num_images_param": "number_of_images",
|
||||||
|
"default_params": {
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024,
|
||||||
|
"steps": 25,
|
||||||
|
"cfg": 4,
|
||||||
|
"ip_adapter_weight": 0.8,
|
||||||
|
"ip_adapter_weight_type": "style transfer precise",
|
||||||
|
"output_format": "png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
# Mesh 管道专用的内部模型(internal=True 不展示到前端下拉):
|
||||||
|
# 输入两张图:image=原图做 IP-Adapter 风格参考;controlnet_input=normal/depth 结构参考。
|
||||||
|
# 用途:在 Stage 3 对 Trellis 抽出来的 normal 帧做二次重绘,还原原画风。
|
||||||
|
"ip-adapter-controlnet-depth": {
|
||||||
|
"id": "ip-adapter-controlnet-depth",
|
||||||
|
"name": "IP-Adapter + ControlNet Depth (内部)",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model_id": "chigozienri/ip_adapter-sdxl-controlnet-depth:0436c8702ef52616be5c30948551b3af6a86c821cca9b01f11ac297624fff14c",
|
||||||
|
"description": "Mesh 管道 Stage 3 专用:IP-Adapter 保风格 + ControlNet 保几何",
|
||||||
|
"internal": True,
|
||||||
|
"supports_ref_image": True,
|
||||||
|
"ref_image_param": "image", # IP-Adapter 参考图字段
|
||||||
|
"default_params": {
|
||||||
|
"scale": 0.75, # IP-Adapter 权重(建议 0.7-0.8)
|
||||||
|
"controlnet_conditioning_scale": 0.8, # 结构约束(建议 0.7-0.9)
|
||||||
|
"prompt": "same building, same art style, consistent with reference",
|
||||||
|
"negative_prompt": "blurry, distorted, different style, realistic photo, photograph",
|
||||||
|
"num_outputs": 1,
|
||||||
|
"num_inference_steps": 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_style_restore_model_id() -> str:
|
||||||
|
"""Mesh 管道 Stage 3 使用的内部模型短 ID。"""
|
||||||
|
return "ip-adapter-controlnet-depth"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 视角变换模型注册表 ─────────────────────────────────
|
||||||
|
#
|
||||||
|
# 视角变换模型不用于生图,而是将已有图片转换为不同视角。
|
||||||
|
# 输入通常只需一张图,输出为多个固定视角的图片。
|
||||||
|
|
||||||
|
VIEW_TRANSFORM_MODELS: dict[str, dict[str, Any]] = {
|
||||||
|
"zero123plus": {
|
||||||
|
"id": "zero123plus",
|
||||||
|
"name": "Zero123++",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model_id": "jd7h/zero123plusplus:c69c6559a29011b576f1ff0371b3bc1add2856480c60520c7e9ce0b40a6e9052",
|
||||||
|
"description": "单图生成 6 个固定视角,适合建筑/物体的多角度预览",
|
||||||
|
"pipeline": "grid",
|
||||||
|
"output_views": [
|
||||||
|
{"azimuth": 30, "elevation": 30},
|
||||||
|
{"azimuth": 90, "elevation": -20},
|
||||||
|
{"azimuth": 150, "elevation": 30},
|
||||||
|
{"azimuth": 210, "elevation": -20},
|
||||||
|
{"azimuth": 270, "elevation": 30},
|
||||||
|
{"azimuth": 330, "elevation": -20},
|
||||||
|
],
|
||||||
|
"grid_layout": {"cols": 2, "rows": 3},
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
"trellis": {
|
||||||
|
"id": "trellis",
|
||||||
|
"name": "Trellis (Mesh Pipeline)",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model_id": "firtoz/trellis:e8f6c45206993f297372f5436b90350817bd9b4a0d52d2a76df50c1c8afa2b3c",
|
||||||
|
"description": "3D 重建 + 任意视角 + 风格还原,建筑友好(~30s 重建 + 72s/张重绘)",
|
||||||
|
"pipeline": "mesh",
|
||||||
|
"default_params": {
|
||||||
|
"texture_size": 1024,
|
||||||
|
"mesh_simplify": 0.95,
|
||||||
|
"generate_color": True,
|
||||||
|
"generate_normal": True,
|
||||||
|
"generate_model": True,
|
||||||
|
"save_gaussian_ply": False,
|
||||||
|
"ss_sampling_steps": 12,
|
||||||
|
"slat_sampling_steps": 12,
|
||||||
|
"ss_guidance_strength": 7.5,
|
||||||
|
"slat_guidance_strength": 3.0,
|
||||||
|
},
|
||||||
|
"default_azimuths": [0, 60, 120, 180, 240, 300],
|
||||||
|
"default_elevations": [0, 0, 0, 0, 0, 0],
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
"hunyuan3d": {
|
||||||
|
"id": "hunyuan3d",
|
||||||
|
"name": "Hunyuan3D-2 (高质量)",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model_id": "tencent/hunyuan3d-2:b1b9449a1277e10402781c5d41eb30c0a0683504fb23fab591ca9dfc2aabe1cb",
|
||||||
|
"description": "几何质量最佳(~127s),需自定义渲染器(二期启用)",
|
||||||
|
"pipeline": "mesh",
|
||||||
|
"default_params": {
|
||||||
|
"steps": 50,
|
||||||
|
"guidance_scale": 5.5,
|
||||||
|
"octree_resolution": 256,
|
||||||
|
"remove_background": True,
|
||||||
|
},
|
||||||
|
"enabled": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_view_transform_model_config(model_id: str | None = None) -> dict[str, Any]:
|
||||||
|
"""获取视角变换模型配置,默认返回 zero123plus。"""
|
||||||
|
if model_id and model_id in VIEW_TRANSFORM_MODELS:
|
||||||
|
return VIEW_TRANSFORM_MODELS[model_id]
|
||||||
|
return VIEW_TRANSFORM_MODELS["zero123plus"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_view_transform_models_list() -> list[dict]:
|
||||||
|
"""返回前端下拉列表所需的视角变换模型摘要信息(只包含 enabled=True 的)。"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": cfg["id"],
|
||||||
|
"name": cfg["name"],
|
||||||
|
"description": cfg["description"],
|
||||||
|
"pipeline": cfg.get("pipeline", "grid"),
|
||||||
|
}
|
||||||
|
for cfg in VIEW_TRANSFORM_MODELS.values()
|
||||||
|
if cfg.get("enabled", True)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_image_model_id() -> str:
|
||||||
|
"""返回 .env 中配置的默认模型短 ID,若不在注册表中则回退到 flux-schnell。"""
|
||||||
|
env_model = os.getenv("IMAGE_MODEL", "flux-schnell")
|
||||||
|
for mid, cfg in IMAGE_MODELS.items():
|
||||||
|
if cfg["model_id"] == env_model or mid == env_model:
|
||||||
|
return mid
|
||||||
|
return "flux-schnell"
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_model_config(model_id: str | None = None) -> dict[str, Any]:
|
||||||
|
"""根据短 ID 获取模型配置,未指定或不存在则使用默认模型。"""
|
||||||
|
if model_id and model_id in IMAGE_MODELS:
|
||||||
|
return IMAGE_MODELS[model_id]
|
||||||
|
return IMAGE_MODELS[get_default_image_model_id()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_ref_image_model_id() -> str | None:
|
||||||
|
"""返回有参考图时推荐使用的模型 ID(第一个 supports_ref_image=True 的模型)。"""
|
||||||
|
for mid, cfg in IMAGE_MODELS.items():
|
||||||
|
if cfg.get("supports_ref_image"):
|
||||||
|
return mid
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_models_list() -> list[dict]:
|
||||||
|
"""返回前端下拉列表所需的模型摘要信息(过滤掉 internal=True 的内部模型)。"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": cfg["id"],
|
||||||
|
"name": cfg["name"],
|
||||||
|
"description": cfg["description"],
|
||||||
|
"supports_ref_image": cfg.get("supports_ref_image", False),
|
||||||
|
}
|
||||||
|
for cfg in IMAGE_MODELS.values()
|
||||||
|
if not cfg.get("internal", False)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 记忆系统配置 ─────────────────────────────────────
|
||||||
|
|
||||||
|
def get_deepseek_api_key() -> str:
|
||||||
|
return os.getenv("DEEPSEEK_API_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
|
def get_ollama_base_url() -> str:
|
||||||
|
return os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||||
|
|
||||||
|
|
||||||
|
def get_mem0_embedding_model() -> str:
|
||||||
|
return os.getenv("MEM0_EMBEDDING_MODEL", "nomic-embed-text")
|
||||||
|
|
||||||
|
|
||||||
|
def get_max_recent_turns() -> int:
|
||||||
|
return int(os.getenv("MAX_RECENT_TURNS", "20"))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 图像输出配置 ─────────────────────────────────────
|
||||||
|
|
||||||
|
def get_image_aspect_ratio() -> str:
|
||||||
|
return os.getenv("IMAGE_ASPECT_RATIO", "1:1")
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_output_format() -> str:
|
||||||
|
return os.getenv("IMAGE_OUTPUT_FORMAT", "png")
|
||||||
67
art-agent/backend/app/db.py
Normal file
67
art-agent/backend/app/db.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
数据库初始化 + User 模型。
|
||||||
|
使用 SQLite(单文件),存放在 data/epeekit.db。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlmodel import Field, Session, SQLModel, create_engine, select
|
||||||
|
|
||||||
|
DATA_DIR = Path(__file__).parent.parent / "data"
|
||||||
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
DATABASE_URL = f"sqlite:///{DATA_DIR / 'epeekit.db'}"
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL, echo=False)
|
||||||
|
|
||||||
|
|
||||||
|
class User(SQLModel, table=True):
|
||||||
|
id: str = Field(default_factory=lambda: uuid.uuid4().hex, primary_key=True)
|
||||||
|
username: str = Field(index=True, unique=True)
|
||||||
|
hashed_password: str
|
||||||
|
display_name: str = ""
|
||||||
|
is_admin: bool = False
|
||||||
|
is_active: bool = True
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
def create_db_and_tables():
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def get_session():
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_default_admin():
|
||||||
|
"""如果 users 表为空,创建默认管理员账号。"""
|
||||||
|
import os
|
||||||
|
from app.auth import hash_password
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
user = session.exec(select(User).limit(1)).first()
|
||||||
|
if user is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
password = os.getenv("ADMIN_DEFAULT_PASSWORD", "")
|
||||||
|
if not password:
|
||||||
|
password = secrets.token_urlsafe(12)
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f" 默认管理员账号已创建")
|
||||||
|
print(f" 用户名: admin")
|
||||||
|
print(f" 密码: {password}")
|
||||||
|
print(f" 请登录后尽快修改密码!")
|
||||||
|
print(f"{'='*50}\n")
|
||||||
|
|
||||||
|
admin = User(
|
||||||
|
username="admin",
|
||||||
|
hashed_password=hash_password(password),
|
||||||
|
display_name="管理员",
|
||||||
|
is_admin=True,
|
||||||
|
)
|
||||||
|
session.add(admin)
|
||||||
|
session.commit()
|
||||||
54
art-agent/backend/app/main.py
Normal file
54
art-agent/backend/app/main.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from app.api.chat import router as chat_router
|
||||||
|
from app.api.auth import router as auth_router
|
||||||
|
from app.api.admin import router as admin_router
|
||||||
|
from app.api.memory import router as memory_router
|
||||||
|
from app.db import create_db_and_tables, ensure_default_admin
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
create_db_and_tables()
|
||||||
|
ensure_default_admin()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="EPEEKit API", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 确保存储目录存在
|
||||||
|
UPLOADS_DIR = Path(__file__).parent.parent / "uploads"
|
||||||
|
GENERATED_DIR = Path(__file__).parent.parent / "generated"
|
||||||
|
UPLOADS_DIR.mkdir(exist_ok=True)
|
||||||
|
GENERATED_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# 静态文件服务:提供上传的参考图和生成的图片
|
||||||
|
app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads")
|
||||||
|
app.mount("/generated", StaticFiles(directory=str(GENERATED_DIR)), name="generated")
|
||||||
|
|
||||||
|
app.include_router(auth_router, prefix="/api")
|
||||||
|
app.include_router(admin_router, prefix="/api")
|
||||||
|
app.include_router(chat_router, prefix="/api")
|
||||||
|
app.include_router(memory_router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
99
art-agent/backend/app/memory.py
Normal file
99
art-agent/backend/app/memory.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Mem0 记忆层初始化与健康检查。
|
||||||
|
启动时验证 Ollama 可达和模型可用,失败则抛出 RuntimeError 阻止后端启动。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
from mem0 import Memory
|
||||||
|
|
||||||
|
from app.config import (
|
||||||
|
get_deepseek_api_key,
|
||||||
|
get_ollama_base_url,
|
||||||
|
get_mem0_embedding_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_memory: Memory | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _check_ollama_health() -> None:
|
||||||
|
"""检查 Ollama 服务是否可达,以及 embedding 模型是否已安装。"""
|
||||||
|
base_url = get_ollama_base_url()
|
||||||
|
model_name = get_mem0_embedding_model()
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = httpx.get(f"{base_url}/api/tags", timeout=5)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPStatusError) as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"无法连接 Ollama 服务({base_url})。"
|
||||||
|
f"请确认 Ollama 已启动:启动方式参见 https://ollama.com\n"
|
||||||
|
f"原始错误:{e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
models = resp.json().get("models", [])
|
||||||
|
installed = [m.get("name", "").split(":")[0] for m in models]
|
||||||
|
if model_name not in installed:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Ollama 已运行,但未找到 embedding 模型 '{model_name}'。\n"
|
||||||
|
f"已安装的模型:{installed}\n"
|
||||||
|
f"请运行:ollama pull {model_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Ollama 健康检查通过:%s 模型可用", model_name)
|
||||||
|
|
||||||
|
|
||||||
|
def init_memory() -> Memory:
|
||||||
|
"""初始化 Mem0 Memory 单例。首次调用时执行健康检查。"""
|
||||||
|
global _memory
|
||||||
|
if _memory is not None:
|
||||||
|
return _memory
|
||||||
|
|
||||||
|
_check_ollama_health()
|
||||||
|
|
||||||
|
deepseek_key = get_deepseek_api_key()
|
||||||
|
if not deepseek_key:
|
||||||
|
raise RuntimeError(
|
||||||
|
"DEEPSEEK_API_KEY 未配置。Mem0 需要该 key 进行事实提取。\n"
|
||||||
|
"请在 .env 中设置 DEEPSEEK_API_KEY"
|
||||||
|
)
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"llm": {
|
||||||
|
"provider": "deepseek",
|
||||||
|
"config": {
|
||||||
|
"model": "deepseek-chat",
|
||||||
|
"temperature": 0.1,
|
||||||
|
"max_tokens": 1500,
|
||||||
|
"api_key": deepseek_key,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"embedder": {
|
||||||
|
"provider": "ollama",
|
||||||
|
"config": {
|
||||||
|
"model": get_mem0_embedding_model(),
|
||||||
|
"ollama_base_url": get_ollama_base_url(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"vector_store": {
|
||||||
|
"provider": "qdrant",
|
||||||
|
"config": {
|
||||||
|
"collection_name": "epeekit_memories",
|
||||||
|
"embedding_model_dims": 768,
|
||||||
|
"path": "./data/qdrant",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_memory = Memory.from_config(config)
|
||||||
|
logger.info("Mem0 记忆层初始化成功")
|
||||||
|
return _memory
|
||||||
|
|
||||||
|
|
||||||
|
def get_memory() -> Memory:
|
||||||
|
"""获取已初始化的 Memory 实例。未初始化时自动调用 init_memory()。"""
|
||||||
|
if _memory is None:
|
||||||
|
return init_memory()
|
||||||
|
return _memory
|
||||||
0
art-agent/backend/app/services/__init__.py
Normal file
0
art-agent/backend/app/services/__init__.py
Normal file
573
art-agent/backend/app/services/image_gen.py
Normal file
573
art-agent/backend/app/services/image_gen.py
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
"""图像生成服务 — Provider 抽象层。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
from replicate import Client as ReplicateClient
|
||||||
|
|
||||||
|
from app.config import get_image_aspect_ratio, get_image_model_config, get_image_output_format
|
||||||
|
from app.services.image_prompt_strategy import prepare_image_prompt_for_model
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).parent.parent.parent
|
||||||
|
GENERATED_DIR = BACKEND_ROOT / "generated"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_replicate_client() -> ReplicateClient:
|
||||||
|
"""
|
||||||
|
创建 Replicate 客户端。
|
||||||
|
Replicate SDK 内部创建 httpx transport 时不读取代理环境变量,
|
||||||
|
需要我们手动把代理配置注入到 transport 中。
|
||||||
|
"""
|
||||||
|
timeout = httpx.Timeout(5.0, read=300.0, write=30.0, connect=30.0, pool=10.0)
|
||||||
|
proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
|
||||||
|
|
||||||
|
transport_kwargs: dict[str, Any] = {}
|
||||||
|
if proxy_url:
|
||||||
|
transport_kwargs["proxy"] = proxy_url
|
||||||
|
|
||||||
|
our_transport = httpx.AsyncHTTPTransport(**transport_kwargs)
|
||||||
|
|
||||||
|
client = ReplicateClient(
|
||||||
|
timeout=timeout,
|
||||||
|
transport=our_transport,
|
||||||
|
)
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
_replicate_client = _make_replicate_client()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 公共工具 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def to_data_uri(image_path: str) -> str:
|
||||||
|
"""将本地路径或已有 URL 转为 Replicate 可接受的格式(data URI 或原始 URL)。"""
|
||||||
|
if image_path.startswith("data:") or image_path.startswith("http"):
|
||||||
|
return image_path
|
||||||
|
local = BACKEND_ROOT / image_path.lstrip("/")
|
||||||
|
if local.exists():
|
||||||
|
mime = mimetypes.guess_type(str(local))[0] or "image/png"
|
||||||
|
b64 = base64.b64encode(local.read_bytes()).decode()
|
||||||
|
return f"data:{mime};base64,{b64}"
|
||||||
|
return image_path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_image_bytes(image_path: str) -> bytes:
|
||||||
|
"""将本地路径或 data URI 转为原始字节,供 OpenAI images.edit 使用。"""
|
||||||
|
if image_path.startswith("data:"):
|
||||||
|
# data:image/png;base64,xxxx
|
||||||
|
_, b64_part = image_path.split(",", 1)
|
||||||
|
return base64.b64decode(b64_part)
|
||||||
|
if image_path.startswith("http"):
|
||||||
|
raise ValueError("_load_image_bytes 不支持远程 URL,请先下载到本地")
|
||||||
|
local = BACKEND_ROOT / image_path.lstrip("/")
|
||||||
|
if local.exists():
|
||||||
|
return local.read_bytes()
|
||||||
|
raise FileNotFoundError(f"参考图文件未找到: {local}")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_image_base64(image_path: str) -> tuple[str, str]:
|
||||||
|
"""将图片路径/data URI 解析为 (mime_type, base64_string)。
|
||||||
|
|
||||||
|
统一处理三种输入形式:
|
||||||
|
- data URI (data:image/png;base64,xxxx) → 直接提取 mime 和 base64
|
||||||
|
- 本地路径 (/uploads/xxx.png) → 读取文件并编码
|
||||||
|
- 其他 → 尝试作为本地路径处理
|
||||||
|
"""
|
||||||
|
if image_path.startswith("data:"):
|
||||||
|
header, b64_part = image_path.split(",", 1)
|
||||||
|
# header 格式: data:image/png;base64
|
||||||
|
mime = header.split(";")[0].replace("data:", "")
|
||||||
|
return mime, b64_part
|
||||||
|
|
||||||
|
local = BACKEND_ROOT / image_path.lstrip("/")
|
||||||
|
if local.exists():
|
||||||
|
mime = mimetypes.guess_type(str(local))[0] or "image/png"
|
||||||
|
b64 = base64.b64encode(local.read_bytes()).decode()
|
||||||
|
return mime, b64
|
||||||
|
|
||||||
|
raise FileNotFoundError(f"参考图文件未找到: {local}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _download_image(url: str) -> str:
|
||||||
|
"""下载远程图片到本地 generated/ 目录,返回本地 URL 路径。"""
|
||||||
|
filename = f"{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(60.0)) as client:
|
||||||
|
resp = await client.get(url, follow_redirects=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
filepath.write_bytes(resp.content)
|
||||||
|
return f"/generated/{filename}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Provider 抽象基类 ─────────────────────────────────
|
||||||
|
|
||||||
|
class ImageProvider(ABC):
|
||||||
|
"""所有图像生成 provider 的基类。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
model_config: dict[str, Any],
|
||||||
|
prompt: str,
|
||||||
|
num_images: int = 1,
|
||||||
|
ref_image_urls: list[str] | None = None,
|
||||||
|
negative_prompt: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""生成图片并返回本地 URL 列表。negative_prompt 仅部分后端使用(如 Replicate SDXL)。"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class ReplicateProvider(ImageProvider):
|
||||||
|
"""通过 Replicate API 调用模型。"""
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
model_config: dict[str, Any],
|
||||||
|
prompt: str,
|
||||||
|
num_images: int = 1,
|
||||||
|
ref_image_urls: list[str] | None = None,
|
||||||
|
negative_prompt: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
replicate_model_id = model_config["model_id"]
|
||||||
|
default_params: dict[str, Any] = model_config.get("default_params", {})
|
||||||
|
|
||||||
|
local_urls: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Replicate 模型(InstantStyle/Kolors)只支持单张参考图,取第一张
|
||||||
|
single_ref = ref_image_urls[0] if ref_image_urls else None
|
||||||
|
input_params = self._build_input(
|
||||||
|
model_config,
|
||||||
|
default_params,
|
||||||
|
prompt,
|
||||||
|
num_images,
|
||||||
|
single_ref,
|
||||||
|
negative_prompt=negative_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
output = await _replicate_client.async_run(
|
||||||
|
replicate_model_id, input=input_params, wait=False
|
||||||
|
)
|
||||||
|
|
||||||
|
items = output if isinstance(output, list) else [output]
|
||||||
|
for item in items:
|
||||||
|
url = str(item)
|
||||||
|
if url.startswith("https://") or url.startswith("http://") or url.startswith("data:"):
|
||||||
|
local_urls.append(await _download_image(url))
|
||||||
|
else:
|
||||||
|
local_urls.append(f"[生成失败: 模型返回非图片内容: {url[:200]}]")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
|
||||||
|
local_urls.append(f"[生成失败: {detail}]")
|
||||||
|
|
||||||
|
return local_urls
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_input(
|
||||||
|
model_config: dict[str, Any],
|
||||||
|
default_params: dict[str, Any],
|
||||||
|
prompt: str,
|
||||||
|
num_images: int = 1,
|
||||||
|
ref_image_url: str | None = None,
|
||||||
|
negative_prompt: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
根据模型配置构建 Replicate input 参数。
|
||||||
|
|
||||||
|
Replicate 上的 IP-Adapter 模型只支持单张参考图。
|
||||||
|
"""
|
||||||
|
model_id = model_config["model_id"]
|
||||||
|
supports_ref = model_config.get("supports_ref_image", False)
|
||||||
|
ref_param_name = model_config.get("ref_image_param", "image")
|
||||||
|
num_images_param = model_config.get("num_images_param", "num_outputs")
|
||||||
|
|
||||||
|
if supports_ref:
|
||||||
|
params: dict[str, Any] = {"prompt": prompt}
|
||||||
|
for k, v in default_params.items():
|
||||||
|
params[k] = v
|
||||||
|
params[num_images_param] = num_images
|
||||||
|
if ref_image_url:
|
||||||
|
params[ref_param_name] = to_data_uri(ref_image_url)
|
||||||
|
return params
|
||||||
|
|
||||||
|
is_flux = "flux" in model_id.lower()
|
||||||
|
params = {"prompt": prompt, num_images_param: num_images}
|
||||||
|
|
||||||
|
if is_flux:
|
||||||
|
params["aspect_ratio"] = default_params.get(
|
||||||
|
"aspect_ratio", get_image_aspect_ratio()
|
||||||
|
)
|
||||||
|
params["output_format"] = default_params.get(
|
||||||
|
"output_format", get_image_output_format()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
params["width"] = default_params.get("width", 1024)
|
||||||
|
params["height"] = default_params.get("height", 1024)
|
||||||
|
if "num_inference_steps" in default_params:
|
||||||
|
params["num_inference_steps"] = default_params["num_inference_steps"]
|
||||||
|
if "guidance_scale" in default_params:
|
||||||
|
params["guidance_scale"] = default_params["guidance_scale"]
|
||||||
|
if negative_prompt is not None:
|
||||||
|
params["negative_prompt"] = negative_prompt
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIImageProvider(ImageProvider):
|
||||||
|
"""通过 OpenAI 兼容 API(向量引擎中转)调用 GPT Image 系列模型。
|
||||||
|
|
||||||
|
有参考图时使用 images.edit(支持最多 16 张参考图),
|
||||||
|
无参考图时使用 images.generate(纯文生图)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._client: AsyncOpenAI | None = None
|
||||||
|
|
||||||
|
def _get_client(self) -> AsyncOpenAI:
|
||||||
|
if self._client is None:
|
||||||
|
self._client = AsyncOpenAI(
|
||||||
|
api_key=os.getenv("VECTORENGINE_API_KEY"),
|
||||||
|
base_url=os.getenv("VECTORENGINE_BASE_URL", "https://api.vectorengine.ai/v1"),
|
||||||
|
timeout=httpx.Timeout(5.0, read=180.0, write=60.0, connect=30.0),
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
model_config: dict[str, Any],
|
||||||
|
prompt: str,
|
||||||
|
num_images: int = 1,
|
||||||
|
ref_image_urls: list[str] | None = None,
|
||||||
|
negative_prompt: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
_ = negative_prompt
|
||||||
|
client = self._get_client()
|
||||||
|
default_params = model_config.get("default_params", {})
|
||||||
|
model_id = model_config["model_id"]
|
||||||
|
|
||||||
|
local_urls: list[str] = []
|
||||||
|
try:
|
||||||
|
if ref_image_urls:
|
||||||
|
resp = await self._edit_with_refs(
|
||||||
|
client, model_id, prompt, ref_image_urls, num_images, default_params
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
resp = await client.images.generate(
|
||||||
|
model=model_id,
|
||||||
|
prompt=prompt,
|
||||||
|
n=num_images,
|
||||||
|
size=default_params.get("size", "1024x1024"),
|
||||||
|
quality=default_params.get("quality", "high"),
|
||||||
|
)
|
||||||
|
|
||||||
|
for img_data in resp.data:
|
||||||
|
if img_data.url:
|
||||||
|
local_urls.append(await _download_image(img_data.url))
|
||||||
|
elif img_data.b64_json:
|
||||||
|
filename = f"{uuid.uuid4().hex}.png"
|
||||||
|
filepath = GENERATED_DIR / filename
|
||||||
|
filepath.write_bytes(base64.b64decode(img_data.b64_json))
|
||||||
|
local_urls.append(f"/generated/{filename}")
|
||||||
|
else:
|
||||||
|
local_urls.append("[生成失败: 模型未返回图片数据]")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
|
||||||
|
local_urls.append(f"[生成失败: {detail}]")
|
||||||
|
|
||||||
|
return local_urls
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _edit_with_refs(
|
||||||
|
client: AsyncOpenAI,
|
||||||
|
model_id: str,
|
||||||
|
prompt: str,
|
||||||
|
ref_image_urls: list[str],
|
||||||
|
num_images: int,
|
||||||
|
default_params: dict[str, Any],
|
||||||
|
):
|
||||||
|
"""使用 images.edit 端点传入参考图(GPT Image 系列最多 16 张)。"""
|
||||||
|
image_files: list[Any] = []
|
||||||
|
for url in ref_image_urls:
|
||||||
|
image_files.append(_load_image_bytes(url))
|
||||||
|
|
||||||
|
image_arg: Any = image_files[0] if len(image_files) == 1 else image_files
|
||||||
|
|
||||||
|
return await client.images.edit(
|
||||||
|
model=model_id,
|
||||||
|
image=image_arg,
|
||||||
|
prompt=prompt,
|
||||||
|
n=num_images,
|
||||||
|
size=default_params.get("size", "1024x1024"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GeminiNativeImageProvider(ImageProvider):
|
||||||
|
"""通过向量引擎中转调用 Gemini 原生 generateContent 接口。
|
||||||
|
|
||||||
|
Gemini 原生接口支持文字 + 图片混合输入(最多 14 张参考图),
|
||||||
|
在一次 generateContent 调用中同时理解参考图并生成新图片。
|
||||||
|
这是 OpenAI 兼容的 images/generate 和 images/edit 都无法覆盖的能力。
|
||||||
|
|
||||||
|
API 格式:
|
||||||
|
POST /v1beta/models/{model}:generateContent?key={API_KEY}
|
||||||
|
Body: { contents: [{ parts: [...] }], generationConfig: { responseModalities: ["TEXT","IMAGE"] } }
|
||||||
|
Response: candidates[0].content.parts[] → text 或 inline_data (base64)
|
||||||
|
|
||||||
|
多图 + 生图耗时较长,上游或代理可能提前断开(httpx: Server disconnected without sending a response)。
|
||||||
|
使用较长超时 + 对可恢复网络错误自动重试。
|
||||||
|
"""
|
||||||
|
|
||||||
|
_RETRYABLE: tuple[type[BaseException], ...] = (
|
||||||
|
httpx.RemoteProtocolError,
|
||||||
|
httpx.ConnectError,
|
||||||
|
httpx.ReadTimeout,
|
||||||
|
httpx.WriteTimeout,
|
||||||
|
httpx.ConnectTimeout,
|
||||||
|
httpx.PoolTimeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _gemini_timeout(cls) -> httpx.Timeout:
|
||||||
|
"""可通过环境变量调大,多参考图时请求体大、响应慢。"""
|
||||||
|
read_s = float(os.getenv("VECTORENGINE_GEMINI_READ_TIMEOUT", "600"))
|
||||||
|
write_s = float(os.getenv("VECTORENGINE_GEMINI_WRITE_TIMEOUT", "180"))
|
||||||
|
connect_s = float(os.getenv("VECTORENGINE_GEMINI_CONNECT_TIMEOUT", "60"))
|
||||||
|
pool_s = float(os.getenv("VECTORENGINE_GEMINI_POOL_TIMEOUT", "60"))
|
||||||
|
return httpx.Timeout(
|
||||||
|
connect=connect_s,
|
||||||
|
read=read_s,
|
||||||
|
write=write_s,
|
||||||
|
pool=pool_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
model_config: dict[str, Any],
|
||||||
|
prompt: str,
|
||||||
|
num_images: int = 1,
|
||||||
|
ref_image_urls: list[str] | None = None,
|
||||||
|
negative_prompt: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
_ = negative_prompt
|
||||||
|
api_key = os.getenv("VECTORENGINE_API_KEY", "")
|
||||||
|
base_url = os.getenv("VECTORENGINE_BASE_URL", "https://api.vectorengine.ai/v1")
|
||||||
|
# 从 /v1 回退到根 URL,拼接 /v1beta/models/... 端点
|
||||||
|
api_root = base_url.rstrip("/").removesuffix("/v1")
|
||||||
|
model_id = model_config["model_id"]
|
||||||
|
|
||||||
|
url = f"{api_root}/v1beta/models/{model_id}:generateContent?key={api_key}"
|
||||||
|
|
||||||
|
parts = self._build_parts(prompt, ref_image_urls)
|
||||||
|
body = {
|
||||||
|
"contents": [{"parts": parts}],
|
||||||
|
"generationConfig": {
|
||||||
|
"responseModalities": ["TEXT", "IMAGE"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
local_urls: list[str] = []
|
||||||
|
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
|
||||||
|
max_retries = max(1, int(os.getenv("VECTORENGINE_GEMINI_MAX_RETRIES", "3")))
|
||||||
|
timeout = self._gemini_timeout()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data: dict[str, Any] | None = None
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
proxy=proxy,
|
||||||
|
timeout=timeout,
|
||||||
|
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
|
||||||
|
) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
url,
|
||||||
|
json=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
break
|
||||||
|
except self._RETRYABLE as e:
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
await asyncio.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
local_urls.append("[生成失败: 未收到上游响应]")
|
||||||
|
return local_urls
|
||||||
|
|
||||||
|
local_urls = self._extract_images(data)
|
||||||
|
|
||||||
|
if not local_urls:
|
||||||
|
# Gemini 可能只返回了文字(拒绝生图或纯文字回复)
|
||||||
|
text_parts = self._extract_text(data)
|
||||||
|
hint = text_parts[:200] if text_parts else "模型未返回图片"
|
||||||
|
local_urls.append(f"[生成失败: {hint}]")
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
detail = e.response.text[:500] if e.response else str(e)
|
||||||
|
local_urls.append(f"[生成失败: HTTP {e.response.status_code} - {detail}]")
|
||||||
|
except self._RETRYABLE as e:
|
||||||
|
hint = (
|
||||||
|
"连接被上游或代理提前关闭,常见于多图参考或生图较慢。"
|
||||||
|
"可稍后重试,或在 .env 中增大 VECTORENGINE_GEMINI_READ_TIMEOUT / 检查代理稳定性。"
|
||||||
|
)
|
||||||
|
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
|
||||||
|
local_urls.append(f"[生成失败: {detail}。{hint}]")
|
||||||
|
except Exception as e:
|
||||||
|
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
|
||||||
|
local_urls.append(f"[生成失败: {detail}]")
|
||||||
|
|
||||||
|
return local_urls
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_parts(
|
||||||
|
prompt: str, ref_image_urls: list[str] | None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""构建 Gemini generateContent 的 parts 数组:文字 + 内联图片。"""
|
||||||
|
parts: list[dict[str, Any]] = [{"text": prompt}]
|
||||||
|
if ref_image_urls:
|
||||||
|
for url in ref_image_urls:
|
||||||
|
mime, b64 = _resolve_image_base64(url)
|
||||||
|
parts.append({
|
||||||
|
"inline_data": {
|
||||||
|
"mime_type": mime,
|
||||||
|
"data": b64,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return parts
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_images(response_data: dict) -> list[str]:
|
||||||
|
"""从 Gemini 响应中提取所有图片并保存到本地。"""
|
||||||
|
local_urls: list[str] = []
|
||||||
|
candidates = response_data.get("candidates", [])
|
||||||
|
for candidate in candidates:
|
||||||
|
parts = candidate.get("content", {}).get("parts", [])
|
||||||
|
for part in parts:
|
||||||
|
inline = part.get("inlineData") or part.get("inline_data")
|
||||||
|
if inline and inline.get("data"):
|
||||||
|
mime = inline.get("mimeType") or inline.get("mime_type", "image/png")
|
||||||
|
ext = ".png" if "png" in mime else ".jpg" if "jpeg" in mime or "jpg" in mime else ".webp" if "webp" in mime else ".png"
|
||||||
|
filename = f"{uuid.uuid4().hex}{ext}"
|
||||||
|
filepath = GENERATED_DIR / filename
|
||||||
|
filepath.write_bytes(base64.b64decode(inline["data"]))
|
||||||
|
local_urls.append(f"/generated/{filename}")
|
||||||
|
return local_urls
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_text(response_data: dict) -> str:
|
||||||
|
"""从 Gemini 响应中提取文字内容(用于调试或错误提示)。"""
|
||||||
|
texts: list[str] = []
|
||||||
|
candidates = response_data.get("candidates", [])
|
||||||
|
for candidate in candidates:
|
||||||
|
parts = candidate.get("content", {}).get("parts", [])
|
||||||
|
for part in parts:
|
||||||
|
if part.get("text"):
|
||||||
|
texts.append(part["text"])
|
||||||
|
return "\n".join(texts)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Provider 注册 ─────────────────────────────────────
|
||||||
|
|
||||||
|
_PROVIDERS: dict[str, ImageProvider] = {
|
||||||
|
"replicate": ReplicateProvider(),
|
||||||
|
"openai": OpenAIImageProvider(),
|
||||||
|
"gemini_native": GeminiNativeImageProvider(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 公开 API ──────────────────────────────────────────
|
||||||
|
|
||||||
|
class GenerateResult:
|
||||||
|
"""图片生成结果,包含生成的 URL 列表和实际使用的模型信息。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
urls: list[str],
|
||||||
|
model_name: str,
|
||||||
|
model_id: str,
|
||||||
|
*,
|
||||||
|
effective_prompt: str | None = None,
|
||||||
|
negative_prompt: str | None = None,
|
||||||
|
):
|
||||||
|
self.urls = urls
|
||||||
|
self.model_name = model_name
|
||||||
|
self.model_id = model_id
|
||||||
|
self.effective_prompt = effective_prompt
|
||||||
|
self.negative_prompt = negative_prompt
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_images(
|
||||||
|
prompt: str,
|
||||||
|
num_images: int = 1,
|
||||||
|
ref_image_urls: list[str] | None = None,
|
||||||
|
model_id: str | None = None,
|
||||||
|
) -> GenerateResult:
|
||||||
|
"""
|
||||||
|
统一入口:根据 model_id 查注册表,分发到对应 provider。
|
||||||
|
|
||||||
|
ref_image_urls: 参考图路径列表(可为 None 或空列表)。
|
||||||
|
model_id 为空时使用 .env 中配置的默认模型。
|
||||||
|
"""
|
||||||
|
config = get_image_model_config(model_id)
|
||||||
|
provider_name = config.get("provider", "replicate")
|
||||||
|
provider = _PROVIDERS.get(provider_name)
|
||||||
|
|
||||||
|
model_name = config.get("name", "未知模型")
|
||||||
|
resolved_id = config.get("id", model_id or "unknown")
|
||||||
|
|
||||||
|
if not provider:
|
||||||
|
return GenerateResult([f"[未知 provider: {provider_name}]"], model_name, resolved_id)
|
||||||
|
|
||||||
|
effective_refs = ref_image_urls or []
|
||||||
|
|
||||||
|
# Replicate IP-Adapter 系列模型必须有参考图才能工作
|
||||||
|
if config.get("supports_ref_image") and not effective_refs and provider_name == "replicate":
|
||||||
|
return GenerateResult(
|
||||||
|
[f"[生成失败: {model_name} 是风格迁移模型,需要上传参考图才能使用]"],
|
||||||
|
model_name,
|
||||||
|
resolved_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
prepared = prepare_image_prompt_for_model(
|
||||||
|
config,
|
||||||
|
prompt,
|
||||||
|
has_reference_images=bool(effective_refs),
|
||||||
|
)
|
||||||
|
if not prepared.prompt.strip():
|
||||||
|
return GenerateResult(
|
||||||
|
[f"[生成失败: 经模型策略处理后的 prompt 为空]"],
|
||||||
|
model_name,
|
||||||
|
resolved_id,
|
||||||
|
effective_prompt=prepared.prompt,
|
||||||
|
negative_prompt=prepared.negative_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
urls = await provider.generate(
|
||||||
|
config,
|
||||||
|
prepared.prompt,
|
||||||
|
num_images,
|
||||||
|
effective_refs or None,
|
||||||
|
negative_prompt=prepared.negative_prompt,
|
||||||
|
)
|
||||||
|
return GenerateResult(
|
||||||
|
urls,
|
||||||
|
model_name,
|
||||||
|
resolved_id,
|
||||||
|
effective_prompt=prepared.prompt,
|
||||||
|
negative_prompt=prepared.negative_prompt,
|
||||||
|
)
|
||||||
139
art-agent/backend/app/services/image_prompt_strategy.py
Normal file
139
art-agent/backend/app/services/image_prompt_strategy.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""按生图模型预处理 prompt(与 provider 解耦)。
|
||||||
|
|
||||||
|
各策略在对应函数中注明依据:厂商文档、Replicate 模型 API 字段说明或社区通用写法。
|
||||||
|
统一入口:prepare_image_prompt_for_model。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# ─── SDXL(Stability / Replicate)────────────────────────────
|
||||||
|
# Replicate stability-ai/sdxl 提供独立字段 negative_prompt(见模型 API 页)。
|
||||||
|
# 下列为 CLIP 系文生图常见「质量/解剖」排除词,作未显式指定时的基线。
|
||||||
|
DEFAULT_SDXL_NEGATIVE = (
|
||||||
|
"low quality, worst quality, normal quality, lowres, blurry, jpeg artifacts, "
|
||||||
|
"watermark, signature, text, logo, deformed, disfigured, bad anatomy, bad hands, "
|
||||||
|
"extra fingers, mutated, cropped, poorly drawn face"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PreparedImagePrompt:
|
||||||
|
"""下游只读:prompt 必填;negative_prompt 仅部分后端使用(当前为 SDXL)。"""
|
||||||
|
|
||||||
|
prompt: str
|
||||||
|
negative_prompt: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_image_prompt_for_model(
|
||||||
|
model_config: dict[str, Any],
|
||||||
|
raw_prompt: str,
|
||||||
|
*,
|
||||||
|
has_reference_images: bool = False,
|
||||||
|
) -> PreparedImagePrompt:
|
||||||
|
"""根据注册表 id 选择策略。未知 id 时原样透传。"""
|
||||||
|
short_id = model_config.get("id", "")
|
||||||
|
text = (raw_prompt or "").strip()
|
||||||
|
if not text:
|
||||||
|
return PreparedImagePrompt(prompt="")
|
||||||
|
|
||||||
|
dispatch: dict[str, Any] = {
|
||||||
|
"gpt-image-1.5": _openai_gpt_image,
|
||||||
|
"gemini-3.1-flash-image": _gemini_native_image,
|
||||||
|
"flux-schnell": _flux_bfl,
|
||||||
|
"flux-dev": _flux_bfl,
|
||||||
|
"sdxl": _sdxl_replicate,
|
||||||
|
"instant-style": _replicate_ip_adapter_scene,
|
||||||
|
"kolors-ipadapter": _replicate_ip_adapter_scene,
|
||||||
|
}
|
||||||
|
fn = dispatch.get(short_id, _passthrough)
|
||||||
|
return fn(text, model_config, has_reference_images)
|
||||||
|
|
||||||
|
|
||||||
|
def _passthrough(text: str, _model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt:
|
||||||
|
return PreparedImagePrompt(prompt=text)
|
||||||
|
|
||||||
|
|
||||||
|
def _openai_gpt_image(text: str, _model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt:
|
||||||
|
"""OpenAI GPT Image:自然语言指令遵循强,宜为完整、具体的场景描述。
|
||||||
|
|
||||||
|
参考:https://platform.openai.com/docs/guides/image-generation
|
||||||
|
"""
|
||||||
|
return PreparedImagePrompt(prompt=_collapse_ws(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _gemini_native_image(text: str, _model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt:
|
||||||
|
"""Gemini 原生生图:多模态指令 + 文本;英文描述通常效果稳定。
|
||||||
|
|
||||||
|
参考:Google AI 文档 generateContent 与图像输出 modality 说明。
|
||||||
|
"""
|
||||||
|
return PreparedImagePrompt(prompt=_collapse_ws(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _flux_bfl(text: str, _model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt:
|
||||||
|
"""Black Forest Labs FLUX:Subject + Action + Style + Context;靠前放置重点;无 negative API。
|
||||||
|
|
||||||
|
参考:https://docs.bfl.ai/guides/prompting_guide_t2i_fundamentals
|
||||||
|
若用户从 SDXL 复制了 Negative 段,尽量剥掉以免干扰文意。
|
||||||
|
"""
|
||||||
|
cleaned = _strip_pasted_negative_block(text)
|
||||||
|
return PreparedImagePrompt(prompt=_collapse_ws(cleaned))
|
||||||
|
|
||||||
|
|
||||||
|
def _sdxl_replicate(text: str, model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt:
|
||||||
|
"""Replicate SDXL:prompt + negative_prompt 双字段;支持显式拆分。
|
||||||
|
|
||||||
|
约定(可选):正提示与负提示用单独一行分隔符,便于 Agent/用户手写。
|
||||||
|
- ---NEGATIVE--- 或 |||NEG|||
|
||||||
|
未拆分时使用 default_params.negative_prompt 或模块默认 DEFAULT_SDXL_NEGATIVE。
|
||||||
|
"""
|
||||||
|
neg_fallback = model_config.get("default_params", {}).get(
|
||||||
|
"negative_prompt", DEFAULT_SDXL_NEGATIVE
|
||||||
|
)
|
||||||
|
if "---NEGATIVE---" in text:
|
||||||
|
pos, _, neg = text.partition("---NEGATIVE---")
|
||||||
|
pos = pos.strip()
|
||||||
|
neg = neg.strip()
|
||||||
|
return PreparedImagePrompt(
|
||||||
|
prompt=_collapse_ws(pos),
|
||||||
|
negative_prompt=neg or neg_fallback,
|
||||||
|
)
|
||||||
|
if "|||NEG|||" in text:
|
||||||
|
pos, _, neg = text.partition("|||NEG|||")
|
||||||
|
pos = pos.strip()
|
||||||
|
neg = neg.strip()
|
||||||
|
return PreparedImagePrompt(
|
||||||
|
prompt=_collapse_ws(pos),
|
||||||
|
negative_prompt=neg or neg_fallback,
|
||||||
|
)
|
||||||
|
return PreparedImagePrompt(
|
||||||
|
prompt=_collapse_ws(text),
|
||||||
|
negative_prompt=neg_fallback,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _replicate_ip_adapter_scene(
|
||||||
|
text: str, _model_config: dict[str, Any], has_reference_images: bool
|
||||||
|
) -> PreparedImagePrompt:
|
||||||
|
"""IP-Adapter / InstantStyle / Kolors:参考图承担风格与纹理,prompt 侧重场景与内容语义。
|
||||||
|
|
||||||
|
Replicate 各模型 README 均强调 prompt + 参考图配合;无参考图时由上层拦截。
|
||||||
|
有参考图时不额外堆叠长前缀,避免稀释主体描述。
|
||||||
|
"""
|
||||||
|
_ = has_reference_images
|
||||||
|
return PreparedImagePrompt(prompt=_collapse_ws(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _collapse_ws(s: str) -> str:
|
||||||
|
return " ".join(s.split())
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_pasted_negative_block(text: str) -> str:
|
||||||
|
lower = text.lower()
|
||||||
|
for sep in ("\n---negative---\n", "\nnegative prompt:", "\nnegative:"):
|
||||||
|
idx = lower.find(sep)
|
||||||
|
if idx != -1:
|
||||||
|
return text[:idx].strip()
|
||||||
|
return text
|
||||||
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.
16
art-agent/backend/requirements.txt
Normal file
16
art-agent/backend/requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
fastapi==0.135.3
|
||||||
|
uvicorn==0.44.0
|
||||||
|
watchfiles==0.24.0
|
||||||
|
openai==2.32.0
|
||||||
|
replicate==1.0.7
|
||||||
|
sse-starlette==3.3.4
|
||||||
|
python-multipart==0.0.26
|
||||||
|
httpx==0.28.1
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
Pillow==12.2.0
|
||||||
|
imageio-ffmpeg==0.5.1
|
||||||
|
mem0ai==1.0.11
|
||||||
|
ollama==0.6.1
|
||||||
|
sqlmodel==0.0.38
|
||||||
|
pwdlib[argon2,bcrypt]==0.3.0
|
||||||
|
python-jose[cryptography]==3.5.0
|
||||||
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()))
|
||||||
6
art-agent/frontend/next-env.d.ts
vendored
Normal file
6
art-agent/frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
16
art-agent/frontend/next.config.ts
Normal file
16
art-agent/frontend/next.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
allowedDevOrigins: ["*.trycloudflare.com"],
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{ protocol: "http", hostname: "localhost" },
|
||||||
|
{ protocol: "http", hostname: "127.0.0.1" },
|
||||||
|
{ protocol: "https", hostname: "*.trycloudflare.com" },
|
||||||
|
{ protocol: "https", hostname: "replicate.delivery" },
|
||||||
|
{ protocol: "https", hostname: "*.replicate.delivery" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
3191
art-agent/frontend/package-lock.json
generated
Normal file
3191
art-agent/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
art-agent/frontend/package.json
Normal file
28
art-agent/frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "epeekit-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"next": "^15.1.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"postcss": "^8.5.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
art-agent/frontend/postcss.config.mjs
Normal file
8
art-agent/frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1084
art-agent/frontend/src/app/gallery/page.tsx
Normal file
1084
art-agent/frontend/src/app/gallery/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
597
art-agent/frontend/src/app/globals.css
Normal file
597
art-agent/frontend/src/app/globals.css
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ===== 青绿山水色彩体系(白底·绢本) ===== */
|
||||||
|
:root {
|
||||||
|
--bg-primary: #FAFAF7;
|
||||||
|
--bg-secondary: #F2F1EC;
|
||||||
|
--bg-tertiary: #E8E6DF;
|
||||||
|
--bg-card: rgba(255, 255, 255, 0.8);
|
||||||
|
--text-primary: #1A1A1A;
|
||||||
|
--text-secondary: #6B7B8A;
|
||||||
|
--accent: #2E8B7A;
|
||||||
|
--accent-hover: #3AA38E;
|
||||||
|
--accent-secondary: #3A7FB8;
|
||||||
|
--hot: #C4654A;
|
||||||
|
--border: rgba(46, 139, 122, 0.15);
|
||||||
|
--border-glow: rgba(46, 139, 122, 0.30);
|
||||||
|
--mist: rgba(46, 139, 122, 0.04);
|
||||||
|
--gold: #B8935A;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
/* 绢本底纹:极淡暖白,像宣纸的自然质感 */
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse at 20% 80%, rgba(46, 139, 122, 0.03) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 80% 20%, rgba(58, 127, 184, 0.02) 0%, transparent 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 排版层级体系(7 级) ===== */
|
||||||
|
|
||||||
|
.typo-display {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-secondary) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typo-h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-primary);
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.typo-h1::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, var(--accent) 0%, transparent 100%);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typo-h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding-left: 12px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.typo-h2::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typo-strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typo-caption {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typo-micro {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 表面层级系统(Surface Elevation) ===== */
|
||||||
|
|
||||||
|
.surface-1 {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-2 {
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.1);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.1);
|
||||||
|
border: 1px solid rgba(46, 139, 122, 0.12);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-3 {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(24px) saturate(1.15);
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(1.15);
|
||||||
|
border: 1px solid rgba(46, 139, 122, 0.18);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 网格底纹(绢本纹理潜意识层) ===== */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-image: radial-gradient(circle, var(--accent) 0.5px, transparent 0.5px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
opacity: 0.018;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 山雾背景 — 远景层 ===== */
|
||||||
|
@keyframes fogDriftFar {
|
||||||
|
0% { transform: translate(0, 0) scale(1); }
|
||||||
|
33% { transform: translate(2%, -1.5%) scale(1.02); }
|
||||||
|
66% { transform: translate(-1.5%, 1%) scale(0.98); }
|
||||||
|
100% { transform: translate(0, 0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes fogDriftNear {
|
||||||
|
0% { transform: translate(0, 0) scale(1); }
|
||||||
|
25% { transform: translate(-3%, 2%) scale(1.03); }
|
||||||
|
50% { transform: translate(1%, -1%) scale(0.97); }
|
||||||
|
75% { transform: translate(2.5%, 1.5%) scale(1.01); }
|
||||||
|
100% { transform: translate(0, 0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fog-layer-far {
|
||||||
|
position: fixed;
|
||||||
|
inset: -10%;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 50% at 15% 75%, rgba(46, 139, 122, 0.04) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 40% at 75% 25%, rgba(58, 127, 184, 0.03) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse 90% 60% at 50% 50%, rgba(46, 139, 122, 0.02) 0%, transparent 70%);
|
||||||
|
animation: fogDriftFar 60s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.fog-layer-near {
|
||||||
|
position: fixed;
|
||||||
|
inset: -15%;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 70% 45% at 80% 70%, rgba(46, 139, 122, 0.03) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse 50% 60% at 25% 30%, rgba(58, 127, 184, 0.025) 0%, transparent 50%);
|
||||||
|
animation: fogDriftNear 45s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 山雾玻璃面板(兼容旧用法) ===== */
|
||||||
|
.glass-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.65);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.1);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 静态发光边框(卡片等普通元素) ===== */
|
||||||
|
.neon-border {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: border-color 200ms ease, box-shadow 200ms ease, transform 200ms ease;
|
||||||
|
}
|
||||||
|
.neon-border:hover {
|
||||||
|
border-color: var(--border-glow);
|
||||||
|
box-shadow: 0 0 18px rgba(46, 139, 122, 0.08),
|
||||||
|
inset 0 0 12px rgba(46, 139, 122, 0.03);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 云烟缭绕边框 ===== */
|
||||||
|
|
||||||
|
@property --glow-angle {
|
||||||
|
syntax: "<angle>";
|
||||||
|
initial-value: 0deg;
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
@keyframes mistSpin {
|
||||||
|
to { --glow-angle: 360deg; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-border {
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid rgba(46, 139, 122, 0.20) !important;
|
||||||
|
box-shadow: 0 0 10px rgba(46, 139, 122, 0.06);
|
||||||
|
transition: border-color 400ms ease, box-shadow 400ms ease;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.glow-border:hover {
|
||||||
|
border-color: rgba(46, 139, 122, 0.32) !important;
|
||||||
|
box-shadow: 0 0 16px rgba(46, 139, 122, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-border::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 1.5px;
|
||||||
|
background: conic-gradient(
|
||||||
|
from var(--glow-angle),
|
||||||
|
transparent 0%,
|
||||||
|
transparent 15%,
|
||||||
|
rgba(46, 139, 122, 0.2) 25%,
|
||||||
|
rgba(58, 127, 184, 0.3) 38%,
|
||||||
|
rgba(46, 139, 122, 0.35) 50%,
|
||||||
|
rgba(58, 127, 184, 0.3) 62%,
|
||||||
|
rgba(46, 139, 122, 0.2) 75%,
|
||||||
|
transparent 85%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
mask-composite: exclude;
|
||||||
|
animation: mistSpin 6s linear infinite;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 600ms ease;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-border::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -6px;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: conic-gradient(
|
||||||
|
from var(--glow-angle),
|
||||||
|
transparent 0%,
|
||||||
|
transparent 20%,
|
||||||
|
rgba(46, 139, 122, 0.05) 35%,
|
||||||
|
rgba(58, 127, 184, 0.06) 50%,
|
||||||
|
rgba(46, 139, 122, 0.05) 65%,
|
||||||
|
transparent 80%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
filter: blur(12px);
|
||||||
|
animation: mistSpin 6s linear infinite;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 600ms ease;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-border:hover::before { opacity: 1; }
|
||||||
|
.glow-border:hover::after { opacity: 1; }
|
||||||
|
|
||||||
|
/* ===== 雾气滚动边缘 ===== */
|
||||||
|
.fog-scroll {
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0px,
|
||||||
|
black 36px,
|
||||||
|
black calc(100% - 36px),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0px,
|
||||||
|
black 36px,
|
||||||
|
black calc(100% - 36px),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 数据流光(AI 活动时输入栏上方) ===== */
|
||||||
|
@keyframes dataStream {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(200%); }
|
||||||
|
}
|
||||||
|
.data-stream {
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
var(--accent) 30%,
|
||||||
|
var(--accent-secondary) 50%,
|
||||||
|
var(--accent) 70%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
animation: dataStream 2s ease-in-out infinite;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.data-stream-slow {
|
||||||
|
animation-duration: 3s;
|
||||||
|
}
|
||||||
|
.data-stream-fast {
|
||||||
|
animation-duration: 1.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 水波纹 loading 动画 ===== */
|
||||||
|
@keyframes waveRise {
|
||||||
|
0%, 100% { transform: scaleY(0.4); }
|
||||||
|
50% { transform: scaleY(1); }
|
||||||
|
}
|
||||||
|
.wave-dot {
|
||||||
|
width: 4px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
animation: waveRise 1.2s ease-in-out infinite;
|
||||||
|
transform-origin: center bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 图片加载占位 — 石青渐变呼吸脉冲 ===== */
|
||||||
|
@keyframes stoneBreath {
|
||||||
|
0%, 100% { opacity: 0.6; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
.stone-placeholder {
|
||||||
|
background: linear-gradient(135deg, rgba(46, 139, 122, 0.10) 0%, rgba(58, 127, 184, 0.06) 100%);
|
||||||
|
animation: stoneBreath 2s ease-in-out infinite;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== shimmer 骨架屏 ===== */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
.skeleton-shimmer {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--bg-tertiary) 0%,
|
||||||
|
var(--bg-secondary) 40%,
|
||||||
|
var(--bg-tertiary) 60%,
|
||||||
|
var(--bg-tertiary) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.8s ease-in-out infinite;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 涟漪效果 ===== */
|
||||||
|
@keyframes rippleExpand {
|
||||||
|
0% { transform: scale(0); opacity: 0.4; }
|
||||||
|
100% { transform: scale(2.5); opacity: 0; }
|
||||||
|
}
|
||||||
|
.ripple-ring {
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
animation: rippleExpand 600ms ease-out forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 水墨晕染揭示 ===== */
|
||||||
|
@keyframes inkReveal {
|
||||||
|
0% { clip-path: circle(0% at 50% 50%); }
|
||||||
|
100% { clip-path: circle(75% at 50% 50%); }
|
||||||
|
}
|
||||||
|
.ink-reveal {
|
||||||
|
animation: inkReveal 600ms ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 错误状态震动 ===== */
|
||||||
|
@keyframes errorShake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
20% { transform: translateX(-2px); }
|
||||||
|
40% { transform: translateX(2px); }
|
||||||
|
60% { transform: translateX(-2px); }
|
||||||
|
80% { transform: translateX(2px); }
|
||||||
|
}
|
||||||
|
.error-shake {
|
||||||
|
animation: errorShake 300ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 呼吸脉冲(发送按钮就绪态) ===== */
|
||||||
|
@keyframes breathPulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 8px rgba(46, 139, 122, 0.18); }
|
||||||
|
50% { box-shadow: 0 0 20px rgba(46, 139, 122, 0.35); }
|
||||||
|
}
|
||||||
|
.breath-pulse {
|
||||||
|
animation: breathPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 输入框水面感应 ===== */
|
||||||
|
@keyframes waterExpand {
|
||||||
|
0% { transform: scaleX(0); }
|
||||||
|
100% { transform: scaleX(1); }
|
||||||
|
}
|
||||||
|
.water-focus-line {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent 0%, var(--accent) 50%, transparent 100%);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transition: transform 300ms ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.water-focus-active .water-focus-line {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 用户消息气泡 — 左上切角 ===== */
|
||||||
|
.user-bubble {
|
||||||
|
border-radius: 2px 16px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 自定义滚动条 ===== */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(46, 139, 122, 0.15);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(46, 139, 122, 0.30);
|
||||||
|
}
|
||||||
|
|
||||||
|
select option {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 通用动画 ===== */
|
||||||
|
.sidebar-overlay {
|
||||||
|
animation: fadeIn 200ms ease-out;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes floatUp {
|
||||||
|
from { opacity: 1; transform: translateY(0); }
|
||||||
|
to { opacity: 0; transform: translateY(-12px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 通用按钮 hover 浮起 ===== */
|
||||||
|
.btn-hover-lift {
|
||||||
|
transition: transform 200ms ease, box-shadow 200ms ease;
|
||||||
|
}
|
||||||
|
.btn-hover-lift:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 侧栏会话左侧竖线 hover ===== */
|
||||||
|
.session-hover-line {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.session-hover-line::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 1px;
|
||||||
|
transform: scaleY(0);
|
||||||
|
transition: transform 200ms ease-out;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
.session-hover-line:hover::before {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 全屏加载 — 云雾凝聚 ===== */
|
||||||
|
@keyframes mistCondense {
|
||||||
|
0% { transform: scale(1.5); opacity: 0; filter: blur(20px); }
|
||||||
|
50% { transform: scale(1.1); opacity: 0.6; filter: blur(8px); }
|
||||||
|
100% { transform: scale(1); opacity: 1; filter: blur(0); }
|
||||||
|
}
|
||||||
|
@keyframes mistRing {
|
||||||
|
0% { transform: scale(0.3); opacity: 0; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
100% { transform: scale(2); opacity: 0; }
|
||||||
|
}
|
||||||
|
.mist-loader {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.mist-loader-ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
animation: mistRing 2s ease-out infinite;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.mist-loader-ring-2 {
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
.mist-loader-core {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 20px rgba(46, 139, 122, 0.4), 0 0 60px rgba(46, 139, 122, 0.15);
|
||||||
|
animation: mistCondense 1.5s ease-out forwards, breathPulse 2s ease-in-out 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 隐藏滚动条(保留滚动能力) ===== */
|
||||||
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.hide-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Phase 徽章 ===== */
|
||||||
|
.phase-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(184, 147, 90, 0.12);
|
||||||
|
color: var(--gold);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.phase-chip-accent {
|
||||||
|
background: rgba(46, 139, 122, 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 占位卡片(透明虚线边框) ===== */
|
||||||
|
.placeholder-card {
|
||||||
|
border: 1px dashed rgba(46, 139, 122, 0.25);
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(46, 139, 122, 0.02) 0px,
|
||||||
|
rgba(46, 139, 122, 0.02) 12px,
|
||||||
|
transparent 12px,
|
||||||
|
transparent 24px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 段落分隔(用于 Phase 说明) ===== */
|
||||||
|
.section-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent 0%, var(--border-glow) 50%, transparent 100%);
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 100dvh 兼容 ===== */
|
||||||
|
@supports (height: 100dvh) {
|
||||||
|
.h-screen {
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
art-agent/frontend/src/app/layout.tsx
Normal file
46
art-agent/frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Script from "next/script";
|
||||||
|
import { AuthProvider } from "@/lib/auth-context";
|
||||||
|
import { AuthGuard } from "@/lib/auth-guard";
|
||||||
|
import { AppProvider } from "@/lib/app-context";
|
||||||
|
import { CommandPalette } from "@/components/docs/command-palette";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "EPEEKit",
|
||||||
|
description: "AI 美术资源生成工具集",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<body className="antialiased">
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthGuard>
|
||||||
|
<AppProvider>
|
||||||
|
{children}
|
||||||
|
<CommandPalette />
|
||||||
|
</AppProvider>
|
||||||
|
</AuthGuard>
|
||||||
|
</AuthProvider>
|
||||||
|
{/* <model-viewer> 自定义元素,供 3D 结果面板使用(占位) */}
|
||||||
|
<Script
|
||||||
|
type="module"
|
||||||
|
src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"
|
||||||
|
strategy="lazyOnload"
|
||||||
|
/>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
art-agent/frontend/src/app/login/page.tsx
Normal file
140
art-agent/frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, type FormEvent } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||||
|
import { staggerContainer, staggerItem, buttonTap, errorFloat } from "@/components/ui/motion-presets";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { login, isAuthenticated } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
router.replace("/");
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, router]);
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!username.trim() || !password) return;
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(username.trim(), password);
|
||||||
|
router.replace("/");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "登录失败");
|
||||||
|
setTimeout(() => setError(""), 4000);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)] relative">
|
||||||
|
<AmbientParticles count={25} />
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="w-full max-w-sm mx-4 relative z-10"
|
||||||
|
variants={staggerContainer}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
>
|
||||||
|
<motion.div variants={staggerItem} className="text-center mb-8">
|
||||||
|
{/* Logo 发光 */}
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-4
|
||||||
|
bg-[var(--accent)]/10 border border-[var(--accent)]/20
|
||||||
|
shadow-[0_0_40px_rgba(46,139,122,0.15)]">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="var(--accent)" />
|
||||||
|
<circle cx="23" cy="12" r="3.5" stroke="var(--accent)" strokeWidth="1.8" fill="none" />
|
||||||
|
<path d="M23 15.5v5" stroke="var(--accent)" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="typo-display mb-1">EPEEKit</h1>
|
||||||
|
<p className="typo-caption mt-1.5">AI 美术资源生成工具</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.form
|
||||||
|
variants={staggerItem}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="surface-2 rounded-2xl p-6 space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="用户名"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
|
className="w-full px-4 py-3 rounded-xl
|
||||||
|
bg-[var(--bg-primary)] border border-[var(--border)]
|
||||||
|
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||||
|
focus:outline-none focus:border-[var(--accent)]/50
|
||||||
|
focus:shadow-[0_0_10px_rgba(46,139,122,0.06)]
|
||||||
|
transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="密码"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="w-full px-4 py-3 rounded-xl
|
||||||
|
bg-[var(--bg-primary)] border border-[var(--border)]
|
||||||
|
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||||
|
focus:outline-none focus:border-[var(--accent)]/50
|
||||||
|
focus:shadow-[0_0_10px_rgba(46,139,122,0.06)]
|
||||||
|
transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
key="login-error"
|
||||||
|
initial="visible"
|
||||||
|
animate="visible"
|
||||||
|
exit="exit"
|
||||||
|
variants={errorFloat}
|
||||||
|
className="text-sm text-[var(--hot)] bg-[var(--hot)]/10 rounded-xl px-4 py-2.5
|
||||||
|
border border-[var(--hot)]/20 error-shake"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !username.trim() || !password}
|
||||||
|
whileTap={buttonTap}
|
||||||
|
className="w-full py-3 rounded-xl font-semibold
|
||||||
|
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||||
|
hover:bg-[var(--accent-hover)]
|
||||||
|
hover:shadow-[0_0_20px_rgba(46,139,122,0.25)]
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
transition-all cursor-pointer btn-hover-lift"
|
||||||
|
>
|
||||||
|
{loading ? "登录中..." : "登录"}
|
||||||
|
</motion.button>
|
||||||
|
</motion.form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
548
art-agent/frontend/src/app/page.tsx
Normal file
548
art-agent/frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { ChatMessages } from "@/components/chat/chat-messages";
|
||||||
|
import { ChatInput, type ChatInputHandle } from "@/components/chat/chat-input";
|
||||||
|
import { SessionQuickPicks } from "@/components/chat/session-quick-picks";
|
||||||
|
import { TaskTypeTabs } from "@/components/chat/task-type-tabs";
|
||||||
|
import { Params3D } from "@/components/workbench/params-3d";
|
||||||
|
import { ParamsMotionBar } from "@/components/workbench/params-motion";
|
||||||
|
import { AdvancedControls } from "@/components/workbench/advanced-controls";
|
||||||
|
import { CandidatePanel } from "@/components/workbench/candidate-panel";
|
||||||
|
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||||
|
import { TopNav } from "@/components/layout/top-nav";
|
||||||
|
import { ImageDetailPanel } from "@/components/detail/image-detail-panel";
|
||||||
|
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||||
|
import { useApp } from "@/lib/app-context";
|
||||||
|
import { sendChat, getImageUrl, uploadRefImage } from "@/lib/api";
|
||||||
|
import { generateId } from "@/lib/store";
|
||||||
|
import { scrollBtnEnter, scrollBtnClick, staggerContainer, staggerItem } from "@/components/ui/motion-presets";
|
||||||
|
import type { ChatMessage, ImageAsset, ApiMessage, AnnotationData } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const {
|
||||||
|
activeSession,
|
||||||
|
activeSessionId,
|
||||||
|
appendMessage,
|
||||||
|
addAsset,
|
||||||
|
updateSessionThumbnail,
|
||||||
|
detailImage,
|
||||||
|
sidebarCollapsed,
|
||||||
|
setSidebarCollapsed,
|
||||||
|
sessionTaskType,
|
||||||
|
} = useApp();
|
||||||
|
|
||||||
|
const messages = activeSession?.messages ?? [];
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [streamingText, setStreamingText] = useState("");
|
||||||
|
const [streamingImages, setStreamingImages] = useState<ImageAsset[]>([]);
|
||||||
|
const [statusText, setStatusText] = useState("");
|
||||||
|
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
|
||||||
|
const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
|
||||||
|
const [lastRefImageUrls, setLastRefImageUrls] = useState<string[]>([]);
|
||||||
|
const lastRefPerSession = useRef<Map<string, string[]>>(new Map());
|
||||||
|
const [mainDragging, setMainDragging] = useState(false);
|
||||||
|
const dragCounter = useRef(0);
|
||||||
|
const chatInputRef = useRef<ChatInputHandle>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollPositions = useRef<Map<string, number>>(new Map());
|
||||||
|
const prevSessionId = useRef<string | null>(null);
|
||||||
|
const isSwitching = useRef(false);
|
||||||
|
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||||
|
const [showCandidates, setShowCandidates] = useState(false);
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback((instant?: boolean) => {
|
||||||
|
if (isSwitching.current && !instant) return;
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (instant) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollRef.current?.scrollTo({
|
||||||
|
top: scrollRef.current.scrollHeight,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el || !activeSessionId) return;
|
||||||
|
const handler = () => {
|
||||||
|
if (!isSwitching.current) {
|
||||||
|
scrollPositions.current.set(activeSessionId, el.scrollTop);
|
||||||
|
}
|
||||||
|
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
|
setShowScrollBtn(distFromBottom > 200);
|
||||||
|
};
|
||||||
|
el.addEventListener("scroll", handler, { passive: true });
|
||||||
|
return () => el.removeEventListener("scroll", handler);
|
||||||
|
}, [activeSessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeSessionId) return;
|
||||||
|
if (prevSessionId.current && prevSessionId.current !== activeSessionId) {
|
||||||
|
isSwitching.current = true;
|
||||||
|
}
|
||||||
|
prevSessionId.current = activeSessionId;
|
||||||
|
setLastRefImageUrls(lastRefPerSession.current.get(activeSessionId) ?? []);
|
||||||
|
}, [activeSessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeSessionId || !isSwitching.current) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const saved = scrollPositions.current.get(activeSessionId);
|
||||||
|
if (saved !== undefined) {
|
||||||
|
el.scrollTop = saved;
|
||||||
|
} else {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
|
setShowScrollBtn(distFromBottom > 200);
|
||||||
|
isSwitching.current = false;
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeSessionId, messages.length]);
|
||||||
|
|
||||||
|
const handleSend = useCallback(
|
||||||
|
async (text: string, refImageServerUrls: string[], imageModel: string | null = null) => {
|
||||||
|
if (!activeSessionId || !activeSession) return;
|
||||||
|
|
||||||
|
let finalText = text;
|
||||||
|
let finalRefServerUrls = [...refImageServerUrls];
|
||||||
|
|
||||||
|
if (pendingAnnotation) {
|
||||||
|
const annotationDescs = pendingAnnotation.annotations
|
||||||
|
.filter((a) => a.text)
|
||||||
|
.map((a) => {
|
||||||
|
if (a.type === "rect") {
|
||||||
|
return `[区域 (${Math.round(a.x * 100)}%, ${Math.round(a.y * 100)}%) 大小 ${Math.round((a.w ?? 0) * 100)}%×${Math.round((a.h ?? 0) * 100)}%]: ${a.text}`;
|
||||||
|
}
|
||||||
|
return `[标注]: ${a.text}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
if (annotationDescs) {
|
||||||
|
finalText = `${text}\n\n--- 图片标注 ---\n${annotationDescs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingAnnotation.snapshot && finalRefServerUrls.length === 0) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(pendingAnnotation.snapshot);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const file = new File([blob], "annotation.png", { type: "image/png" });
|
||||||
|
const uploadResult = await uploadRefImage(file);
|
||||||
|
finalRefServerUrls = [uploadResult.url];
|
||||||
|
} catch {
|
||||||
|
// 忽略转换/上传失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingAnnotation(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalRefServerUrls.length > 0) {
|
||||||
|
lastRefPerSession.current.set(activeSessionId, finalRefServerUrls);
|
||||||
|
setLastRefImageUrls(finalRefServerUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewUrls = finalRefServerUrls.map((u) => getImageUrl(u));
|
||||||
|
|
||||||
|
const userMessage: ChatMessage = {
|
||||||
|
id: generateId("msg-"),
|
||||||
|
role: "user",
|
||||||
|
content: finalText,
|
||||||
|
refImageUrls: previewUrls.length > 0 ? previewUrls : undefined,
|
||||||
|
};
|
||||||
|
appendMessage(activeSessionId, userMessage);
|
||||||
|
setIsLoading(true);
|
||||||
|
setIsGeneratingImage(false);
|
||||||
|
setStreamingText("");
|
||||||
|
setStreamingImages([]);
|
||||||
|
setStatusText("");
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
const apiMessages: ApiMessage[] = [
|
||||||
|
...activeSession.messages.map((m) => ({ role: m.role, content: m.content })),
|
||||||
|
{ role: userMessage.role, content: userMessage.content },
|
||||||
|
];
|
||||||
|
|
||||||
|
let assistantText = "";
|
||||||
|
let collectedImages: ImageAsset[] = [];
|
||||||
|
let usedModelName = "";
|
||||||
|
|
||||||
|
const llmModel = activeSession?.llmModel ?? null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const event of sendChat(apiMessages, finalRefServerUrls.length > 0 ? finalRefServerUrls : null, imageModel, activeSessionId, llmModel)) {
|
||||||
|
switch (event.type) {
|
||||||
|
case "text_delta":
|
||||||
|
assistantText += event.data.text as string;
|
||||||
|
setStreamingText(assistantText);
|
||||||
|
scrollToBottom();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "tool_start":
|
||||||
|
setStatusText(event.data.message as string);
|
||||||
|
setIsGeneratingImage(true);
|
||||||
|
scrollToBottom();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "image_result": {
|
||||||
|
const rawUrls = (event.data.images as string[]) || [];
|
||||||
|
const newUrls = rawUrls.filter((u) => u && !u.startsWith("["));
|
||||||
|
const prompt = (event.data.prompt_used as string) || "";
|
||||||
|
const modelName = (event.data.model_name as string) || "";
|
||||||
|
|
||||||
|
if (newUrls.length === 0) break;
|
||||||
|
|
||||||
|
const newAssets: ImageAsset[] = newUrls.map((url) => ({
|
||||||
|
id: generateId("img-"),
|
||||||
|
url,
|
||||||
|
prompt,
|
||||||
|
sessionId: activeSessionId,
|
||||||
|
tags: activeSession.tags ? [...activeSession.tags] : [],
|
||||||
|
favorited: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}));
|
||||||
|
newAssets.forEach((a) => addAsset(a));
|
||||||
|
|
||||||
|
if (newAssets.length > 0) {
|
||||||
|
updateSessionThumbnail(activeSessionId, newAssets[0].url);
|
||||||
|
}
|
||||||
|
|
||||||
|
collectedImages = [...collectedImages, ...newAssets];
|
||||||
|
setStreamingImages([...collectedImages]);
|
||||||
|
setIsGeneratingImage(false);
|
||||||
|
if (modelName) {
|
||||||
|
usedModelName = modelName;
|
||||||
|
setStatusText(`由 ${modelName} 生成`);
|
||||||
|
} else {
|
||||||
|
setStatusText("");
|
||||||
|
}
|
||||||
|
scrollToBottom();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tool_error": {
|
||||||
|
const errors = (event.data.errors as string[]) || [];
|
||||||
|
const modelName = (event.data.model_name as string) || "";
|
||||||
|
const errorDetail = errors.join("\n");
|
||||||
|
const modelHint = modelName ? ` (${modelName})` : "";
|
||||||
|
assistantText += `\n\n⚠️ 图片生成失败${modelHint}:\n${errorDetail}`;
|
||||||
|
setStreamingText(assistantText);
|
||||||
|
setStatusText("");
|
||||||
|
setIsGeneratingImage(false);
|
||||||
|
scrollToBottom();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "memory_warning":
|
||||||
|
console.warn("[记忆系统]", event.data.message);
|
||||||
|
setStatusText(`⚠ ${event.data.message}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
assistantText += `\n\n[错误: ${event.data.message}]`;
|
||||||
|
setStreamingText(assistantText);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "done":
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
assistantText += `\n\n[请求失败: ${e instanceof Error ? e.message : "未知错误"}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantMessage: ChatMessage = {
|
||||||
|
id: generateId("msg-"),
|
||||||
|
role: "assistant",
|
||||||
|
content: assistantText || "(生成完成)",
|
||||||
|
images: collectedImages.length > 0 ? collectedImages : undefined,
|
||||||
|
modelName: usedModelName || undefined,
|
||||||
|
};
|
||||||
|
appendMessage(activeSessionId, assistantMessage);
|
||||||
|
setIsLoading(false);
|
||||||
|
setIsGeneratingImage(false);
|
||||||
|
setStreamingText("");
|
||||||
|
setStreamingImages([]);
|
||||||
|
setStatusText("");
|
||||||
|
scrollToBottom();
|
||||||
|
},
|
||||||
|
[activeSession, activeSessionId, appendMessage, addAsset, updateSessionThumbnail, pendingAnnotation, scrollToBottom]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAnnotationComplete = useCallback((data: AnnotationData) => {
|
||||||
|
setPendingAnnotation(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isEmptyState = messages.length === 0 && !isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col relative z-[1]">
|
||||||
|
{/* 环境粒子(萤火 + 云雾) */}
|
||||||
|
<AmbientParticles count={isEmptyState ? 28 : 18} />
|
||||||
|
|
||||||
|
<TopNav />
|
||||||
|
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<main
|
||||||
|
className="flex-1 flex flex-col min-w-0 relative"
|
||||||
|
onDragEnter={(e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter.current++;
|
||||||
|
if (e.dataTransfer.types.includes("Files")) setMainDragging(true);
|
||||||
|
}}
|
||||||
|
onDragOver={(e: DragEvent) => e.preventDefault()}
|
||||||
|
onDragLeave={(e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter.current--;
|
||||||
|
if (dragCounter.current <= 0) { dragCounter.current = 0; setMainDragging(false); }
|
||||||
|
}}
|
||||||
|
onDrop={(e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter.current = 0;
|
||||||
|
setMainDragging(false);
|
||||||
|
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||||
|
files.forEach((f) => chatInputRef.current?.uploadFile(f));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 拖拽覆盖层 */}
|
||||||
|
{mainDragging && (
|
||||||
|
<div className="absolute inset-0 z-50 flex items-center justify-center
|
||||||
|
bg-[var(--bg-primary)]/80 backdrop-blur-sm
|
||||||
|
border-2 border-dashed border-[var(--accent)]/50 rounded-xl m-2
|
||||||
|
pointer-events-none">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="var(--accent)" strokeWidth="1.5" className="mx-auto opacity-70">
|
||||||
|
<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>
|
||||||
|
<p className="text-sm text-[var(--accent)] font-medium">松开以添加参考图(可多张)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 桌面端侧边栏展开按钮 */}
|
||||||
|
{sidebarCollapsed && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarCollapsed(false)}
|
||||||
|
className="absolute left-2 top-2 z-10 p-1.5 rounded-lg
|
||||||
|
bg-[var(--bg-secondary)]/80 backdrop-blur-sm
|
||||||
|
border border-[var(--border)]
|
||||||
|
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||||
|
hover:border-[var(--accent)]/40
|
||||||
|
transition-all cursor-pointer btn-hover-lift
|
||||||
|
hidden md:flex"
|
||||||
|
title="展开侧边栏"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M13 17l5-5-5-5M6 17l5-5-5-5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 标注提示条 */}
|
||||||
|
{pendingAnnotation && (
|
||||||
|
<div className="flex-shrink-0 px-5 py-2.5 bg-[var(--accent)]/5 border-b border-[var(--accent)]/20
|
||||||
|
flex items-center gap-3 backdrop-blur-sm">
|
||||||
|
<img
|
||||||
|
src={pendingAnnotation.snapshot}
|
||||||
|
alt="标注预览"
|
||||||
|
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/40
|
||||||
|
shadow-[0_0_8px_rgba(46,139,122,0.12)]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-[var(--accent)]">
|
||||||
|
标注已就绪({pendingAnnotation.annotations.length} 处)— 输入修改意见后发送
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPendingAnnotation(null)}
|
||||||
|
className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||||
|
cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={scrollRef} className="flex-1 overflow-y-auto fog-scroll relative">
|
||||||
|
<div className="min-h-full">
|
||||||
|
{isEmptyState ? (
|
||||||
|
<div className="h-full flex items-center justify-center" style={{ minHeight: "calc(100vh - 200px)" }}>
|
||||||
|
<motion.div
|
||||||
|
className="text-center space-y-5"
|
||||||
|
variants={staggerContainer}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
>
|
||||||
|
{/* Logo 发光效果 */}
|
||||||
|
<motion.div variants={staggerItem} className="inline-flex items-center justify-center w-16 h-16 rounded-2xl
|
||||||
|
bg-[var(--accent)]/10 border border-[var(--accent)]/20
|
||||||
|
shadow-[0_0_30px_rgba(46,139,122,0.12)]">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="var(--accent)" />
|
||||||
|
<circle cx="23" cy="12" r="3.5" stroke="var(--accent)" strokeWidth="1.8" fill="none" />
|
||||||
|
<path d="M23 15.5v5" stroke="var(--accent)" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
|
||||||
|
</svg>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div variants={staggerItem}>
|
||||||
|
<h2 className="typo-display mb-2">
|
||||||
|
欢迎使用 EPEEKit
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] max-w-md leading-relaxed mx-auto">
|
||||||
|
描述你想要的美术资源,我来帮你生成。
|
||||||
|
<br />
|
||||||
|
你可以上传参考图来引导风格方向。
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div variants={staggerItem} className="flex flex-wrap justify-center gap-2 pt-2">
|
||||||
|
{[
|
||||||
|
"画一个赛博朋克风格的退出按钮",
|
||||||
|
"设计一个卡通风格的金币图标",
|
||||||
|
"画一个奇幻风格的游戏角色立绘",
|
||||||
|
].map((hint) => (
|
||||||
|
<button
|
||||||
|
key={hint}
|
||||||
|
onClick={() => handleSend(hint, [], null)}
|
||||||
|
className="text-xs px-4 py-2 rounded-xl
|
||||||
|
border border-[var(--border)] text-[var(--text-secondary)]
|
||||||
|
hover:border-[var(--accent)]/50 hover:text-[var(--accent)]
|
||||||
|
hover:bg-[var(--accent)]/5
|
||||||
|
transition-all cursor-pointer btn-hover-lift"
|
||||||
|
>
|
||||||
|
{hint}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ChatMessages
|
||||||
|
messages={messages}
|
||||||
|
isLoading={isLoading}
|
||||||
|
streamingText={streamingText}
|
||||||
|
streamingImages={streamingImages}
|
||||||
|
statusText={statusText}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 回到底部 — 落叶归根(水滴形) */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showScrollBtn && (
|
||||||
|
<motion.button
|
||||||
|
key="scroll-btn"
|
||||||
|
variants={scrollBtnEnter}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
exit="exit"
|
||||||
|
onClick={() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}}
|
||||||
|
className="absolute bottom-16 right-4 z-10
|
||||||
|
w-9 h-11 flex items-center justify-center
|
||||||
|
surface-2 text-[var(--text-secondary)]
|
||||||
|
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
||||||
|
shadow-lg shadow-black/30
|
||||||
|
cursor-pointer"
|
||||||
|
style={{ borderRadius: "50% 50% 50% 50% / 35% 35% 65% 65%" }}
|
||||||
|
title="回到底部"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="mt-1">
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0 border-t border-[var(--border)] bg-[var(--bg-secondary)]/60 backdrop-blur-md">
|
||||||
|
{/* 任务类型切换(2D / 3D / 动作) */}
|
||||||
|
<TaskTypeTabs />
|
||||||
|
|
||||||
|
{/* 3D / 动作参数条(按任务类型显示) */}
|
||||||
|
{sessionTaskType === "model3d" && <Params3D />}
|
||||||
|
{sessionTaskType === "motion" && <ParamsMotionBar />}
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<SessionQuickPicks />
|
||||||
|
</div>
|
||||||
|
<div className="px-3 md:px-5 py-2 flex items-center gap-1.5">
|
||||||
|
<AdvancedControls />
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCandidates(true)}
|
||||||
|
className="h-7 px-2 rounded-lg text-xs flex items-center gap-1.5
|
||||||
|
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||||
|
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||||
|
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
|
||||||
|
title="候选评估(占位)"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="3" width="7" height="7" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" />
|
||||||
|
</svg>
|
||||||
|
候选
|
||||||
|
<span className="phase-chip">P1</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChatInput
|
||||||
|
ref={chatInputRef}
|
||||||
|
onSend={handleSend}
|
||||||
|
disabled={isLoading}
|
||||||
|
isGeneratingImage={isGeneratingImage}
|
||||||
|
lastRefServerUrls={lastRefImageUrls}
|
||||||
|
lastRefPreviewUrls={lastRefImageUrls.map((u) => getImageUrl(u))}
|
||||||
|
onClearLastRefImage={() => {
|
||||||
|
if (activeSessionId) lastRefPerSession.current.delete(activeSessionId);
|
||||||
|
setLastRefImageUrls([]);
|
||||||
|
}}
|
||||||
|
onFileDrop={() => {
|
||||||
|
dragCounter.current = 0;
|
||||||
|
setMainDragging(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{detailImage && (
|
||||||
|
<ImageDetailPanel onAnnotationComplete={handleAnnotationComplete} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCandidates && (
|
||||||
|
<CandidatePanel
|
||||||
|
candidates={
|
||||||
|
messages
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.find((m) => m.role === "assistant" && m.images && m.images.length > 0)?.images ??
|
||||||
|
streamingImages ??
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
onPick={(id) => {
|
||||||
|
alert(`(占位)已选 ${id} 作为定稿`);
|
||||||
|
setShowCandidates(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setShowCandidates(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
419
art-agent/frontend/src/components/chat/chat-input.tsx
Normal file
419
art-agent/frontend/src/components/chat/chat-input.tsx
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, useCallback, useImperativeHandle, forwardRef, type KeyboardEvent, type DragEvent, type ClipboardEvent } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ModelSelector } from "./model-selector";
|
||||||
|
import { DataStream } from "@/components/ui/data-stream";
|
||||||
|
import { useRipple, RippleLayer } from "@/components/ui/ripple-effect";
|
||||||
|
import { uploadRefImage, type UploadProgress } from "@/lib/api";
|
||||||
|
import { sendBounce } from "@/components/ui/motion-presets";
|
||||||
|
|
||||||
|
type UploadStatus = "idle" | "uploading" | "done" | "error";
|
||||||
|
|
||||||
|
interface UploadItem {
|
||||||
|
id: string;
|
||||||
|
status: UploadStatus;
|
||||||
|
previewUrl: string | null;
|
||||||
|
serverUrl: string | null;
|
||||||
|
progress: number;
|
||||||
|
errorMsg: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatInputProps {
|
||||||
|
onSend: (text: string, refImageServerUrls: string[], imageModel: string | null) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
/** 上次使用的参考图服务端路径列表,用于自动沿用 */
|
||||||
|
lastRefServerUrls?: string[];
|
||||||
|
/** 上次参考图的完整可预览 URL 列表 */
|
||||||
|
lastRefPreviewUrls?: string[];
|
||||||
|
/** 用户主动清除沿用参考图时的回调 */
|
||||||
|
onClearLastRefImage?: () => void;
|
||||||
|
/** 文件在 ChatInput 区域内被 drop 时触发,通知父组件清除拖拽覆盖层 */
|
||||||
|
onFileDrop?: () => void;
|
||||||
|
/** 是否正在生成图片(用于数据流光加速) */
|
||||||
|
isGeneratingImage?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatInputHandle {
|
||||||
|
uploadFile: (file: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _uploadCounter = 0;
|
||||||
|
|
||||||
|
function imageFilesFromClipboard(data: DataTransfer | null): File[] {
|
||||||
|
if (!data?.items?.length) return [];
|
||||||
|
const out: File[] = [];
|
||||||
|
for (let i = 0; i < data.items.length; i++) {
|
||||||
|
const it = data.items[i];
|
||||||
|
if (it.kind === "file" && it.type.startsWith("image/")) {
|
||||||
|
const f = it.getAsFile();
|
||||||
|
if (f) out.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
|
||||||
|
{ onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop, isGeneratingImage },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [uploads, setUploads] = useState<UploadItem[]>([]);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const { ripples, trigger: triggerRipple } = useRipple();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const sendBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const abortRefs = useRef<Map<string, AbortController>>(new Map());
|
||||||
|
|
||||||
|
const hasActiveUpload = uploads.some((u) => u.status === "uploading");
|
||||||
|
const allDone = uploads.length > 0 && uploads.every((u) => u.status === "done" || u.status === "error");
|
||||||
|
const doneUrls = uploads.filter((u) => u.status === "done" && u.serverUrl).map((u) => u.serverUrl!);
|
||||||
|
|
||||||
|
const canSend = text.trim() && !disabled && !hasActiveUpload;
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed || disabled) return;
|
||||||
|
if (hasActiveUpload) return;
|
||||||
|
|
||||||
|
// 投石入水涟漪
|
||||||
|
if (sendBtnRef.current) {
|
||||||
|
const rect = sendBtnRef.current.getBoundingClientRect();
|
||||||
|
const parent = sendBtnRef.current.offsetParent as HTMLElement | null;
|
||||||
|
const parentRect = parent?.getBoundingClientRect() ?? rect;
|
||||||
|
triggerRipple(rect.left - parentRect.left + rect.width / 2, rect.top - parentRect.top + rect.height / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const refUrls = doneUrls.length > 0 ? doneUrls : (lastRefServerUrls ?? []);
|
||||||
|
onSend(trimmed, refUrls, selectedModel || null);
|
||||||
|
setText("");
|
||||||
|
clearAllUploads();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAllUploads = useCallback(() => {
|
||||||
|
abortRefs.current.forEach((c) => c.abort());
|
||||||
|
abortRefs.current.clear();
|
||||||
|
setUploads((prev) => {
|
||||||
|
prev.forEach((u) => { if (u.previewUrl) URL.revokeObjectURL(u.previewUrl); });
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeUpload = useCallback((id: string) => {
|
||||||
|
abortRefs.current.get(id)?.abort();
|
||||||
|
abortRefs.current.delete(id);
|
||||||
|
setUploads((prev) => {
|
||||||
|
const item = prev.find((u) => u.id === id);
|
||||||
|
if (item?.previewUrl) URL.revokeObjectURL(item.previewUrl);
|
||||||
|
return prev.filter((u) => u.id !== id);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startUpload = useCallback(async (file: File) => {
|
||||||
|
const id = `upload-${++_uploadCounter}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRefs.current.set(id, controller);
|
||||||
|
|
||||||
|
const previewUrl = URL.createObjectURL(file);
|
||||||
|
const newItem: UploadItem = {
|
||||||
|
id,
|
||||||
|
status: "uploading",
|
||||||
|
previewUrl,
|
||||||
|
serverUrl: null,
|
||||||
|
progress: 0,
|
||||||
|
errorMsg: null,
|
||||||
|
};
|
||||||
|
setUploads((prev) => [...prev, newItem]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadRefImage(
|
||||||
|
file,
|
||||||
|
(p: UploadProgress) => {
|
||||||
|
setUploads((prev) =>
|
||||||
|
prev.map((u) => (u.id === id ? { ...u, progress: p.percent } : u))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
controller.signal
|
||||||
|
);
|
||||||
|
setUploads((prev) =>
|
||||||
|
prev.map((u) =>
|
||||||
|
u.id === id ? { ...u, status: "done", serverUrl: result.url, progress: 100 } : u
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||||
|
const msg = err instanceof Error ? err.message : "上传失败";
|
||||||
|
setUploads((prev) =>
|
||||||
|
prev.map((u) =>
|
||||||
|
u.id === id ? { ...u, status: "error", progress: 0, errorMsg: msg } : u
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({ uploadFile: startUpload }), [startUpload]);
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (files) {
|
||||||
|
Array.from(files).forEach((f) => startUpload(f));
|
||||||
|
}
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
const handleDragLeave = () => setIsDragging(false);
|
||||||
|
const handleDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
onFileDrop?.();
|
||||||
|
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||||
|
files.forEach((f) => startUpload(f));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasteCapture = useCallback(
|
||||||
|
(e: ClipboardEvent) => {
|
||||||
|
if (disabled) return;
|
||||||
|
const files = imageFilesFromClipboard(e.clipboardData);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
files.forEach((f) => startUpload(f));
|
||||||
|
},
|
||||||
|
[disabled, startUpload]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`border-t border-[var(--border)] bg-[var(--bg-secondary)]/80 backdrop-blur-xl
|
||||||
|
transition-colors ${
|
||||||
|
isDragging ? "bg-[var(--accent)]/5 border-[var(--accent)]/40" : ""
|
||||||
|
}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onPasteCapture={handlePasteCapture}
|
||||||
|
>
|
||||||
|
{/* 数据流光 */}
|
||||||
|
<DataStream active={disabled} speed={isGeneratingImage ? "fast" : "slow"} />
|
||||||
|
|
||||||
|
<div className="relative px-3 md:px-5 py-2.5 md:py-3">
|
||||||
|
{isDragging && (
|
||||||
|
<div className="mb-2 text-center text-xs text-[var(--accent)]">
|
||||||
|
松开以添加参考图(可多张)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 沿用上次参考图提示 */}
|
||||||
|
{uploads.length === 0 && lastRefPreviewUrls && lastRefPreviewUrls.length > 0 && (
|
||||||
|
<div className="mb-3 flex items-center gap-2.5">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{lastRefPreviewUrls.map((url, i) => (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={url}
|
||||||
|
alt={`沿用参考图 ${i + 1}`}
|
||||||
|
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/30
|
||||||
|
shadow-[0_0_8px_rgba(46,139,122,0.1)]"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
|
沿用上次参考图({lastRefPreviewUrls.length} 张)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onClearLastRefImage}
|
||||||
|
className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--hot)]
|
||||||
|
cursor-pointer transition-colors"
|
||||||
|
title="清除参考图"
|
||||||
|
>
|
||||||
|
✕ 清除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 参考图上传状态区(多图) */}
|
||||||
|
{uploads.length > 0 && (
|
||||||
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
|
{uploads.map((item) => (
|
||||||
|
<div key={item.id} className="relative flex-shrink-0">
|
||||||
|
{item.previewUrl && (
|
||||||
|
<img
|
||||||
|
src={item.previewUrl}
|
||||||
|
alt="参考图"
|
||||||
|
className={`w-16 h-16 rounded-xl object-cover border border-[var(--border)] transition-opacity ${
|
||||||
|
item.status === "uploading" ? "opacity-60" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.status === "uploading" && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 36 36">
|
||||||
|
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(46,139,122,0.12)" strokeWidth="3" />
|
||||||
|
<circle
|
||||||
|
cx="18" cy="18" r="14" fill="none"
|
||||||
|
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
|
||||||
|
strokeDasharray={`${item.progress * 0.88} 88`}
|
||||||
|
className="transition-all duration-200"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.status === "done" && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--accent)]
|
||||||
|
text-[var(--bg-primary)] text-[10px] flex items-center justify-center font-bold
|
||||||
|
shadow-[0_0_8px_rgba(46,139,122,0.3)]">
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.status === "error" && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--hot)]
|
||||||
|
text-white text-[10px] flex items-center justify-center font-bold">
|
||||||
|
!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.status !== "uploading" && (
|
||||||
|
<button
|
||||||
|
onClick={() => removeUpload(item.id)}
|
||||||
|
className="absolute -top-1.5 -left-1.5 w-5 h-5 rounded-full
|
||||||
|
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||||
|
text-[var(--text-secondary)] text-xs flex items-center justify-center
|
||||||
|
hover:bg-[var(--hot)] hover:text-white hover:border-[var(--hot)]
|
||||||
|
cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex flex-col justify-center gap-1 min-w-0">
|
||||||
|
{hasActiveUpload && (
|
||||||
|
<span className="text-xs text-[var(--accent)] flex items-center gap-1.5">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
|
||||||
|
shadow-[0_0_6px_rgba(46,139,122,0.3)]" />
|
||||||
|
上传中...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{allDone && (
|
||||||
|
<span className="text-xs text-[var(--accent)]">
|
||||||
|
{doneUrls.length} 张参考图已就绪
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{uploads.some((u) => u.status === "error") && (
|
||||||
|
<span className="text-xs text-[var(--hot)]">
|
||||||
|
{uploads.filter((u) => u.status === "error").length} 张上传失败
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<ModelSelector value={selectedModel} onChange={setSelectedModel} />
|
||||||
|
|
||||||
|
{/* 上传参考图按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={disabled || hasActiveUpload}
|
||||||
|
className={`relative flex-shrink-0 w-10 h-10 rounded-xl border
|
||||||
|
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||||
|
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
||||||
|
hover:bg-[var(--accent)]/5
|
||||||
|
flex items-center justify-center transition-all
|
||||||
|
disabled:opacity-50 cursor-pointer btn-hover-lift ${
|
||||||
|
doneUrls.length > 0
|
||||||
|
? "border-[var(--accent)]/40 text-[var(--accent)]"
|
||||||
|
: "border-[var(--border)]"
|
||||||
|
}`}
|
||||||
|
title="上传参考图(可多选)"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" 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>
|
||||||
|
{doneUrls.length > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--accent)]
|
||||||
|
text-[var(--bg-primary)] text-[9px] flex items-center justify-center font-bold">
|
||||||
|
{doneUrls.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 文字输入框 — 水面感应 */}
|
||||||
|
<div className={`relative flex-1 ${isFocused ? "water-focus-active" : ""}`}>
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
|
placeholder="描述你想要的美术资源...(可粘贴图片)"
|
||||||
|
title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图"
|
||||||
|
disabled={disabled}
|
||||||
|
rows={1}
|
||||||
|
className="w-full resize-none rounded-xl border border-[var(--border)]
|
||||||
|
bg-[var(--bg-tertiary)] text-[var(--text-primary)]
|
||||||
|
placeholder:text-[var(--text-secondary)]
|
||||||
|
px-4 py-2.5 text-sm leading-relaxed
|
||||||
|
focus:outline-none focus:border-[var(--accent)]/50
|
||||||
|
disabled:opacity-50 transition-all
|
||||||
|
min-h-[42px] max-h-[120px]"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 发送按钮 — 投石入水 */}
|
||||||
|
<motion.button
|
||||||
|
ref={sendBtnRef}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSend}
|
||||||
|
whileTap={canSend ? sendBounce : undefined}
|
||||||
|
className={`flex-shrink-0 w-10 h-10 rounded-xl
|
||||||
|
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||||
|
hover:bg-[var(--accent-hover)]
|
||||||
|
hover:shadow-[0_0_15px_rgba(46,139,122,0.25)]
|
||||||
|
flex items-center justify-center transition-all
|
||||||
|
disabled:opacity-50 cursor-pointer
|
||||||
|
${canSend ? "breath-pulse" : ""}`}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||||
|
</svg>
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
{/* 涟漪层 */}
|
||||||
|
<RippleLayer ripples={ripples} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
186
art-agent/frontend/src/components/chat/chat-messages.tsx
Normal file
186
art-agent/frontend/src/components/chat/chat-messages.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import type { ChatMessage, ImageAsset } from "@/lib/types";
|
||||||
|
import { ImageGrid } from "./image-grid";
|
||||||
|
import { messageEnter, assistantEnter } from "@/components/ui/motion-presets";
|
||||||
|
|
||||||
|
function StreamingLines({ text }: { text: string }) {
|
||||||
|
const lines = useMemo(() => text.split("\n"), [text]);
|
||||||
|
const total = lines.length;
|
||||||
|
return (
|
||||||
|
<div className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
const fromEnd = total - 1 - i;
|
||||||
|
const opacity = fromEnd <= 0 ? 1 : fromEnd === 1 ? 0.85 : fromEnd === 2 ? 0.7 : 0.6;
|
||||||
|
return (
|
||||||
|
<span key={i} style={{ opacity, transition: "opacity 0.3s ease" }}>
|
||||||
|
{line}
|
||||||
|
{i < total - 1 && "\n"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyButton({ text }: { text: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
// fallback
|
||||||
|
}
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="p-1 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||||
|
hover:bg-[var(--accent)]/5 transition-colors cursor-pointer btn-hover-lift"
|
||||||
|
title="复制文本"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessagesProps {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
isLoading: boolean;
|
||||||
|
streamingText: string;
|
||||||
|
streamingImages: ImageAsset[];
|
||||||
|
statusText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessages({
|
||||||
|
messages,
|
||||||
|
isLoading,
|
||||||
|
streamingText,
|
||||||
|
streamingImages,
|
||||||
|
statusText,
|
||||||
|
}: ChatMessagesProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto px-3 md:px-5 py-4 md:py-6 space-y-4 md:space-y-5">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<motion.div
|
||||||
|
key={msg.id}
|
||||||
|
variants={msg.role === "user" ? messageEnter : assistantEnter}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className={`group/msg flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start gap-0.5">
|
||||||
|
<div
|
||||||
|
className={`max-w-[90vw] md:max-w-[80vw] px-4 md:px-5 py-3 ${
|
||||||
|
msg.role === "user"
|
||||||
|
? "user-bubble bg-gradient-to-r from-[var(--accent)]/8 to-[var(--accent)]/15 text-[var(--text-primary)]"
|
||||||
|
: "rounded-2xl surface-2 text-[var(--text-primary)] border-l-2 border-l-[var(--accent)]/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 多图参考(新格式) */}
|
||||||
|
{msg.refImageUrls && msg.refImageUrls.length > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{msg.refImageUrls.map((url, i) => (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={url}
|
||||||
|
alt={`参考图 ${i + 1}`}
|
||||||
|
className="max-w-[120px] max-h-[90px] rounded-lg object-cover
|
||||||
|
border border-[var(--accent)]/20
|
||||||
|
shadow-[0_0_10px_rgba(46,139,122,0.08)]"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block typo-micro" style={{ textTransform: "none" }}>
|
||||||
|
参考图({msg.refImageUrls.length} 张)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 兼容旧数据:单图 */}
|
||||||
|
{!msg.refImageUrls && msg.refImageUrl && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<img
|
||||||
|
src={msg.refImageUrl}
|
||||||
|
alt="参考图"
|
||||||
|
className="max-w-[160px] max-h-[120px] rounded-lg object-cover
|
||||||
|
border border-[var(--accent)]/20
|
||||||
|
shadow-[0_0_10px_rgba(46,139,122,0.08)]"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block">参考图</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="whitespace-pre-wrap text-sm leading-relaxed">{msg.content}</div>
|
||||||
|
{msg.images && msg.images.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ImageGrid images={msg.images} />
|
||||||
|
{msg.modelName && (
|
||||||
|
<div className="mt-1.5 text-[10px] text-[var(--accent)]/50 typo-caption">
|
||||||
|
由 {msg.modelName} 生成
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{msg.content && (
|
||||||
|
<div className={`opacity-0 group-hover/msg:opacity-100 transition-opacity
|
||||||
|
${msg.role === "user" ? "mr-1 self-end" : "ml-1"}`}>
|
||||||
|
<CopyButton text={msg.content} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* AI 正在回复(流式) */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isLoading && (
|
||||||
|
<motion.div
|
||||||
|
key="streaming"
|
||||||
|
variants={assistantEnter}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||||
|
className="flex justify-start"
|
||||||
|
>
|
||||||
|
<div className="max-w-[90%] md:max-w-[80%] rounded-2xl px-4 md:px-5 py-3 surface-2 border-l-2 border-l-[var(--accent)]/40">
|
||||||
|
{statusText && (
|
||||||
|
<div className="text-xs text-[var(--accent)] mb-2 flex items-center gap-2">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
|
||||||
|
shadow-[0_0_8px_rgba(46,139,122,0.4)]" />
|
||||||
|
{statusText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{streamingText && (
|
||||||
|
<StreamingLines text={streamingText} />
|
||||||
|
)}
|
||||||
|
{streamingImages.length > 0 && <ImageGrid images={streamingImages} />}
|
||||||
|
{!streamingText && !statusText && (
|
||||||
|
<div className="flex items-end gap-1 h-5">
|
||||||
|
<div className="wave-dot" style={{ animationDelay: "0s" }} />
|
||||||
|
<div className="wave-dot" style={{ animationDelay: "0.15s" }} />
|
||||||
|
<div className="wave-dot" style={{ animationDelay: "0.3s" }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
art-agent/frontend/src/components/chat/image-grid.tsx
Normal file
161
art-agent/frontend/src/components/chat/image-grid.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { getImageUrl } from "@/lib/api";
|
||||||
|
import { useApp } from "@/lib/app-context";
|
||||||
|
import { useFireflyBurst, FireflyBurst } from "@/components/ui/firefly-burst";
|
||||||
|
import { inkReveal, staggerContainer, staggerItem } from "@/components/ui/motion-presets";
|
||||||
|
import type { ImageAsset } from "@/lib/types";
|
||||||
|
|
||||||
|
interface ImageGridProps {
|
||||||
|
images: ImageAsset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function FavoriteButton({ asset }: { asset: ImageAsset }) {
|
||||||
|
const { toggleFavorite } = useApp();
|
||||||
|
const { sparks, burst } = useFireflyBurst();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!asset.favorited) burst();
|
||||||
|
toggleFavorite(asset.id);
|
||||||
|
}}
|
||||||
|
className="relative p-1.5 rounded-lg cursor-pointer transition-colors
|
||||||
|
hover:bg-[var(--accent)]/10"
|
||||||
|
style={{ color: asset.favorited ? "var(--gold)" : "rgba(255,255,255,0.5)" }}
|
||||||
|
title={asset.favorited ? "取消收藏" : "收藏"}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
|
||||||
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||||
|
</svg>
|
||||||
|
<FireflyBurst sparks={sparks} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageGrid({ images }: ImageGridProps) {
|
||||||
|
const { setDetailImage } = useApp();
|
||||||
|
|
||||||
|
const handleDownload = async (imageUrl: string, index: number) => {
|
||||||
|
try {
|
||||||
|
const fullUrl = getImageUrl(imageUrl);
|
||||||
|
const response = await fetch(fullUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `epeekit-${Date.now()}-${index + 1}.png`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
alert("下载失败,请重试");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridCols =
|
||||||
|
images.length === 1
|
||||||
|
? "grid-cols-1 max-w-[280px] md:max-w-sm"
|
||||||
|
: "grid-cols-2 max-w-[320px] md:max-w-xl";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={`grid ${gridCols} gap-3 my-3`}
|
||||||
|
variants={staggerContainer}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
>
|
||||||
|
{images.map((asset, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={asset.id}
|
||||||
|
variants={staggerItem}
|
||||||
|
className="group relative rounded-xl overflow-hidden neon-border
|
||||||
|
bg-[var(--bg-card)] backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<ImageReveal src={getImageUrl(asset.url)} alt={`生成图片 ${i + 1}`}>
|
||||||
|
<img
|
||||||
|
src={getImageUrl(asset.url)}
|
||||||
|
alt={`生成图片 ${i + 1}`}
|
||||||
|
className="w-full aspect-square object-cover cursor-pointer
|
||||||
|
transition-transform duration-300 group-hover:scale-[1.03]"
|
||||||
|
loading="lazy"
|
||||||
|
onClick={() => setDetailImage(asset)}
|
||||||
|
/>
|
||||||
|
</ImageReveal>
|
||||||
|
|
||||||
|
{/* 底部渐变 + 操作栏 */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 right-0 px-3 py-2
|
||||||
|
bg-gradient-to-t from-black/80 via-black/40 to-transparent
|
||||||
|
opacity-100 md:opacity-0 md:group-hover:opacity-100
|
||||||
|
transition-opacity duration-200
|
||||||
|
flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setDetailImage(asset)}
|
||||||
|
className="p-1.5 rounded-lg text-white/70 hover:text-[var(--accent)]
|
||||||
|
hover:bg-[var(--accent)]/10 cursor-pointer transition-colors"
|
||||||
|
title="放大查看"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(asset.url, i)}
|
||||||
|
className="p-1.5 rounded-lg text-white/70 hover:text-[var(--accent)]
|
||||||
|
hover:bg-[var(--accent)]/10 cursor-pointer transition-colors"
|
||||||
|
title="保存"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" 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>
|
||||||
|
</button>
|
||||||
|
<FavoriteButton asset={asset} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 最新生成标签 */}
|
||||||
|
{i === 0 && images.length > 1 && (
|
||||||
|
<div className="absolute top-2 left-2">
|
||||||
|
<span className="typo-micro px-2 py-0.5 rounded-md
|
||||||
|
bg-[var(--accent)]/20 text-[var(--accent)]
|
||||||
|
border border-[var(--accent)]/30 backdrop-blur-sm"
|
||||||
|
style={{ textTransform: "uppercase" }}>
|
||||||
|
New
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
art-agent/frontend/src/components/chat/model-selector.tsx
Normal file
136
art-agent/frontend/src/components/chat/model-selector.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import type { ImageModelInfo } from "@/lib/types";
|
||||||
|
import { fetchModels } from "@/lib/api";
|
||||||
|
import { slideUp } from "@/components/ui/motion-presets";
|
||||||
|
|
||||||
|
import { getStoreUserId } from "@/lib/store";
|
||||||
|
|
||||||
|
function getModelStorageKey() {
|
||||||
|
const uid = getStoreUserId() || "_anonymous";
|
||||||
|
return `epeekit-${uid}-selected-image-model`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelSelectorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (modelId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
||||||
|
const [models, setModels] = useState<ImageModelInfo[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchModels()
|
||||||
|
.then(({ models: list, default: defaultId }) => {
|
||||||
|
setModels(list);
|
||||||
|
const saved = localStorage.getItem(getModelStorageKey());
|
||||||
|
const validIds = new Set(list.map((m) => m.id));
|
||||||
|
if (saved && validIds.has(saved)) {
|
||||||
|
onChange(saved);
|
||||||
|
} else if (!value || !validIds.has(value)) {
|
||||||
|
onChange(defaultId);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const selected = models.find((m) => m.id === value);
|
||||||
|
|
||||||
|
if (models.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs
|
||||||
|
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||||
|
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||||
|
hover:border-[var(--accent)]/40 hover:bg-[var(--accent)]/5
|
||||||
|
transition-all cursor-pointer btn-hover-lift"
|
||||||
|
title="切换生图模型"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<span className="max-w-[100px] truncate">{selected?.name ?? "模型"}</span>
|
||||||
|
<svg
|
||||||
|
width="10" height="10" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" strokeWidth="2.5"
|
||||||
|
className={`transition-transform ${open ? "rotate-180" : ""}`}
|
||||||
|
>
|
||||||
|
<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 bottom-full left-0 mb-1.5 w-56 rounded-xl
|
||||||
|
surface-3 overflow-hidden z-50"
|
||||||
|
>
|
||||||
|
{models.map((m) => {
|
||||||
|
const isActive = m.id === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(m.id);
|
||||||
|
localStorage.setItem(getModelStorageKey(), m.id);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-3.5 py-2.5 flex flex-col gap-0.5
|
||||||
|
transition-all cursor-pointer
|
||||||
|
${isActive
|
||||||
|
? "bg-[var(--accent)]/8 text-[var(--accent)]"
|
||||||
|
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium flex items-center gap-1.5">
|
||||||
|
{m.name}
|
||||||
|
{m.supports_ref_image && (
|
||||||
|
<span className="typo-micro px-1.5 py-0.5 rounded-full
|
||||||
|
bg-[var(--accent)]/10 text-[var(--accent)] leading-none
|
||||||
|
border border-[var(--accent)]/20"
|
||||||
|
style={{ textTransform: "none", fontSize: "10px" }}>
|
||||||
|
参考图
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="typo-caption">{m.description}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
487
art-agent/frontend/src/components/detail/annotation-canvas.tsx
Normal file
487
art-agent/frontend/src/components/detail/annotation-canvas.tsx
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, useEffect, useCallback, type MouseEvent } from "react";
|
||||||
|
import type { Annotation } from "@/lib/types";
|
||||||
|
import { generateId } from "@/lib/store";
|
||||||
|
|
||||||
|
type Tool = "rect" | "arrow" | "freehand" | "text";
|
||||||
|
|
||||||
|
interface AnnotationCanvasProps {
|
||||||
|
imageUrl: string;
|
||||||
|
onComplete: (annotations: Annotation[], snapshot: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnnotationCanvas({ imageUrl, onComplete, onCancel }: AnnotationCanvasProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
|
const [tool, setTool] = useState<Tool>("rect");
|
||||||
|
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
||||||
|
const [drawing, setDrawing] = useState(false);
|
||||||
|
const [startPos, setStartPos] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [currentPos, setCurrentPos] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [freehandPoints, setFreehandPoints] = useState<{ x: number; y: number }[]>([]);
|
||||||
|
const [editingAnnotation, setEditingAnnotation] = useState<string | null>(null);
|
||||||
|
const [editText, setEditText] = useState("");
|
||||||
|
const [undoStack, setUndoStack] = useState<Annotation[][]>([]);
|
||||||
|
const [imgLoaded, setImgLoaded] = useState(false);
|
||||||
|
|
||||||
|
// 获取鼠标相对于 canvas 的坐标(归一化到图片尺寸)
|
||||||
|
const getRelPos = useCallback((e: MouseEvent): { x: number; y: number } | null => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return null;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: (e.clientX - rect.left) / rect.width,
|
||||||
|
y: (e.clientY - rect.top) / rect.height,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 加载图片
|
||||||
|
useEffect(() => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.onload = () => {
|
||||||
|
imageRef.current = img;
|
||||||
|
setImgLoaded(true);
|
||||||
|
};
|
||||||
|
img.src = imageUrl;
|
||||||
|
}, [imageUrl]);
|
||||||
|
|
||||||
|
// 渲染 canvas
|
||||||
|
const render = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const img = imageRef.current;
|
||||||
|
if (!canvas || !img) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
|
||||||
|
// 绘制已有标注
|
||||||
|
for (const ann of annotations) {
|
||||||
|
drawAnnotation(ctx, ann, w, h, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制当前正在创建的标注
|
||||||
|
if (drawing && startPos && currentPos) {
|
||||||
|
ctx.strokeStyle = "#f43f5e";
|
||||||
|
ctx.lineWidth = Math.max(2, w * 0.003);
|
||||||
|
ctx.setLineDash([w * 0.005, w * 0.003]);
|
||||||
|
|
||||||
|
if (tool === "rect") {
|
||||||
|
const rx = startPos.x * w, ry = startPos.y * h;
|
||||||
|
const rw = (currentPos.x - startPos.x) * w;
|
||||||
|
const rh = (currentPos.y - startPos.y) * h;
|
||||||
|
ctx.strokeRect(rx, ry, rw, rh);
|
||||||
|
} else if (tool === "arrow") {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(startPos.x * w, startPos.y * h);
|
||||||
|
ctx.lineTo(currentPos.x * w, currentPos.y * h);
|
||||||
|
ctx.stroke();
|
||||||
|
drawArrowHead(ctx, startPos.x * w, startPos.y * h, currentPos.x * w, currentPos.y * h, w * 0.015);
|
||||||
|
} else if (tool === "freehand" && freehandPoints.length > 1) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(freehandPoints[0].x * w, freehandPoints[0].y * h);
|
||||||
|
for (let i = 1; i < freehandPoints.length; i++) {
|
||||||
|
ctx.lineTo(freehandPoints[i].x * w, freehandPoints[i].y * h);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
}, [annotations, drawing, startPos, currentPos, freehandPoints, tool]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (imgLoaded) render();
|
||||||
|
}, [imgLoaded, render]);
|
||||||
|
|
||||||
|
// 鼠标事件
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
if (tool === "text") {
|
||||||
|
const pos = getRelPos(e);
|
||||||
|
if (!pos) return;
|
||||||
|
const ann: Annotation = {
|
||||||
|
id: generateId("ann-"),
|
||||||
|
type: "text",
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
text: "",
|
||||||
|
};
|
||||||
|
pushUndo();
|
||||||
|
setAnnotations((prev) => [...prev, ann]);
|
||||||
|
setEditingAnnotation(ann.id);
|
||||||
|
setEditText("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = getRelPos(e);
|
||||||
|
if (!pos) return;
|
||||||
|
setDrawing(true);
|
||||||
|
setStartPos(pos);
|
||||||
|
setCurrentPos(pos);
|
||||||
|
if (tool === "freehand") {
|
||||||
|
setFreehandPoints([pos]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!drawing) return;
|
||||||
|
const pos = getRelPos(e);
|
||||||
|
if (!pos) return;
|
||||||
|
setCurrentPos(pos);
|
||||||
|
if (tool === "freehand") {
|
||||||
|
setFreehandPoints((prev) => [...prev, pos]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (!drawing || !startPos || !currentPos) {
|
||||||
|
setDrawing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushUndo();
|
||||||
|
|
||||||
|
if (tool === "rect") {
|
||||||
|
const ann: Annotation = {
|
||||||
|
id: generateId("ann-"),
|
||||||
|
type: "rect",
|
||||||
|
x: Math.min(startPos.x, currentPos.x),
|
||||||
|
y: Math.min(startPos.y, currentPos.y),
|
||||||
|
w: Math.abs(currentPos.x - startPos.x),
|
||||||
|
h: Math.abs(currentPos.y - startPos.y),
|
||||||
|
text: "",
|
||||||
|
};
|
||||||
|
setAnnotations((prev) => [...prev, ann]);
|
||||||
|
setEditingAnnotation(ann.id);
|
||||||
|
setEditText("");
|
||||||
|
} else if (tool === "arrow") {
|
||||||
|
const ann: Annotation = {
|
||||||
|
id: generateId("ann-"),
|
||||||
|
type: "arrow",
|
||||||
|
x: startPos.x,
|
||||||
|
y: startPos.y,
|
||||||
|
w: currentPos.x - startPos.x,
|
||||||
|
h: currentPos.y - startPos.y,
|
||||||
|
text: "",
|
||||||
|
};
|
||||||
|
setAnnotations((prev) => [...prev, ann]);
|
||||||
|
setEditingAnnotation(ann.id);
|
||||||
|
setEditText("");
|
||||||
|
} else if (tool === "freehand") {
|
||||||
|
const ann: Annotation = {
|
||||||
|
id: generateId("ann-"),
|
||||||
|
type: "freehand",
|
||||||
|
x: freehandPoints[0]?.x ?? 0,
|
||||||
|
y: freehandPoints[0]?.y ?? 0,
|
||||||
|
points: [...freehandPoints],
|
||||||
|
text: "",
|
||||||
|
};
|
||||||
|
setAnnotations((prev) => [...prev, ann]);
|
||||||
|
setFreehandPoints([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDrawing(false);
|
||||||
|
setStartPos(null);
|
||||||
|
setCurrentPos(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushUndo = () => {
|
||||||
|
setUndoStack((prev) => [...prev, [...annotations]]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUndo = () => {
|
||||||
|
if (undoStack.length === 0) return;
|
||||||
|
const prev = undoStack[undoStack.length - 1];
|
||||||
|
setUndoStack((s) => s.slice(0, -1));
|
||||||
|
setAnnotations(prev);
|
||||||
|
setEditingAnnotation(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
pushUndo();
|
||||||
|
setAnnotations([]);
|
||||||
|
setEditingAnnotation(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitText = () => {
|
||||||
|
if (!editingAnnotation) return;
|
||||||
|
setAnnotations((prev) =>
|
||||||
|
prev.map((a) => (a.id === editingAnnotation ? { ...a, text: editText } : a))
|
||||||
|
);
|
||||||
|
setEditingAnnotation(null);
|
||||||
|
setEditText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = () => {
|
||||||
|
// 先提交正在编辑的文本
|
||||||
|
let finalAnnotations = annotations;
|
||||||
|
if (editingAnnotation) {
|
||||||
|
finalAnnotations = annotations.map((a) =>
|
||||||
|
a.id === editingAnnotation ? { ...a, text: editText } : a
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
// 最终渲染一次(含所有文本)
|
||||||
|
const img = imageRef.current;
|
||||||
|
if (img) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (ctx) {
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
for (const ann of finalAnnotations) {
|
||||||
|
drawAnnotation(ctx, ann, canvas.width, canvas.height, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = canvas.toDataURL("image/png");
|
||||||
|
onComplete(finalAnnotations, snapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOOLS: { key: Tool; label: string; icon: string }[] = [
|
||||||
|
{ key: "rect", label: "矩形", icon: "□" },
|
||||||
|
{ key: "arrow", label: "箭头", icon: "→" },
|
||||||
|
{ key: "freehand", label: "画笔", icon: "✎" },
|
||||||
|
{ key: "text", label: "文字", icon: "T" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* 工具栏 */}
|
||||||
|
<div className="flex items-center gap-1 px-3 py-2 border-b border-[var(--border)] bg-[var(--bg-tertiary)] flex-wrap">
|
||||||
|
{TOOLS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTool(t.key)}
|
||||||
|
className={`px-2.5 py-1 text-xs rounded cursor-pointer transition-colors ${
|
||||||
|
tool === t.key
|
||||||
|
? "bg-[var(--accent)] text-white"
|
||||||
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-primary)]"
|
||||||
|
}`}
|
||||||
|
title={t.label}
|
||||||
|
>
|
||||||
|
{t.icon} {t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="w-px h-4 bg-[var(--border)] mx-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleUndo}
|
||||||
|
disabled={undoStack.length === 0}
|
||||||
|
className="px-2 py-1 text-xs rounded text-[var(--text-secondary)]
|
||||||
|
hover:text-[var(--text-primary)] bg-[var(--bg-primary)]
|
||||||
|
disabled:opacity-30 cursor-pointer"
|
||||||
|
>
|
||||||
|
撤销
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={annotations.length === 0}
|
||||||
|
className="px-2 py-1 text-xs rounded text-[var(--text-secondary)]
|
||||||
|
hover:text-[var(--text-primary)] bg-[var(--bg-primary)]
|
||||||
|
disabled:opacity-30 cursor-pointer"
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-2.5 py-1 text-xs rounded text-[var(--text-secondary)]
|
||||||
|
hover:text-[var(--text-primary)] cursor-pointer"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleComplete}
|
||||||
|
className="px-3 py-1 text-xs rounded bg-[var(--accent)] text-white
|
||||||
|
hover:bg-[var(--accent-hover)] cursor-pointer"
|
||||||
|
>
|
||||||
|
完成标注
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Canvas 区域 */}
|
||||||
|
<div ref={containerRef} className="flex-1 overflow-auto p-3 relative">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="max-w-full rounded-lg cursor-crosshair"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 文字输入弹出框 */}
|
||||||
|
{editingAnnotation && (() => {
|
||||||
|
const ann = annotations.find((a) => a.id === editingAnnotation);
|
||||||
|
if (!ann) return null;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return null;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return null;
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
let px: number, py: number;
|
||||||
|
if (ann.type === "rect") {
|
||||||
|
px = (ann.x + (ann.w ?? 0)) * rect.width + (rect.left - containerRect.left);
|
||||||
|
py = ann.y * rect.height + (rect.top - containerRect.top);
|
||||||
|
} else {
|
||||||
|
px = ann.x * rect.width + (rect.left - containerRect.left) + 10;
|
||||||
|
py = ann.y * rect.height + (rect.top - containerRect.top);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute z-10 bg-[var(--bg-secondary)] border border-[var(--accent)]
|
||||||
|
rounded-lg shadow-lg p-2 min-w-[180px]"
|
||||||
|
style={{ left: px, top: py }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") commitText();
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setAnnotations((prev) => prev.filter((a) => a.id !== editingAnnotation));
|
||||||
|
setEditingAnnotation(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="输入批注..."
|
||||||
|
className="w-full px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border)]
|
||||||
|
rounded text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||||
|
focus:outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-1 mt-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setAnnotations((prev) => prev.filter((a) => a.id !== editingAnnotation));
|
||||||
|
setEditingAnnotation(null);
|
||||||
|
}}
|
||||||
|
className="px-2 py-0.5 text-[10px] text-[var(--text-secondary)] cursor-pointer"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={commitText}
|
||||||
|
className="px-2 py-0.5 text-[10px] bg-[var(--accent)] text-white
|
||||||
|
rounded cursor-pointer"
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 绘制辅助函数 ---
|
||||||
|
|
||||||
|
function drawAnnotation(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
ann: Annotation,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
isFinal: boolean,
|
||||||
|
) {
|
||||||
|
const lineWidth = Math.max(2, w * 0.003);
|
||||||
|
ctx.strokeStyle = "#f43f5e";
|
||||||
|
ctx.fillStyle = "#f43f5e";
|
||||||
|
ctx.lineWidth = lineWidth;
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
switch (ann.type) {
|
||||||
|
case "rect":
|
||||||
|
ctx.strokeRect(ann.x * w, ann.y * h, (ann.w ?? 0) * w, (ann.h ?? 0) * h);
|
||||||
|
break;
|
||||||
|
case "arrow": {
|
||||||
|
const x1 = ann.x * w, y1 = ann.y * h;
|
||||||
|
const x2 = (ann.x + (ann.w ?? 0)) * w, y2 = (ann.y + (ann.h ?? 0)) * h;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x1, y1);
|
||||||
|
ctx.lineTo(x2, y2);
|
||||||
|
ctx.stroke();
|
||||||
|
drawArrowHead(ctx, x1, y1, x2, y2, w * 0.015);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "freehand":
|
||||||
|
if (ann.points && ann.points.length > 1) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ann.points[0].x * w, ann.points[0].y * h);
|
||||||
|
for (let i = 1; i < ann.points.length; i++) {
|
||||||
|
ctx.lineTo(ann.points[i].x * w, ann.points[i].y * h);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "text":
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制文字标签
|
||||||
|
if (ann.text) {
|
||||||
|
const fontSize = Math.max(12, w * 0.018);
|
||||||
|
ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
|
||||||
|
const metrics = ctx.measureText(ann.text);
|
||||||
|
const padding = fontSize * 0.4;
|
||||||
|
|
||||||
|
let tx: number, ty: number;
|
||||||
|
if (ann.type === "rect") {
|
||||||
|
tx = ann.x * w;
|
||||||
|
ty = ann.y * h - padding;
|
||||||
|
} else {
|
||||||
|
tx = ann.x * w;
|
||||||
|
ty = ann.y * h - padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文字背景
|
||||||
|
ctx.fillStyle = "rgba(244, 63, 94, 0.85)";
|
||||||
|
ctx.fillRect(
|
||||||
|
tx - padding * 0.5,
|
||||||
|
ty - fontSize,
|
||||||
|
metrics.width + padding,
|
||||||
|
fontSize + padding
|
||||||
|
);
|
||||||
|
|
||||||
|
// 文字
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.fillText(ann.text, tx, ty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawArrowHead(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
fromX: number, fromY: number,
|
||||||
|
toX: number, toY: number,
|
||||||
|
size: number,
|
||||||
|
) {
|
||||||
|
const angle = Math.atan2(toY - fromY, toX - fromX);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(toX, toY);
|
||||||
|
ctx.lineTo(toX - size * Math.cos(angle - Math.PI / 6), toY - size * Math.sin(angle - Math.PI / 6));
|
||||||
|
ctx.lineTo(toX - size * Math.cos(angle + Math.PI / 6), toY - size * Math.sin(angle + Math.PI / 6));
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
252
art-agent/frontend/src/components/detail/image-detail-panel.tsx
Normal file
252
art-agent/frontend/src/components/detail/image-detail-panel.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { getImageUrl } from "@/lib/api";
|
||||||
|
import { useApp } from "@/lib/app-context";
|
||||||
|
import { AnnotationCanvas } from "./annotation-canvas";
|
||||||
|
import { useFireflyBurst, FireflyBurst } from "@/components/ui/firefly-burst";
|
||||||
|
import { slideUp } from "@/components/ui/motion-presets";
|
||||||
|
import type { Annotation, AnnotationData } from "@/lib/types";
|
||||||
|
|
||||||
|
interface ImageDetailPanelProps {
|
||||||
|
onAnnotationComplete?: (data: AnnotationData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps) {
|
||||||
|
const { detailImage, setDetailImage, toggleFavorite, tags, deleteAssetById } = useApp();
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
const [showAnnotate, setShowAnnotate] = useState(false);
|
||||||
|
const { sparks, burst } = useFireflyBurst();
|
||||||
|
|
||||||
|
if (!detailImage) return null;
|
||||||
|
|
||||||
|
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
const fullUrl = getImageUrl(detailImage.url);
|
||||||
|
const response = await fetch(fullUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `epeekit-${Date.now()}.png`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
alert("下载失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyPrompt = () => {
|
||||||
|
navigator.clipboard.writeText(detailImage.prompt);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnnotationComplete = (annotations: Annotation[], snapshot: string) => {
|
||||||
|
setShowAnnotate(false);
|
||||||
|
const data: AnnotationData = {
|
||||||
|
imageUrl: detailImage.url,
|
||||||
|
annotations,
|
||||||
|
snapshot,
|
||||||
|
};
|
||||||
|
onAnnotationComplete?.(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showAnnotate) {
|
||||||
|
return (
|
||||||
|
<aside className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
|
||||||
|
md:w-[480px] flex-shrink-0 border-l border-[var(--border)]
|
||||||
|
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
|
||||||
|
flex flex-col overflow-hidden">
|
||||||
|
<AnnotationCanvas
|
||||||
|
imageUrl={getImageUrl(detailImage.url)}
|
||||||
|
onComplete={handleAnnotationComplete}
|
||||||
|
onCancel={() => setShowAnnotate(false)}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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-[360px] 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)]">
|
||||||
|
<span className="text-sm font-semibold text-[var(--text-primary)]">图片详情</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setDetailImage(null)}
|
||||||
|
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-4">
|
||||||
|
<div className="relative rounded-xl overflow-hidden neon-border bg-[var(--bg-primary)]">
|
||||||
|
<img
|
||||||
|
src={getImageUrl(detailImage.url)}
|
||||||
|
alt="预览"
|
||||||
|
className="w-full transition-transform"
|
||||||
|
style={{ transform: `scale(${scale})`, transformOrigin: "center" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 缩放控制 */}
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}
|
||||||
|
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||||
|
hover:text-[var(--accent)] border border-[var(--border)]
|
||||||
|
hover:border-[var(--accent)]/40 cursor-pointer transition-all btn-hover-lift"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)] min-w-[40px] text-center font-medium">
|
||||||
|
{Math.round(scale * 100)}%
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setScale((s) => Math.min(3, s + 0.25))}
|
||||||
|
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||||
|
hover:text-[var(--accent)] border border-[var(--border)]
|
||||||
|
hover:border-[var(--accent)]/40 cursor-pointer transition-all btn-hover-lift"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setScale(1)}
|
||||||
|
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||||
|
hover:text-[var(--accent)] border border-[var(--border)]
|
||||||
|
hover:border-[var(--accent)]/40 cursor-pointer transition-all btn-hover-lift"
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="px-4 pb-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||||||
|
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||||
|
hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
|
||||||
|
transition-all cursor-pointer btn-hover-lift"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||||
|
</svg>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!detailImage.favorited) burst();
|
||||||
|
toggleFavorite(detailImage.id);
|
||||||
|
}}
|
||||||
|
className={`relative flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||||||
|
border transition-all cursor-pointer btn-hover-lift ${
|
||||||
|
detailImage.favorited
|
||||||
|
? "border-[var(--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)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill={detailImage.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>
|
||||||
|
{detailImage.favorited ? "已收藏" : "收藏"}
|
||||||
|
<FireflyBurst sparks={sparks} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAnnotate(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||||||
|
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||||
|
hover:text-[var(--accent)] border border-[var(--border)]
|
||||||
|
hover:border-[var(--accent)]/40 transition-all cursor-pointer btn-hover-lift"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||||
|
</svg>
|
||||||
|
标注
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteAssetById(detailImage.id)}
|
||||||
|
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||||||
|
bg-[var(--bg-tertiary)] text-[var(--hot)]/60
|
||||||
|
hover:text-[var(--hot)] border border-[var(--border)]
|
||||||
|
hover:border-[var(--hot)]/40 transition-all cursor-pointer btn-hover-lift"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Prompt */}
|
||||||
|
{detailImage.prompt && (
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="typo-caption font-medium">Prompt</span>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyPrompt}
|
||||||
|
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||||
|
cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-xl bg-[var(--bg-primary)] border border-[var(--border)]
|
||||||
|
text-xs text-[var(--text-secondary)] leading-relaxed break-all">
|
||||||
|
{detailImage.prompt}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 标签 */}
|
||||||
|
{detailImage.tags.length > 0 && (
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
<span className="typo-caption font-medium block mb-1.5">标签</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{detailImage.tags.map((tid) => {
|
||||||
|
const tag = tagMap.get(tid);
|
||||||
|
if (!tag) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={tid}
|
||||||
|
className="text-[10px] px-2 py-0.5 rounded-lg font-medium"
|
||||||
|
style={{ backgroundColor: tag.color + "18", color: tag.color }}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 元信息 */}
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<span className="typo-caption font-medium block mb-1.5">信息</span>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||||
|
<div>创建时间:{new Date(detailImage.createdAt).toLocaleString("zh-CN")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user