Compare commits

...

15 Commits

140 changed files with 30403 additions and 9 deletions

View File

File diff suppressed because it is too large Load Diff

View 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 + 工作台 TaskTypeTabs2D/3D/动作)按类型显示 Params3D/ParamsMotionBar + TaskCard 加 variant + 3D 结果面板 Model3DPanel多视图/360°/`<model-viewer>` 三模式color/normal/stylized 通道切)+ 动作结果面板 MotionPanel视频/帧序列双模式+scrubber+ 资源库顶部模态 Tab图片/3D/动作)+ 3D/MOTION 徽章叠加;`<model-viewer>` 走 next/script lazyOnloadtypes/placeholder-store/app-context 全量扩展 doc/assets3D/assetsMotion顺手把 `sendBounce``ease``as const` 修预存 build 类型错,`npx next build` 全绿
- [CL-20260420-1800] 按SPEC §4 Phase 3落地Mesh Pipeline风格还原config注册internal=True的ip-adapter-controlnet-depth$0.07/张)+ get_image_models_list过滤internal新增services/style_restorer.py(restore/restore_batch带Semaphore并发3防rate limit)view_transform._transform_view_mesh接入Stage 3preserve_style=True时用原图做IP-Adapter风格参考+normal帧做ControlNet结构约束SPEC §1.4路A单帧失败自动回退Stage 2 color帧不阻塞整体images[i]新增color_url/restyled/restyle_error_stage升级为stage3 (N/M restyled)新增scripts/test_style_restore.py支持--single冒烟/全量/--scale/--cn-scale覆盖参数复用Phase 2 normal帧免重跑Trellis默认scale=0.75 cn_scale=0.8P3-4调参等用户验收一次完整6视角 ~$0.46/~105s
- [CL-20260420-1700] 按SPEC §4 Phase 2落地Mesh Pipeline阶段2新增services/video_frame_extractor.py用imageio-ffmpeg静态二进制(系统ffmpeg未装的兜底)+PIL一次抽全帧到temp dir再按帧索引挑6个方位角+切左右半得color/normalview_transform.py的mesh管道现已走到Stage2返回images[{url,azimuth,elevation,normal_url,frame_idx}]Stage 2失败不牵连Stage1的mesh/video产物新增scripts/test_frame_extract.py复用已有combined_video做验收省钱requirements加imageio-ffmpeg==0.5.1Agent自测120帧视频6视角映射到0/20/40/60/80/100索引+512×512切图均正确等用户Checkpoint 2验收后开Phase 3
- [CL-20260420-1600] 按SPEC §4 Phase 1落地Mesh Pipeline阶段1新增services/mesh_generator.py封装Trellis调用+下载.glb/color_video/normal_video到generated/config注册trellis(enabled)+hunyuan3d(占位)+加pipeline字段view_transform.py按pipeline分流grid/meshtools.py扩model_id/azimuths/preserve_style新增scripts/test_trellis.py独立验收入口Zero123++现有流程完全不变等用户跑脚本验收后开Phase 2
- [CL-20260420-1530] 写入Mesh-Pipeline视角变换方案SPEC跨会话实施计划Trellis生mesh+360°video→ffmpeg抽帧→IP-Adapter+ControlNet重绘保留卡通风含3个Replicate模型版本ID/schema+6 Phase任务清单+风险兜底位于docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
- [CL-20260420-1501] 后端装watchfiles并写入requirements修复Windows uvicorn --reload退化为StatReload导致代码改动不热重载view_transform 12:12改完但后端15:00仍跑旧码踩坑PF-20260420-1501需重启后端生效
- [CL-20260420-1212] 修复Zero123++切图错位jd7h部署版实际输出2列×3行而非3×2config改cols=2/rows=3_split_grid_image新增按实际ratio自动纠错防御未来布局歧义踩坑记为PF-20260420-1212
- [CL-20260417-1510] start-tunnel.ps1 修复 PS5.1 中文 here-string 解析失败WriteAllLines 写 .env、脚本 UTF-8 BOM、同目录 cloudflared.exe 优先;根 .gitignore 忽略下载的 cloudflared.exe
- [CL-20260417-1500] 按 INFOLAYER.md 一次性铺开 Phase 1+ 前端 UI 骨架:新增风格库/角色库/训练中心/项目设置四大页面模块+工作台快选条+高级控制+候选评估+Task Card+资源库多维过滤placeholder-store.ts 管理占位数据phase-chip 标注阶段
- [CL-20260416-1600] 视觉风格从暗绿底全面转为白底青绿(绢本山水):背景#FAFAF7+文字#1A1A1A+accent#2E8B7A13个文件颜色替换面板改白色毛玻璃云雾透明度降低VISUAL-STYLE-GUIDE.md重写
- [CL-20260416-1500] UX风格指南实现度补全涟漪/sendBounce/会话切换动画/Error飘散/云雾加载/流式opacity/排版类使用/左上切角/石青占位等10项全部落地文档每项标注✅状态+实现文件
- [CL-20260416-1330] 交互体验全面升级新增framer-motion动画引擎7级排版体系+4级Surface层级+网格底纹科技感,萤火粒子/数据流光/水墨晕染/投石入水/云雾凝聚/萤火点亮等微交互,用户/助手消息视觉拉开差距统一Loading/空态/Error状态全组件hover浮起+侧栏竖线+呼吸脉冲新增UX-STYLE-GUIDE.md文档
- [CL-20260416-1000] 集成Zero123++视角变换新增transform_view工具+view_transform.py服务单图输入生成6个固定视角图片grid拆分config.py新增VIEW_TRANSFORM_MODELS注册表loop.py特殊处理视角变换SSE事件进阶方案记入延期
- [CL-20260416-0930] 设备绑定配置解耦:新增.cursor/local-env.json(gitignored)存放nodejs_path和shellproject-launcher Skill和hooks改为读取该文件hooks增加bash版+dispatcher跨平台新增local-env-check Rule自动探测ENVIRONMENT.md去除硬编码
- [CL-20260416-0900] 环境依赖全面修复+统一环境文档安装Node.js v22.15.0+创建Python venv+安装全部前后端依赖requirements.txt锁定精确版本新增requirements-lock.txt和ENVIRONMENT.md更新project-launcher Skill路径
- [CL-20260416-0830] 生图按模型预处理 prompt新增 image_prompt_strategySDXL 默认负向词+---NEGATIVE---拆分Flux 剥粘贴的 Negative 段GPT/Gemini/IP-Adapter 规范化GenerateResult 带 effective_prompt/negative_promptReplicate SDXL 传 negative_prompt工具说明 SDXL 分隔符
- [CL-20260416-0810] 对话输入栏支持剪贴板粘贴图片为参考图ChatInput根容器onPasteCapture提取image/*文件并走startUploadplaceholder/title提示
- [CL-20260416-0745] Gemini原生生图延长读写超时+对RemoteProtocolError等可重试错误自动重试(默认3次指数退避)环境变量可微调VECTORENGINE_GEMINI_*_TIMEOUT
- [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 AppProviderAppProvider 未初始化时区分已认证/未认证,避免子组件拿到 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] 修复多会话滚动位置共享 bugper-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

View 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+Outlinetextarea+react-markdown+remark-gfm 编辑预览,`[[asset:id]]` 在正文内渲染为资产卡片AssetPicker 浮层 + Ctrl/Cmd+K CommandPalette 全站搜索。工作台TaskTypeTabs 切 2D/3D/动作,按类型显示 Params3D管道/视图/分辨率)或 ParamsMotionBar模型/帧数/fps/循环/输出/角色/动作描述TaskCard 加 variant 区分颜色图标和 3D/动作专有字段。结果详情Model3DPanel多视图+360°占位+`<model-viewer>` 三模式,通道切 color/normal/stylized、MotionPanel视频/帧序列双模式+scrubber+MP4/GIF 下载占位)。资源库:顶部模态 Tab图片/3D/动作)+ 计数与 Phase 徽章3D/动作网格叠徽章+视角/帧数,动作卡片 hover 自动播放视频。`<model-viewer>` 通过 `next/script` lazyOnload 引入,真实管线接入时只换数据源。一次性写完避免后续反复返工;`npx next build` 通过。
### [CL-20260420-1800] 2026-04-20 — Mesh Pipeline Phase 3 风格还原接入IP-Adapter + ControlNet
- **tags**: 视角变换, Mesh Pipeline, 风格还原, IP-Adapter, ControlNet, style_restorer, Phase 3
- **affected_files**: config.py, services/style_restorer.py (新增), services/view_transform.py, scripts/test_style_restore.py (新增), VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
- **summary**: 按 SPEC §4 Phase 3 落地 Mesh Pipeline 最后一段。在 `IMAGE_MODELS``internal=True` 注册 `chigozienri/ip_adapter-sdxl-controlnet-depth`$0.07/张 ~72sL40S`get_image_models_list()` 改过滤 internal 防污染前端下拉;新增 helper `get_style_restore_model_id()`。新增 `services/style_restorer.py``restore()` 调 Replicate`image`=用户原图做 IP-Adapter 风格参考,`controlnet_input`=Stage 2 的 normal 帧做结构约束,即 SPEC §1.4 的"路 A"`restore_batch()``asyncio.Semaphore(3)` 批量重绘防 Replicate rate limit。`view_transform._transform_view_mesh` 接入 Stage 3`preserve_style=True` 时对每帧调 restore_batch**单帧失败自动回退为 Stage 2 color 帧且不阻塞整体**error 字段挂出原因),与"Stage 2 失败不影响 Stage 1"的独立交付设计一致;`images[i]` 新增 `color_url/restyled/restyle_error` 字段,`_stage` 升级为 `stage3 (N/M restyled)`。新增 `scripts/test_style_restore.py` 供 Checkpoint 3支持 `--single` 冒烟($0.07/全量($0.42)、`--scale/--cn-scale` 覆盖参数、默认自动挑最新一组 normal 帧,复用 Phase 2 产物无需再跑 Trellis。默认参 scale=0.75 / controlnet=0.8P3-4 调参由用户验收阶段确认。一次完整 6 视角请求 ~$0.46 / ~105s。
### [CL-20260420-1700] 2026-04-20 — Mesh Pipeline Phase 2 抽帧 + combined_video 左右切分
- **tags**: 视角变换, Mesh Pipeline, Trellis, ffmpeg, imageio-ffmpeg, PIL, video_frame_extractor, Phase 2
- **affected_files**: video_frame_extractor.py (新增), view_transform.py, requirements.txt, scripts/test_frame_extract.py (新增), VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
- **summary**: 按 SPEC §4 Phase 2 落地 Mesh Pipeline 的阶段 2 抽帧。系统 ffmpeg 未装,改用 `imageio-ffmpeg==0.5.1` 自带的 Windows 静态二进制(`ffmpeg-win64-v4.2.2.exe`wheel 22.6MB,免系统依赖)。新增 `services/video_frame_extractor.py` 实现 `extract_by_azimuth(combined_video_path, azimuths, elevations, total_rotation_deg=360)`:策略采用"一次全帧抽取到临时目录 + 按帧索引挑 + PIL 按中线切左右半",比 `-ss` 逐帧抽更稳(免 keyframe 对齐5s 视频成本可忽略ffmpeg + PIL 阻塞部分用 `run_in_executor` 丢到线程池。`view_transform._transform_view_mesh` 串接 Stage1→Stage2默认 `azimuths=[0,60,120,180,240,300]`,返回 `images[{url,azimuth,elevation,normal_url,frame_idx}]`Stage 2 失败不会把整体置为失败Stage 1 的 mesh/video 仍有交付价值,单独填 `error` 字段)。新增 `scripts/test_frame_extract.py` 供用户复用已有 `generated/trellis_combined_*.mp4` 做 Checkpoint 2 验收,无需再花钱跑 Trellis。Agent 已自测 120 帧 combined_video6 视角映射到 frame_idx=0/20/40/60/80/100完全均匀+ 所有切图 512×512 方形。等用户 Checkpoint 2 验收后开 Phase 3IP-Adapter+ControlNet 风格还原)。
### [CL-20260420-1600] 2026-04-20 — Mesh Pipeline Phase 1 接入 Trellis阶段 1 地基)
- **tags**: 视角变换, Mesh Pipeline, Trellis, Replicate, services, config, tools, Phase 1
- **affected_files**: mesh_generator.py (新增), view_transform.py, config.py, tools.py, scripts/test_trellis.py (新增), VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
- **summary**: 按 SPEC §4 Phase 1 落地 Mesh Pipeline 第二条视角变换管道的阶段 1。新增 `services/mesh_generator.py` 封装 Trellis 调用(`firtoz/trellis:e8f6c452...`+ httpx 下载 `.glb/color_video/normal_video``generated/``config.py``VIEW_TRANSFORM_MODELS` 注册 `trellis`enabled`hunyuan3d`enabled=False 占位),给所有条目加 `pipeline` 字段;`view_transform.py``pipeline` 分流 grid/meshmesh 阶段 1 只返回 mesh_url + color_video + normal_videoimages 暂空;`tools.py``transform_view` 工具扩 `model_id/azimuths/preserve_style`。另新增 `scripts/test_trellis.py` 作为独立验收入口。Zero123++ 现有行为完全不变。等用户手跑脚本验收后开 Phase 2ffmpeg 抽帧)。
### [CL-20260420-1530] 2026-04-20 — 写入 Mesh-Pipeline 视角变换方案 SPEC跨会话实施计划
- **tags**: 视角变换, Mesh Pipeline, Trellis, Hunyuan3D-2, SPEC, 方案
- **affected_files**: docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
- **summary**: 针对"Zero123++ 只出 6 固定视角 + 建筑大角度易崩"的痛点,规划第二条视角变换路径 Mesh Pipeline。三段式Trellis 生 .glb + 360° color_video$0.041/30s → ffmpeg 按 azimuth 抽帧 → `chigozienri/ip_adapter-sdxl-controlnet-depth` 用原图做 IP-Adapter 风格还原。文档含三个 Replicate 模型的版本 ID/OpenAPI schema、config 扩展设计、6 个 Phase 可独立执行任务清单、跨会话恢复指引。Hunyuan3D-2 + pyrender 自由相机留作 Phase 5 二期可选。一期工程量 ~5h单次总成本 ~$0.46/~105s。
### [CL-20260420-1501] 2026-04-20 — 后端增加 watchfiles 依赖,修复 Windows uvicorn --reload 失效
- **tags**: uvicorn, watchfiles, 热重载, Windows, reload, requirements
- **affected_files**: requirements.txt, requirements-lock.txt
- **summary**: 排查"Zero123++ 切图还是错位"时发现CL-20260420-1212 的修复在磁盘上完全正确,但后端 uvicorn 进程是 11:55 启动从未 reload。根因是 venv 里 `watchfiles``watchdog` 都没装,`--reload` 退化成 StatReload 轮询式 reloader在 Windows + 深层子目录 + 新建文件 (`view_transform.py`) 场景下基本失效。解决pip 安装 `watchfiles==0.24.0` 并写入 requirements 两个文件。**仅装依赖不会对当前进程生效,必须 kill + 重启后端**。踩坑记为 PF-20260420-1501。
### [CL-20260420-1212] 2026-04-20 — 修复 Zero123++ 视角切图布局反转3×2→2×3
- **tags**: Zero123++, 视角变换, 切图, bug修复, grid_layout
- **affected_files**: config.py, view_transform.py
- **summary**: 用户反馈 `transform_view` 输出的 6 张视角图严重错位(每张呈窄长条、上下拼两个物体)。根因是 `jd7h/zero123plusplus` Replicate 部署版实际输出 **2 列 × 3 行** 的高图,但 `config.py` 里按官方 README "3×2 grid" 字面意思配成了 `cols=3, rows=2`。修复:(1) `grid_layout` 改为 `{"cols": 2, "rows": 3}`(2) `_split_grid_image` 新增健壮性保护——假定单视角正方形,若配置 ratio 与实际图像 ratio 差异 >20%,自动交换 cols/rows 并打 warning让未来换其他视角变换模型时免疫类似布局歧义。踩坑已记录 PF-20260420-1212。
### [CL-20260417-1510] 2026-04-17 — Cloudflare 穿透脚本兼容 PS5.1 + 本地 cloudflared
- **tags**: 内网穿透, cloudflared, PowerShell, start-tunnel, env
- **affected_files**: art-agent/start-tunnel.ps1, .gitignore
- **summary**: Windows PowerShell 5.1 在 UTF-8 无 BOM 下解析含中文的 `@"` here-string 会报错,导致穿透脚本无法运行。改为用 `WriteAllLines` 写入 `frontend/.env.local`,并将脚本保存为 UTF-8 BOM。脚本启动时若存在 `art-agent/cloudflared.exe` 则自动加入 PATH 优先使用。根目录 `.gitignore` 忽略本地下载的 `cloudflared.exe`(体积大)。
### [CL-20260417-1500] 2026-04-17 — 按 INFOLAYER.md 实现完整前端信息架构Phase 1+ 占位骨架)
- **tags**: 前端, UI骨架, INFOLAYER, 多项目, 风格库, 角色库, 训练中心, 项目设置, TaskCard, 候选评估, 占位数据
- **affected_files**: types.ts, placeholder-store.ts, app-context.tsx, globals.css, app/page.tsx, app/gallery/page.tsx, app/styles/*, app/characters/*, app/training/*, app/settings/page.tsx, top-nav.tsx, project-switcher.tsx, project-card.tsx, session-quick-picks.tsx, task-card.tsx, advanced-controls.tsx, candidate-panel.tsx, sidebar.tsx
- **summary**: 一次性铺开 INFOLAYER.md Phase 13 完整 UI 骨架:新增 /styles /characters /training /settings 四大模块(列表+详情+创建流程),工作台加入风格/角色/规格快选条、高级控制抽屉、候选评估面板、Task Card 组件,资源库加二级过滤(资产类型/风格/角色)+ 语义搜索切换TopNav 加项目切换器和新导航项Sidebar 加项目信息卡。所有 Phase 1+ 数据通过新建的 placeholder-store.ts + localStorage 占位持久化首次注入示例AppContext 扩充相关字段。所有真实后端行为均为 `alert("(占位)...")`UI 上以 phase-chipP1/P2/P3明示阶段。搜索"(占位)"可快速定位待接入点。
### [CL-20260416-1600] 2026-04-16 — 视觉风格从暗绿底全面转为白底青绿(绢本山水)
- **tags**: 前端, 视觉风格, 色彩体系, CSS变量, 白底, 千里江山图
- **affected_files**: globals.css, page.tsx, login/page.tsx, gallery/page.tsx, chat-messages.tsx, chat-input.tsx, top-nav.tsx, sidebar.tsx, session-list.tsx, profile-modal.tsx, image-detail-panel.tsx, ambient-particles.tsx, VISUAL-STYLE-GUIDE.md
- **summary**: 背景从墨绿黑(#0C1210)改为绢本白(#FAFAF7),文字改为墨黑(#1A1A1A)accent色加深(#4DB8A4#2E8B7A)适配白底所有面板从深色毛玻璃改为白色毛玻璃13个文件中硬编码的旧rgba全部替换云雾/粒子透明度降低VISUAL-STYLE-GUIDE.md同步重写。设计理念千里江山图是青绿设色于绢本白底白底留白才能让青绿有呼吸感。
### [CL-20260416-1500] 2026-04-16 — UX 风格指南实现度补全10 项未实现特性 + 文档状态标注)
- **tags**: 前端, UX补全, 动效, framer-motion, 微交互, 文档
- **affected_files**: chat-input.tsx, chat-messages.tsx, image-grid.tsx, motion-presets.ts, page.tsx, login/page.tsx, gallery/page.tsx, globals.css, app-context.tsx, sidebar.tsx, profile-modal.tsx, UX-STYLE-GUIDE.md
- **summary**: 逐项分析 UX-STYLE-GUIDE.md 中所有特性的实现度(原 ~65%),补全 10 项未实现特性涟漪接入发送按钮、sendBounce 弹缩、会话切换滑入滑出动画、Error 飘散消失+自动清除、回到底部水滴形、全屏云雾凝聚加载、AI 回复逐行 opacity 阶梯、typo-h1/h2/strong 实际使用、用户消息左上切角、图片占位石青渐变呼吸脉冲。文档更新为每项标注 ✅ 状态 + 实现文件路径。
### [CL-20260416-1330] 2026-04-16 — 交互体验全面升级:信息层级 + 科技质感 + 微交互趣味性
- **tags**: 前端, UX升级, 动效, framer-motion, 排版体系, 微交互, 视觉层级, 科技感
- **affected_files**: globals.css, page.tsx, chat-messages.tsx, chat-input.tsx, image-grid.tsx, session-list.tsx, sidebar.tsx, top-nav.tsx, image-detail-panel.tsx, profile-modal.tsx, model-selector.tsx, login/page.tsx, gallery/page.tsx, UX-STYLE-GUIDE.md, motion-presets.ts, ambient-particles.tsx, data-stream.tsx, ripple-effect.tsx, firefly-burst.tsx
- **summary**: 四阶段全面升级。Phase 1globals.css 新增 7 级排版体系Display 渐变→Micro 大写)+ 4 级 Surface 层级 + 点阵网格底纹 + 水波纹/shimmer/涟漪等CSS动画。Phase 2新建环境萤火粒子组件纯CSS+ 数据流光组件AI 活动时输入栏上方),用户/助手消息视觉差异(渐变背景+切角 vs glass-panel+左侧竖线。Phase 3引入 framer-motion统一动画参数 motion-presets.ts发送按钮投石入水spring 弹缩+呼吸脉冲)、输入框水面感应(焦点扩展线+亮度随输入增强、AI 回复云雾凝聚blur 消散入场、收藏萤火点亮金色粒子爆发、图片水墨晕染clip-path 圆形揭示。Phase 4统一 Loading 水波纹 + shimmer 骨架屏 + 错误赭石红震动 + 空态 Display 渐变欢迎语 + stagger 入场,滚到底部按钮改圆形 spring 弹入,侧栏会话左侧竖线 hover全组件 hover 浮起。新增 UX-STYLE-GUIDE.md 交互体验文档。
### [CL-20260416-1000] 2026-04-16 — 集成 Zero123++ 视角变换工具单图→6视角
- **tags**: 视角变换, Zero123++, Novel View Synthesis, 新工具, Replicate
- **affected_files**: config.py, tools.py, loop.py, view_transform.py
- **summary**: 新增 `transform_view` 工具集成 Zero123++Replicate输入一张图片生成 6 个固定视角图片。独立服务模块 `view_transform.py` 处理 Replicate 调用 + grid 拼接图 Pillow 拆分。config.py 新增 `VIEW_TRANSFORM_MODELS` 注册表。loop.py 对视角变换结果做特殊 SSE 事件处理。进阶方案3D重建+ControlNet记入延期方案。
### [CL-20260416-0930] 2026-04-16 — 设备绑定配置与 Skill/Hooks 解耦local-env.json
- **tags**: 基础设施, local-env, 跨设备, hooks, project-launcher, Skill
- **affected_files**: local-env.json, local-env.example.json, hooks.json, run-hook.ps1, session-init.sh, check-changelog.sh, local-env-check.mdc, project-launcher/SKILL.md, .gitignore, ENVIRONMENT.md
- **summary**: 新增 `.cursor/local-env.json`gitignored存放设备绑定配置nodejs_path、shellproject-launcher Skill 和 hooks 均改为从中读取。hooks 增加 bash 版脚本 + PowerShell dispatcher 支持跨平台。新增 `local-env-check` Rule 在会话开始时自动探测生成。ENVIRONMENT.md 去除平台硬编码改为通用说明。
### [CL-20260416-0900] 2026-04-16 — 环境依赖全面修复 + 统一环境文档
- **tags**: 环境, 依赖, Node.js, Python, venv, npm, requirements, ENVIRONMENT
- **affected_files**: requirements.txt, requirements-lock.txt, ENVIRONMENT.md, project-launcher/SKILL.md
- **summary**: 排查缺失依赖venv/node_modules 均不存在),安装 Node.js v22.15.0 到 %LOCALAPPDATA%\nodejs\,创建后端 venv 并安装全部依赖14 个直接 + 47 个间接npm install 前端依赖46 个包。requirements.txt 锁定精确版本,新增 requirements-lock.txt 和 ENVIRONMENT.md 统一环境文档。project-launcher Skill Node.js 路径已同步更新。
### [CL-20260416-0830] 2026-04-16 — 生图按模型预处理 prompt
- **tags**: 后端, 生图, prompt, SDXL, Flux, Replicate, image_gen, Agent工具
- **affected_files**: image_prompt_strategy.py, image_gen.py, tools.py
- **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 — 新增 GeminiNativeImageProviderGemini 原生 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` 条目。

View File

@@ -2,10 +2,201 @@
## 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用精选图训练 LoRA20-50 张),批量产出同风格资源
4. 贯穿全程:标准化 Prompt 模板 + Negative Prompt 模板,确保品质下限
各阶段可叠加使用不互斥。LoRA 训练需要同风格样本积累到足够数量。
2026-04-13 调研补充 — 风格迁移模型层级:
- Kolors IP-Adapter当前在用通用 IP-Adapter风格迁移弱内容+风格+构图混合提取
- InstantStyleReplicate 可用):专门分离内容/风格,风格迁移强,但贵且慢
- Style IPAdapter for NoobAI-XLCivitAI最强画风迁移线条+着色技法),需 ComfyUI
- ICAS 框架(学术前沿 2025.04IP-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风格迁移弱混合提取内容+风格+构图)
- InstantStylejyoung105/instant-style风格迁移强但贵$0.12/次)且慢(~128s
- Style IPAdapter for NoobAI-XL最强画风迁移但需 ComfyUI 环境Replicate 上无法使用
迁移到 HF Inference Endpoints 后可自由组合以上所有方案。
- **chosen_alternative**: 当前使用 Replicate APIKolors 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] 进阶视角变换 Pipeline3D 重建 + ControlNet 风格还原)
- **status**: deferred
- **tags**: 视角变换, 3D重建, ControlNet, Zero123++, TripoSR, InstantMesh, Novel View Synthesis, 手绘风格, IP-Adapter
- **recorded**: 2026-04-16
- **source_chat**: [视角变换方案讨论与 Zero123++ 集成](view-transform-zero123plus)
- **prerequisite**: Zero123++ 基础验证完成、确认视角变换是高频需求后
- **related_files**:
- art-agent/backend/app/services/view_transform.py
- art-agent/backend/app/config.py
- art-agent/backend/app/agent/tools.py
- **context**: |
当前已集成 Zero123++(固定 6 视角输出),但存在两个局限:
1. 视角固定6 个预设角度),无法自定义任意角度
2. 手绘风格保持度不够——Zero123++ 更擅长写实/3D 渲染风格
进阶方案分三个层级:
A. 更强的多视角生成模型:
- Era3D高质量多视角一致性建筑类友好
- SyncDreamer同步多视角生成一致性好
- Wonder3D多视角 + 法线图,精确视角控制
B. ControlNet 引导的视角变换:
- 用 Depth Anything V2 从原图提取深度图
- 用 ControlNetdepth/normal 模式)+ 目标视角描述重新生成
- 通过 IP-Adapter 注入原图风格保持手绘风格
C. 最佳质量方案(多步组合 Pipeline
原图 → TripoSR/InstantMesh 3D 重建 → 旋转到任意目标角度渲染灰度/线稿
→ ControlNet(canny/depth) + IP-Adapter(原图风格) → 手绘风格新视角图
这是质量最高的方案:视角精准 + 风格保真
实施依赖:
- 方案 B/C 需要 ComfyUI 环境(与 hf-inference-endpoints 延期方案关联)
- 方案 C 的 TripoSR/InstantMesh 可先在 Replicate 上验证
- **chosen_alternative**: 当前使用 Zero123++ 固定 6 视角输出(基础验证阶段)
- **deferred_reason**: 先用 Zero123++ 验证视角变换的实际需求频率和用户反馈,再投入进阶方案
---
## Completed / Cancelled Items
(暂无已完成或已废弃的方案
### [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 本地向量库),与上下文管理一并实施

View File

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

View File

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

View File

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

18
.cursor/hooks.json Normal file
View 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
View File

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

View File

@@ -0,0 +1,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"
# === 豁免检查 4changelog 文件不存在 ===
if (-not (Test-Path $changelog)) {
Write-Output '{}'
exit 0
}
$clMtime = (Get-Item $changelog).LastWriteTime
# === 豁免检查 5ack 标记文件足够新 ===
$ackFile = ".cursor\changelog\.changelog-ack"
if ((Test-Path $ackFile) -and (Get-Item $ackFile).LastWriteTime -ge $clMtime) {
Write-Output '{}'
exit 0
}
# === 豁免检查 6stdin 中无文件编辑证据 ===
# 防止跨会话残留 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

View File

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

View File

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

View File

@@ -0,0 +1,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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
# Project Profile Log
详细记录每次项目画像更新的完整上下文,按时间正序追加。
## 记录
### 2026-04-11 — 初始化项目画像
- **分类**: 项目定位
- **精简版**: 面向游戏美术团队的 AI Agent 工具,通过对话驱动产出美术资源
- **原始上下文**: "我希望做一个agent工具方便我们团队的美术更简易便捷地主要通过对话的形式去产出美术资源"
- **来源对话**: 初始化会话
- **操作**: 新增
### 2026-04-11 — 初始化项目画像
- **分类**: 设计约定
- **精简版**: 目标用户是美术人员,交互设计需优先考虑非技术用户的友好度
- **原始上下文**: "方便我们团队的美术更简易便捷地"
- **来源对话**: 初始化会话
- **操作**: 新增
### 2026-04-11 — 初始化项目画像
- **分类**: 产品方向
- **精简版**: 当前阶段平面资源、UI 类资源、交互设计、美术风格远期场景、3D 资源
- **原始上下文**: "目前可能主要考虑产出平面资源和UI类资源、交互、美术风格等以后也考虑要产出场景甚至3D资源"
- **来源对话**: 初始化会话
- **操作**: 新增

View File

@@ -0,0 +1,13 @@
# Project Profile
## 项目定位
- 面向游戏美术团队的 AI Agent 工具,通过对话驱动产出美术资源
## 技术栈与架构
## 设计约定
- 目标用户是美术人员,交互设计需优先考虑非技术用户的友好度
## 产品方向
- 当前阶段平面资源、UI 类资源、交互设计、美术风格
- 远期规划场景资源、3D 资源生成

View File

@@ -25,8 +25,12 @@ Agent回复时使用简体中文。
| 位置 | 用途 | 示例 |
|------|------|------|
| `~/.cursor/skills/` | 跨项目通用 Skill,全局可用 | epee-orchestrator、deferred-decisions |
| `.cursor/skills/` | 当前项目专属 Skill | 项目特定的代码生成、配置管理等 |
| `.cursor/skills/` | **所有 Skill 的主存储位置**(含通用和项目专属) | epee-orchestrator、deferred-decisions、项目特定 Skill |
| `~/.cursor/skills/` | 跨项目通用 Skill 的全局副本(可选,方便其他项目复用) | epee-orchestrator、deferred-decisions |
> **重要**:无论 Skill 是通用还是项目专属,都**必须**在项目的 `.cursor/skills/` 下保留一份,
> 以确保能被 Git 管理和版本控制。全局目录 `~/.cursor/skills/` 仅作为跨项目共享的便利副本,
> 不作为唯一存储位置。
### Agent 创建 Rule 或 Skill 时必须遵守
@@ -35,5 +39,5 @@ Agent回复时使用简体中文。
3. 确认后放入对应目录:
- 通用 Rule → `.cursor/rules/common/`
- 项目专属 Rule → `.cursor/rules/project/`
- 通用 Skill → `~/.cursor/skills/`
- 项目专属 Skill → `.cursor/skills/`
- **所有 Skill(含通用)→ `.cursor/skills/`**(必须,确保 Git 可管理)
- 通用 Skill 额外同步 → `~/.cursor/skills/`(可选,方便其他项目使用)

View 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 项,附 `[已记录到开发日志]`。

View File

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

View File

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

View File

@@ -28,6 +28,9 @@ Agent **必须**执行以下操作:
2. 更新或新增对应 Skill 的条目(格式参见 registry.md 中的条目结构)
3. 确保条目中的能力描述和触发场景与 Skill 实际内容一致
> **注意**:此项已纳入 `changelog-recall.mdc` 的"任务完成 Checklist"第 3 项。
> 如果 Agent 在 checklist 阶段发现遗漏,必须立即补执行。
### Skill 自迭代(强制)
每个 SKILL.md 必须包含一个"自迭代日志"章节,用于记录使用该 Skill 过程中发现的经验教训。
@@ -38,6 +41,9 @@ Agent **必须**执行以下操作:
2. 用户需要反复补充同类信息
3. 生成产物与用户预期存在系统性偏差
> **注意**:此项已纳入 `changelog-recall.mdc` 的"任务完成 Checklist"第 4 项。
> Agent 不应等到"下次使用 Skill 时"才想起自迭代——当次就应检查。
**流程**
1. 识别问题根因,归纳为一条简明的检查项

View 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 最多提醒一次

View 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. 提取该条目的锚点 IDHTML 注释中的 `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 定义。

View File

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

View File

@@ -0,0 +1,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 内置终端中后台运行——用户需要看到窗口并能手动关闭

View File

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

View File

@@ -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 的 itemsreminded/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 过程中发现的必要检查项。
### 已知必要检查
(暂无)

View 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.mdL2中 Grep 该 ID
- 找到 → 读取该条目的 3-5 行摘要,通常足以消除歧义
- 未找到(已滚出 L2 窗口)→ 进入步骤 3
3. 在 changelog-full.mdL1中 Grep 该 ID
- 找到 → 用 Read 工具读取该 ID 所在行号 ±20 行范围(精准读取,不读全文)
- 未找到 → 放弃上溯,该条目上下文不可用
```
### 上溯原则
- **按需触发**:只有在 L3 信息不足以支撑当前任务判断时才上溯,不预防性地批量读取
- **精准读取**:对 L1 的访问必须通过 Grep 定位行号 + Read 局部读取,禁止全文读取
- **最小化**:一次上溯通常只涉及 1-3 条记录,不批量上溯
## 操作 A记录写入核心流程
### 触发条件
Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后,自动触发。
以下情况**不触发**
- 纯对话讨论、方案设计、问题解答(无文件改动)
- 只读操作(查看文件、搜索代码)
- 只改动了 `.cursor/` 目录下的基础设施文件如画像、延期方案、changelog 自身)
### 写入流程
```
1. 生成锚点 IDCL-YYYYMMDD-HHMM检查是否与已有 ID 冲突,冲突则追加字母后缀)
2. 从刚完成的任务中提取以下信息:
- 做了什么what一句话概括
- 为什么这样做why动机和背景
- 改了哪里where受影响的文件/模块列表
- 关键决策decisions如果有方案选择记录选了什么、放弃了什么
- 注意事项notes后续可能受影响的地方、已知限制等
3. 生成三层内容(共享同一个锚点 ID
- L1 完整条目(包含以上全部信息)
- L2 摘要条目what + why + where3-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 改动"),真实反映开发过程
## 操作 BL2 自动触发读取
### 触发条件
`changelog-recall.mdc` Rule 调度。当 Agent 开始处理一个新任务时,判断该任务是否
与近期改动相关。
### 匹配策略(文件 + 标签双匹配)
```
1. 从当前任务中提取:
- 涉及的文件路径
- 语义关键词(模块名、功能领域等)
2. 读取 changelog-recent.md逐条检查
- 硬匹配:当前任务涉及的文件出现在条目的 affected_files 中
- 软匹配:当前任务的语义关键词与条目的 tags 有交集
3. 任一匹配命中 → 将匹配到的 L2 条目作为上下文纳入考量
4. 在回复中自然融入,不显式提及"根据开发日志"
```
## 操作 CL1 手动检索
### 触发条件
用户主动要求回顾完整改动记录时触发。典型话语:
- "回顾一下最近的改动"
- "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 的一行条目不受本条约束(它就是单行,天然无此风险)。

View File

@@ -0,0 +1,125 @@
---
name: epee-orchestrator
description: >-
EPEE Skill Orchestrator元层调度系统。检测任务是否可由已有 Skill 处理并分流,
或发现 Skill 缺口并引导创建新 Skill。当 Agent 遇到手动配置密集、重复模式明确、
手工指引过长的任务时触发。也用于 Skill Registry 的维护和同步。
---
# EPEE Skill Orchestrator
元层调度系统,负责 Skill 分流、缺口发现和 Registry 维护。
## 操作 ASkill 分流(匹配已有 Skill
### 流程
```
1. 读取 registry.md 获取所有已注册 Skill 的摘要
2. 将当前任务特征与每个 Skill 的"触发场景"关键词匹配
3. 如果匹配到:
a. 告知用户"此任务可以通过 [Skill名] 高效完成"
b. 简述该 Skill 的能力及与当前任务的契合点
c. 用户确认后,读取并激活对应 Skill 的 SKILL.md
```
### 匹配规则
- 优先匹配触发场景关键词与当前任务描述的交集
- 如有多个 Skill 匹配,按相关度排序推荐,由用户选择
- `deferred-decisions` 等标记为"基础设施"类型的 Skill 不参与任务分流匹配,
仅作为 Orchestrator 的下游工具使用
## 操作 BSkill 缺口发现 + 创建引导
### 触发条件
操作 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 中记录方案概要,便于未来回忆
```
## 操作 CRegistry 同步
### 触发条件
- 新 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 化设计可以打断这种级联——每项独立检查,不依赖前一项的执行记忆。

View 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"触发(原被动写入提醒已升级为强制 checklistL3 概要每次会话自动注入上下文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 和 shellOllama 必须先于后端启动Mem0 embedding 依赖);前后端窗口必须可见

View 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. 生成条目 IDPF-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]` 标题行且字段完整跟随。

View File

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

View File

@@ -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. 从精简版条目中提取锚点 IDHTML 注释中的 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. 为每条新记录生成锚点 IDPF-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 过程中发现的必要检查项。
### 已知必要检查
(暂无)

View 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 的后台命令方式,
那样窗口不可见,用户无法直接查看和关闭
## 单独启动
如果用户只说"启动前端"或"启动后端",只启动对应的服务即可,不需要全部启动。
## 访问信息
启动完成后告知用户:
- Ollamahttp://localhost:11434Mem0 embedding 服务)
- 后端 APIhttp://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
View 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*

View File

@@ -1,3 +0,0 @@
{
"AndreaNovelHelper.workspaceDisabled": true
}

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

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

177
art-agent/README.md Normal file
View File

@@ -0,0 +1,177 @@
# EPEEKit
AI 美术资源生成工具集 — 通过对话生成游戏美术资源。
## 快速开始
### 1. 准备 API Key
你需要两个 API Key
- **OpenAI API Key**(或兼容 APIhttps://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

View 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

View File

View File

View 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

View 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": "视角变换模型;默认 zero123plustrellis 画风还原更强但慢",
},
"azimuths": {
"type": "array",
"items": {"type": "integer"},
"description": "mesh 管道专用自定义方位角数组0-359默认 [0,60,120,180,240,300]",
},
"preserve_style": {
"type": "boolean",
"default": True,
"description": "mesh 管道专用:是否对抽帧结果走 IP-Adapter+ControlNet 重绘还原原画风",
},
},
"required": [],
},
},
},
]
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}"}

View File

View 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} 已禁用"}

View 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)

View 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())

View 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}

View 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

View 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_outputsKolors 用 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")

View 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()

View 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"}

View 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

View 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,
)

View 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
# ─── SDXLStability / 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 FLUXSubject + 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 SDXLprompt + 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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

6
art-agent/frontend/next-env.d.ts vendored Normal file
View 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.

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View 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;
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
);
});

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

@@ -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();
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

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