Merge pull request 'photoGen' (#2) from photoGen into main

Reviewed-on: http://114.55.102.172/gitea/shiyong.qin/EPEEAIKit/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
2026-04-17 19:30:54 +08:00
69 changed files with 9570 additions and 890 deletions

View File

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

View File

@@ -2,6 +2,14 @@
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
- [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
@@ -44,20 +52,3 @@
- [CL-20260413-0540] 非 vision LLM + 参考图时禁止猜测风格prompt 只描述内容不写画风,风格交由 IP-Adapter 处理
- [CL-20260413-0520] 多图生成改为单次 API 调用(利用模型原生批量参数),避免速率限制 + 修复空错误信息显示
- [CL-20260413-0500] 图片生成后在对话中显示实际使用的模型名称(如"由 Kolors IP-Adapter 生成"),便于模型溯源
- [CL-20260413-0440] 修复 Kolors IP-Adapter 404 错误:社区模型需 `owner/model:version_hash` 格式,补全版本 hash 后生图成功
- [CL-20260413-0430] 图片生成失败时将具体错误信息Replicate API 报错等)透传到前端展示,新增 tool_error SSE 事件
- [CL-20260413-0330] README 补充访问地址汇总(本机/局域网/外网)+ 穿透管理文档(安装/启停/排错)
- [CL-20260412-2345] 参考图上传独立化:选图即上传 + 环形进度条 + 成功/失败状态反馈 + 失败重试,后端新增独立上传端点
- [CL-20260412-2315] 修复 Cloudflare Tunnel 跨域开发警告next.config.ts 新增 allowedDevOrigins 通配符
- [CL-20260412-2300] 集成 Kolors IP-Adapter 模型:参考图可直接用于风格生成,模型注册表新增通用参考图支持机制
- [CL-20260412-2230] 用户消息气泡中显示参考图缩略图ChatMessage 新增 refImageUrl发送后在气泡中渲染参考图
- [CL-20260412-2200] 修复 DeepSeek 不支持 image_url 参考图上传报错:新增 vision 能力检测,非 vision 模型走文字提示
- [CL-20260412-2130] 修复图片生成失败时前端显示破碎图标:后端分离错误信息与有效 URL前端过滤无效 URL 不渲染空图片
- [CL-20260412-2100] 生图模型动态切换Provider 抽象架构 + 前端模型选择器 + Replicate 多模型flux-schnell/flux-dev/SDXL按消息粒度切换
- [CL-20260412-1800] 移动端适配 + Cloudflare Tunnel 内网穿透响应式布局改造9 个组件)+ 一键穿透脚本 + cloudflared 安装
- [CL-20260412-1530] 修复 .env 配置加载时序问题load_dotenv 前置 + override=True + config.py 改函数式懒读取
- [CL-20260412-1500] 品牌重命名 Art Agent → EPEEKit + 后端 API/模型配置集中化到 .env + config.py
- [CL-20260412-0300] 完成交互原型全面重构:三栏布局 + 多会话标签系统 + Canvas 标注 + 资源库页面
- [CL-20260411-1630] 安装 Node.js v24.14.1 + Python venv + 前后端依赖,后端启动验证通过
- [CL-20260411-1600] 从零创建美术 Agent MVP 全部前后端代码FastAPI + Next.js + Agent Loop + SSE
- [CL-20260411-1500] 完成美术 Agent 产品决策5 个核心问题和技术选型8 个维度)两份文档

View File

@@ -3,6 +3,46 @@
最近 ~10 次改动的摘要记录,按时间倒序排列。
当 Agent 检测到当前任务与近期改动相关时自动读取。
### [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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -83,7 +83,7 @@ Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后
4. 写入三个文件(按以下顺序):
a. 读取 changelog-full.md在 "## 记录" 下方追加 L1 条目
b. 读取 changelog-recent.md在顶部插入 L2 条目,如超过 10 条则移除最旧的
b. 读取 changelog-recent.md在顶部插入 L2 条目,如超过 20 条则移除最旧的与下方「L2 窗口」一致)
c. 读取 changelog-headlines.md在顶部插入 L3 条目,如超过 50 条则移除最旧的
5. 在回复末尾附一行提示:"[已记录到开发日志]"
@@ -247,3 +247,5 @@ Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后
### 已知必要检查
1. **大任务收尾遗漏风险** — 当单次任务涉及 5+ 个文件改动时Agent 容易在输出总结回复后遗漏被动写入流程。应在生成最终回复前,先执行 `changelog-recall.mdc` 中的"任务完成 Checklist",确认三层日志已写入后再输出回复。绝不能"先回复再补写"。
2. **L3/L2 滚动窗口必须落地** — 操作 A 第 4 步不是「插入即结束」写入后必须数清条目L3 为以 `- [` 开头的列表行L2 为 `### [CL-` 标题行。L3 超过 50 条、L2 超过 20 条时,必须从**文件底部(最旧)**整段删除条目直至恰好满足上限;禁止长期只追加不修剪。若发现 `changelog-headlines.md` 列表行多于 50说明上次写入未执行本项本次补修剪并自检。

View File

@@ -39,7 +39,7 @@
- **触发场景**: "开发日志"、"changelog"、"最近改了什么"、"回顾改动"、"查看开发记录"、代码改动后自动写入
- **输出**: .cursor/changelog/ 下的 changelog-full.md、changelog-recent.md、changelog-headlines.md
- **路径**: .cursor/skills/dev-changelog/SKILL.md
- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklistL3 概要每次会话自动注入上下文stop Hook 双保险兜底
- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklistL3 概要每次会话自动注入上下文stop Hook 双保险兜底;写入后须维护 L3≤50 / L2≤20 滚动窗口(见 Skill 自迭代检查项)
### pitfall-journal
- **类型**: 基础设施
@@ -49,10 +49,18 @@
- **路径**: .cursor/skills/pitfall-journal/SKILL.md
- **备注**: 与 dev-changelog 互补——changelog 记事实pitfall 记经验;由 pitfall-recall Rule 触发自动检索
### 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
- **备注**: 项目专属 SkillOllama 必须先于后端启动Mem0 embedding 依赖);前后端窗口必须可见
- **备注**: 项目专属 Skill启动前必须先读取 `.cursor/local-env.json` 获取 nodejs_path 和 shellOllama 必须先于后端启动Mem0 embedding 依赖);前后端窗口必须可见

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

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

5
.gitignore vendored
View File

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

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

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

View File

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

View File

@@ -1,6 +1,7 @@
"""Agent 可调用的工具定义和实现。"""
from app.services.image_gen import generate_images
from app.services.view_transform import transform_view
# OpenAI Function Calling 格式的工具定义
TOOL_DEFINITIONS = [
@@ -32,7 +33,27 @@ TOOL_DEFINITIONS = [
"required": ["prompt"],
},
},
}
},
{
"type": "function",
"function": {
"name": "transform_view",
"description": (
"将一张图片转换为多个不同视角。"
"使用 Zero123++ 模型从单张图片生成 6 个固定视角的图片。"
"适用于建筑、物体等需要从不同角度查看的场景。"
"输入图片必须是正方形(或会被自动裁切为正方形),建议分辨率 >= 320x320。"
"输出 6 个视角:方位角 30°/90°/150°/210°/270°/330°"
"仰角交替为 30°/-20°正俯视/微仰视)。"
"此工具需要参考图作为输入——必须先有用户上传的图片才能使用。"
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
]
@@ -65,4 +86,13 @@ async def execute_tool(
"model_id": result.model_id,
}
if tool_name == "transform_view":
if not ref_image_urls:
return {
"success": False,
"error": "视角变换需要一张输入图片,请先上传参考图",
}
result = await transform_view(ref_image_urls[0])
return result
return {"success": False, "error": f"未知工具: {tool_name}"}

View File

@@ -228,6 +228,50 @@ IMAGE_MODELS: dict[str, dict[str, Any]] = {
}
# ─── 视角变换模型注册表 ─────────────────────────────────
#
# 视角变换模型不用于生图,而是将已有图片转换为不同视角。
# 输入通常只需一张图,输出为多个固定视角的图片。
VIEW_TRANSFORM_MODELS: dict[str, dict[str, Any]] = {
"zero123plus": {
"id": "zero123plus",
"name": "Zero123++",
"provider": "replicate",
"model_id": "jd7h/zero123plusplus:c69c6559a29011b576f1ff0371b3bc1add2856480c60520c7e9ce0b40a6e9052",
"description": "单图生成 6 个固定视角,适合建筑/物体的多角度预览",
"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": 3, "rows": 2},
},
}
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]:
"""返回前端下拉列表所需的视角变换模型摘要信息。"""
return [
{
"id": cfg["id"],
"name": cfg["name"],
"description": cfg["description"],
}
for cfg in VIEW_TRANSFORM_MODELS.values()
]
def get_default_image_model_id() -> str:
"""返回 .env 中配置的默认模型短 ID若不在注册表中则回退到 flux-schnell。"""
env_model = os.getenv("IMAGE_MODEL", "flux-schnell")

View File

@@ -0,0 +1,157 @@
"""视角变换服务 — 调用 Zero123++ 等模型,将单张图片转为多视角图片。"""
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
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 中的视角。
"""
img = Image.open(image_path)
w, h = img.size
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,
) -> dict[str, Any]:
"""调用视角变换模型,返回多视角图片列表。
Args:
image_path: 输入图片路径本地路径、data URI 或 URL
model_id: 视角变换模型 ID默认 zero123plus
Returns:
{
"success": bool,
"images": [{"url": str, "azimuth": int, "elevation": int}, ...],
"grid_image": str, # 原始拼接图的本地 URL
"model_name": str,
"error": str | None,
}
"""
config = get_view_transform_model_config(model_id)
model_name = config["name"]
replicate_model_id = config["model_id"]
grid_layout = config["grid_layout"]
output_views = config["output_views"]
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
try:
image_uri = to_data_uri(image_path)
input_params = {"image": image_uri}
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,
"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,
"model_name": model_name,
"error": detail,
}

Binary file not shown.

View File

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

@@ -8,6 +8,7 @@
"name": "epeekit-frontend",
"version": "0.1.0",
"dependencies": {
"framer-motion": "^12.38.0",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
@@ -138,9 +139,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -157,9 +155,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -176,9 +171,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -195,9 +187,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -214,9 +203,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -233,9 +219,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -252,9 +235,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -271,9 +251,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -290,9 +267,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -315,9 +289,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -340,9 +311,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -365,9 +333,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -390,9 +355,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -415,9 +377,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -440,9 +399,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -465,9 +421,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -654,9 +607,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -673,9 +623,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -692,9 +639,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -711,9 +655,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -897,9 +838,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -917,9 +855,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -937,9 +872,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -957,9 +889,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1134,6 +1063,33 @@
"node": ">=10.13.0"
}
},
"node_modules/framer-motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.38.0",
"motion-utils": "^12.36.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1294,9 +1250,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1318,9 +1271,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1342,9 +1292,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1366,9 +1313,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1434,6 +1378,21 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/motion-dom": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.36.0"
}
},
"node_modules/motion-utils": {
"version": "12.36.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",

View File

@@ -9,17 +9,18 @@
"lint": "next lint"
},
"dependencies": {
"framer-motion": "^12.38.0",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0",
"postcss": "^8.5.0",
"tailwindcss": "^4.0.0",
"@tailwindcss/postcss": "^4.0.0",
"postcss": "^8.5.0"
"typescript": "^5.7.0"
}
}

View File

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

View File

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

View File

@@ -1,24 +1,41 @@
"use client";
import { useState, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { TopNav } from "@/components/layout/top-nav";
import { AmbientParticles } from "@/components/ui/ambient-particles";
import { useFireflyBurst, FireflyBurst } from "@/components/ui/firefly-burst";
import { useApp } from "@/lib/app-context";
import { getImageUrl } from "@/lib/api";
import { staggerContainer, staggerItem, slideUp } from "@/components/ui/motion-presets";
import type { ImageAsset } from "@/lib/types";
type ViewMode = "grid" | "list";
type SortBy = "time" | "name";
type SearchMode = "keyword" | "semantic";
const ASSET_TYPES = [
{ id: "icon", label: "Icon", keywords: ["icon", "图标", "徽标"] },
{ id: "character", label: "角色立绘", keywords: ["立绘", "角色", "character", "半身", "全身"] },
{ id: "scene", label: "场景", keywords: ["场景", "背景", "scene", "landscape"] },
{ id: "item", label: "道具", keywords: ["道具", "武器", "装备", "item"] },
{ id: "ui", label: "UI 元素", keywords: ["ui", "按钮", "卡片", "边框"] },
];
export default function GalleryPage() {
const { assets, tags, toggleFavorite, deleteAssetById, updateAsset } = useApp();
const { assets, tags, toggleFavorite, deleteAssetById, stylePacks, characters } = useApp();
const [viewMode, setViewMode] = useState<ViewMode>("grid");
const [sortBy, setSortBy] = useState<SortBy>("time");
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [searchMode, setSearchMode] = useState<SearchMode>("keyword");
const [showFavOnly, setShowFavOnly] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [detailAsset, setDetailAsset] = useState<ImageAsset | null>(null);
const [selectedAssetType, setSelectedAssetType] = useState<string | null>(null);
const [selectedStyleId, setSelectedStyleId] = useState<string | null>(null);
const [selectedCharacterId, setSelectedCharacterId] = useState<string | null>(null);
const tagMap = new Map(tags.map((t) => [t.id, t]));
@@ -35,7 +52,37 @@ export default function GalleryPage() {
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase();
if (searchMode === "keyword") {
result = result.filter((a) => a.prompt.toLowerCase().includes(q));
} else {
const tokens = q.split(/\s+/).filter(Boolean);
result = result.filter((a) => {
const text = a.prompt.toLowerCase();
return tokens.some((t) => text.includes(t));
});
}
}
if (selectedAssetType) {
const kws = ASSET_TYPES.find((t) => t.id === selectedAssetType)?.keywords ?? [];
result = result.filter((a) => kws.some((k) => a.prompt.toLowerCase().includes(k.toLowerCase())));
}
if (selectedStyleId) {
const sp = stylePacks.find((s) => s.id === selectedStyleId);
const kws = sp?.keywords ?? [];
result = result.filter((a) =>
kws.some((k) => a.prompt.toLowerCase().includes(k.toLowerCase())) ||
a.prompt.toLowerCase().includes((sp?.name ?? "").toLowerCase())
);
}
if (selectedCharacterId) {
const ch = characters.find((c) => c.id === selectedCharacterId);
const kws = [...(ch?.visualHooks ?? []), ch?.name].filter(Boolean) as string[];
result = result.filter((a) =>
kws.some((k) => a.prompt.toLowerCase().includes(k.toLowerCase()))
);
}
result.sort((a, b) => {
@@ -44,7 +91,8 @@ export default function GalleryPage() {
});
return result;
}, [assets, showFavOnly, selectedTagIds, searchQuery, sortBy]);
}, [assets, showFavOnly, selectedTagIds, searchQuery, searchMode, sortBy,
selectedAssetType, selectedStyleId, selectedCharacterId, stylePacks, characters]);
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
@@ -110,12 +158,13 @@ export default function GalleryPage() {
return (
<div className="h-screen flex flex-col relative z-[1]">
<AmbientParticles count={filtered.length === 0 ? 25 : 15} />
<TopNav />
<div className="flex-1 flex overflow-hidden">
<main className="flex-1 flex flex-col min-w-0">
{/* 工具栏 */}
<div className="flex-shrink-0 border-b border-[var(--border)] bg-[var(--bg-secondary)]/80
<div className="flex-shrink-0 border-b border-[var(--border)] surface-1
backdrop-blur-xl px-4 md:px-5 py-3
flex items-center gap-2 md:gap-3 flex-wrap">
{/* 区块标题 */}
@@ -125,7 +174,7 @@ export default function GalleryPage() {
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
<span className="typo-strong text-sm"></span>
</div>
{/* 搜索 */}
@@ -139,11 +188,20 @@ export default function GalleryPage() {
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索 Prompt..."
placeholder={searchMode === "semantic" ? "语义搜索(占位)..." : "搜索 Prompt..."}
className="flex-1 bg-transparent border-none text-sm
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none"
/>
<button
onClick={() => setSearchMode(searchMode === "keyword" ? "semantic" : "keyword")}
className="flex items-center gap-1 text-[10px] text-[var(--text-secondary)]
hover:text-[var(--accent)] cursor-pointer transition-colors"
title="切换搜索模式"
>
{searchMode === "semantic" ? "语义" : "关键词"}
{searchMode === "semantic" && <span className="phase-chip">P2</span>}
</button>
</div>
{/* 标签筛选 */}
@@ -233,6 +291,84 @@ export default function GalleryPage() {
</div>
</div>
{/* 二级过滤器:资产类型 / 风格 / 角色 */}
<div className="flex-shrink-0 border-b border-[var(--border)] surface-1
px-4 md:px-5 py-2 flex items-center gap-3 flex-wrap text-xs">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<button
onClick={() => setSelectedAssetType(null)}
className={`px-2 py-0.5 rounded-md cursor-pointer transition-all text-[11px] ${
selectedAssetType === null
? "bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/30"
: "border border-[var(--border)] text-[var(--text-secondary)]"
}`}
>
</button>
{ASSET_TYPES.map((t) => (
<button
key={t.id}
onClick={() => setSelectedAssetType(t.id === selectedAssetType ? null : t.id)}
className={`px-2 py-0.5 rounded-md cursor-pointer transition-all text-[11px] ${
selectedAssetType === t.id
? "bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/30"
: "border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{t.label}
</button>
))}
</div>
{stylePacks.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<select
value={selectedStyleId ?? ""}
onChange={(e) => setSelectedStyleId(e.target.value || null)}
className="px-2 py-0.5 text-[11px] rounded-md bg-[var(--bg-tertiary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none cursor-pointer"
>
<option value=""></option>
{stylePacks.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
)}
{characters.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<select
value={selectedCharacterId ?? ""}
onChange={(e) => setSelectedCharacterId(e.target.value || null)}
className="px-2 py-0.5 text-[11px] rounded-md bg-[var(--bg-tertiary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none cursor-pointer"
>
<option value=""></option>
{characters.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
)}
{(selectedAssetType || selectedStyleId || selectedCharacterId) && (
<button
onClick={() => {
setSelectedAssetType(null);
setSelectedStyleId(null);
setSelectedCharacterId(null);
}}
className="ml-auto text-[10px] text-[var(--text-secondary)] hover:text-[var(--hot)] cursor-pointer"
>
</button>
)}
</div>
{/* 批量操作栏 */}
{selectedIds.size > 0 && (
<div className="flex-shrink-0 px-5 py-2.5 bg-[var(--accent)]/5 border-b border-[var(--accent)]/20
@@ -272,19 +408,28 @@ export default function GalleryPage() {
<div className="flex-1 overflow-y-auto p-4 md:p-5">
{filtered.length === 0 ? (
<div className="h-full flex items-center justify-center">
<div className="text-center space-y-3">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
bg-[var(--accent)]/10 border border-[var(--accent)]/20">
<motion.div
className="text-center space-y-4"
variants={staggerContainer}
initial="hidden"
animate="visible"
>
<motion.div variants={staggerItem} className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
bg-[var(--accent)]/10 border border-[var(--accent)]/20
shadow-[0_0_20px_rgba(46,139,122,0.1)]">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" opacity="0.6">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
<p className="text-sm text-[var(--text-secondary)]">
</motion.div>
<motion.p variants={staggerItem} className="typo-display text-lg">
{assets.length === 0 ? "还没有生成过图片" : "没有匹配的资源"}
</p>
</div>
</motion.p>
<motion.p variants={staggerItem} className="typo-caption">
{assets.length === 0 ? "在对话中生成的图片会自动出现在这里" : "试试调整筛选条件"}
</motion.p>
</motion.div>
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 md:gap-4">
@@ -294,7 +439,7 @@ export default function GalleryPage() {
className={`group relative rounded-xl overflow-hidden neon-border cursor-pointer
bg-[var(--bg-card)] backdrop-blur-sm transition-all ${
selectedIds.has(asset.id)
? "!border-[var(--accent)] ring-1 ring-[var(--accent)]/30 shadow-[0_0_15px_rgba(77,184,164,0.08)]"
? "!border-[var(--accent)] ring-1 ring-[var(--accent)]/30 shadow-[0_0_15px_rgba(46,139,122,0.08)]"
: ""
}`}
onClick={() => setDetailAsset(asset)}
@@ -310,7 +455,7 @@ export default function GalleryPage() {
<div
className={`w-5 h-5 rounded-md border-2 flex items-center justify-center transition-all ${
selectedIds.has(asset.id)
? "bg-[var(--accent)] border-[var(--accent)] shadow-[0_0_8px_rgba(77,184,164,0.25)]"
? "bg-[var(--accent)] border-[var(--accent)] shadow-[0_0_8px_rgba(46,139,122,0.25)]"
: "border-white/40 bg-black/30 backdrop-blur-sm opacity-100 md:opacity-0 md:group-hover:opacity-100"
}`}
>
@@ -527,15 +672,17 @@ function GalleryDetailModal({
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
onClick={onClose}
>
<div
className="glass-panel rounded-2xl max-w-3xl w-full mx-2 md:mx-4
max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col
shadow-xl shadow-black/40"
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
className="surface-3 rounded-2xl max-w-3xl w-full mx-2 md:mx-4
max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[var(--border)]">
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
<span className="typo-h2"></span>
<button
onClick={onClose}
className="text-[var(--text-secondary)] hover:text-[var(--accent)] cursor-pointer
@@ -565,7 +712,7 @@ function GalleryDetailModal({
onClick={onDownload}
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(77,184,164,0.25)]
hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
cursor-pointer transition-all"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -602,7 +749,7 @@ function GalleryDetailModal({
{asset.prompt && (
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-[var(--text-secondary)]">Prompt</span>
<span className="typo-strong text-xs">Prompt</span>
<button
onClick={() => navigator.clipboard.writeText(asset.prompt)}
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
@@ -621,7 +768,7 @@ function GalleryDetailModal({
{/* 标签 */}
{asset.tags.length > 0 && (
<div>
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5"></span>
<span className="typo-strong text-xs block mb-1.5"></span>
<div className="flex flex-wrap gap-1.5">
{asset.tags.map((tid) => {
const tag = tagMap.get(tid);
@@ -648,7 +795,7 @@ function GalleryDetailModal({
</div>
</div>
</div>
</div>
</motion.div>
</div>
);
}

View File

@@ -1,20 +1,20 @@
@import "tailwindcss";
/* ===== 青绿山水色彩体系 ===== */
/* ===== 青绿山水色彩体系(白底·绢本) ===== */
:root {
--bg-primary: #0C1210;
--bg-secondary: #131E1A;
--bg-tertiary: #1C2A24;
--bg-card: rgba(16, 30, 24, 0.65);
--text-primary: #E0E8E2;
--text-secondary: #7A9485;
--accent: #4DB8A4;
--accent-hover: #6CD4BE;
--accent-secondary: #3A8FB7;
--bg-primary: #FAFAF7;
--bg-secondary: #F2F1EC;
--bg-tertiary: #E8E6DF;
--bg-card: rgba(255, 255, 255, 0.8);
--text-primary: #1A1A1A;
--text-secondary: #6B7B8A;
--accent: #2E8B7A;
--accent-hover: #3AA38E;
--accent-secondary: #3A7FB8;
--hot: #C4654A;
--border: rgba(77, 184, 164, 0.12);
--border-glow: rgba(77, 184, 164, 0.25);
--mist: rgba(77, 184, 164, 0.06);
--border: rgba(46, 139, 122, 0.15);
--border-glow: rgba(46, 139, 122, 0.30);
--mist: rgba(46, 139, 122, 0.04);
--gold: #B8935A;
}
@@ -22,8 +22,126 @@ body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 14px;
letter-spacing: 0.01em;
overscroll-behavior: none;
-webkit-tap-highlight-color: transparent;
/* 绢本底纹:极淡暖白,像宣纸的自然质感 */
background-image:
radial-gradient(ellipse at 20% 80%, rgba(46, 139, 122, 0.03) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(58, 127, 184, 0.02) 0%, transparent 50%);
}
/* ===== 排版层级体系7 级) ===== */
.typo-display {
font-size: 32px;
font-weight: 300;
letter-spacing: -0.02em;
line-height: 1.2;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-secondary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: transparent;
}
.typo-h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.3;
color: var(--text-primary);
position: relative;
padding-bottom: 10px;
}
.typo-h1::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 40px;
height: 2px;
background: linear-gradient(90deg, var(--accent) 0%, transparent 100%);
border-radius: 1px;
}
.typo-h2 {
font-size: 18px;
font-weight: 600;
letter-spacing: 0;
line-height: 1.4;
color: var(--text-primary);
padding-left: 12px;
position: relative;
}
.typo-h2::before {
content: '';
position: absolute;
left: 0;
top: 2px;
bottom: 2px;
width: 3px;
background: var(--accent);
border-radius: 2px;
}
.typo-strong {
font-weight: 600;
color: var(--accent);
}
.typo-caption {
font-size: 12px;
font-weight: 400;
letter-spacing: 0.02em;
color: var(--text-secondary);
}
.typo-micro {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
}
/* ===== 表面层级系统Surface Elevation ===== */
.surface-1 {
background: var(--bg-secondary);
border: 1px solid var(--border);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.surface-2 {
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(20px) saturate(1.1);
-webkit-backdrop-filter: blur(20px) saturate(1.1);
border: 1px solid rgba(46, 139, 122, 0.12);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06),
0 1px 2px rgba(0, 0, 0, 0.04);
}
.surface-3 {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(24px) saturate(1.15);
-webkit-backdrop-filter: blur(24px) saturate(1.15);
border: 1px solid rgba(46, 139, 122, 0.18);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08),
0 2px 8px rgba(0, 0, 0, 0.04);
}
/* ===== 网格底纹(绢本纹理潜意识层) ===== */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(circle, var(--accent) 0.5px, transparent 0.5px);
background-size: 24px 24px;
opacity: 0.018;
pointer-events: none;
z-index: 0;
}
/* ===== 山雾背景 — 远景层 ===== */
@@ -41,60 +159,50 @@ body {
100% { transform: translate(0, 0) scale(1); }
}
body::before {
content: '';
.fog-layer-far {
position: fixed;
inset: -10%;
background:
radial-gradient(ellipse 80% 50% at 15% 75%, rgba(77, 184, 164, 0.07) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 75% 25%, rgba(58, 143, 183, 0.05) 0%, transparent 55%),
radial-gradient(ellipse 90% 60% at 50% 50%, rgba(77, 184, 164, 0.03) 0%, transparent 70%);
radial-gradient(ellipse 80% 50% at 15% 75%, rgba(46, 139, 122, 0.04) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 75% 25%, rgba(58, 127, 184, 0.03) 0%, transparent 55%),
radial-gradient(ellipse 90% 60% at 50% 50%, rgba(46, 139, 122, 0.02) 0%, transparent 70%);
animation: fogDriftFar 60s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
body::after {
content: '';
.fog-layer-near {
position: fixed;
inset: -15%;
background:
radial-gradient(ellipse 70% 45% at 80% 70%, rgba(77, 184, 164, 0.06) 0%, transparent 55%),
radial-gradient(ellipse 50% 60% at 25% 30%, rgba(58, 143, 183, 0.04) 0%, transparent 50%);
radial-gradient(ellipse 70% 45% at 80% 70%, rgba(46, 139, 122, 0.03) 0%, transparent 55%),
radial-gradient(ellipse 50% 60% at 25% 30%, rgba(58, 127, 184, 0.025) 0%, transparent 50%);
animation: fogDriftNear 45s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
/* ===== 山雾玻璃面板 ===== */
/* ===== 山雾玻璃面板(兼容旧用法) ===== */
.glass-panel {
background:
linear-gradient(165deg, rgba(16, 30, 24, 0.15) 0%, transparent 50%),
rgba(16, 30, 24, 0.55);
backdrop-filter: blur(24px) saturate(1.1);
-webkit-backdrop-filter: blur(24px) saturate(1.1);
background: rgba(255, 255, 255, 0.65);
backdrop-filter: blur(20px) saturate(1.1);
-webkit-backdrop-filter: blur(20px) saturate(1.1);
border: 1px solid var(--border);
}
/* ===== 静态霓虹边框(卡片等普通元素) ===== */
/* ===== 静态发光边框(卡片等普通元素) ===== */
.neon-border {
border: 1px solid var(--border);
transition: border-color 200ms ease, box-shadow 200ms ease;
transition: border-color 200ms ease, box-shadow 200ms ease, transform 200ms ease;
}
.neon-border:hover {
border-color: var(--border-glow);
box-shadow: 0 0 18px rgba(77, 184, 164, 0.08),
inset 0 0 12px rgba(77, 184, 164, 0.03);
box-shadow: 0 0 18px rgba(46, 139, 122, 0.08),
inset 0 0 12px rgba(46, 139, 122, 0.03);
transform: translateY(-1px);
}
/* ===== 云烟缭绕边框 ===== */
/*
* .glow-border — 选中态的云烟边框
* 常态:石青色微弱均匀发光
* hover云烟缓慢缭绕opacity 平滑过渡
* 移开:云烟淡散回静态
*/
@property --glow-angle {
syntax: "<angle>";
initial-value: 0deg;
@@ -107,19 +215,16 @@ body::after {
.glow-border {
position: relative;
background: var(--bg-secondary);
border: 1px solid rgba(77, 184, 164, 0.18) !important;
box-shadow: 0 0 10px rgba(77, 184, 164, 0.05),
inset 0 0 8px rgba(77, 184, 164, 0.02);
border: 1px solid rgba(46, 139, 122, 0.20) !important;
box-shadow: 0 0 10px rgba(46, 139, 122, 0.06);
transition: border-color 400ms ease, box-shadow 400ms ease;
z-index: 0;
}
.glow-border:hover {
border-color: rgba(77, 184, 164, 0.28) !important;
box-shadow: 0 0 16px rgba(77, 184, 164, 0.08),
inset 0 0 12px rgba(77, 184, 164, 0.03);
border-color: rgba(46, 139, 122, 0.32) !important;
box-shadow: 0 0 16px rgba(46, 139, 122, 0.10);
}
/* 云烟边框层 — 宽弧段柔化渐变6s 缓慢旋转 */
.glow-border::before {
content: '';
position: absolute;
@@ -130,11 +235,11 @@ body::after {
from var(--glow-angle),
transparent 0%,
transparent 15%,
rgba(77, 184, 164, 0.2) 25%,
rgba(58, 143, 183, 0.35) 38%,
rgba(77, 184, 164, 0.4) 50%,
rgba(58, 143, 183, 0.35) 62%,
rgba(77, 184, 164, 0.2) 75%,
rgba(46, 139, 122, 0.2) 25%,
rgba(58, 127, 184, 0.3) 38%,
rgba(46, 139, 122, 0.35) 50%,
rgba(58, 127, 184, 0.3) 62%,
rgba(46, 139, 122, 0.2) 75%,
transparent 85%,
transparent 100%
);
@@ -149,7 +254,6 @@ body::after {
z-index: -1;
}
/* 雾气扩散层 — 更大范围、更模糊 */
.glow-border::after {
content: '';
position: absolute;
@@ -159,9 +263,9 @@ body::after {
from var(--glow-angle),
transparent 0%,
transparent 20%,
rgba(77, 184, 164, 0.06) 35%,
rgba(58, 143, 183, 0.08) 50%,
rgba(77, 184, 164, 0.06) 65%,
rgba(46, 139, 122, 0.05) 35%,
rgba(58, 127, 184, 0.06) 50%,
rgba(46, 139, 122, 0.05) 65%,
transparent 80%,
transparent 100%
);
@@ -194,6 +298,144 @@ body::after {
);
}
/* ===== 数据流光AI 活动时输入栏上方) ===== */
@keyframes dataStream {
0% { transform: translateX(-100%); }
100% { transform: translateX(200%); }
}
.data-stream {
height: 2px;
background: linear-gradient(
90deg,
transparent 0%,
var(--accent) 30%,
var(--accent-secondary) 50%,
var(--accent) 70%,
transparent 100%
);
animation: dataStream 2s ease-in-out infinite;
opacity: 0.7;
}
.data-stream-slow {
animation-duration: 3s;
}
.data-stream-fast {
animation-duration: 1.2s;
}
/* ===== 水波纹 loading 动画 ===== */
@keyframes waveRise {
0%, 100% { transform: scaleY(0.4); }
50% { transform: scaleY(1); }
}
.wave-dot {
width: 4px;
height: 16px;
border-radius: 2px;
background: var(--accent);
animation: waveRise 1.2s ease-in-out infinite;
transform-origin: center bottom;
}
/* ===== 图片加载占位 — 石青渐变呼吸脉冲 ===== */
@keyframes stoneBreath {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.stone-placeholder {
background: linear-gradient(135deg, rgba(46, 139, 122, 0.10) 0%, rgba(58, 127, 184, 0.06) 100%);
animation: stoneBreath 2s ease-in-out infinite;
border-radius: 8px;
}
/* ===== shimmer 骨架屏 ===== */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton-shimmer {
background: linear-gradient(
90deg,
var(--bg-tertiary) 0%,
var(--bg-secondary) 40%,
var(--bg-tertiary) 60%,
var(--bg-tertiary) 100%
);
background-size: 200% 100%;
animation: shimmer 1.8s ease-in-out infinite;
border-radius: 8px;
}
/* ===== 涟漪效果 ===== */
@keyframes rippleExpand {
0% { transform: scale(0); opacity: 0.4; }
100% { transform: scale(2.5); opacity: 0; }
}
.ripple-ring {
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid var(--accent);
animation: rippleExpand 600ms ease-out forwards;
pointer-events: none;
}
/* ===== 水墨晕染揭示 ===== */
@keyframes inkReveal {
0% { clip-path: circle(0% at 50% 50%); }
100% { clip-path: circle(75% at 50% 50%); }
}
.ink-reveal {
animation: inkReveal 600ms ease-out forwards;
}
/* ===== 错误状态震动 ===== */
@keyframes errorShake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-2px); }
40% { transform: translateX(2px); }
60% { transform: translateX(-2px); }
80% { transform: translateX(2px); }
}
.error-shake {
animation: errorShake 300ms ease-out;
}
/* ===== 呼吸脉冲(发送按钮就绪态) ===== */
@keyframes breathPulse {
0%, 100% { box-shadow: 0 0 8px rgba(46, 139, 122, 0.18); }
50% { box-shadow: 0 0 20px rgba(46, 139, 122, 0.35); }
}
.breath-pulse {
animation: breathPulse 2s ease-in-out infinite;
}
/* ===== 输入框水面感应 ===== */
@keyframes waterExpand {
0% { transform: scaleX(0); }
100% { transform: scaleX(1); }
}
.water-focus-line {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent 0%, var(--accent) 50%, transparent 100%);
transform: scaleX(0);
transition: transform 300ms ease-out;
pointer-events: none;
}
.water-focus-active .water-focus-line {
transform: scaleX(1);
}
/* ===== 用户消息气泡 — 左上切角 ===== */
.user-bubble {
border-radius: 2px 16px 16px 16px;
}
/* ===== 自定义滚动条 ===== */
::-webkit-scrollbar {
width: 6px;
@@ -203,11 +445,11 @@ body::after {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(77, 184, 164, 0.15);
background: rgba(46, 139, 122, 0.15);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(77, 184, 164, 0.28);
background: rgba(46, 139, 122, 0.30);
}
select option {
@@ -215,6 +457,7 @@ select option {
color: var(--text-primary);
}
/* ===== 通用动画 ===== */
.sidebar-overlay {
animation: fadeIn 200ms ease-out;
}
@@ -226,7 +469,127 @@ select option {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes floatUp {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-12px); }
}
/* ===== 通用按钮 hover 浮起 ===== */
.btn-hover-lift {
transition: transform 200ms ease, box-shadow 200ms ease;
}
.btn-hover-lift:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* ===== 侧栏会话左侧竖线 hover ===== */
.session-hover-line {
position: relative;
}
.session-hover-line::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 2px;
background: var(--accent);
border-radius: 1px;
transform: scaleY(0);
transition: transform 200ms ease-out;
transform-origin: center;
}
.session-hover-line:hover::before {
transform: scaleY(1);
}
/* ===== 全屏加载 — 云雾凝聚 ===== */
@keyframes mistCondense {
0% { transform: scale(1.5); opacity: 0; filter: blur(20px); }
50% { transform: scale(1.1); opacity: 0.6; filter: blur(8px); }
100% { transform: scale(1); opacity: 1; filter: blur(0); }
}
@keyframes mistRing {
0% { transform: scale(0.3); opacity: 0; }
50% { opacity: 0.5; }
100% { transform: scale(2); opacity: 0; }
}
.mist-loader {
position: relative;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
}
.mist-loader-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid var(--accent);
animation: mistRing 2s ease-out infinite;
opacity: 0;
}
.mist-loader-ring-2 {
animation-delay: 1s;
}
.mist-loader-core {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 20px rgba(46, 139, 122, 0.4), 0 0 60px rgba(46, 139, 122, 0.15);
animation: mistCondense 1.5s ease-out forwards, breathPulse 2s ease-in-out 1.5s infinite;
}
/* ===== 隐藏滚动条(保留滚动能力) ===== */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* ===== Phase 徽章 ===== */
.phase-chip {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 9px;
font-weight: 600;
background: rgba(184, 147, 90, 0.12);
color: var(--gold);
letter-spacing: 0.03em;
}
.phase-chip-accent {
background: rgba(46, 139, 122, 0.12);
color: var(--accent);
}
/* ===== 占位卡片(透明虚线边框) ===== */
.placeholder-card {
border: 1px dashed rgba(46, 139, 122, 0.25);
background:
repeating-linear-gradient(
135deg,
rgba(46, 139, 122, 0.02) 0px,
rgba(46, 139, 122, 0.02) 12px,
transparent 12px,
transparent 24px
);
}
/* ===== 段落分隔(用于 Phase 说明) ===== */
.section-divider {
height: 1px;
background: linear-gradient(90deg, transparent 0%, var(--border-glow) 50%, transparent 100%);
margin: 20px 0;
}
/* ===== 100dvh 兼容 ===== */
@supports (height: 100dvh) {
.h-screen {
height: 100dvh;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { useApp } from "@/lib/app-context";
import type { TaskIR } from "@/lib/types";
interface TaskCardProps {
ir: TaskIR;
onConfirm?: (ir: TaskIR) => void;
onCancel?: () => void;
}
/**
* 任务说明卡Phase 1 占位组件)
*
* 对应 INFOLAYER.md 3.3.2 节:自然语言 → IR 的"任务说明卡"。
* 用于在对话中展示 Agent 解析出的任务意图,供用户确认、微调或取消。
* 数据结构见 types.ts 中的 TaskIR。
*/
export function TaskCard({ ir, 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;
return (
<div className="rounded-xl border border-[var(--accent)]/20 bg-[var(--accent)]/5
px-4 py-3 max-w-xl backdrop-blur-sm">
<div className="flex items-center gap-2 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
<span className="typo-strong text-xs text-[var(--accent)]"></span>
<span className="phase-chip">Phase 1</span>
</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>
</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
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)]
cursor-pointer transition-all"
>
</button>
</div>
</div>
<div className="text-[10px] text-[var(--text-secondary)] mt-2">
Agent Phase 1
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,16 @@ import {
useState,
type ReactNode,
} from "react";
import type { Session, Tag, ImageAsset, ChatMessage } from "./types";
import type {
Session,
Tag,
ImageAsset,
ChatMessage,
Project,
StylePack,
CharacterCard,
TrainingTask,
} from "./types";
import {
loadSessions,
saveSessions,
@@ -28,6 +37,21 @@ import {
generateId,
setStoreUserId,
} from "./store";
import {
loadProjects,
saveProjects,
loadActiveProjectId,
saveActiveProjectId,
loadStylePacks,
upsertStylePack as storeUpsertStyle,
deleteStylePack as storeDeleteStyle,
loadCharacters,
upsertCharacter as storeUpsertChar,
deleteCharacter as storeDeleteChar,
loadTrainingTasks,
upsertTrainingTask as storeUpsertTraining,
deleteTrainingTask as storeDeleteTraining,
} from "./placeholder-store";
import { useAuth } from "./auth-context";
interface AppContextValue {
@@ -65,6 +89,34 @@ interface AppContextValue {
// 左栏折叠
sidebarCollapsed: boolean;
setSidebarCollapsed: (v: boolean) => void;
// ===== Phase 1+ 占位数据 =====
projects: Project[];
activeProjectId: string;
activeProject: Project | null;
switchProject: (id: string) => void;
upsertProject: (p: Project) => void;
stylePacks: StylePack[];
upsertStylePack: (sp: StylePack) => void;
deleteStylePack: (id: string) => void;
characters: CharacterCard[];
upsertCharacter: (c: CharacterCard) => void;
deleteCharacter: (id: string) => void;
trainingTasks: TrainingTask[];
upsertTrainingTask: (t: TrainingTask) => void;
deleteTrainingTask: (id: string) => void;
/** 当前会话绑定的风格/角色UI 层占位,不实际下发后端) */
sessionStyleId: string | null;
setSessionStyleId: (id: string | null) => void;
sessionCharacterId: string | null;
setSessionCharacterId: (id: string | null) => void;
/** 当前会话选择的资产规格(占位) */
sessionAssetSpec: string | null;
setSessionAssetSpec: (spec: string | null) => void;
}
const AppContext = createContext<AppContextValue | null>(null);
@@ -90,6 +142,16 @@ export function AppProvider({ children }: { children: ReactNode }) {
});
const [initialized, setInitialized] = useState(false);
// ===== Phase 1+ 占位数据 =====
const [projects, setProjects] = useState<Project[]>([]);
const [activeProjectId, setActiveProjectId] = useState<string>("");
const [stylePacks, setStylePacks] = useState<StylePack[]>([]);
const [characters, setCharacters] = useState<CharacterCard[]>([]);
const [trainingTasks, setTrainingTasks] = useState<TrainingTask[]>([]);
const [sessionStyleId, setSessionStyleId] = useState<string | null>(null);
const [sessionCharacterId, setSessionCharacterId] = useState<string | null>(null);
const [sessionAssetSpec, setSessionAssetSpec] = useState<string | null>(null);
// 当用户变化时,切换 store 的 userId 并重新加载数据
useEffect(() => {
if (!isAuthenticated || !user) {
@@ -100,8 +162,16 @@ export function AppProvider({ children }: { children: ReactNode }) {
setSessions(loadSessions());
setTags(loadTags());
setAssets(loadAssets());
setProjects(loadProjects());
setActiveProjectId(loadActiveProjectId());
setStylePacks(loadStylePacks());
setCharacters(loadCharacters());
setTrainingTasks(loadTrainingTasks());
setActiveSessionId(null);
setDetailImage(null);
setSessionStyleId(null);
setSessionCharacterId(null);
setSessionAssetSpec(null);
setInitialized(true);
}, [user?.id, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -268,6 +338,61 @@ export function AppProvider({ children }: { children: ReactNode }) {
return result;
}, []);
// ===== Phase 1+ 占位操作 =====
const activeProject = useMemo(
() => projects.find((p) => p.id === activeProjectId) ?? null,
[projects, activeProjectId]
);
const switchProject = useCallback((id: string) => {
setActiveProjectId(id);
saveActiveProjectId(id);
setSessionStyleId(null);
setSessionCharacterId(null);
}, []);
const upsertProjectCb = useCallback((p: Project) => {
setProjects((prev) => {
const idx = prev.findIndex((x) => x.id === p.id);
const next = idx >= 0
? prev.map((x) => (x.id === p.id ? { ...p, updatedAt: Date.now() } : x))
: [{ ...p, updatedAt: Date.now() }, ...prev];
saveProjects(next);
return next;
});
}, []);
const upsertStylePackCb = useCallback((sp: StylePack) => {
storeUpsertStyle(sp);
setStylePacks(loadStylePacks());
}, []);
const deleteStylePackCb = useCallback((id: string) => {
storeDeleteStyle(id);
setStylePacks(loadStylePacks());
}, []);
const upsertCharacterCb = useCallback((c: CharacterCard) => {
storeUpsertChar(c);
setCharacters(loadCharacters());
}, []);
const deleteCharacterCb = useCallback((id: string) => {
storeDeleteChar(id);
setCharacters(loadCharacters());
}, []);
const upsertTrainingTaskCb = useCallback((t: TrainingTask) => {
storeUpsertTraining(t);
setTrainingTasks(loadTrainingTasks());
}, []);
const deleteTrainingTaskCb = useCallback((id: string) => {
storeDeleteTraining(id);
setTrainingTasks(loadTrainingTasks());
}, []);
const value = useMemo<AppContextValue>(
() => ({
sessions,
@@ -295,6 +420,26 @@ export function AppProvider({ children }: { children: ReactNode }) {
setDetailImage,
sidebarCollapsed,
setSidebarCollapsed,
projects,
activeProjectId,
activeProject,
switchProject,
upsertProject: upsertProjectCb,
stylePacks,
upsertStylePack: upsertStylePackCb,
deleteStylePack: deleteStylePackCb,
characters,
upsertCharacter: upsertCharacterCb,
deleteCharacter: deleteCharacterCb,
trainingTasks,
upsertTrainingTask: upsertTrainingTaskCb,
deleteTrainingTask: deleteTrainingTaskCb,
sessionStyleId,
setSessionStyleId,
sessionCharacterId,
setSessionCharacterId,
sessionAssetSpec,
setSessionAssetSpec,
}),
[
sessions, activeSessionId, activeSession,
@@ -303,6 +448,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
tags, addTag, deleteTagCb, selectedTagIds,
assets, addAssetCb, updateAssetCb, deleteAssetCb, toggleFavoriteCb,
detailImage, sidebarCollapsed,
projects, activeProjectId, activeProject, switchProject, upsertProjectCb,
stylePacks, upsertStylePackCb, deleteStylePackCb,
characters, upsertCharacterCb, deleteCharacterCb,
trainingTasks, upsertTrainingTaskCb, deleteTrainingTaskCb,
sessionStyleId, sessionCharacterId, sessionAssetSpec,
]
);
@@ -312,7 +462,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
if (isAuthenticated) {
return (
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)]">
<div className="text-[var(--text-secondary)] text-sm">...</div>
<div className="mist-loader">
<div className="mist-loader-ring" />
<div className="mist-loader-ring mist-loader-ring-2" />
<div className="mist-loader-core" />
</div>
</div>
);
}

View File

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

View File

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

View File

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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