photoGen #2
@@ -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 1–3 产品形态的前端 UI 骨架:新增风格库 `/styles`、角色库 `/characters`、训练中心 `/training`、项目设置 `/settings` 四大页面模块(含列表 + 详情 + 创建流程);工作台输入区上方加入风格/角色/资产规格快选条、高级控制抽屉、候选评估侧面板、Task Card IR 占位组件;资源库新增资产类型/风格/角色多维过滤和语义搜索切换;TopNav 扩充导航项与项目切换器,Sidebar 加入项目信息卡;新增 `lib/placeholder-store.ts` 管理 Phase 1+ 占位数据(项目/风格集/角色卡/训练任务)并在 `app-context.tsx` 中统一暴露;`globals.css` 补充 `phase-chip` / `placeholder-card` / `hide-scrollbar` / `section-divider` 工具类。
|
||||
- **why**: INFOLAYER.md 规划了 Phase 0–3 完整产品形态,其中 Phase 1+(多项目、风格库/角色库、训练中心、Task Card、候选评估等)尚未具备后端能力。用户希望先把完整交互结构和界面落地,"数据或展示上写占位"即可,方便内部演示、收集反馈以及后续按 Phase 增量接入真实后端。
|
||||
- **decisions**: (1) 占位数据用独立的 `placeholder-store.ts` + `localStorage` 持久化,与现有 `store.ts` 解耦,首次加载注入示例数据(2 个项目 / 4 个风格集 / 2 个角色 / 4 个训练任务)。(2) 详情页直接用 `[id]/page.tsx` 同时处理 `new` 与编辑两种模式(判断 `params.id === 'new'`),不再新增 `/styles/new` 等专用子路由。(3) Task Card 作为独立可复用组件 (`components/chat/task-card.tsx`),不强制接入当前 chat loop,等真实 IR 管线就绪时再 wire up。(4) 候选评估面板从"最后一条含 images 的 assistant 消息"取数据做 demo。(5) Gallery 风格/角色过滤用 prompt 关键词子串匹配做占位,未来等后端补 asset 归因字段后再切到真实字段。(6) 所有 Phase 1+ 功能按钮均挂 `phase-chip`(P1/P2/P3)明示阶段,避免用户误以为已可用。
|
||||
- **notes**: 所有"应用到会话""Fork""训练""批量下载"等交互都是 `alert("(占位)...")`,不产生真实后端调用;真实接入时搜索 `(占位)` 或 `phase-chip` 可快速定位需要替换的位置。`AppContext` 新增字段:`projects` / `activeProjectId` / `stylePacks` / `characters` / `trainingTasks` / `sessionStyleId` / `sessionCharacterId` / `sessionAssetSpec` 及对应 upsert/delete 方法;切换用户时会重新加载全部占位数据。`/styles/[id]` 和 `/characters/[id]` 的"生成图片"列表也是基于关键词从现有 assets 里筛的占位结果,不是真正的归因。`ChatMessage` 未扩展,Task Card 目前仅以组件形式存在,实际渲染位置需要后续决定(工作台消息流或独立抽屉)。
|
||||
- **source_chat**: [实现 INFOLAYER 完整 UI 骨架](607b2d99-ff5b-48a6-91b5-1ada004cd8d5)
|
||||
|
||||
### [CL-20260416-1600] 2026-04-16 16:00 — 视觉风格从暗绿底全面转为白底青绿(绢本山水)
|
||||
- **tags**: 前端, 视觉风格, 色彩体系, CSS变量, 白底, 千里江山图
|
||||
- **affected_files**:
|
||||
- art-agent/frontend/src/app/globals.css
|
||||
- art-agent/frontend/src/app/page.tsx
|
||||
- art-agent/frontend/src/app/login/page.tsx
|
||||
- art-agent/frontend/src/app/gallery/page.tsx
|
||||
- art-agent/frontend/src/components/chat/chat-messages.tsx
|
||||
- art-agent/frontend/src/components/chat/chat-input.tsx
|
||||
- art-agent/frontend/src/components/layout/top-nav.tsx
|
||||
- art-agent/frontend/src/components/sidebar/sidebar.tsx
|
||||
- art-agent/frontend/src/components/sidebar/session-list.tsx
|
||||
- art-agent/frontend/src/components/profile-modal.tsx
|
||||
- art-agent/frontend/src/components/detail/image-detail-panel.tsx
|
||||
- art-agent/frontend/src/components/ui/ambient-particles.tsx
|
||||
- docs/art-agent/VISUAL-STYLE-GUIDE.md
|
||||
- **what**: 整个应用从暗色(墨绿黑 #0C1210)底色改为白色(绢本白 #FAFAF7)底色,文字改为墨黑(#1A1A1A),框体和光效保留千里江山图的蓝绿配色但色值加深适配白底
|
||||
- **why**: 用户反馈当前风格"太偏绿了"。评估后认同——原版将背景、面板、文字全都带绿调,导致全屏绿色信息过载。千里江山图本身是青绿设色于白色绢本之上,白底才更贴近原画精髓
|
||||
- **decisions**:
|
||||
- 背景 #0C1210 → #FAFAF7(绢本微暖白),面板从绿底毛玻璃改为白色毛玻璃
|
||||
- accent #4DB8A4 → #2E8B7A(加深以确保白底上对比度)
|
||||
- 文字 #E0E8E2 → #1A1A1A(墨黑),次级文字 #7A9485 → #6B7B8A(灰蓝去绿调)
|
||||
- 所有 rgba(77,184,164,...) 硬编码统一替换为 rgba(46,139,122,...)
|
||||
- 云雾背景透明度大幅降低(白底上需要更淡才不会突兀)
|
||||
- Logo 内部颜色从 #0C1210 改为 white(石青底白字)
|
||||
- 保留所有动效系统(云烟缭绕、萤火粒子、水波纹等),仅调整颜色参数
|
||||
- **notes**:
|
||||
- surface-1/2/3 从深色半透明改为白色半透明,阴影也从重阴影改为轻阴影
|
||||
- 网格底纹 opacity 从 0.03 降为 0.018
|
||||
- VISUAL-STYLE-GUIDE.md 文档同步重写
|
||||
- **source_chat**: [白底青绿视觉风格改造]
|
||||
|
||||
### [CL-20260416-1500] 2026-04-16 15:00 — UX风格指南实现度补全:接入涟漪/sendBounce/会话切换动画/Error飘散/全屏云雾加载/流式opacity阶梯等10项
|
||||
- **tags**: 前端, UX补全, 动效, framer-motion, 微交互, 排版使用, 实现状态文档
|
||||
- **affected_files**:
|
||||
- art-agent/frontend/src/components/chat/chat-input.tsx
|
||||
- art-agent/frontend/src/components/chat/chat-messages.tsx
|
||||
- art-agent/frontend/src/components/chat/image-grid.tsx
|
||||
- art-agent/frontend/src/components/ui/motion-presets.ts
|
||||
- art-agent/frontend/src/app/page.tsx
|
||||
- art-agent/frontend/src/app/login/page.tsx
|
||||
- art-agent/frontend/src/app/gallery/page.tsx
|
||||
- art-agent/frontend/src/app/globals.css
|
||||
- art-agent/frontend/src/lib/app-context.tsx
|
||||
- art-agent/frontend/src/components/sidebar/sidebar.tsx
|
||||
- art-agent/frontend/src/components/profile-modal.tsx
|
||||
- docs/art-agent/UX-STYLE-GUIDE.md
|
||||
- **what**: 补全 UX-STYLE-GUIDE.md 中描述但未实现的 10 项特性,并在文档中标注每项实现状态
|
||||
- **why**: 分析发现风格指南完成度约 65-70%,多项微交互只写了文档描述或代码定义了但未接入业务
|
||||
- **decisions**: 对每项未实现特性逐一补全代码,同时更新文档加入 ✅/🔶/⬜ 状态标记
|
||||
- **notes**: 补全项包括:(1) ripple-effect.tsx 接入发送按钮 (2) sendBounce 替代 buttonTap (3) 会话切换 sessionSlide 滑入滑出 (4) Error errorFloat 飘散消失+4s自动清除 (5) 回到底部按钮水滴形 (6) 全屏加载改云雾凝聚 mist-loader (7) AI回复 StreamingLines 逐行 opacity 阶梯 (8) typo-h1/h2/strong 实际使用到组件 (9) 用户消息 .user-bubble 左上切角 (10) 图片占位 .stone-placeholder 石青渐变呼吸脉冲。文档每个 section 加入实现文件路径和状态标记。
|
||||
|
||||
### [CL-20260416-1330] 2026-04-16 13:30 — 交互体验全面升级:信息层级 + 科技质感 + 微交互趣味性
|
||||
- **tags**: 前端, UX升级, 动效, framer-motion, 排版体系, 微交互, 视觉层级, 科技感, 粒子, 流光
|
||||
- **affected_files**:
|
||||
- art-agent/frontend/src/app/globals.css
|
||||
- art-agent/frontend/src/app/page.tsx
|
||||
- art-agent/frontend/src/app/login/page.tsx
|
||||
- art-agent/frontend/src/app/gallery/page.tsx
|
||||
- art-agent/frontend/src/components/chat/chat-messages.tsx
|
||||
- art-agent/frontend/src/components/chat/chat-input.tsx
|
||||
- art-agent/frontend/src/components/chat/image-grid.tsx
|
||||
- art-agent/frontend/src/components/chat/model-selector.tsx
|
||||
- art-agent/frontend/src/components/sidebar/sidebar.tsx
|
||||
- art-agent/frontend/src/components/sidebar/session-list.tsx
|
||||
- art-agent/frontend/src/components/layout/top-nav.tsx
|
||||
- art-agent/frontend/src/components/detail/image-detail-panel.tsx
|
||||
- art-agent/frontend/src/components/profile-modal.tsx
|
||||
- art-agent/frontend/src/components/ui/motion-presets.ts (新建)
|
||||
- art-agent/frontend/src/components/ui/ambient-particles.tsx (新建)
|
||||
- art-agent/frontend/src/components/ui/data-stream.tsx (新建)
|
||||
- art-agent/frontend/src/components/ui/ripple-effect.tsx (新建)
|
||||
- art-agent/frontend/src/components/ui/firefly-burst.tsx (新建)
|
||||
- docs/art-agent/UX-STYLE-GUIDE.md (新建)
|
||||
- **what**: 四阶段全面升级交互体验:信息层级体系、科技质感注入、微交互趣味性、统一状态反馈
|
||||
- **why**: 用户要求信息层级差异更突出、画面更高级典雅且具科技感、交互有巧思让使用心情愉悦
|
||||
- **decisions**:
|
||||
- 新增 framer-motion 作为动画引擎(~30KB gzipped),不引入其他 UI 库
|
||||
- 排版从 5 级扩展到 7 级(Display 渐变文字 / H1 底部石青线 / H2 左侧竖线 / Body Strong 石青色 / Caption / Micro 大写加宽字间距),CSS 工具类 `.typo-*`
|
||||
- Surface 层级从 1 级扩展到 4 级(Surface-0 背景 → Surface-3 弹窗),每级递增 backdrop-blur、border 亮度、box-shadow
|
||||
- 背景网格底纹(radial-gradient 点阵 opacity 0.03)从 body::before 实现,原云雾改为独立 DOM 元素 `.fog-layer-far/near`
|
||||
- 环境粒子纯 CSS 实现(15-28 个随机漂浮+闪烁的石青色小光点),空态时增多营造"未探索深山"感
|
||||
- 数据流光(石青色 linear-gradient 横向流动 2px),AI 活动时出现在输入栏上方,生图时加速
|
||||
- 用户消息改为右对齐渐变背景+右上切角,助手消息改为 surface-2 + 左侧 2px 石青竖线
|
||||
- 消息入场动画:用户消息 translateY+scale spring,助手消息 blur 消散(云雾凝聚)
|
||||
- 发送按钮 whileTap scale 0.85,可发送时呼吸脉冲发光
|
||||
- 输入框水面感应:焦点时底部 scaleX 扩展线 + box-shadow 随输入长度增强
|
||||
- 收藏星标改为赭金色,点击时 5-7 个金色粒子向四周飘散(萤火点亮)
|
||||
- 图片水墨晕染:skeleton shimmer 占位 → clip-path circle 从中心扩展揭示
|
||||
- Loading 从 bounce 圆点改为水波纹 scaleY 动画,skeleton 用 shimmer 横向扫描
|
||||
- 错误状态赭石红 + translateX 震动 2px × 4 次
|
||||
- 空态使用 Display 级渐变文字 + stagger 入场动画
|
||||
- 滚到底部按钮改圆形 + spring 弹入/退出
|
||||
- 侧栏会话项 hover 时左侧 2px 竖线 slide-in
|
||||
- 所有按钮 hover 添加 translateY(-1px) + shadow 加深浮起效果
|
||||
- 所有弹出层改用 surface-3(强模糊+明显阴影+更亮边框)
|
||||
- 统一动画参数:motion-presets.ts 定义 spring/springGentle、fadeIn/slideUp/mistReveal、messageEnter/assistantEnter、buttonTap/sendBounce、ripple/sparkle/inkReveal、staggerContainer/staggerItem
|
||||
- **notes**:
|
||||
- framer-motion 通过 npm install 添加(3 个包),前端 bundle 增加约 30KB gzipped
|
||||
- 环境粒子使用 CSS animation 而非 Canvas,不影响性能
|
||||
- 所有动画使用 transform/opacity/filter,避免触发 layout
|
||||
- body::before 原来用于云雾现改为点阵网格,云雾改为 AmbientParticles 组件内的 DOM 元素
|
||||
- 构建验证通过(next build 无错误),First Load JS 增量约 50KB(framer-motion)
|
||||
- **source_chat**: [交互体验全面升级](6b0e5860-9c5a-4b44-aa96-affba21e9472)
|
||||
|
||||
### [CL-20260416-1000] 2026-04-16 10:00 — 集成 Zero123++ 视角变换工具(单图→6视角)
|
||||
- **tags**: 视角变换, Zero123++, Novel View Synthesis, 新工具, Replicate, 图片处理
|
||||
- **affected_files**:
|
||||
- art-agent/backend/app/config.py
|
||||
- art-agent/backend/app/agent/tools.py
|
||||
- art-agent/backend/app/agent/loop.py
|
||||
- art-agent/backend/app/services/view_transform.py
|
||||
- **what**: 新增 `transform_view` 工具,集成 Zero123++ 模型实现单图生成 6 个固定视角图片
|
||||
- **why**: 用户需要将手绘建筑从侧面视角转为其他角度,视角变换是与生图完全不同的能力,需要独立工具
|
||||
- **decisions**:
|
||||
- 视角变换作为独立工具 `transform_view` 而非 `generate_image` 的扩展——输入不需要 prompt,只需一张图
|
||||
- 新增独立服务模块 `view_transform.py`,与 `image_gen.py` 分离——逻辑完全不同(无 prompt、grid 拆分)
|
||||
- 在 `config.py` 中新增 `VIEW_TRANSFORM_MODELS` 独立注册表——与 `IMAGE_MODELS` 分离,未来可扩展更多视角变换模型
|
||||
- Zero123++ 输出为 3x2 grid 拼接图,用 Pillow 拆分为 6 张独立图片返回
|
||||
- 进阶方案(3D 重建 + ControlNet 风格还原 pipeline)记录到延期方案 `[advanced-view-transform]`
|
||||
- **notes**:
|
||||
- Zero123++ 视角固定(Azimuth: 30/90/150/210/270/330°, Elevation: 30/-20 交替),不支持自定义角度
|
||||
- 手绘风格保持度可能不理想(模型偏向写实/3D 风格),需实际测试验证
|
||||
- 输入图片需为正方形,建议 >= 320x320
|
||||
- Replicate 上的模型 ID: `jd7h/zero123plusplus:c69c6559...`,~$0.074/次,~76s
|
||||
- loop.py 中对 `transform_view` 做了特殊结果处理(返回带视角信息的图片列表)
|
||||
|
||||
### [CL-20260416-0930] 2026-04-16 09:30 — 设备绑定配置与 Skill/Hooks 解耦(local-env.json)
|
||||
- **tags**: 基础设施, local-env, 跨设备, hooks, project-launcher, Skill
|
||||
- **affected_files**:
|
||||
- .cursor/local-env.json
|
||||
- .cursor/local-env.example.json
|
||||
- .cursor/hooks.json
|
||||
- .cursor/hooks/run-hook.ps1
|
||||
- .cursor/hooks/session-init.sh
|
||||
- .cursor/hooks/check-changelog.sh
|
||||
- .cursor/hooks/README.md
|
||||
- .cursor/rules/project/local-env-check.mdc
|
||||
- .cursor/skills/project-launcher/SKILL.md
|
||||
- .gitignore
|
||||
- art-agent/ENVIRONMENT.md
|
||||
- **what**: 将设备绑定的配置(Node.js 路径、shell 类型)从 Skill 和 hooks 中解耦到 `.cursor/local-env.json`(gitignored)。project-launcher Skill 改为从 local-env.json 读取路径;hooks 增加 run-hook.ps1 dispatcher + bash 版脚本支持跨平台;新增 local-env-check Rule 自动探测生成;ENVIRONMENT.md 去除平台硬编码改为通用说明
|
||||
- **why**: Node.js 路径和 shell 类型在不同设备上不同,硬编码在 Skill/hooks 中导致换机或跨平台时需要修改 git tracked 文件
|
||||
- **decisions**: 采用 `.cursor/local-env.json`(gitignored)+ `.cursor/local-env.example.json`(tracked 模板)的模式,类似 `.env` / `.env.example` 的惯例;hooks.json 的跨平台靠为每个 hook 提供 .ps1 和 .sh 双版本
|
||||
- **notes**: hooks.json 的 command 字段本身仍需按平台手动调整(Windows 用 powershell,macOS/Linux 用 bash),这是 Cursor hooks 机制的限制
|
||||
- **source_chat**: [local-env 设备配置解耦]
|
||||
|
||||
### [CL-20260416-0900] 2026-04-16 09:00 — 环境依赖全面修复 + 统一环境文档
|
||||
- **tags**: 环境, 依赖, Node.js, Python, venv, npm, requirements, ENVIRONMENT
|
||||
- **affected_files**:
|
||||
- art-agent/backend/requirements.txt
|
||||
- art-agent/backend/requirements-lock.txt
|
||||
- art-agent/ENVIRONMENT.md
|
||||
- .cursor/skills/project-launcher/SKILL.md
|
||||
- **what**: 排查并修复缺失的前后端依赖(venv 和 node_modules 均不存在),安装 Node.js v22.15.0 到 %LOCALAPPDATA%\nodejs\,创建 Python venv 并安装全部后端依赖,npm install 前端依赖;requirements.txt 从范围版本更新为精确锁定版本;新增 requirements-lock.txt(完整依赖树 61 个包)和 ENVIRONMENT.md 统一环境文档
|
||||
- **why**: venv 和 node_modules 被 gitignore,换机/重装后需要重新搭建;原 Node.js 安装已丢失;requirements.txt 仅用 >= 范围无版本锁定
|
||||
- **decisions**: Node.js 采用 zip 解压到 %LOCALAPPDATA% 而非 MSI 安装(无管理员权限);requirements.txt 直接锁定到当前精确版本而非继续用范围约束
|
||||
- **notes**: project-launcher Skill 中的 Node.js 路径已同步更新为 %LOCALAPPDATA%\nodejs\
|
||||
- **source_chat**: [环境依赖修复与文档生成]
|
||||
|
||||
### [CL-20260416-0830] 2026-04-16 08:30 — 生图按模型预处理 prompt(官网/社区策略对齐)
|
||||
- **tags**: 后端, 生图, prompt, SDXL, Flux, GPT Image, Gemini, Replicate, IP-Adapter, image_gen, Agent
|
||||
- **affected_files**:
|
||||
|
||||
@@ -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#2E8B7A,13个文件颜色替换,面板改白色毛玻璃,云雾透明度降低,VISUAL-STYLE-GUIDE.md重写
|
||||
- [CL-20260416-1500] UX风格指南实现度补全:涟漪/sendBounce/会话切换动画/Error飘散/云雾加载/流式opacity/排版类使用/左上切角/石青占位等10项全部落地,文档每项标注✅状态+实现文件
|
||||
- [CL-20260416-1330] 交互体验全面升级:新增framer-motion动画引擎,7级排版体系+4级Surface层级+网格底纹科技感,萤火粒子/数据流光/水墨晕染/投石入水/云雾凝聚/萤火点亮等微交互,用户/助手消息视觉拉开差距,统一Loading/空态/Error状态,全组件hover浮起+侧栏竖线+呼吸脉冲,新增UX-STYLE-GUIDE.md文档
|
||||
- [CL-20260416-1000] 集成Zero123++视角变换:新增transform_view工具+view_transform.py服务,单图输入生成6个固定视角图片(grid拆分),config.py新增VIEW_TRANSFORM_MODELS注册表,loop.py特殊处理视角变换SSE事件,进阶方案记入延期
|
||||
- [CL-20260416-0930] 设备绑定配置解耦:新增.cursor/local-env.json(gitignored)存放nodejs_path和shell,project-launcher Skill和hooks改为读取该文件;hooks增加bash版+dispatcher跨平台;新增local-env-check Rule自动探测;ENVIRONMENT.md去除硬编码
|
||||
- [CL-20260416-0900] 环境依赖全面修复+统一环境文档:安装Node.js v22.15.0+创建Python venv+安装全部前后端依赖;requirements.txt锁定精确版本;新增requirements-lock.txt和ENVIRONMENT.md;更新project-launcher Skill路径
|
||||
- [CL-20260416-0830] 生图按模型预处理 prompt:新增 image_prompt_strategy(SDXL 默认负向词+---NEGATIVE---拆分,Flux 剥粘贴的 Negative 段,GPT/Gemini/IP-Adapter 规范化);GenerateResult 带 effective_prompt/negative_prompt;Replicate SDXL 传 negative_prompt;工具说明 SDXL 分隔符
|
||||
- [CL-20260416-0810] 对话输入栏支持剪贴板粘贴图片为参考图:ChatInput根容器onPasteCapture提取image/*文件并走startUpload,placeholder/title提示
|
||||
- [CL-20260416-0745] Gemini原生生图:延长读写超时+对RemoteProtocolError等可重试错误自动重试(默认3次指数退避),环境变量可微调VECTORENGINE_GEMINI_*_TIMEOUT
|
||||
@@ -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 个维度)两份文档
|
||||
|
||||
@@ -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 1–3 完整 UI 骨架:新增 /styles /characters /training /settings 四大模块(列表+详情+创建流程),工作台加入风格/角色/规格快选条、高级控制抽屉、候选评估面板、Task Card 组件,资源库加二级过滤(资产类型/风格/角色)+ 语义搜索切换;TopNav 加项目切换器和新导航项;Sidebar 加项目信息卡。所有 Phase 1+ 数据通过新建的 placeholder-store.ts + localStorage 占位持久化(首次注入示例),AppContext 扩充相关字段。所有真实后端行为均为 `alert("(占位)...")`,UI 上以 phase-chip(P1/P2/P3)明示阶段。搜索"(占位)"可快速定位待接入点。
|
||||
|
||||
### [CL-20260416-1600] 2026-04-16 — 视觉风格从暗绿底全面转为白底青绿(绢本山水)
|
||||
- **tags**: 前端, 视觉风格, 色彩体系, CSS变量, 白底, 千里江山图
|
||||
- **affected_files**: globals.css, page.tsx, login/page.tsx, gallery/page.tsx, chat-messages.tsx, chat-input.tsx, top-nav.tsx, sidebar.tsx, session-list.tsx, profile-modal.tsx, image-detail-panel.tsx, ambient-particles.tsx, VISUAL-STYLE-GUIDE.md
|
||||
- **summary**: 背景从墨绿黑(#0C1210)改为绢本白(#FAFAF7),文字改为墨黑(#1A1A1A),accent色加深(#4DB8A4→#2E8B7A)适配白底,所有面板从深色毛玻璃改为白色毛玻璃,13个文件中硬编码的旧rgba全部替换,云雾/粒子透明度降低,VISUAL-STYLE-GUIDE.md同步重写。设计理念:千里江山图是青绿设色于绢本白底,白底留白才能让青绿有呼吸感。
|
||||
|
||||
### [CL-20260416-1500] 2026-04-16 — UX 风格指南实现度补全(10 项未实现特性 + 文档状态标注)
|
||||
- **tags**: 前端, UX补全, 动效, framer-motion, 微交互, 文档
|
||||
- **affected_files**: chat-input.tsx, chat-messages.tsx, image-grid.tsx, motion-presets.ts, page.tsx, login/page.tsx, gallery/page.tsx, globals.css, app-context.tsx, sidebar.tsx, profile-modal.tsx, UX-STYLE-GUIDE.md
|
||||
- **summary**: 逐项分析 UX-STYLE-GUIDE.md 中所有特性的实现度(原 ~65%),补全 10 项未实现特性:涟漪接入发送按钮、sendBounce 弹缩、会话切换滑入滑出动画、Error 飘散消失+自动清除、回到底部水滴形、全屏云雾凝聚加载、AI 回复逐行 opacity 阶梯、typo-h1/h2/strong 实际使用、用户消息左上切角、图片占位石青渐变呼吸脉冲。文档更新为每项标注 ✅ 状态 + 实现文件路径。
|
||||
|
||||
### [CL-20260416-1330] 2026-04-16 — 交互体验全面升级:信息层级 + 科技质感 + 微交互趣味性
|
||||
- **tags**: 前端, UX升级, 动效, framer-motion, 排版体系, 微交互, 视觉层级, 科技感
|
||||
- **affected_files**: globals.css, page.tsx, chat-messages.tsx, chat-input.tsx, image-grid.tsx, session-list.tsx, sidebar.tsx, top-nav.tsx, image-detail-panel.tsx, profile-modal.tsx, model-selector.tsx, login/page.tsx, gallery/page.tsx, UX-STYLE-GUIDE.md, motion-presets.ts, ambient-particles.tsx, data-stream.tsx, ripple-effect.tsx, firefly-burst.tsx
|
||||
- **summary**: 四阶段全面升级。Phase 1:globals.css 新增 7 级排版体系(Display 渐变→Micro 大写)+ 4 级 Surface 层级 + 点阵网格底纹 + 水波纹/shimmer/涟漪等CSS动画。Phase 2:新建环境萤火粒子组件(纯CSS)+ 数据流光组件(AI 活动时输入栏上方),用户/助手消息视觉差异(渐变背景+切角 vs glass-panel+左侧竖线)。Phase 3:引入 framer-motion,统一动画参数 motion-presets.ts;发送按钮投石入水(spring 弹缩+呼吸脉冲)、输入框水面感应(焦点扩展线+亮度随输入增强)、AI 回复云雾凝聚(blur 消散入场)、收藏萤火点亮(金色粒子爆发)、图片水墨晕染(clip-path 圆形揭示)。Phase 4:统一 Loading 水波纹 + shimmer 骨架屏 + 错误赭石红震动 + 空态 Display 渐变欢迎语 + stagger 入场,滚到底部按钮改圆形 spring 弹入,侧栏会话左侧竖线 hover,全组件 hover 浮起。新增 UX-STYLE-GUIDE.md 交互体验文档。
|
||||
|
||||
### [CL-20260416-1000] 2026-04-16 — 集成 Zero123++ 视角变换工具(单图→6视角)
|
||||
- **tags**: 视角变换, Zero123++, Novel View Synthesis, 新工具, Replicate
|
||||
- **affected_files**: config.py, tools.py, loop.py, view_transform.py
|
||||
- **summary**: 新增 `transform_view` 工具集成 Zero123++(Replicate),输入一张图片生成 6 个固定视角图片。独立服务模块 `view_transform.py` 处理 Replicate 调用 + grid 拼接图 Pillow 拆分。config.py 新增 `VIEW_TRANSFORM_MODELS` 注册表。loop.py 对视角变换结果做特殊 SSE 事件处理。进阶方案(3D重建+ControlNet)记入延期方案。
|
||||
|
||||
### [CL-20260416-0930] 2026-04-16 — 设备绑定配置与 Skill/Hooks 解耦(local-env.json)
|
||||
- **tags**: 基础设施, local-env, 跨设备, hooks, project-launcher, Skill
|
||||
- **affected_files**: local-env.json, local-env.example.json, hooks.json, run-hook.ps1, session-init.sh, check-changelog.sh, local-env-check.mdc, project-launcher/SKILL.md, .gitignore, ENVIRONMENT.md
|
||||
- **summary**: 新增 `.cursor/local-env.json`(gitignored)存放设备绑定配置(nodejs_path、shell),project-launcher Skill 和 hooks 均改为从中读取。hooks 增加 bash 版脚本 + PowerShell dispatcher 支持跨平台。新增 `local-env-check` Rule 在会话开始时自动探测生成。ENVIRONMENT.md 去除平台硬编码改为通用说明。
|
||||
|
||||
### [CL-20260416-0900] 2026-04-16 — 环境依赖全面修复 + 统一环境文档
|
||||
- **tags**: 环境, 依赖, Node.js, Python, venv, npm, requirements, ENVIRONMENT
|
||||
- **affected_files**: requirements.txt, requirements-lock.txt, ENVIRONMENT.md, project-launcher/SKILL.md
|
||||
- **summary**: 排查缺失依赖(venv/node_modules 均不存在),安装 Node.js v22.15.0 到 %LOCALAPPDATA%\nodejs\,创建后端 venv 并安装全部依赖(14 个直接 + 47 个间接),npm install 前端依赖(46 个包)。requirements.txt 锁定精确版本,新增 requirements-lock.txt 和 ENVIRONMENT.md 统一环境文档。project-launcher Skill Node.js 路径已同步更新。
|
||||
|
||||
### [CL-20260416-0830] 2026-04-16 — 生图按模型预处理 prompt
|
||||
- **tags**: 后端, 生图, prompt, SDXL, Flux, Replicate, image_gen, Agent工具
|
||||
- **affected_files**: image_prompt_strategy.py, image_gen.py, tools.py
|
||||
|
||||
@@ -147,6 +147,44 @@
|
||||
- **chosen_alternative**: 当前资源库使用手动标签 + Prompt 文本搜索
|
||||
- **deferred_reason**: 当前阶段资源量少,手动管理足够;等资源积累到一定量级后再引入自动化
|
||||
|
||||
### [advanced-view-transform] 进阶视角变换 Pipeline(3D 重建 + ControlNet 风格还原)
|
||||
- **status**: deferred
|
||||
- **tags**: 视角变换, 3D重建, ControlNet, Zero123++, TripoSR, InstantMesh, Novel View Synthesis, 手绘风格, IP-Adapter
|
||||
- **recorded**: 2026-04-16
|
||||
- **source_chat**: [视角变换方案讨论与 Zero123++ 集成](view-transform-zero123plus)
|
||||
- **prerequisite**: Zero123++ 基础验证完成、确认视角变换是高频需求后
|
||||
- **related_files**:
|
||||
- art-agent/backend/app/services/view_transform.py
|
||||
- art-agent/backend/app/config.py
|
||||
- art-agent/backend/app/agent/tools.py
|
||||
- **context**: |
|
||||
当前已集成 Zero123++(固定 6 视角输出),但存在两个局限:
|
||||
1. 视角固定(6 个预设角度),无法自定义任意角度
|
||||
2. 手绘风格保持度不够——Zero123++ 更擅长写实/3D 渲染风格
|
||||
|
||||
进阶方案分三个层级:
|
||||
|
||||
A. 更强的多视角生成模型:
|
||||
- Era3D:高质量多视角一致性,建筑类友好
|
||||
- SyncDreamer:同步多视角生成,一致性好
|
||||
- Wonder3D:多视角 + 法线图,精确视角控制
|
||||
|
||||
B. ControlNet 引导的视角变换:
|
||||
- 用 Depth Anything V2 从原图提取深度图
|
||||
- 用 ControlNet(depth/normal 模式)+ 目标视角描述重新生成
|
||||
- 通过 IP-Adapter 注入原图风格保持手绘风格
|
||||
|
||||
C. 最佳质量方案(多步组合 Pipeline):
|
||||
原图 → TripoSR/InstantMesh 3D 重建 → 旋转到任意目标角度渲染灰度/线稿
|
||||
→ ControlNet(canny/depth) + IP-Adapter(原图风格) → 手绘风格新视角图
|
||||
这是质量最高的方案:视角精准 + 风格保真
|
||||
|
||||
实施依赖:
|
||||
- 方案 B/C 需要 ComfyUI 环境(与 hf-inference-endpoints 延期方案关联)
|
||||
- 方案 C 的 TripoSR/InstantMesh 可先在 Replicate 上验证
|
||||
- **chosen_alternative**: 当前使用 Zero123++ 固定 6 视角输出(基础验证阶段)
|
||||
- **deferred_reason**: 先用 Zero123++ 验证视角变换的实际需求频率和用户反馈,再投入进阶方案
|
||||
|
||||
---
|
||||
|
||||
## Completed / Cancelled Items
|
||||
|
||||
5
.cursor/distillery/golden-rules.md
Normal file
5
.cursor/distillery/golden-rules.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Golden Rules
|
||||
|
||||
经过实践验证(权重 >= 5)的精炼认知,每次新会话自动注入。
|
||||
|
||||
<!-- 当条目达到权重阈值后由 Agent 自动写入 -->
|
||||
8
.cursor/distillery/insights.md
Normal file
8
.cursor/distillery/insights.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Problem Distillery — Insights
|
||||
|
||||
从已解决的顽固问题中蒸馏出的精炼方法论。
|
||||
|
||||
<!-- 新条目追加在此行下方 -->
|
||||
|
||||
---
|
||||
last_distill_date: 2026-04-16
|
||||
5
.cursor/distillery/problems.md
Normal file
5
.cursor/distillery/problems.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Problem Distillery — Problems
|
||||
|
||||
反复出现的顽固问题追踪记录,按时间倒序排列。
|
||||
|
||||
<!-- 新条目追加在此行下方 -->
|
||||
@@ -3,13 +3,13 @@
|
||||
"hooks": {
|
||||
"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
38
.cursor/hooks/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Cursor Hooks 说明
|
||||
|
||||
## 结构
|
||||
|
||||
```
|
||||
hooks/
|
||||
├── run-hook.ps1 # Windows dispatcher(读取 local-env.json 后分发)
|
||||
├── session-init.ps1 # sessionStart hook(PowerShell 版)
|
||||
├── session-init.sh # sessionStart hook(bash 版)
|
||||
├── check-changelog.ps1 # stop hook(PowerShell 版)
|
||||
├── check-changelog.sh # stop hook(bash 版)
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 跨平台适配
|
||||
|
||||
`hooks.json` 中的 `command` 字段是**平台绑定的**——Windows 用 `powershell`,macOS/Linux 用 `bash`。
|
||||
|
||||
### Windows(当前)
|
||||
|
||||
hooks.json 使用 `powershell ... run-hook.ps1` 作为入口,dispatcher 根据
|
||||
`.cursor/local-env.json` 的 `shell` 字段决定执行 `.ps1` 还是 `.sh` 脚本。
|
||||
|
||||
### 迁移到 macOS / Linux
|
||||
|
||||
将 hooks.json 的 command 改为直接调用 `.sh` 脚本:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"sessionStart": [{ "command": "bash .cursor/hooks/session-init.sh", "timeout": 5 }],
|
||||
"stop": [{ "command": "bash .cursor/hooks/check-changelog.sh", "timeout": 10, "loop_limit": 1 }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同时更新 `.cursor/local-env.json` 的 `shell` 字段为 `bash` 或 `zsh`。
|
||||
58
.cursor/hooks/check-changelog.sh
Normal file
58
.cursor/hooks/check-changelog.sh
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# Changelog sync guard — stop hook (bash 版)
|
||||
|
||||
input=$(cat)
|
||||
|
||||
# === 豁免检查 1:环境变量跳过标志 ===
|
||||
if [ -n "$CURSOR_SKIP_CHANGELOG" ]; then
|
||||
echo '{}'; exit 0
|
||||
fi
|
||||
|
||||
# === 豁免检查 2:从 stdin 解析 composer_mode ===
|
||||
mode=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('composer_mode',''))" 2>/dev/null || echo "")
|
||||
if [ -n "$mode" ] && [ "$mode" != "agent" ]; then
|
||||
echo '{}'; exit 0
|
||||
fi
|
||||
|
||||
# === 豁免检查 3:从 stdin 文本匹配 debug 上下文关键词 ===
|
||||
if echo "$input" | grep -qiE '"mode"\s*:\s*"debug"|debug[\s_-]?mode|Debug Mode'; then
|
||||
echo '{}'; exit 0
|
||||
fi
|
||||
|
||||
changelog=".cursor/changelog/changelog-headlines.md"
|
||||
srcDir="art-agent"
|
||||
|
||||
# === 豁免检查 4:changelog 文件不存在 ===
|
||||
if [ ! -f "$changelog" ]; then
|
||||
echo '{}'; exit 0
|
||||
fi
|
||||
|
||||
clMtime=$(stat -c %Y "$changelog" 2>/dev/null || stat -f %m "$changelog" 2>/dev/null)
|
||||
|
||||
# === 豁免检查 5:ack 标记文件足够新 ===
|
||||
ackFile=".cursor/changelog/.changelog-ack"
|
||||
if [ -f "$ackFile" ]; then
|
||||
ackMtime=$(stat -c %Y "$ackFile" 2>/dev/null || stat -f %m "$ackFile" 2>/dev/null)
|
||||
if [ "$ackMtime" -ge "$clMtime" ] 2>/dev/null; then
|
||||
echo '{}'; exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# === 豁免检查 6:stdin 中无文件编辑证据 ===
|
||||
if ! echo "$input" | grep -qE 'StrReplace|Write\s*tool|edit_file|file_write|write_to_file|Created file|Modified file|Wrote contents|"tool"\s*:\s*"(str_replace|write|edit)"'; then
|
||||
echo '{}'; exit 0
|
||||
fi
|
||||
|
||||
# === 核心检查:是否有源文件比 changelog 更新 ===
|
||||
newerFile=$(find "$srcDir" -type f \( -name "*.py" -o -name "*.tsx" -o -name "*.ts" -o -name "*.css" \) \
|
||||
! -path "*/node_modules/*" ! -path "*/.next/*" ! -path "*/__pycache__/*" ! -path "*/venv/*" \
|
||||
-newer "$changelog" -print -quit 2>/dev/null)
|
||||
|
||||
if [ -n "$newerFile" ]; then
|
||||
fname=$(basename "$newerFile")
|
||||
msg="[Hook] Source file updated (e.g. $fname) but changelog not synced. Run dev-changelog Skill operation A NOW to write all three changelog layers."
|
||||
echo "{\"followup_message\":\"$msg\"}"
|
||||
else
|
||||
echo '{}'
|
||||
fi
|
||||
exit 0
|
||||
35
.cursor/hooks/run-hook.ps1
Normal file
35
.cursor/hooks/run-hook.ps1
Normal file
@@ -0,0 +1,35 @@
|
||||
# run-hook.ps1 — 通用 hook dispatcher
|
||||
# 从 .cursor/local-env.json 读取 shell 类型,决定执行 .ps1 还是 .sh 脚本
|
||||
# 用法:powershell -ExecutionPolicy Bypass -File .cursor/hooks/run-hook.ps1 <hook-name>
|
||||
# 示例:run-hook.ps1 session-init → 执行 session-init.ps1 或 session-init.sh
|
||||
|
||||
param([string]$HookName)
|
||||
|
||||
$localEnvPath = ".cursor\local-env.json"
|
||||
$shell = "powershell"
|
||||
|
||||
if (Test-Path $localEnvPath) {
|
||||
try {
|
||||
$config = Get-Content $localEnvPath -Raw | ConvertFrom-Json
|
||||
if ($config.shell) { $shell = $config.shell }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
$hookDir = ".cursor\hooks"
|
||||
$input = [Console]::In.ReadToEnd()
|
||||
|
||||
if ($shell -eq "powershell") {
|
||||
$scriptPath = Join-Path $hookDir "$HookName.ps1"
|
||||
if (Test-Path $scriptPath) {
|
||||
$input | powershell -ExecutionPolicy Bypass -File $scriptPath
|
||||
} else {
|
||||
Write-Output '{}'
|
||||
}
|
||||
} else {
|
||||
$scriptPath = Join-Path $hookDir "$HookName.sh"
|
||||
if (Test-Path $scriptPath) {
|
||||
$input | bash $scriptPath
|
||||
} else {
|
||||
Write-Output '{}'
|
||||
}
|
||||
}
|
||||
15
.cursor/hooks/session-init.sh
Normal file
15
.cursor/hooks/session-init.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# session-init.sh — 会话启动时检测 composer_mode,非 agent 模式设置跳过标志
|
||||
|
||||
input=$(cat)
|
||||
|
||||
mode=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('composer_mode',''))" 2>/dev/null || echo "")
|
||||
|
||||
rm -f ".cursor/changelog/.changelog-ack"
|
||||
|
||||
if [ -n "$mode" ] && [ "$mode" != "agent" ]; then
|
||||
echo "{\"env\":{\"CURSOR_SKIP_CHANGELOG\":\"1\",\"CURSOR_COMPOSER_MODE\":\"$mode\"}}"
|
||||
else
|
||||
echo "{}"
|
||||
fi
|
||||
exit 0
|
||||
5
.cursor/local-env.example.json
Normal file
5
.cursor/local-env.example.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"_comment": "设备绑定的本地环境配置。复制为 local-env.json 并修改为本机实际值。",
|
||||
"nodejs_path": "C:\\Users\\<username>\\AppData\\Local\\nodejs",
|
||||
"shell": "powershell"
|
||||
}
|
||||
44
.cursor/rules/common/distillery-recall.mdc
Normal file
44
.cursor/rules/common/distillery-recall.mdc
Normal file
@@ -0,0 +1,44 @@
|
||||
## Problem Distillery 上下文注入
|
||||
|
||||
### Golden Rules 注入
|
||||
|
||||
每次会话处理用户第一个任务前,执行以下操作:
|
||||
|
||||
1. 读取 `.cursor/distillery/golden-rules.md`(不存在则跳过)
|
||||
2. 如文件存在且有实质条目(不仅是标题),将全部条目作为背景知识注入上下文
|
||||
3. 这些是经过实践反复验证的精炼认知,Agent 在分析和解决问题时应自然参考
|
||||
4. 不需要在回复中显式提及"根据 Golden Rule"
|
||||
|
||||
### 蒸馏提醒检查
|
||||
|
||||
每次会话处理用户第一个任务前,额外检查:
|
||||
|
||||
1. 读取 `.cursor/distillery/insights.md`(不存在则跳过)
|
||||
2. 检查文件尾部的 `last_distill_date` 字段
|
||||
3. 如果距今超过 7 天,读取 `.cursor/distillery/problems.md`
|
||||
4. 统计 `status: resolved` 且无 `distilled:` 标记的条目数量
|
||||
5. 如有未蒸馏的已解决条目,提醒用户:
|
||||
> 你有 N 个已解决的顽固问题尚未总结,要花几分钟蒸馏一下吗?
|
||||
6. 每次会话最多提醒一次
|
||||
|
||||
### 顽固问题检测
|
||||
|
||||
Agent 在整个对话过程中应保持对以下信号的被动感知:
|
||||
|
||||
1. 用户表达问题未解决:"还有问题"、"没解决"、"还是一样"、"又出现了"、"不行"、"没用"
|
||||
2. Agent 自身意识到同一问题已尝试 2 次以上仍未解决
|
||||
3. pitfall-journal 中已有记录的问题再次出现
|
||||
|
||||
检测到上述信号时,读取 `problem-distillery` Skill 并执行其操作 A。
|
||||
|
||||
### 验证触发
|
||||
|
||||
当操作 A 触发时(追踪新的或再次出现的顽固问题),如果 `insights.md` 非空,
|
||||
还应执行操作 D 的被动验证流程——匹配是否有相关的已蒸馏经验可供参考。
|
||||
|
||||
### 注意
|
||||
|
||||
- Golden Rules 注入是极轻量操作(预期 < 30 行),每次会话都执行
|
||||
- 蒸馏提醒按需触发,只在条件满足时提醒
|
||||
- problems.md 的详细记录**不主动读取**,仅在操作 A/B/C 时按需读取
|
||||
- 本 Rule 只负责触发和注入,具体操作流程由 `problem-distillery` Skill 定义
|
||||
47
.cursor/rules/project/local-env-check.mdc
Normal file
47
.cursor/rules/project/local-env-check.mdc
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
description: 检查本地环境配置文件 .cursor/local-env.json 是否存在,不存在时自动探测并生成
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
## 本地环境配置自动初始化
|
||||
|
||||
每次会话处理用户第一个任务前,检查 `.cursor/local-env.json` 是否存在:
|
||||
|
||||
### 文件存在时
|
||||
|
||||
读取并验证 JSON 格式正确、包含 `nodejs_path` 和 `shell` 两个字段。
|
||||
字段缺失时补充探测(见下方流程),不覆盖已有字段。
|
||||
|
||||
### 文件不存在时
|
||||
|
||||
执行自动探测流程,生成 `.cursor/local-env.json`:
|
||||
|
||||
```
|
||||
1. 探测 shell 类型:
|
||||
- Windows($env:OS 包含 "Windows")→ "powershell"
|
||||
- macOS/Linux → 读取 $SHELL 环境变量末段(bash/zsh/fish 等)
|
||||
|
||||
2. 探测 Node.js 路径:
|
||||
- Windows:运行 `where.exe node`,排除 Cursor 内置的 node
|
||||
(路径含 "cursor" 的不算),取第一个有效结果的父目录
|
||||
- macOS/Linux:运行 `which node`,取父目录
|
||||
- 找不到 → 设为 null
|
||||
|
||||
3. 写入 .cursor/local-env.json:
|
||||
{
|
||||
"nodejs_path": "<探测到的路径或 null>",
|
||||
"shell": "<探测到的 shell>"
|
||||
}
|
||||
|
||||
4. 简要告知用户:
|
||||
> 已自动生成本地环境配置 `.cursor/local-env.json`,
|
||||
> 检测到 Node.js 路径为 xxx,shell 为 xxx。如需调整请手动编辑该文件。
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
- `local-env.json` 已在 `.gitignore` 中,不会进入版本控制
|
||||
- 该文件存储**设备绑定**的配置,与 `.env`(存储密钥/API key)互补
|
||||
- 只在文件不存在或字段缺失时触发探测,不会每次会话都重新探测
|
||||
- 探测结果不一定准确(如多个 Node.js 版本并存),用户可随时手动修改
|
||||
@@ -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,说明上次写入未执行本项,本次补修剪并自检。
|
||||
|
||||
@@ -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"触发(原被动写入提醒已升级为强制 checklist);L3 概要每次会话自动注入上下文;stop Hook 双保险兜底
|
||||
- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklist);L3 概要每次会话自动注入上下文;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
|
||||
- **备注**: 项目专属 Skill;Ollama 必须先于后端启动(Mem0 embedding 依赖);前后端窗口必须可见
|
||||
- **备注**: 项目专属 Skill;启动前必须先读取 `.cursor/local-env.json` 获取 nodejs_path 和 shell;Ollama 必须先于后端启动(Mem0 embedding 依赖);前后端窗口必须可见
|
||||
|
||||
232
.cursor/skills/problem-distillery/SKILL.md
Normal file
232
.cursor/skills/problem-distillery/SKILL.md
Normal file
@@ -0,0 +1,232 @@
|
||||
---
|
||||
name: problem-distillery
|
||||
description: >-
|
||||
从顽固问题中蒸馏方法论的渐进式知识系统。追踪反复出现且未被彻底解决的问题,
|
||||
记录解决过程和弯路,定期提炼精炼认知,经实践验证后自动升级为常规注入。
|
||||
当用户表达"还有问题"、"没解决"、"还是一样"、"又出现了"等语义,
|
||||
或 Agent 自身意识到同一问题已尝试 2 次以上仍未解决时触发。
|
||||
---
|
||||
|
||||
# Problem Distillery
|
||||
|
||||
从反复出现的顽固问题中,经过"追踪 → 解决 → 蒸馏 → 验证 → 注入"的完整生命周期,
|
||||
蒸馏出经过实践验证的精炼认知和方法论。
|
||||
|
||||
与其他系统的区别:
|
||||
- **pitfall-journal**:互补。pitfall 是事后快照(debug 完记一笔),distillery 是过程追踪 + 知识提炼
|
||||
- **dev-changelog**:不重叠。changelog 记事实改动,distillery 记问题解决过程和认知沉淀
|
||||
|
||||
## 数据文件
|
||||
|
||||
```
|
||||
.cursor/distillery/
|
||||
problems.md -- 问题记录(open + resolved),操作 A/B 写入
|
||||
insights.md -- 蒸馏后的精炼方法论,操作 C 写入
|
||||
golden-rules.md -- 权重达标后升级的条目,操作 D 管理,新会话自动注入
|
||||
```
|
||||
|
||||
## 条目格式
|
||||
|
||||
### problems.md 条目
|
||||
|
||||
```markdown
|
||||
### [PD-YYYYMMDD-HHMM] 一句话标题
|
||||
- **status**: open | resolved
|
||||
- **fingerprint**: 关键特征词列表(用于匹配同一问题)
|
||||
- **category**: tech | workflow | decision | other
|
||||
- **first_seen**: YYYY-MM-DD HH:MM
|
||||
- **attempts**:
|
||||
1. [YYYY-MM-DD HH:MM] 尝试了什么 → 结果如何
|
||||
2. [YYYY-MM-DD HH:MM] 又尝试了什么 → 结果如何
|
||||
- **resolution**: (解决后填写)最终解决方案
|
||||
- **dead_ends**: (解决后填写)走过的弯路及其失败原因
|
||||
- **spark**: (解决后填写)一句话启发——这个问题教会了什么
|
||||
- **distilled**: (蒸馏后填写)IN-xxx
|
||||
```
|
||||
|
||||
### insights.md 条目
|
||||
|
||||
```markdown
|
||||
### [IN-YYYYMMDD-NN] 一句话方法论
|
||||
- **source_problems**: [PD-xxx, PD-yyy]
|
||||
- **weight**: 0
|
||||
- **promoted**: false | true | demoted
|
||||
- **content**: 2-3 句精炼认知
|
||||
```
|
||||
|
||||
文件尾部保留元数据:
|
||||
|
||||
```markdown
|
||||
---
|
||||
last_distill_date: YYYY-MM-DD
|
||||
```
|
||||
|
||||
### golden-rules.md
|
||||
|
||||
```markdown
|
||||
# Golden Rules
|
||||
|
||||
经过实践验证(权重 >= 5)的精炼认知,每次新会话自动注入。
|
||||
|
||||
1. [GR-001] 一句话认知(来源: IN-xxx, 累计验证 N 次)
|
||||
2. [GR-002] ...
|
||||
```
|
||||
|
||||
硬上限 15 条。超出时按权重排序保留 top-15,被淘汰的条目降回 insights.md(promoted 改为 demoted)。
|
||||
|
||||
## 操作 A:追踪记录(问题进行中)
|
||||
|
||||
### 触发条件
|
||||
|
||||
以下**任一**场景触发:
|
||||
|
||||
1. **用户明确表达**问题未解决:
|
||||
- "还有问题"、"问题没有解决"、"还是一样"、"又出现了"
|
||||
- "试了好多次了"、"这个问题反复出现"、"不行"、"没用"
|
||||
- 以及其他表达"问题反复发生、Agent 没有完全解决"的语义
|
||||
2. **Agent 自身意识到**同一问题已经尝试了 2 次以上仍未解决
|
||||
|
||||
### 适用范围
|
||||
|
||||
不限于技术问题——工作流设计、产品决策等各类反复纠结的问题均适用。
|
||||
|
||||
### 流程
|
||||
|
||||
```
|
||||
1. 提取当前问题的指纹:
|
||||
- 技术问题:错误信息关键词、涉及文件/模块、症状描述
|
||||
- 非技术问题:核心矛盾点、涉及领域、反复出现的决策困境
|
||||
|
||||
2. 读取 problems.md,用指纹匹配 status=open 的条目
|
||||
|
||||
3. 匹配到已有条目 → 在 attempts 中追加本次尝试记录
|
||||
|
||||
4. 无匹配 → 创建新条目:
|
||||
- 生成 ID:PD-YYYYMMDD-HHMM
|
||||
- status: open
|
||||
- 记录首次尝试
|
||||
|
||||
5. 告知用户:"已开始追踪这个问题 [PD-xxx]"(首次)
|
||||
或 "已更新追踪记录 [PD-xxx],这是第 N 次尝试"(后续)
|
||||
```
|
||||
|
||||
### 与 pitfall-journal 的衔接
|
||||
|
||||
当 pitfall-journal 中某个条目的同一问题反复出现(用户再次报告相同症状),
|
||||
Agent 应意识到这已超出 pitfall 的"一次性记录"范畴,主动触发操作 A 建立追踪。
|
||||
|
||||
## 操作 B:解决记录
|
||||
|
||||
### 触发条件
|
||||
|
||||
存在 status=open 的追踪条目,且满足以下**任一**:
|
||||
- 用户确认问题已解决:"好了"、"解决了"、"终于可以了"
|
||||
- Agent 判断问题已解决(测试通过、错误消失等)
|
||||
|
||||
### 流程
|
||||
|
||||
```
|
||||
1. 填写 resolution:最终的解决方案
|
||||
2. 填写 dead_ends:走过的弯路及其失败原因(从 attempts 中归纳)
|
||||
3. 填写 spark:一句话启发——这个问题教会了什么
|
||||
4. status 改为 resolved
|
||||
5. 回复末尾附 [顽固问题已解决并记录]
|
||||
```
|
||||
|
||||
### 注意
|
||||
|
||||
- 如果问题在当前会话中从发现到解决只花了 1-2 次尝试,不需要走 distillery 流程
|
||||
(那是 pitfall-journal 的范畴)
|
||||
- 只有经历了"反复尝试"的问题才值得 distillery 追踪
|
||||
|
||||
## 操作 C:定期蒸馏
|
||||
|
||||
### 触发条件
|
||||
|
||||
由 `distillery-recall` Rule 在每次会话开头检查:
|
||||
- 读取 insights.md 尾部的 `last_distill_date`
|
||||
- 如果距今超过 7 天,且 problems.md 中有未标记 `distilled` 的 resolved 条目
|
||||
- 则提醒用户:"你有 N 个已解决的顽固问题尚未总结,要花几分钟蒸馏一下吗?"
|
||||
- 每次会话最多提醒一次
|
||||
|
||||
### 流程(用户同意后)
|
||||
|
||||
```
|
||||
1. 读取所有 status=resolved 且无 distilled 标记的条目
|
||||
2. Agent 分析这些问题的共性,提出归纳建议:
|
||||
- 哪些问题有共同的根因模式?
|
||||
- 能提炼出什么通用的认知或方法论?
|
||||
- 建议的表述(2-3 句精炼认知)
|
||||
3. 用户确认/修改后:
|
||||
- 写入 insights.md(ID 格式:IN-YYYYMMDD-NN,NN 为当日序号)
|
||||
- weight 初始为 0
|
||||
- promoted: false
|
||||
4. 更新 insights.md 尾部的 last_distill_date
|
||||
5. 在对应 problems.md 条目上标记 distilled: IN-xxx
|
||||
```
|
||||
|
||||
## 操作 D:验证与升级
|
||||
|
||||
### 被动验证
|
||||
|
||||
当操作 A 触发时(遇到新的或再次出现的顽固问题),额外执行:
|
||||
|
||||
```
|
||||
1. 读取 insights.md,对当前问题做语义匹配
|
||||
2. 如有相关条目,向用户展示:
|
||||
"之前总结过一条相关经验 [IN-xxx]: [内容摘要],可能对当前问题有帮助。"
|
||||
3. 在问题解决流程中,跟踪该经验是否发挥了作用:
|
||||
- 用户确认"这个提示有用"
|
||||
- 或 Agent 判断解决方案与该 insight 的方向一致
|
||||
4. 确认有用 → weight += 1,在 insights.md 中更新
|
||||
5. 确认无用 → 不扣分(weight 只增不减,避免偶然失误惩罚好经验)
|
||||
```
|
||||
|
||||
### 升级为 Golden Rule
|
||||
|
||||
```
|
||||
1. 当某条 insight 的 weight >= 5:
|
||||
a. 检查 golden-rules.md 当前条目数
|
||||
b. 如 < 15 → 直接升级:
|
||||
- 在 golden-rules.md 中追加条目(ID: GR-NNN,NNN 为递增序号)
|
||||
- insights.md 中 promoted 改为 true
|
||||
c. 如 = 15 → 比较权重:
|
||||
- 新条目权重 > golden-rules.md 中最低权重条目 → 替换
|
||||
- 被替换的条目降回 insights.md(promoted 改为 demoted)
|
||||
- 否则不升级
|
||||
2. 升级后告知用户:
|
||||
"经验 [IN-xxx] 已累计验证 N 次,升级为 Golden Rule [GR-NNN],后续新会话将自动注入。"
|
||||
```
|
||||
|
||||
### 新会话注入
|
||||
|
||||
由 `distillery-recall` Rule 负责:
|
||||
- 每次会话开头读取 `golden-rules.md`
|
||||
- 如果非空,将全部条目作为背景知识注入上下文
|
||||
- 注入方式与 `changelog-headlines` 同级别——轻量、不显式提及来源
|
||||
|
||||
## 容量与性能控制
|
||||
|
||||
| 文件 | 策略 | 阈值 |
|
||||
|------|------|------|
|
||||
| problems.md | resolved 且已 distilled 的条目超过 50 条时,归档到 problems-archive.md | 50 条 |
|
||||
| insights.md | 无上限(条目本身是精炼的,每条 3-5 行) | — |
|
||||
| golden-rules.md | 硬上限,按权重淘汰 | 15 条 |
|
||||
| 新会话注入成本 | 只读 golden-rules.md(预期 < 30 行) | 极轻量 |
|
||||
|
||||
## 与其他系统的协作
|
||||
|
||||
| 系统 | 关系 | 说明 |
|
||||
|------|------|------|
|
||||
| `pitfall-journal` | 上游来源 | pitfall 条目反复出现时,升级为 distillery 追踪 |
|
||||
| `dev-changelog` | 不重叠 | changelog 记事实改动,distillery 记过程和认知 |
|
||||
| `distillery-recall` Rule | 下游消费者 | 负责 golden-rules 注入和蒸馏提醒 |
|
||||
| `epee-orchestrator` | 注册 | 在 registry.md 中注册本 Skill |
|
||||
|
||||
## 自迭代日志
|
||||
|
||||
本节记录使用本 Skill 过程中发现的必要检查项。
|
||||
|
||||
### 已知必要检查
|
||||
|
||||
(暂无)
|
||||
@@ -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. 启动后端(在独立可见终端窗口中):
|
||||
- Windows(PowerShell 或 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
5
.gitignore
vendored
@@ -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
206
art-agent/ENVIRONMENT.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# EPEEKit 开发环境说明
|
||||
|
||||
> 最后更新:2026-04-16
|
||||
|
||||
## 系统要求
|
||||
|
||||
| 项目 | 最低版本 | 备注 |
|
||||
|------|---------|------|
|
||||
| OS | Windows 10+ / macOS / Linux | 跨平台支持 |
|
||||
| Python | 3.12+ | 用于后端 |
|
||||
| Node.js | 22+ LTS | 用于前端 |
|
||||
| npm | 10+ | 随 Node.js 安装 |
|
||||
| Ollama | latest | Mem0 embedding 服务依赖,需启动 `nomic-embed-text` 模型 |
|
||||
|
||||
## 本地环境配置(local-env.json)
|
||||
|
||||
设备绑定的路径(如 Node.js 安装位置)**不硬编码在任何 Skill 或配置中**,
|
||||
统一通过 `.cursor/local-env.json` 管理。
|
||||
|
||||
| 字段 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `nodejs_path` | Node.js 安装目录(含 node/npm),为 `null` 表示已在系统 PATH | `C:\Users\xxx\AppData\Local\nodejs` |
|
||||
| `shell` | 当前设备的 shell 类型 | `powershell` / `bash` / `zsh` |
|
||||
|
||||
- **首次使用**:Agent 会自动探测并生成,也可手动复制 `.cursor/local-env.example.json` 并修改
|
||||
- **已 gitignored**:不进入版本控制,每台设备独立维护
|
||||
- **模板参考**:`.cursor/local-env.example.json`
|
||||
|
||||
### Node.js PATH 注意事项
|
||||
|
||||
如果 `nodejs_path` 不为 null,说明 Node.js 未在系统 PATH 中,启动前端前需注入:
|
||||
|
||||
```powershell
|
||||
# PowerShell(路径来自 local-env.json 的 nodejs_path)
|
||||
$env:PATH = "你的nodejs_path;" + $env:PATH
|
||||
```
|
||||
|
||||
```bash
|
||||
# bash/zsh
|
||||
export PATH="你的nodejs_path:$PATH"
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
EPEEAIKit/
|
||||
├── .cursor/
|
||||
│ ├── local-env.json # 本地环境配置(gitignored,设备绑定)
|
||||
│ ├── local-env.example.json # 本地环境配置模板(git tracked)
|
||||
│ ├── hooks.json # Cursor hooks 配置
|
||||
│ └── hooks/ # Hook 脚本(.ps1 + .sh 双版本)
|
||||
├── art-agent/
|
||||
│ ├── backend/ # FastAPI 后端(Python)
|
||||
│ │ ├── app/ # 应用代码
|
||||
│ │ ├── venv/ # Python 虚拟环境(gitignored)
|
||||
│ │ ├── requirements.txt # 直接依赖(锁定版本)
|
||||
│ │ └── requirements-lock.txt # 完整依赖树(pip freeze)
|
||||
│ ├── frontend/ # Next.js 前端(TypeScript)
|
||||
│ │ ├── src/ # 应用代码
|
||||
│ │ ├── node_modules/ # npm 依赖(gitignored)
|
||||
│ │ ├── package.json # 直接依赖
|
||||
│ │ └── package-lock.json # 完整依赖树锁定
|
||||
│ └── ENVIRONMENT.md # 本文件
|
||||
```
|
||||
|
||||
## 后端依赖(Python)
|
||||
|
||||
### 直接依赖
|
||||
|
||||
| 包名 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| fastapi | 0.135.3 | Web 框架 |
|
||||
| uvicorn | 0.44.0 | ASGI 服务器 |
|
||||
| openai | 2.32.0 | OpenAI / 向量引擎 LLM & 生图 API |
|
||||
| replicate | 1.0.7 | Replicate 生图 API(Flux/SDXL/Kolors 等) |
|
||||
| sse-starlette | 3.3.4 | Server-Sent Events 流式响应 |
|
||||
| python-multipart | 0.0.26 | 文件上传处理 |
|
||||
| httpx | 0.28.1 | 异步 HTTP 客户端 |
|
||||
| python-dotenv | 1.2.2 | 环境变量 `.env` 加载 |
|
||||
| Pillow | 12.2.0 | 图像处理 |
|
||||
| mem0ai | 1.0.11 | Mem0 长期记忆系统 |
|
||||
| ollama | 0.6.1 | Ollama 本地 LLM/embedding 客户端 |
|
||||
| sqlmodel | 0.0.38 | SQLModel ORM(SQLite 用户表) |
|
||||
| pwdlib[argon2,bcrypt] | 0.3.0 | 密码哈希(Argon2id + bcrypt 兼容) |
|
||||
| python-jose[cryptography] | 3.5.0 | JWT 认证 |
|
||||
|
||||
### 间接依赖(关键)
|
||||
|
||||
| 包名 | 版本 | 来源 |
|
||||
|------|------|------|
|
||||
| pydantic | 2.13.1 | fastapi 依赖 |
|
||||
| starlette | 1.0.0 | fastapi 依赖 |
|
||||
| SQLAlchemy | 2.0.49 | sqlmodel / mem0ai 依赖 |
|
||||
| qdrant-client | 1.17.1 | mem0ai 依赖(向量数据库) |
|
||||
| numpy | 2.4.4 | qdrant-client 依赖 |
|
||||
| grpcio | 1.80.0 | qdrant-client 依赖 |
|
||||
| cryptography | 46.0.7 | python-jose[cryptography] 依赖 |
|
||||
| argon2-cffi | 25.1.0 | pwdlib[argon2] 依赖 |
|
||||
| bcrypt | 5.0.0 | pwdlib[bcrypt] 依赖 |
|
||||
|
||||
完整依赖树见 `requirements-lock.txt`(共 61 个包)。
|
||||
|
||||
## 前端依赖(Node.js)
|
||||
|
||||
### 直接依赖(dependencies)
|
||||
|
||||
| 包名 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| next | 15.5.15 | React 全栈框架 |
|
||||
| react | 19.2.5 | UI 库 |
|
||||
| react-dom | 19.2.5 | React DOM 渲染 |
|
||||
|
||||
### 开发依赖(devDependencies)
|
||||
|
||||
| 包名 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| typescript | 5.9.3 | TypeScript 编译器 |
|
||||
| tailwindcss | 4.2.2 | CSS 工具框架 |
|
||||
| @tailwindcss/postcss | 4.2.2 | Tailwind PostCSS 插件 |
|
||||
| postcss | 8.5.9 | CSS 后处理器 |
|
||||
| @types/node | 22.19.17 | Node.js 类型定义 |
|
||||
| @types/react | 19.2.14 | React 类型定义 |
|
||||
| @types/react-dom | 19.2.3 | ReactDOM 类型定义 |
|
||||
|
||||
完整依赖树见 `package-lock.json`(共 46 个包)。
|
||||
|
||||
## 外部服务依赖
|
||||
|
||||
| 服务 | 端口 | 用途 | 必需 |
|
||||
|------|------|------|------|
|
||||
| Ollama | 11434 | 本地 embedding(nomic-embed-text)| 是(Mem0 依赖) |
|
||||
| Qdrant | 6333 | 本地向量数据库(Mem0 存储) | 是(Mem0 依赖) |
|
||||
|
||||
## 环境变量
|
||||
|
||||
后端通过 `.env` 文件加载配置,主要包含:
|
||||
- LLM API Key(向量引擎 / DeepSeek)
|
||||
- Replicate API Token
|
||||
- 代理配置(HTTPS_PROXY)
|
||||
- Gemini 超时参数(VECTORENGINE_GEMINI_*_TIMEOUT)
|
||||
- JWT Secret
|
||||
|
||||
详见 `art-agent/backend/.env`(gitignored,不纳入版本控制)。
|
||||
|
||||
## 环境搭建步骤
|
||||
|
||||
### 0. 本地环境配置(首次)
|
||||
|
||||
复制 `.cursor/local-env.example.json` 为 `.cursor/local-env.json`,修改为本机实际值。
|
||||
或者启动 Cursor Agent 会话,Agent 会自动探测并生成。
|
||||
|
||||
### 1. 后端
|
||||
|
||||
```bash
|
||||
cd art-agent/backend
|
||||
|
||||
# 创建虚拟环境
|
||||
python -m venv venv
|
||||
|
||||
# 激活虚拟环境
|
||||
# Windows PowerShell:
|
||||
.\venv\Scripts\Activate.ps1
|
||||
# macOS/Linux:
|
||||
source venv/bin/activate
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 启动(需要先启动 Ollama)
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### 2. 前端
|
||||
|
||||
```bash
|
||||
cd art-agent/frontend
|
||||
|
||||
# 如果 Node.js 未在系统 PATH 中(参考 local-env.json 的 nodejs_path):
|
||||
# PowerShell: $env:PATH = "你的nodejs_path;" + $env:PATH
|
||||
# bash/zsh: export PATH="你的nodejs_path:$PATH"
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. Ollama
|
||||
|
||||
```bash
|
||||
# 启动 Ollama 服务
|
||||
ollama serve
|
||||
|
||||
# 拉取 embedding 模型(首次)
|
||||
ollama pull nomic-embed-text
|
||||
```
|
||||
|
||||
## 访问地址
|
||||
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| 前端页面 | http://localhost:3000 |
|
||||
| 后端 API | http://localhost:8000 |
|
||||
| API 文档 | http://localhost:8000/docs |
|
||||
| Ollama | http://localhost:11434 |
|
||||
@@ -46,6 +46,7 @@ SYSTEM_PROMPT = """\
|
||||
- 如果用户提供了参考图(支持多张),将参考图的风格元素融入生成 prompt
|
||||
- 理解多张参考图各自的角色(如"图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": {
|
||||
|
||||
@@ -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}"}
|
||||
|
||||
@@ -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")
|
||||
|
||||
157
art-agent/backend/app/services/view_transform.py
Normal file
157
art-agent/backend/app/services/view_transform.py
Normal 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,
|
||||
}
|
||||
BIN
art-agent/backend/requirements-lock.txt
Normal file
BIN
art-agent/backend/requirements-lock.txt
Normal file
Binary file not shown.
@@ -1,14 +1,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
|
||||
|
||||
127
art-agent/frontend/package-lock.json
generated
127
art-agent/frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
511
art-agent/frontend/src/app/characters/[id]/page.tsx
Normal file
511
art-agent/frontend/src/app/characters/[id]/page.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import type { CharacterCard, CharacterRefImage } from "@/lib/types";
|
||||
|
||||
const ANGLES = [
|
||||
"正面",
|
||||
"半侧面",
|
||||
"侧面",
|
||||
"背面",
|
||||
"半身",
|
||||
"全身",
|
||||
"表情·微笑",
|
||||
"表情·愤怒",
|
||||
"表情·惊讶",
|
||||
"服饰细节",
|
||||
];
|
||||
|
||||
export default function CharacterDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const isNew = params.id === "new";
|
||||
const {
|
||||
characters,
|
||||
upsertCharacter,
|
||||
deleteCharacter,
|
||||
setSessionCharacterId,
|
||||
projects,
|
||||
activeProjectId,
|
||||
assets,
|
||||
} = useApp();
|
||||
|
||||
const [c, setC] = useState<CharacterCard | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) {
|
||||
const now = Date.now();
|
||||
setC({
|
||||
id: `char-${now.toString(36)}`,
|
||||
name: "",
|
||||
description: "",
|
||||
personality: [],
|
||||
visualHooks: [],
|
||||
consistency: "medium",
|
||||
refImages: [],
|
||||
projectId: activeProjectId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else {
|
||||
const found = characters.find((x) => x.id === params.id);
|
||||
if (found) setC(found);
|
||||
}
|
||||
}, [params.id, characters, isNew, activeProjectId]);
|
||||
|
||||
if (!c) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<TopNav />
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<p className="typo-caption">加载中...</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!c.name.trim()) {
|
||||
alert("请填写角色名称");
|
||||
return;
|
||||
}
|
||||
upsertCharacter(c);
|
||||
alert("已保存(占位)");
|
||||
if (isNew) router.push(`/characters/${c.id}`);
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
setSessionCharacterId(c.id);
|
||||
alert(`已将角色 "${c.name}" 应用到当前会话(占位)`);
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm(`确认删除角色 "${c.name}"?`)) return;
|
||||
deleteCharacter(c.id);
|
||||
router.push("/characters");
|
||||
};
|
||||
|
||||
const addRef = (angle: string) => {
|
||||
setC({
|
||||
...c,
|
||||
refImages: [
|
||||
...c.refImages,
|
||||
{ id: `r-${Date.now()}`, url: "", angle, caption: "占位参考图" },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const removeRef = (id: string) => {
|
||||
setC({ ...c, refImages: c.refImages.filter((r) => r.id !== id) });
|
||||
};
|
||||
|
||||
const addPersonality = (kw: string) => {
|
||||
if (!kw || c.personality.includes(kw)) return;
|
||||
setC({ ...c, personality: [...c.personality, kw] });
|
||||
};
|
||||
|
||||
const addHook = (kw: string) => {
|
||||
if (!kw || c.visualHooks.includes(kw)) return;
|
||||
setC({ ...c, visualHooks: [...c.visualHooks, kw] });
|
||||
};
|
||||
|
||||
const relatedAssets = assets
|
||||
.filter((a) =>
|
||||
c.visualHooks.some((h) => a.prompt.toLowerCase().includes(h.toLowerCase())) ||
|
||||
a.prompt.toLowerCase().includes(c.name.toLowerCase())
|
||||
)
|
||||
.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<AmbientParticles count={12} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] mb-4">
|
||||
<Link href="/characters" className="hover:text-[var(--accent)] cursor-pointer">
|
||||
角色库
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--text-primary)]">
|
||||
{isNew ? "新建角色" : c.name || "未命名"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<input
|
||||
value={c.name}
|
||||
onChange={(e) => setC({ ...c, name: e.target.value })}
|
||||
placeholder="角色名称"
|
||||
className="text-2xl font-bold bg-transparent border-none
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]/60
|
||||
focus:outline-none flex-1"
|
||||
/>
|
||||
<span className="phase-chip flex-shrink-0">Phase 1</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
应用到当前会话
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--text-primary)] hover:border-[var(--accent)]/40
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
{!isNew && (
|
||||
<>
|
||||
<Link
|
||||
href={`/training/new?type=character&source=${c.id}`}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--gold)]/30 bg-[var(--gold)]/8
|
||||
text-[var(--gold)] hover:bg-[var(--gold)]/15
|
||||
cursor-pointer btn-hover-lift transition-all
|
||||
flex items-center gap-1"
|
||||
>
|
||||
训练角色 LoRA
|
||||
<span className="text-[9px]">P2</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--hot)]/60 hover:text-[var(--hot)]
|
||||
hover:border-[var(--hot)]/40
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-5">
|
||||
{/* 基本信息 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">基本信息</h2>
|
||||
<textarea
|
||||
value={c.description}
|
||||
onChange={(e) => setC({ ...c, description: e.target.value })}
|
||||
placeholder="一句话简要描述这个角色..."
|
||||
rows={2}
|
||||
className="w-full p-3 mb-3 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-sm focus:outline-none focus:border-[var(--accent)]/50 resize-none"
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
<LabeledInput label="性别" value={c.gender ?? ""}
|
||||
onChange={(v) => setC({ ...c, gender: v })} />
|
||||
<LabeledInput label="年龄段" value={c.ageRange ?? ""}
|
||||
onChange={(v) => setC({ ...c, ageRange: v })} />
|
||||
<LabeledInput label="体型" value={c.body ?? ""}
|
||||
onChange={(v) => setC({ ...c, body: v })} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 性格关键词 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">性格关键词</h2>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{c.personality.map((k) => (
|
||||
<TagChip
|
||||
key={k}
|
||||
label={k}
|
||||
onRemove={() => setC({ ...c, personality: c.personality.filter((x) => x !== k) })}
|
||||
/>
|
||||
))}
|
||||
<KeywordInput onAdd={addPersonality} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 背景故事 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">背景故事摘要</h2>
|
||||
<textarea
|
||||
value={c.story ?? ""}
|
||||
onChange={(e) => setC({ ...c, story: e.target.value })}
|
||||
placeholder="角色的背景设定、重要经历..."
|
||||
rows={3}
|
||||
className="w-full p-3 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-sm focus:outline-none focus:border-[var(--accent)]/50 resize-none"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 关键视觉要素 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">关键视觉要素</h2>
|
||||
<p className="typo-caption mb-2">
|
||||
角色的识别性特征(发色、瞳色、标志性饰品、武器等)。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{c.visualHooks.map((h) => (
|
||||
<TagChip
|
||||
key={h}
|
||||
label={h}
|
||||
onRemove={() => setC({ ...c, visualHooks: c.visualHooks.filter((x) => x !== h) })}
|
||||
/>
|
||||
))}
|
||||
<KeywordInput onAdd={addHook} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 角色参考组 */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="typo-h2">角色参考组</h2>
|
||||
<AngleDropdown onPick={addRef} />
|
||||
</div>
|
||||
<p className="typo-caption mb-3">
|
||||
多角度参考图集合:正面 / 半侧面 / 半身 / 表情变化 / 关键服饰细节。
|
||||
</p>
|
||||
<div className="grid grid-cols-3 md:grid-cols-4 gap-2.5">
|
||||
{c.refImages.map((ref) => (
|
||||
<RefImageCard
|
||||
key={ref.id}
|
||||
ref_={ref}
|
||||
onRemove={() => removeRef(ref.id)}
|
||||
/>
|
||||
))}
|
||||
{c.refImages.length === 0 && (
|
||||
<div className="col-span-3 md:col-span-4 py-8 text-center text-xs text-[var(--text-secondary)]
|
||||
placeholder-card rounded-xl">
|
||||
尚未添加参考图
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 生成历史 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">使用该角色生成的图片</h2>
|
||||
{relatedAssets.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-[var(--text-secondary)]
|
||||
placeholder-card rounded-xl">
|
||||
尚无历史生成
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-2">
|
||||
{relatedAssets.map((a) => (
|
||||
<div key={a.id}
|
||||
className="aspect-square rounded-lg overflow-hidden border border-[var(--border)]
|
||||
bg-[var(--bg-tertiary)] flex items-center justify-center text-[9px] text-[var(--text-secondary)]">
|
||||
占位缩略
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
{/* 一致性级别 */}
|
||||
<div className="rounded-xl surface-2 p-4">
|
||||
<span className="typo-strong text-xs block mb-2">默认一致性级别</span>
|
||||
<div className="flex gap-1.5">
|
||||
{(["low", "medium", "high"] as const).map((lv) => (
|
||||
<button
|
||||
key={lv}
|
||||
onClick={() => setC({ ...c, consistency: lv })}
|
||||
className={`flex-1 px-2 py-1.5 text-xs rounded-lg cursor-pointer transition-all ${
|
||||
c.consistency === lv
|
||||
? "bg-[var(--accent)]/15 text-[var(--accent)] font-medium border border-[var(--accent)]/40"
|
||||
: "border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{lv === "low" ? "低" : lv === "medium" ? "中" : "高"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--text-secondary)] mt-2 leading-relaxed">
|
||||
高一致性会强制使用角色参考图引导生成。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 所属项目 */}
|
||||
<div className="rounded-xl surface-2 p-4">
|
||||
<span className="typo-strong text-xs block mb-2">所属项目</span>
|
||||
<select
|
||||
value={c.projectId ?? ""}
|
||||
onChange={(e) => setC({ ...c, projectId: e.target.value })}
|
||||
className="w-full text-xs px-2 py-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="">未归属</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* LoRA */}
|
||||
<div className="placeholder-card rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="typo-strong text-xs">角色 LoRA</span>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed mb-2">
|
||||
{c.loraId
|
||||
? `已绑定:${c.loraId}(占位)`
|
||||
: "尚未绑定。可通过训练中心生成角色 LoRA。"}
|
||||
</p>
|
||||
<Link
|
||||
href={`/training/new?type=character&source=${c.id}`}
|
||||
className="text-xs text-[var(--gold)] hover:underline"
|
||||
>
|
||||
前往训练 →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl surface-2 p-4">
|
||||
<span className="typo-strong text-xs block mb-2">元信息</span>
|
||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>创建时间:{new Date(c.createdAt).toLocaleDateString("zh-CN")}</div>
|
||||
<div>更新时间:{new Date(c.updatedAt).toLocaleDateString("zh-CN")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LabeledInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>{label}</div>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-xs focus:outline-none focus:border-[var(--accent)]/50"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg
|
||||
bg-[var(--accent)]/10 text-[var(--accent)] font-medium">
|
||||
{label}
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="hover:text-[var(--hot)] cursor-pointer"
|
||||
>×</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function KeywordInput({ onAdd }: { onAdd: (kw: string) => void }) {
|
||||
const [val, setVal] = useState("");
|
||||
return (
|
||||
<input
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && val.trim()) {
|
||||
onAdd(val.trim());
|
||||
setVal("");
|
||||
}
|
||||
}}
|
||||
placeholder="+ 添加(回车)"
|
||||
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
focus:outline-none focus:border-[var(--accent)]/50 min-w-[120px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AngleDropdown({ onPick }: { onPick: (angle: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="text-xs px-2.5 py-1 rounded-lg border border-[var(--border)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
|
||||
>
|
||||
+ 添加参考图(占位)
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 w-44 surface-3 rounded-xl py-1 z-10">
|
||||
{ANGLES.map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => { onPick(a); setOpen(false); }}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-[var(--text-primary)]
|
||||
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors"
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RefImageCard({
|
||||
ref_,
|
||||
onRemove,
|
||||
}: {
|
||||
ref_: CharacterRefImage;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative aspect-[3/4] rounded-lg overflow-hidden
|
||||
border border-[var(--border)] group">
|
||||
{ref_.url ? (
|
||||
<img src={ref_.url} alt={ref_.angle} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-[var(--accent-secondary)]/10 to-[var(--accent)]/5
|
||||
flex items-center justify-center p-2">
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] text-[var(--accent)]/70 font-medium mb-0.5">
|
||||
{ref_.angle}
|
||||
</div>
|
||||
<div className="text-[9px] text-[var(--text-secondary)]">占位</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="absolute top-1 right-1 w-5 h-5 rounded-full
|
||||
bg-black/60 text-white text-xs opacity-0 group-hover:opacity-100
|
||||
cursor-pointer transition-opacity flex items-center justify-center"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
art-agent/frontend/src/app/characters/page.tsx
Normal file
217
art-agent/frontend/src/app/characters/page.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { staggerContainer, staggerItem } from "@/components/ui/motion-presets";
|
||||
import type { CharacterCard } from "@/lib/types";
|
||||
|
||||
export default function CharactersPage() {
|
||||
const { characters, projects, activeProjectId } = useApp();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [projectFilter, setProjectFilter] = useState<string>("all");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = [...characters];
|
||||
if (projectFilter !== "all") {
|
||||
list = list.filter((c) => c.projectId === projectFilter);
|
||||
}
|
||||
if (query.trim()) {
|
||||
const q = query.trim().toLowerCase();
|
||||
list = list.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.description.toLowerCase().includes(q) ||
|
||||
c.visualHooks.some((h) => h.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [characters, projectFilter, query]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col relative z-[1]">
|
||||
<AmbientParticles count={16} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<h1 className="typo-h1">角色库</h1>
|
||||
<span className="phase-chip">Phase 1</span>
|
||||
</div>
|
||||
<p className="typo-caption">
|
||||
项目角色卡。包含多角度参考图组、关键视觉要素、一致性级别设置。
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/characters/new"
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-sm rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
|
||||
transition-all cursor-pointer btn-hover-lift flex-shrink-0"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
新建角色
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 mb-6">
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-[200px] max-w-[360px]
|
||||
px-3 py-1.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
focus-within:border-[var(--accent)]/40 transition-colors">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索角色名 / 描述 / 视觉要素..."
|
||||
className="flex-1 bg-transparent border-none text-sm
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 项目筛选 */}
|
||||
<select
|
||||
value={projectFilter}
|
||||
onChange={(e) => setProjectFilter(e.target.value)}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-[var(--text-secondary)] cursor-pointer focus:outline-none
|
||||
focus:border-[var(--accent)]/40"
|
||||
>
|
||||
<option value="all">全部项目</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name === projects.find((x) => x.id === activeProjectId)?.name
|
||||
? `${p.name}(当前)`
|
||||
: p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="typo-display text-lg mb-2">没有匹配的角色</p>
|
||||
<p className="typo-caption">试试调整筛选条件或点击“新建角色”</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
{filtered.map((c) => (
|
||||
<CharCard key={c.id} c={c} projects={projects} />
|
||||
))}
|
||||
<motion.div variants={staggerItem}>
|
||||
<Link
|
||||
href="/characters/new"
|
||||
className="aspect-[3/4] rounded-xl placeholder-card
|
||||
flex flex-col items-center justify-center gap-2
|
||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:border-[var(--accent)]/50 cursor-pointer
|
||||
transition-all btn-hover-lift"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
<span className="text-xs">新建角色</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CharCard({
|
||||
c,
|
||||
projects,
|
||||
}: {
|
||||
c: CharacterCard;
|
||||
projects: { id: string; name: string }[];
|
||||
}) {
|
||||
const projectName = c.projectId
|
||||
? projects.find((p) => p.id === c.projectId)?.name
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<motion.div variants={staggerItem}>
|
||||
<Link
|
||||
href={`/characters/${c.id}`}
|
||||
className="block rounded-xl overflow-hidden neon-border
|
||||
bg-[var(--bg-card)] backdrop-blur-sm cursor-pointer"
|
||||
>
|
||||
<div className="aspect-[3/4] relative overflow-hidden
|
||||
bg-gradient-to-br from-[var(--accent-secondary)]/15 via-[var(--accent)]/10 to-transparent">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-[var(--bg-card)] border border-[var(--accent)]/30
|
||||
flex items-center justify-center mx-auto mb-2">
|
||||
<span className="text-2xl font-bold text-[var(--accent)]">
|
||||
{c.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-[var(--text-secondary)]">
|
||||
头像占位 · {c.refImages.length} 张参考
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{c.loraId && (
|
||||
<span className="absolute top-2 right-2 phase-chip-accent phase-chip">
|
||||
LoRA
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)] truncate mb-1">
|
||||
{c.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] line-clamp-2 mb-2 min-h-[24px]">
|
||||
{c.description}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-[var(--text-secondary)]">
|
||||
<span className="truncate">{projectName || "未归属项目"}</span>
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded-md font-medium"
|
||||
style={{
|
||||
background:
|
||||
c.consistency === "high"
|
||||
? "rgba(46, 139, 122, 0.15)"
|
||||
: c.consistency === "medium"
|
||||
? "rgba(58, 127, 184, 0.12)"
|
||||
: "rgba(107, 123, 138, 0.12)",
|
||||
color:
|
||||
c.consistency === "high"
|
||||
? "var(--accent)"
|
||||
: c.consistency === "medium"
|
||||
? "var(--accent-secondary)"
|
||||
: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
一致性:
|
||||
{c.consistency === "high" ? "高" : c.consistency === "medium" ? "中" : "低"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
result = result.filter((a) => a.prompt.toLowerCase().includes(q));
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-[var(--hot)] bg-[var(--hot)]/10 rounded-xl px-4 py-2.5
|
||||
border border-[var(--hot)]/20">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
key="login-error"
|
||||
initial="visible"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
variants={errorFloat}
|
||||
className="text-sm text-[var(--hot)] bg-[var(--hot)]/10 rounded-xl px-4 py-2.5
|
||||
border border-[var(--hot)]/20 error-shake"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,83 +372,130 @@ 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">
|
||||
{/* Logo 发光效果 */}
|
||||
<div 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)]">
|
||||
<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 ref={scrollRef} className="flex-1 overflow-y-auto fog-scroll relative">
|
||||
<div className="min-h-full">
|
||||
{isEmptyState ? (
|
||||
<div className="h-full flex items-center justify-center" style={{ minHeight: "calc(100vh - 200px)" }}>
|
||||
<motion.div
|
||||
className="text-center space-y-5"
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* Logo 发光效果 */}
|
||||
<motion.div variants={staggerItem} className="inline-flex items-center justify-center w-16 h-16 rounded-2xl
|
||||
bg-[var(--accent)]/10 border border-[var(--accent)]/20
|
||||
shadow-[0_0_30px_rgba(46,139,122,0.12)]">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="var(--accent)" />
|
||||
<circle cx="23" cy="12" r="3.5" stroke="var(--accent)" strokeWidth="1.8" fill="none" />
|
||||
<path d="M23 15.5v5" stroke="var(--accent)" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
<motion.div variants={staggerItem}>
|
||||
<h2 className="typo-display mb-2">
|
||||
欢迎使用 EPEEKit
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)] max-w-md leading-relaxed mx-auto">
|
||||
描述你想要的美术资源,我来帮你生成。
|
||||
<br />
|
||||
你可以上传参考图来引导风格方向。
|
||||
</p>
|
||||
</motion.div>
|
||||
<motion.div variants={staggerItem} className="flex flex-wrap justify-center gap-2 pt-2">
|
||||
{[
|
||||
"画一个赛博朋克风格的退出按钮",
|
||||
"设计一个卡通风格的金币图标",
|
||||
"画一个奇幻风格的游戏角色立绘",
|
||||
].map((hint) => (
|
||||
<button
|
||||
key={hint}
|
||||
onClick={() => handleSend(hint, [], null)}
|
||||
className="text-xs px-4 py-2 rounded-xl
|
||||
border border-[var(--border)] text-[var(--text-secondary)]
|
||||
hover:border-[var(--accent)]/50 hover:text-[var(--accent)]
|
||||
hover:bg-[var(--accent)]/5
|
||||
transition-all cursor-pointer btn-hover-lift"
|
||||
>
|
||||
{hint}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
欢迎使用 EPEEKit
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)] max-w-md leading-relaxed">
|
||||
描述你想要的美术资源,我来帮你生成。
|
||||
<br />
|
||||
你可以上传参考图来引导风格方向。
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-5">
|
||||
{[
|
||||
"画一个赛博朋克风格的退出按钮",
|
||||
"设计一个卡通风格的金币图标",
|
||||
"画一个奇幻风格的游戏角色立绘",
|
||||
].map((hint) => (
|
||||
<button
|
||||
key={hint}
|
||||
onClick={() => handleSend(hint, [], null)}
|
||||
className="text-xs px-4 py-2 rounded-xl
|
||||
border border-[var(--border)] text-[var(--text-secondary)]
|
||||
hover:border-[var(--accent)]/50 hover:text-[var(--accent)]
|
||||
hover:bg-[var(--accent)]/5
|
||||
transition-all cursor-pointer"
|
||||
>
|
||||
{hint}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
isLoading={isLoading}
|
||||
streamingText={streamingText}
|
||||
streamingImages={streamingImages}
|
||||
statusText={statusText}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
isLoading={isLoading}
|
||||
streamingText={streamingText}
|
||||
streamingImages={streamingImages}
|
||||
statusText={statusText}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showScrollBtn && (
|
||||
<button
|
||||
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)]
|
||||
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]"
|
||||
title="回到底部"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{/* 回到底部 — 落叶归根(水滴形) */}
|
||||
<AnimatePresence>
|
||||
{showScrollBtn && (
|
||||
<motion.button
|
||||
key="scroll-btn"
|
||||
variants={scrollBtnEnter}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
onClick={() => {
|
||||
scrollToBottom();
|
||||
}}
|
||||
className="absolute bottom-16 right-4 z-10
|
||||
w-9 h-11 flex items-center justify-center
|
||||
surface-2 text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
||||
shadow-lg shadow-black/30
|
||||
cursor-pointer"
|
||||
style={{ borderRadius: "50% 50% 50% 50% / 35% 35% 65% 65%" }}
|
||||
title="回到底部"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="mt-1">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex-shrink-0 border-t border-[var(--border)] bg-[var(--bg-secondary)]/60 backdrop-blur-md
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
308
art-agent/frontend/src/app/settings/page.tsx
Normal file
308
art-agent/frontend/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
|
||||
const PROJECT_TYPES = [
|
||||
"2D RPG",
|
||||
"横版动作",
|
||||
"卡牌",
|
||||
"模拟经营",
|
||||
"策略",
|
||||
"美术包",
|
||||
"其他",
|
||||
];
|
||||
|
||||
export default function ProjectSettingsPage() {
|
||||
const { activeProject, upsertProject, stylePacks, projects, switchProject } = useApp();
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [desc, setDesc] = useState("");
|
||||
const [defaultStyleId, setDefaultStyleId] = useState<string>("");
|
||||
const [defaultSpec, setDefaultSpec] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProject) return;
|
||||
setName(activeProject.name);
|
||||
setType(activeProject.type);
|
||||
setDesc(activeProject.description);
|
||||
setDefaultStyleId(activeProject.defaultStyleId ?? "");
|
||||
setDefaultSpec(activeProject.defaultSpec ?? "");
|
||||
}, [activeProject]);
|
||||
|
||||
if (!activeProject) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<TopNav />
|
||||
<main className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
||||
尚无激活项目
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
upsertProject({
|
||||
...activeProject,
|
||||
name: name.trim() || activeProject.name,
|
||||
type,
|
||||
description: desc,
|
||||
defaultStyleId: defaultStyleId || undefined,
|
||||
defaultSpec: defaultSpec || undefined,
|
||||
});
|
||||
alert("已保存项目设置(占位:未同步后端)");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<AmbientParticles count={10} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<h1 className="typo-h1">项目设置</h1>
|
||||
<span className="phase-chip">Phase 1</span>
|
||||
</div>
|
||||
<p className="typo-caption mb-6">
|
||||
管理当前项目的基本信息、默认风格与输出规格,以及成员协作(占位)。
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-5">
|
||||
{/* 侧栏:锚点 */}
|
||||
<aside className="lg:col-span-1">
|
||||
<nav className="sticky top-4 space-y-1 text-xs">
|
||||
{[
|
||||
{ id: "basic", label: "基本信息" },
|
||||
{ id: "defaults", label: "默认设置" },
|
||||
{ id: "members", label: "成员与权限" },
|
||||
{ id: "billing", label: "计费与额度" },
|
||||
{ id: "danger", label: "危险操作" },
|
||||
].map((n) => (
|
||||
<a
|
||||
key={n.id}
|
||||
href={`#${n.id}`}
|
||||
className="block px-3 py-1.5 rounded-lg text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] hover:bg-[var(--accent)]/6
|
||||
transition-all"
|
||||
>
|
||||
{n.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="lg:col-span-3 space-y-5">
|
||||
{/* 基本信息 */}
|
||||
<section id="basic" className="rounded-xl surface-2 p-5">
|
||||
<h2 className="typo-h2 mb-4">基本信息</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-1.5" style={{ textTransform: "none" }}>项目名称</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block typo-micro mb-1.5" style={{ textTransform: "none" }}>项目类型</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PROJECT_TYPES.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setType(t)}
|
||||
className={`px-2.5 py-1 text-xs rounded-lg border cursor-pointer transition-all ${
|
||||
type === t
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/10 text-[var(--accent)] font-medium"
|
||||
: "border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block typo-micro mb-1.5" style={{ textTransform: "none" }}>项目描述</label>
|
||||
<textarea
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="简单描述项目定位、目标用户等(占位)"
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 默认设置 */}
|
||||
<section id="defaults" className="rounded-xl surface-2 p-5">
|
||||
<h2 className="typo-h2 mb-4">默认设置</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-1.5" style={{ textTransform: "none" }}>默认风格集</label>
|
||||
<select
|
||||
value={defaultStyleId}
|
||||
onChange={(e) => setDefaultStyleId(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||
>
|
||||
<option value="">未设置</option>
|
||||
{stylePacks.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] text-[var(--text-secondary)] mt-1">
|
||||
新建会话时会自动套用此风格(占位,尚未真实联动)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block typo-micro mb-1.5" style={{ textTransform: "none" }}>默认输出规格</label>
|
||||
<input
|
||||
value={defaultSpec}
|
||||
onChange={(e) => setDefaultSpec(e.target.value)}
|
||||
placeholder="例如:1024×1024 · 透明背景可选"
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="placeholder-card rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="typo-strong text-xs">命名规范与目录结构</span>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-[var(--text-secondary)]">
|
||||
定义导出到项目工程目录时的命名约定和资源归档位置。待 Phase 2 交付。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 成员与权限 */}
|
||||
<section id="members" className="rounded-xl surface-2 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="typo-h2">成员与权限</h2>
|
||||
<span className="phase-chip">Phase 3</span>
|
||||
</div>
|
||||
<div className="placeholder-card rounded-lg p-6 text-center text-xs text-[var(--text-secondary)]">
|
||||
多人协作、角色权限、审核流程占位。Phase 3 交付。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 计费与额度 */}
|
||||
<section id="billing" className="rounded-xl surface-2 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="typo-h2">计费与额度</h2>
|
||||
<span className="phase-chip">Phase 3</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="placeholder-card rounded-lg p-3">
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mb-0.5">本月已用</div>
|
||||
<div className="text-xl font-semibold text-[var(--text-primary)]">¥ 128</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">占位数值</div>
|
||||
</div>
|
||||
<div className="placeholder-card rounded-lg p-3">
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mb-0.5">剩余额度</div>
|
||||
<div className="text-xl font-semibold text-[var(--text-primary)]">¥ 872</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">占位数值</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 危险操作 */}
|
||||
<section id="danger" className="rounded-xl surface-2 p-5 border border-[var(--hot)]/20">
|
||||
<h2 className="typo-h2 mb-4 text-[var(--hot)]/80">危险操作</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-[var(--text-primary)]">归档项目</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)]">归档后不再出现在切换器中,可从"全局设置"恢复(占位)</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => alert("(占位)归档项目")}
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-[var(--border)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer"
|
||||
>
|
||||
归档
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-[var(--hot)]/80">删除项目</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)]">不可恢复。所有关联会话、风格集、角色卡将被清除(占位)</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!confirm("确认删除该项目?此操作不可撤销(占位)")) return;
|
||||
alert("(占位)已请求删除项目。真实删除逻辑待接入。");
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs rounded-lg text-[var(--hot)]/80
|
||||
hover:text-[var(--hot)] hover:bg-[var(--hot)]/10 border border-[var(--hot)]/30
|
||||
cursor-pointer"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 保存栏 */}
|
||||
<div className="flex items-center justify-between sticky bottom-0 py-3 bg-[var(--bg-primary)]/80 backdrop-blur-md -mx-5 px-5">
|
||||
<div className="text-[10px] text-[var(--text-secondary)]">
|
||||
项目 ID: {activeProject.id} · 创建于 {new Date(activeProject.createdAt).toLocaleDateString("zh-CN")}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="px-3.5 py-2 text-xs rounded-xl border border-[var(--border)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer"
|
||||
>
|
||||
返回工作台
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)] cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
保存更改
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 其他项目(快速切换) */}
|
||||
{projects.length > 1 && (
|
||||
<section className="rounded-xl surface-2 p-5">
|
||||
<h2 className="typo-h2 mb-3">其他项目</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{projects.filter((p) => p.id !== activeProject.id).map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => switchProject(p.id)}
|
||||
className="text-left p-3 rounded-lg border border-[var(--border)] hover:border-[var(--accent)]/40
|
||||
cursor-pointer transition-all"
|
||||
>
|
||||
<div className="text-sm text-[var(--text-primary)] truncate">{p.name}</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{p.type}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
464
art-agent/frontend/src/app/styles/[id]/page.tsx
Normal file
464
art-agent/frontend/src/app/styles/[id]/page.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import type { StylePack, StyleRefImage } from "@/lib/types";
|
||||
|
||||
export default function StyleDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const isNew = params.id === "new";
|
||||
const {
|
||||
stylePacks,
|
||||
upsertStylePack,
|
||||
deleteStylePack,
|
||||
setSessionStyleId,
|
||||
assets,
|
||||
} = useApp();
|
||||
|
||||
const [pack, setPack] = useState<StylePack | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) {
|
||||
const now = Date.now();
|
||||
setPack({
|
||||
id: `style-${now.toString(36)}`,
|
||||
name: "",
|
||||
description: "",
|
||||
keywords: [],
|
||||
source: "custom",
|
||||
refImages: [],
|
||||
positivePrompt: "",
|
||||
negativePrompt: "",
|
||||
usageCount: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else {
|
||||
const found = stylePacks.find((s) => s.id === params.id);
|
||||
if (found) setPack(found);
|
||||
}
|
||||
}, [params.id, stylePacks, isNew]);
|
||||
|
||||
if (!pack) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<TopNav />
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<p className="typo-caption">加载中...</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!pack.name.trim()) {
|
||||
alert("请填写风格名称");
|
||||
return;
|
||||
}
|
||||
upsertStylePack(pack);
|
||||
alert("已保存(占位)");
|
||||
if (isNew) router.push(`/styles/${pack.id}`);
|
||||
};
|
||||
|
||||
const handleApplyToSession = () => {
|
||||
setSessionStyleId(pack.id);
|
||||
alert(`已将风格 "${pack.name}" 应用到当前会话(占位)`);
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleFork = () => {
|
||||
const now = Date.now();
|
||||
const forked: StylePack = {
|
||||
...pack,
|
||||
id: `style-${now.toString(36)}`,
|
||||
name: `${pack.name} (副本)`,
|
||||
source: "custom",
|
||||
forkFromId: pack.id,
|
||||
usageCount: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
upsertStylePack(forked);
|
||||
alert("已创建副本(占位)");
|
||||
router.push(`/styles/${forked.id}`);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm(`确认删除风格 "${pack.name}"?(占位操作)`)) return;
|
||||
deleteStylePack(pack.id);
|
||||
router.push("/styles");
|
||||
};
|
||||
|
||||
const addRef = () => {
|
||||
const id = `ref-${Date.now()}`;
|
||||
setPack({
|
||||
...pack,
|
||||
refImages: [...pack.refImages, { id, url: "", caption: "占位参考图" }],
|
||||
});
|
||||
};
|
||||
|
||||
const removeRef = (id: string) => {
|
||||
setPack({ ...pack, refImages: pack.refImages.filter((r) => r.id !== id) });
|
||||
};
|
||||
|
||||
const addKeyword = (kw: string) => {
|
||||
if (!kw || pack.keywords.includes(kw)) return;
|
||||
setPack({ ...pack, keywords: [...pack.keywords, kw] });
|
||||
};
|
||||
|
||||
const removeKeyword = (kw: string) => {
|
||||
setPack({ ...pack, keywords: pack.keywords.filter((k) => k !== kw) });
|
||||
};
|
||||
|
||||
// 历史预览(按 prompt 关键词简单匹配)
|
||||
const relatedAssets = assets
|
||||
.filter((a) =>
|
||||
pack.keywords.some((k) => a.prompt.toLowerCase().includes(k.toLowerCase()))
|
||||
)
|
||||
.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<AmbientParticles count={12} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
{/* 面包屑 */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] mb-4">
|
||||
<Link href="/styles" className="hover:text-[var(--accent)] cursor-pointer">
|
||||
风格库
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--text-primary)]">
|
||||
{isNew ? "新建风格" : pack.name || "未命名"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 标题 + 操作 */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<input
|
||||
value={pack.name}
|
||||
onChange={(e) => setPack({ ...pack, name: e.target.value })}
|
||||
placeholder="风格名称"
|
||||
className="text-2xl font-bold bg-transparent border-none
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]/60
|
||||
focus:outline-none min-w-0 flex-1"
|
||||
/>
|
||||
<span className="phase-chip flex-shrink-0">Phase 1</span>
|
||||
</div>
|
||||
{pack.forkFromId && (
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
Fork 自:
|
||||
<Link
|
||||
href={`/styles/${pack.forkFromId}`}
|
||||
className="text-[var(--accent)] hover:underline"
|
||||
>
|
||||
{stylePacks.find((s) => s.id === pack.forkFromId)?.name || pack.forkFromId}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={handleApplyToSession}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
应用到当前会话
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--text-primary)] hover:border-[var(--accent)]/40
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
{!isNew && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleFork}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
复制 / Fork
|
||||
</button>
|
||||
<Link
|
||||
href={`/training/new?type=style&source=${pack.id}`}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--gold)]/30 bg-[var(--gold)]/8
|
||||
text-[var(--gold)] hover:bg-[var(--gold)]/15
|
||||
cursor-pointer btn-hover-lift transition-all
|
||||
flex items-center gap-1"
|
||||
>
|
||||
训练风格 LoRA
|
||||
<span className="text-[9px]">P2</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--hot)]/60 hover:text-[var(--hot)]
|
||||
hover:border-[var(--hot)]/40
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 左侧:基本信息 + Prompt */}
|
||||
<div className="lg:col-span-2 space-y-5">
|
||||
{/* 描述 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">风格描述</h2>
|
||||
<textarea
|
||||
value={pack.description}
|
||||
onChange={(e) => setPack({ ...pack, description: e.target.value })}
|
||||
placeholder="用一句话描述这个风格的核心特征..."
|
||||
rows={3}
|
||||
className="w-full p-3 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-sm text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none focus:border-[var(--accent)]/50 resize-none"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 关键词 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">关键词标签</h2>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{pack.keywords.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg
|
||||
bg-[var(--accent)]/10 text-[var(--accent)] font-medium"
|
||||
>
|
||||
{k}
|
||||
<button
|
||||
onClick={() => removeKeyword(k)}
|
||||
className="hover:text-[var(--hot)] cursor-pointer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<KeywordInput onAdd={addKeyword} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 风格参考集 */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="typo-h2">风格参考集</h2>
|
||||
<button
|
||||
onClick={addRef}
|
||||
className="text-xs px-2.5 py-1 rounded-lg border border-[var(--border)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
|
||||
>
|
||||
+ 添加参考图(占位)
|
||||
</button>
|
||||
</div>
|
||||
<p className="typo-caption mb-3">
|
||||
3-10 张代表该风格核心特征的参考图,可增删替换。
|
||||
</p>
|
||||
<div className="grid grid-cols-3 md:grid-cols-4 gap-2.5">
|
||||
{pack.refImages.map((ref) => (
|
||||
<RefImageCard
|
||||
key={ref.id}
|
||||
ref_={ref}
|
||||
onRemove={() => removeRef(ref.id)}
|
||||
/>
|
||||
))}
|
||||
{pack.refImages.length === 0 && (
|
||||
<div className="col-span-3 md:col-span-4 py-8 text-center text-xs text-[var(--text-secondary)]
|
||||
placeholder-card rounded-xl">
|
||||
尚未添加参考图
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Prompt 模板 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">Prompt 模板</h2>
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>
|
||||
正向 Prompt
|
||||
</div>
|
||||
<textarea
|
||||
value={pack.positivePrompt ?? ""}
|
||||
onChange={(e) => setPack({ ...pack, positivePrompt: e.target.value })}
|
||||
placeholder="描述风格特征的 Prompt 片段,会在生成时前置注入..."
|
||||
rows={2}
|
||||
className="w-full p-2.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-xs text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none focus:border-[var(--accent)]/50 resize-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>
|
||||
负向 Prompt
|
||||
</div>
|
||||
<textarea
|
||||
value={pack.negativePrompt ?? ""}
|
||||
onChange={(e) => setPack({ ...pack, negativePrompt: e.target.value })}
|
||||
placeholder="要避免的风格特征..."
|
||||
rows={2}
|
||||
className="w-full p-2.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-xs text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none focus:border-[var(--accent)]/50 resize-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 历史预览 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">使用该风格生成的图片</h2>
|
||||
<p className="typo-caption mb-3">
|
||||
自动从资源库聚合(占位:基于关键词匹配的示意)。
|
||||
</p>
|
||||
{relatedAssets.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-[var(--text-secondary)]
|
||||
placeholder-card rounded-xl">
|
||||
尚无历史生成
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-2">
|
||||
{relatedAssets.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="aspect-square rounded-lg overflow-hidden border border-[var(--border)]"
|
||||
>
|
||||
<div className="w-full h-full bg-[var(--bg-tertiary)] flex items-center justify-center text-[9px] text-[var(--text-secondary)]">
|
||||
占位缩略
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 右侧:Phase 2 占位(LoRA / Embedding) + 元信息 */}
|
||||
<aside className="space-y-4">
|
||||
<div className="placeholder-card rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="typo-strong text-xs">风格 LoRA</span>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed mb-2">
|
||||
{pack.loraId
|
||||
? `已绑定:${pack.loraId}(占位)`
|
||||
: "尚未绑定。可通过训练中心生成风格 LoRA 并绑定到此风格。"}
|
||||
</p>
|
||||
<Link
|
||||
href={`/training/new?type=style&source=${pack.id}`}
|
||||
className="text-xs text-[var(--gold)] hover:underline"
|
||||
>
|
||||
前往训练 →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="placeholder-card rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="typo-strong text-xs">风格 Embedding</span>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||
自动计算的风格向量表示,用于语义检索和相似风格推荐。(占位)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl surface-2 p-4">
|
||||
<span className="typo-strong text-xs block mb-2">使用统计</span>
|
||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>使用次数:{pack.usageCount}</div>
|
||||
<div>
|
||||
来源:
|
||||
{pack.source === "preset" ? "平台预设" : pack.source === "custom" ? "项目自建" : "社区共享"}
|
||||
</div>
|
||||
<div>创建时间:{new Date(pack.createdAt).toLocaleDateString("zh-CN")}</div>
|
||||
<div>更新时间:{new Date(pack.updatedAt).toLocaleDateString("zh-CN")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KeywordInput({ onAdd }: { onAdd: (kw: string) => void }) {
|
||||
const [val, setVal] = useState("");
|
||||
return (
|
||||
<input
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && val.trim()) {
|
||||
onAdd(val.trim());
|
||||
setVal("");
|
||||
}
|
||||
}}
|
||||
placeholder="+ 添加关键词(回车)"
|
||||
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none focus:border-[var(--accent)]/50 min-w-[120px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RefImageCard({
|
||||
ref_,
|
||||
onRemove,
|
||||
}: {
|
||||
ref_: StyleRefImage;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative aspect-square rounded-lg overflow-hidden
|
||||
border border-[var(--border)] group">
|
||||
{ref_.url ? (
|
||||
<img src={ref_.url} alt={ref_.caption} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-[var(--accent)]/10 to-[var(--accent-secondary)]/5
|
||||
flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] text-[var(--accent)]/60 mb-0.5">占位</div>
|
||||
<div className="text-[9px] text-[var(--text-secondary)]">
|
||||
{ref_.caption}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="absolute top-1 right-1 w-5 h-5 rounded-full
|
||||
bg-black/60 text-white text-xs opacity-0 group-hover:opacity-100
|
||||
cursor-pointer transition-opacity flex items-center justify-center"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
art-agent/frontend/src/app/styles/page.tsx
Normal file
251
art-agent/frontend/src/app/styles/page.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { staggerContainer, staggerItem } from "@/components/ui/motion-presets";
|
||||
import type { StylePack } from "@/lib/types";
|
||||
|
||||
type SourceFilter = "all" | "preset" | "custom" | "community";
|
||||
|
||||
const ALL_KEYWORDS = ["写实", "二次元", "像素", "水墨", "赛博", "奇幻", "写意", "科幻", "复古"];
|
||||
|
||||
export default function StylesPage() {
|
||||
const { stylePacks } = useApp();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [source, setSource] = useState<SourceFilter>("all");
|
||||
const [keyword, setKeyword] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = [...stylePacks];
|
||||
if (source !== "all") list = list.filter((s) => s.source === source);
|
||||
if (keyword) list = list.filter((s) => s.keywords.includes(keyword));
|
||||
if (query.trim()) {
|
||||
const q = query.trim().toLowerCase();
|
||||
list = list.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.description.toLowerCase().includes(q) ||
|
||||
s.keywords.some((k) => k.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [stylePacks, source, keyword, query]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col relative z-[1]">
|
||||
<AmbientParticles count={16} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<h1 className="typo-h1">风格库</h1>
|
||||
<span className="phase-chip">Phase 1</span>
|
||||
</div>
|
||||
<p className="typo-caption">
|
||||
项目级风格集。每个风格包含关键词、参考集、Prompt 模板和(可选的)LoRA 绑定。
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/styles/new"
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-sm rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
|
||||
transition-all cursor-pointer btn-hover-lift flex-shrink-0"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
新建风格
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-5">
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-[200px] max-w-[360px]
|
||||
px-3 py-1.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
focus-within:border-[var(--accent)]/40 transition-colors">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索风格名 / 描述 / 关键词..."
|
||||
className="flex-1 bg-transparent border-none text-sm
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 来源筛选 */}
|
||||
<div className="flex items-center gap-0.5 bg-[var(--bg-tertiary)] rounded-lg p-0.5 border border-[var(--border)]">
|
||||
{(["all", "preset", "custom", "community"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSource(s)}
|
||||
className={`px-2.5 py-1 text-xs rounded-md cursor-pointer transition-all ${
|
||||
source === s
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)] font-medium"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{s === "all" ? "全部" : s === "preset" ? "预设" : s === "custom" ? "自建" : "社区"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类筛选(关键词) */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-6">
|
||||
<span className="typo-micro mr-1" style={{ textTransform: "none" }}>分类:</span>
|
||||
<button
|
||||
onClick={() => setKeyword(null)}
|
||||
className={`px-2.5 py-1 text-[11px] rounded-lg cursor-pointer transition-all ${
|
||||
!keyword
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/30"
|
||||
: "text-[var(--text-secondary)] border border-transparent hover:border-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{ALL_KEYWORDS.map((k) => {
|
||||
const active = keyword === k;
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setKeyword(active ? null : k)}
|
||||
className={`px-2.5 py-1 text-[11px] rounded-lg cursor-pointer transition-all ${
|
||||
active
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/30"
|
||||
: "text-[var(--text-secondary)] border border-transparent hover:border-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
{k}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 卡片网格 */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="typo-display text-lg mb-2">没有匹配的风格</p>
|
||||
<p className="typo-caption">试试调整筛选条件或点击“新建风格”</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
{filtered.map((pack) => (
|
||||
<StyleCard key={pack.id} pack={pack} />
|
||||
))}
|
||||
{/* 新建卡片 */}
|
||||
<motion.div variants={staggerItem}>
|
||||
<Link
|
||||
href="/styles/new"
|
||||
className="aspect-[3/4] rounded-xl placeholder-card
|
||||
flex flex-col items-center justify-center gap-2
|
||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:border-[var(--accent)]/50 cursor-pointer
|
||||
transition-all btn-hover-lift"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
<span className="text-xs">新建风格</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StyleCard({ pack }: { pack: StylePack }) {
|
||||
const sourceLabel =
|
||||
pack.source === "preset" ? "预设" : pack.source === "custom" ? "自建" : "社区";
|
||||
|
||||
return (
|
||||
<motion.div variants={staggerItem}>
|
||||
<Link
|
||||
href={`/styles/${pack.id}`}
|
||||
className="block rounded-xl overflow-hidden neon-border
|
||||
bg-[var(--bg-card)] backdrop-blur-sm cursor-pointer
|
||||
transition-all"
|
||||
>
|
||||
{/* 代表图(占位) */}
|
||||
<div className="aspect-[4/3] relative overflow-hidden
|
||||
bg-gradient-to-br from-[var(--accent)]/15 via-[var(--accent-secondary)]/10 to-transparent">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-[var(--accent)] tracking-tight opacity-60">
|
||||
{pack.name.slice(0, 2)}
|
||||
</div>
|
||||
<div className="text-[9px] text-[var(--text-secondary)] mt-1">
|
||||
代表图占位({pack.refImages.length} 张参考)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 来源标签 */}
|
||||
<span
|
||||
className="absolute top-2 right-2 text-[9px] px-1.5 py-0.5 rounded-md font-medium"
|
||||
style={{
|
||||
background:
|
||||
pack.source === "preset"
|
||||
? "rgba(46, 139, 122, 0.15)"
|
||||
: pack.source === "custom"
|
||||
? "rgba(58, 127, 184, 0.15)"
|
||||
: "rgba(184, 147, 90, 0.15)",
|
||||
color:
|
||||
pack.source === "preset"
|
||||
? "var(--accent)"
|
||||
: pack.source === "custom"
|
||||
? "var(--accent-secondary)"
|
||||
: "var(--gold)",
|
||||
}}
|
||||
>
|
||||
{sourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)] truncate mb-1">
|
||||
{pack.name}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{pack.keywords.slice(0, 3).map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="text-[9px] px-1.5 py-0.5 rounded-md
|
||||
bg-[var(--accent)]/8 text-[var(--accent)] font-medium"
|
||||
>
|
||||
{k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-[var(--text-secondary)]">
|
||||
<span>使用 {pack.usageCount} 次</span>
|
||||
{pack.loraId && <span className="phase-chip-accent phase-chip">LoRA</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
347
art-agent/frontend/src/app/training/[id]/page.tsx
Normal file
347
art-agent/frontend/src/app/training/[id]/page.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import type { TrainingTask, TrainingTaskStatus } from "@/lib/types";
|
||||
|
||||
const STATUS_LABEL: Record<TrainingTaskStatus, string> = {
|
||||
queued: "排队中",
|
||||
running: "训练中",
|
||||
completed: "已完成",
|
||||
failed: "已失败",
|
||||
deprecated: "已弃用",
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<TrainingTaskStatus, string> = {
|
||||
queued: "#6B7B8A",
|
||||
running: "#2E8B7A",
|
||||
completed: "#3A7FB8",
|
||||
failed: "#C4654A",
|
||||
deprecated: "#B8935A",
|
||||
};
|
||||
|
||||
export default function TrainingDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const {
|
||||
trainingTasks,
|
||||
upsertTrainingTask,
|
||||
deleteTrainingTask,
|
||||
stylePacks,
|
||||
characters,
|
||||
} = useApp();
|
||||
|
||||
const [task, setTask] = useState<TrainingTask | null>(null);
|
||||
const [logOpen, setLogOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const t = trainingTasks.find((x) => x.id === params.id);
|
||||
setTask(t ?? null);
|
||||
}, [params.id, trainingTasks]);
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<TopNav />
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="typo-caption mb-2">未找到该训练任务</p>
|
||||
<Link href="/training" className="text-sm text-[var(--accent)] hover:underline">
|
||||
返回训练中心
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const source =
|
||||
task.type === "style_lora"
|
||||
? stylePacks.find((s) => s.id === task.sourceId)
|
||||
: characters.find((c) => c.id === task.sourceId);
|
||||
|
||||
const handleUse = () => {
|
||||
alert(`(占位)已绑定此 LoRA 到当前会话:${task.name}`);
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleRetrain = () => {
|
||||
alert("(占位)将基于当前训练集重新启动训练任务。");
|
||||
};
|
||||
|
||||
const handleDeprecate = () => {
|
||||
if (!confirm("确认弃用该模型?")) return;
|
||||
upsertTrainingTask({ ...task, status: "deprecated" });
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm("确认删除该训练任务?")) return;
|
||||
deleteTrainingTask(task.id);
|
||||
router.push("/training");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<AmbientParticles count={12} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
{/* 面包屑 */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] mb-4">
|
||||
<Link href="/training" className="hover:text-[var(--accent)] cursor-pointer">
|
||||
训练中心
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--text-primary)]">{task.name}</span>
|
||||
</div>
|
||||
|
||||
{/* 头部 */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<h1 className="typo-h1">{task.name}</h1>
|
||||
<span
|
||||
className="text-[10px] px-2 py-0.5 rounded-md font-medium"
|
||||
style={{
|
||||
background: STATUS_COLOR[task.status] + "22",
|
||||
color: STATUS_COLOR[task.status],
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL[task.status]}
|
||||
</span>
|
||||
<span className="phase-chip">Phase 2</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{task.type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
|
||||
{source && (
|
||||
<>
|
||||
{" · 来源:"}
|
||||
<Link
|
||||
href={
|
||||
task.type === "style_lora"
|
||||
? `/styles/${task.sourceId}`
|
||||
: `/characters/${task.sourceId}`
|
||||
}
|
||||
className="text-[var(--accent)] hover:underline"
|
||||
>
|
||||
{source.name}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{" · 模板:"}
|
||||
{task.template}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{task.status === "completed" && (
|
||||
<button
|
||||
onClick={handleUse}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
使用该模型
|
||||
</button>
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
<button
|
||||
onClick={handleRetrain}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--text-primary)] hover:border-[var(--accent)]/40
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
重新训练
|
||||
</button>
|
||||
)}
|
||||
{task.status !== "deprecated" && task.status !== "queued" && (
|
||||
<button
|
||||
onClick={handleDeprecate}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
弃用
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--hot)]/60 hover:text-[var(--hot)]
|
||||
hover:border-[var(--hot)]/40
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{task.status === "running" && (
|
||||
<div className="rounded-xl surface-2 p-4 mb-5">
|
||||
<div className="flex items-center justify-between mb-2 text-xs">
|
||||
<span className="typo-strong">{task.currentStep ?? "训练中..."}</span>
|
||||
<span className="text-[var(--accent)] font-medium">{task.progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-[var(--bg-tertiary)] overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[var(--accent)] to-[var(--accent-secondary)] transition-all"
|
||||
style={{ width: `${task.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{task.etaSeconds && (
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-1.5">
|
||||
预计剩余 {Math.floor(task.etaSeconds / 60)} 分 {task.etaSeconds % 60} 秒(占位)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
{/* 左:日志 + 训练前后对比 + 训练集预览 */}
|
||||
<div className="lg:col-span-2 space-y-5">
|
||||
{/* 训练前后对比 */}
|
||||
<section className="rounded-xl surface-2 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="typo-h2">训练前后效果对比</h2>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
{task.status === "completed" ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>训练前</div>
|
||||
<div className="aspect-square rounded-lg placeholder-card
|
||||
flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
||||
占位对比图
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>训练后</div>
|
||||
<div className="aspect-square rounded-lg placeholder-card
|
||||
flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
||||
占位对比图
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-secondary)] py-6 text-center">
|
||||
训练完成后将显示同一 Prompt 下的前后对比
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 训练日志 */}
|
||||
<section className="rounded-xl surface-2 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setLogOpen(!logOpen)}
|
||||
className="w-full px-4 py-2.5 flex items-center justify-between
|
||||
hover:bg-[var(--bg-tertiary)]/50 cursor-pointer transition-colors"
|
||||
>
|
||||
<span className="typo-strong text-xs">训练日志</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||
className={`transition-transform ${logOpen ? "" : "rotate-180"}`}>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
{logOpen && (
|
||||
<div className="border-t border-[var(--border)] p-3 bg-[var(--bg-primary)]/40 font-mono text-[11px] max-h-60 overflow-y-auto">
|
||||
{task.logs.length === 0 ? (
|
||||
<p className="text-[var(--text-secondary)]">尚无日志输出</p>
|
||||
) : (
|
||||
task.logs.map((l, i) => (
|
||||
<div key={i} className="text-[var(--text-secondary)] leading-relaxed">
|
||||
{l}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 训练集预览 */}
|
||||
<section>
|
||||
<h2 className="typo-h2 mb-2">训练集预览</h2>
|
||||
<p className="typo-caption mb-3">
|
||||
训练集共 {task.trainingSet.length} 张图片(占位缩略)。
|
||||
</p>
|
||||
{task.trainingSet.length === 0 ? (
|
||||
<div className="py-8 text-center placeholder-card rounded-xl text-xs text-[var(--text-secondary)]">
|
||||
训练集未填充
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 md:grid-cols-6 gap-2">
|
||||
{task.trainingSet.slice(0, 12).map((_, i) => (
|
||||
<div key={i}
|
||||
className="aspect-square rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
flex items-center justify-center text-[9px] text-[var(--text-secondary)]">
|
||||
占位
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 右:版本 + 成本 + 操作入口 */}
|
||||
<aside className="space-y-4">
|
||||
<div className="placeholder-card rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="typo-strong text-xs">模型版本</span>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>当前版本:v1.0(占位)</div>
|
||||
<div>历史版本:v0.9、v0.8(占位)</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => alert("(占位)回滚到上一版本")}
|
||||
className="mt-2 text-xs text-[var(--accent)] hover:underline cursor-pointer"
|
||||
>
|
||||
回滚到上一版本 →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl surface-2 p-4">
|
||||
<span className="typo-strong text-xs block mb-2">训练信息</span>
|
||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>模板:{task.template}</div>
|
||||
<div>预估成本:{task.estCost ?? "—"}</div>
|
||||
<div>创建时间:{new Date(task.createdAt).toLocaleString("zh-CN")}</div>
|
||||
<div>更新时间:{new Date(task.updatedAt).toLocaleString("zh-CN")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{source && (
|
||||
<div className="rounded-xl surface-2 p-4">
|
||||
<span className="typo-strong text-xs block mb-2">来源</span>
|
||||
<Link
|
||||
href={
|
||||
task.type === "style_lora"
|
||||
? `/styles/${task.sourceId}`
|
||||
: `/characters/${task.sourceId}`
|
||||
}
|
||||
className="text-xs text-[var(--accent)] hover:underline block truncate"
|
||||
>
|
||||
{source.name}
|
||||
</Link>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
|
||||
{task.type === "style_lora" ? "风格集" : "角色卡"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
353
art-agent/frontend/src/app/training/new/page.tsx
Normal file
353
art-agent/frontend/src/app/training/new/page.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { generateId } from "@/lib/store";
|
||||
import type { TrainingTask, TrainingTaskType } from "@/lib/types";
|
||||
|
||||
type WizardStep = 1 | 2 | 3 | 4;
|
||||
|
||||
const TEMPLATES = [
|
||||
{ id: "sdxl_lora", name: "SDXL LoRA", desc: "基于 SDXL 底模,兼顾速度与效果(占位)" },
|
||||
{ id: "flux_lora", name: "Flux LoRA", desc: "基于 Flux 底模,细节更精细(占位)" },
|
||||
{ id: "sd15_lora", name: "SD 1.5 LoRA", desc: "轻量训练,适合像素/卡通(占位)" },
|
||||
];
|
||||
|
||||
function TrainingWizardInner() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const { stylePacks, characters, upsertTrainingTask } = useApp();
|
||||
|
||||
const initType = (search.get("type") as TrainingTaskType) || "style_lora";
|
||||
const initSourceId = search.get("sourceId") ?? "";
|
||||
|
||||
const [step, setStep] = useState<WizardStep>(1);
|
||||
const [type, setType] = useState<TrainingTaskType>(initType);
|
||||
const [sourceId, setSourceId] = useState(initSourceId);
|
||||
const [template, setTemplate] = useState("sdxl_lora");
|
||||
const [name, setName] = useState("");
|
||||
const [trainingSetSize, setTrainingSetSize] = useState(12);
|
||||
|
||||
const sources = type === "style_lora" ? stylePacks : characters;
|
||||
|
||||
const selectedSource = useMemo(
|
||||
() => sources.find((s) => s.id === sourceId),
|
||||
[sources, sourceId]
|
||||
);
|
||||
|
||||
const canNext = useMemo(() => {
|
||||
if (step === 1) return !!sourceId;
|
||||
if (step === 2) return !!template && trainingSetSize > 0;
|
||||
if (step === 3) return true;
|
||||
return true;
|
||||
}, [step, sourceId, template, trainingSetSize]);
|
||||
|
||||
const defaultName = useMemo(() => {
|
||||
if (!selectedSource) return "";
|
||||
return `${selectedSource.name} ${type === "style_lora" ? "风格" : "角色"} LoRA v1`;
|
||||
}, [selectedSource, type]);
|
||||
|
||||
const finalName = name.trim() || defaultName;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const now = Date.now();
|
||||
const t: TrainingTask = {
|
||||
id: generateId("train-"),
|
||||
name: finalName || "未命名训练任务",
|
||||
type,
|
||||
status: "queued",
|
||||
progress: 0,
|
||||
sourceId,
|
||||
template,
|
||||
trainingSet: Array.from({ length: trainingSetSize }).map((_, i) => `asset-pick-${i + 1}`),
|
||||
logs: ["[占位] 训练任务已提交,排队中..."],
|
||||
estCost: "≈ ¥18(占位)",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
upsertTrainingTask(t);
|
||||
alert("(占位)训练任务已提交。真实训练能力尚未接入。");
|
||||
router.push(`/training/${t.id}`);
|
||||
};
|
||||
|
||||
const stepLabels = ["选择来源", "训练配置", "数据集校验", "提交确认"];
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<AmbientParticles count={10} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
{/* 面包屑 */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] mb-4">
|
||||
<Link href="/training" className="hover:text-[var(--accent)] cursor-pointer">
|
||||
训练中心
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--text-primary)]">新建训练任务</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<h1 className="typo-h1">新建训练任务</h1>
|
||||
<span className="phase-chip">Phase 2</span>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{stepLabels.map((label, i) => {
|
||||
const n = (i + 1) as WizardStep;
|
||||
const active = step === n;
|
||||
const done = step > n;
|
||||
return (
|
||||
<div key={label} className="flex-1 flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-medium
|
||||
transition-all ${
|
||||
active
|
||||
? "bg-[var(--accent)] text-[var(--bg-primary)]"
|
||||
: done
|
||||
? "bg-[var(--accent)]/20 text-[var(--accent)]"
|
||||
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
{done ? "✓" : n}
|
||||
</div>
|
||||
<span className={`text-xs hidden md:inline ${active ? "text-[var(--text-primary)] font-medium" : "text-[var(--text-secondary)]"}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{i < stepLabels.length - 1 && (
|
||||
<div className={`flex-1 h-[1px] mx-2 ${done ? "bg-[var(--accent)]/40" : "bg-[var(--border)]"}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl surface-2 p-5 mb-5">
|
||||
{/* Step 1: 选择来源 */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>训练类型</label>
|
||||
<div className="flex gap-2">
|
||||
{(["style_lora", "character_lora"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => {
|
||||
setType(t);
|
||||
setSourceId("");
|
||||
}}
|
||||
className={`flex-1 px-3 py-2.5 text-sm rounded-lg border cursor-pointer transition-all ${
|
||||
type === t
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/10 text-[var(--accent)] font-medium"
|
||||
: "border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{t === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>
|
||||
选择{type === "style_lora" ? "风格集" : "角色卡"}来源
|
||||
</label>
|
||||
{sources.length === 0 ? (
|
||||
<div className="placeholder-card rounded-lg p-6 text-center text-xs text-[var(--text-secondary)]">
|
||||
暂无可用{type === "style_lora" ? "风格集" : "角色卡"},请先创建
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-72 overflow-y-auto">
|
||||
{sources.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setSourceId(s.id)}
|
||||
className={`text-left px-3 py-2.5 rounded-lg border cursor-pointer transition-all ${
|
||||
sourceId === s.id
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/8"
|
||||
: "border-[var(--border)] hover:border-[var(--accent)]/40"
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm text-[var(--text-primary)] font-medium truncate">{s.name}</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] truncate mt-0.5">
|
||||
{s.description || "无描述"}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: 训练配置 */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>训练模板</label>
|
||||
<div className="space-y-2">
|
||||
{TEMPLATES.map((tpl) => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
onClick={() => setTemplate(tpl.id)}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg border cursor-pointer transition-all ${
|
||||
template === tpl.id
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/8"
|
||||
: "border-[var(--border)] hover:border-[var(--accent)]/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-primary)] font-medium">{tpl.name}</span>
|
||||
<span className="phase-chip">P2</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{tpl.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>
|
||||
训练轮次(占位) — 当前示例为模拟配置项
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{["快速 (3 epoch)", "标准 (6 epoch)", "深度 (10 epoch)"].map((opt, i) => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => {}}
|
||||
className={`px-2.5 py-2 text-xs rounded-lg border cursor-pointer ${
|
||||
i === 1
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/8 text-[var(--accent)]"
|
||||
: "border-[var(--border)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: 数据集校验 */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>训练集规模(占位)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={trainingSetSize}
|
||||
onChange={(e) => setTrainingSetSize(parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--text-secondary)] mt-1.5">
|
||||
建议 8–30 张(占位)。当前为模拟值,真实训练集选择待 Phase 2。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="placeholder-card rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="typo-strong text-xs">自动校验</span>
|
||||
<span className="text-[10px] text-[var(--accent)]">✓ 通过(占位)</span>
|
||||
</div>
|
||||
<ul className="text-[11px] text-[var(--text-secondary)] space-y-1">
|
||||
<li>· 图片数量:{trainingSetSize} 张(符合建议范围)</li>
|
||||
<li>· 分辨率:多数 ≥ 768px(占位)</li>
|
||||
<li>· 重复检测:未发现重复图(占位)</li>
|
||||
<li>· NSFW 扫描:未检出(占位)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: 提交确认 */}
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>任务名称</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={defaultName || "请先选择来源"}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg surface-2 p-3 text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>类型:<span className="text-[var(--text-primary)]">{type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}</span></div>
|
||||
<div>来源:<span className="text-[var(--text-primary)]">{selectedSource?.name ?? "—"}</span></div>
|
||||
<div>模板:<span className="text-[var(--text-primary)]">{template}</span></div>
|
||||
<div>训练集:<span className="text-[var(--text-primary)]">{trainingSetSize} 张(占位)</span></div>
|
||||
<div>预估成本:<span className="text-[var(--text-primary)]">≈ ¥18(占位)</span></div>
|
||||
<div>预估耗时:<span className="text-[var(--text-primary)]">≈ 20 分钟(占位)</span></div>
|
||||
</div>
|
||||
|
||||
<div className="placeholder-card rounded-lg p-3 text-[11px] text-[var(--text-secondary)]">
|
||||
提交后会进入排队队列。该页面所有交互为占位演示,提交不会产生真实训练调用。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (step === 1) router.push("/training");
|
||||
else setStep((step - 1) as WizardStep);
|
||||
}}
|
||||
className="px-3.5 py-2 text-xs rounded-xl border border-[var(--border)]
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
{step === 1 ? "取消" : "上一步"}
|
||||
</button>
|
||||
|
||||
{step < 4 ? (
|
||||
<button
|
||||
onClick={() => canNext && setStep((step + 1) as WizardStep)}
|
||||
disabled={!canNext}
|
||||
className="px-4 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)] disabled:opacity-40 disabled:cursor-not-allowed
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
下一步
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="px-4 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
cursor-pointer btn-hover-lift transition-all"
|
||||
>
|
||||
提交训练任务
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TrainingWizardPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="h-screen" />}>
|
||||
<TrainingWizardInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
186
art-agent/frontend/src/app/training/page.tsx
Normal file
186
art-agent/frontend/src/app/training/page.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { staggerContainer, staggerItem } from "@/components/ui/motion-presets";
|
||||
import type { TrainingTask, TrainingTaskStatus } from "@/lib/types";
|
||||
|
||||
const STATUS_LABEL: Record<TrainingTaskStatus, string> = {
|
||||
queued: "排队中",
|
||||
running: "训练中",
|
||||
completed: "已完成",
|
||||
failed: "已失败",
|
||||
deprecated: "已弃用",
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<TrainingTaskStatus, string> = {
|
||||
queued: "#6B7B8A",
|
||||
running: "#2E8B7A",
|
||||
completed: "#3A7FB8",
|
||||
failed: "#C4654A",
|
||||
deprecated: "#B8935A",
|
||||
};
|
||||
|
||||
export default function TrainingPage() {
|
||||
const { trainingTasks, stylePacks, characters } = useApp();
|
||||
const [filter, setFilter] = useState<"all" | TrainingTaskStatus>("all");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === "all") return trainingTasks;
|
||||
return trainingTasks.filter((t) => t.status === filter);
|
||||
}, [trainingTasks, filter]);
|
||||
|
||||
const sourceName = (t: TrainingTask) => {
|
||||
if (!t.sourceId) return "—";
|
||||
if (t.type === "style_lora") {
|
||||
return stylePacks.find((s) => s.id === t.sourceId)?.name ?? t.sourceId;
|
||||
}
|
||||
return characters.find((c) => c.id === t.sourceId)?.name ?? t.sourceId;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col relative z-[1]">
|
||||
<AmbientParticles count={14} />
|
||||
<TopNav />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-6xl mx-auto px-4 md:px-8 py-6 md:py-8">
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<h1 className="typo-h1">训练中心</h1>
|
||||
<span className="phase-chip">Phase 2</span>
|
||||
</div>
|
||||
<p className="typo-caption">
|
||||
风格 / 角色 LoRA 训练任务管理。当前阶段均为占位数据,真实训练能力待 Phase 2 交付。
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/training/new"
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-sm rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
|
||||
transition-all cursor-pointer btn-hover-lift flex-shrink-0"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
新建训练任务
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<div className="flex items-center gap-1.5 mb-5 overflow-x-auto hide-scrollbar">
|
||||
{(["all", "queued", "running", "completed", "failed", "deprecated"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFilter(s)}
|
||||
className={`px-2.5 py-1 text-xs rounded-lg cursor-pointer transition-all whitespace-nowrap ${
|
||||
filter === s
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)] font-medium border border-[var(--accent)]/30"
|
||||
: "border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{s === "all" ? "全部" : STATUS_LABEL[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 任务表格 */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-20 placeholder-card rounded-xl">
|
||||
<p className="typo-display text-lg mb-2">暂无训练任务</p>
|
||||
<p className="typo-caption">点击“新建训练任务”开始</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="rounded-xl surface-2 overflow-hidden"
|
||||
>
|
||||
{/* 表头(桌面端) */}
|
||||
<div className="hidden md:grid grid-cols-[2fr_1fr_1fr_1.5fr_1fr_auto] gap-3 px-4 py-2.5
|
||||
border-b border-[var(--border)] text-[10px] typo-micro"
|
||||
style={{ textTransform: "none" }}>
|
||||
<span>任务名称</span>
|
||||
<span>类型</span>
|
||||
<span>来源</span>
|
||||
<span>状态 / 进度</span>
|
||||
<span>创建时间</span>
|
||||
<span className="w-10" />
|
||||
</div>
|
||||
|
||||
{filtered.map((t) => (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
variants={staggerItem}
|
||||
className="border-b border-[var(--border)] last:border-b-0
|
||||
hover:bg-[var(--accent)]/5 transition-colors"
|
||||
>
|
||||
<Link
|
||||
href={`/training/${t.id}`}
|
||||
className="block px-4 py-3 cursor-pointer"
|
||||
>
|
||||
<div className="md:grid md:grid-cols-[2fr_1fr_1fr_1.5fr_1fr_auto] md:gap-3 md:items-center
|
||||
flex flex-col gap-1.5">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{t.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{t.type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] truncate">
|
||||
{sourceName(t)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="flex-shrink-0 text-[10px] px-2 py-0.5 rounded-md font-medium"
|
||||
style={{
|
||||
background: STATUS_COLOR[t.status] + "22",
|
||||
color: STATUS_COLOR[t.status],
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL[t.status]}
|
||||
</span>
|
||||
{t.status === "running" && (
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1.5">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--bg-tertiary)] overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--accent)] transition-all"
|
||||
style={{ width: `${t.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--accent)] flex-shrink-0">
|
||||
{t.progress}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{new Date(t.createdAt).toLocaleDateString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2"
|
||||
className="text-[var(--text-secondary)] hidden md:block">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
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,192 +209,210 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
|
||||
onDrop={handleDrop}
|
||||
onPasteCapture={handlePasteCapture}
|
||||
>
|
||||
{isDragging && (
|
||||
<div className="mb-2 text-center text-xs text-[var(--accent)]">
|
||||
松开以添加参考图(可多张)
|
||||
</div>
|
||||
)}
|
||||
{/* 数据流光 */}
|
||||
<DataStream active={disabled} speed={isGeneratingImage ? "fast" : "slow"} />
|
||||
|
||||
{/* 沿用上次参考图提示 */}
|
||||
{uploads.length === 0 && lastRefPreviewUrls && lastRefPreviewUrls.length > 0 && (
|
||||
<div className="mb-3 flex items-center gap-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
{lastRefPreviewUrls.map((url, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={url}
|
||||
alt={`沿用参考图 ${i + 1}`}
|
||||
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/30
|
||||
shadow-[0_0_8px_rgba(77,184,164,0.1)]"
|
||||
/>
|
||||
))}
|
||||
<div className="relative px-3 md:px-5 py-2.5 md:py-3">
|
||||
{isDragging && (
|
||||
<div className="mb-2 text-center text-xs text-[var(--accent)]">
|
||||
松开以添加参考图(可多张)
|
||||
</div>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
沿用上次参考图({lastRefPreviewUrls.length} 张)
|
||||
</span>
|
||||
<button
|
||||
onClick={onClearLastRefImage}
|
||||
className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--hot)]
|
||||
cursor-pointer transition-colors"
|
||||
title="清除参考图"
|
||||
>
|
||||
✕ 清除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* 参考图上传状态区(多图) */}
|
||||
{uploads.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{uploads.map((item) => (
|
||||
<div key={item.id} className="relative flex-shrink-0">
|
||||
{item.previewUrl && (
|
||||
{/* 沿用上次参考图提示 */}
|
||||
{uploads.length === 0 && lastRefPreviewUrls && lastRefPreviewUrls.length > 0 && (
|
||||
<div className="mb-3 flex items-center gap-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
{lastRefPreviewUrls.map((url, i) => (
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt="参考图"
|
||||
className={`w-16 h-16 rounded-xl object-cover border border-[var(--border)] transition-opacity ${
|
||||
item.status === "uploading" ? "opacity-60" : ""
|
||||
}`}
|
||||
key={i}
|
||||
src={url}
|
||||
alt={`沿用参考图 ${i + 1}`}
|
||||
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/30
|
||||
shadow-[0_0_8px_rgba(46,139,122,0.1)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
沿用上次参考图({lastRefPreviewUrls.length} 张)
|
||||
</span>
|
||||
<button
|
||||
onClick={onClearLastRefImage}
|
||||
className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--hot)]
|
||||
cursor-pointer transition-colors"
|
||||
title="清除参考图"
|
||||
>
|
||||
✕ 清除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参考图上传状态区(多图) */}
|
||||
{uploads.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{uploads.map((item) => (
|
||||
<div key={item.id} className="relative flex-shrink-0">
|
||||
{item.previewUrl && (
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt="参考图"
|
||||
className={`w-16 h-16 rounded-xl object-cover border border-[var(--border)] transition-opacity ${
|
||||
item.status === "uploading" ? "opacity-60" : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{item.status === "uploading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(46,139,122,0.12)" strokeWidth="3" />
|
||||
<circle
|
||||
cx="18" cy="18" r="14" fill="none"
|
||||
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
|
||||
strokeDasharray={`${item.progress * 0.88} 88`}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "done" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--accent)]
|
||||
text-[var(--bg-primary)] text-[10px] flex items-center justify-center font-bold
|
||||
shadow-[0_0_8px_rgba(46,139,122,0.3)]">
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--hot)]
|
||||
text-white text-[10px] flex items-center justify-center font-bold">
|
||||
!
|
||||
</div>
|
||||
)}
|
||||
{item.status !== "uploading" && (
|
||||
<button
|
||||
onClick={() => removeUpload(item.id)}
|
||||
className="absolute -top-1.5 -left-1.5 w-5 h-5 rounded-full
|
||||
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
text-[var(--text-secondary)] text-xs flex items-center justify-center
|
||||
hover:bg-[var(--hot)] hover:text-white hover:border-[var(--hot)]
|
||||
cursor-pointer transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-col justify-center gap-1 min-w-0">
|
||||
{hasActiveUpload && (
|
||||
<span className="text-xs text-[var(--accent)] flex items-center gap-1.5">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
|
||||
shadow-[0_0_6px_rgba(46,139,122,0.3)]" />
|
||||
上传中...
|
||||
</span>
|
||||
)}
|
||||
{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="var(--accent)" strokeWidth="3" strokeLinecap="round"
|
||||
strokeDasharray={`${item.progress * 0.88} 88`}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{allDone && (
|
||||
<span className="text-xs text-[var(--accent)]">
|
||||
{doneUrls.length} 张参考图已就绪
|
||||
</span>
|
||||
)}
|
||||
{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)]">
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--hot)]
|
||||
text-white text-[10px] flex items-center justify-center font-bold">
|
||||
!
|
||||
</div>
|
||||
)}
|
||||
{item.status !== "uploading" && (
|
||||
<button
|
||||
onClick={() => removeUpload(item.id)}
|
||||
className="absolute -top-1.5 -left-1.5 w-5 h-5 rounded-full
|
||||
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
text-[var(--text-secondary)] text-xs flex items-center justify-center
|
||||
hover:bg-[var(--hot)] hover:text-white hover:border-[var(--hot)]
|
||||
cursor-pointer transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{uploads.some((u) => u.status === "error") && (
|
||||
<span className="text-xs text-[var(--hot)]">
|
||||
{uploads.filter((u) => u.status === "error").length} 张上传失败
|
||||
</span>
|
||||
)}
|
||||
</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)]" />
|
||||
上传中...
|
||||
</span>
|
||||
)}
|
||||
{allDone && (
|
||||
<span className="text-xs text-[var(--accent)]">
|
||||
{doneUrls.length} 张参考图已就绪
|
||||
</span>
|
||||
)}
|
||||
{uploads.some((u) => u.status === "error") && (
|
||||
<span className="text-xs text-[var(--hot)]">
|
||||
{uploads.filter((u) => u.status === "error").length} 张上传失败
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<ModelSelector value={selectedModel} onChange={setSelectedModel} />
|
||||
|
||||
{/* 上传参考图按钮 */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled || hasActiveUpload}
|
||||
className={`relative flex-shrink-0 w-10 h-10 rounded-xl border
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
||||
hover:bg-[var(--accent)]/5
|
||||
flex items-center justify-center transition-all
|
||||
disabled:opacity-50 cursor-pointer btn-hover-lift ${
|
||||
doneUrls.length > 0
|
||||
? "border-[var(--accent)]/40 text-[var(--accent)]"
|
||||
: "border-[var(--border)]"
|
||||
}`}
|
||||
title="上传参考图(可多选)"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
{doneUrls.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--accent)]
|
||||
text-[var(--bg-primary)] text-[9px] flex items-center justify-center font-bold">
|
||||
{doneUrls.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* 文字输入框 — 水面感应 */}
|
||||
<div className={`relative flex-1 ${isFocused ? "water-focus-active" : ""}`}>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder="描述你想要的美术资源...(可粘贴图片)"
|
||||
title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图"
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="w-full resize-none rounded-xl border border-[var(--border)]
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-primary)]
|
||||
placeholder:text-[var(--text-secondary)]
|
||||
px-4 py-2.5 text-sm leading-relaxed
|
||||
focus:outline-none focus:border-[var(--accent)]/50
|
||||
disabled:opacity-50 transition-all
|
||||
min-h-[42px] max-h-[120px]"
|
||||
style={{
|
||||
fieldSizing: "content",
|
||||
boxShadow: isFocused
|
||||
? `0 0 ${8 + Math.min(text.length, 50) * 0.3}px rgba(46,139,122,${0.04 + Math.min(text.length, 50) * 0.002})`
|
||||
: "none",
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
<div className="water-focus-line" />
|
||||
</div>
|
||||
|
||||
{/* 发送按钮 — 投石入水 */}
|
||||
<motion.button
|
||||
ref={sendBtnRef}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSend}
|
||||
whileTap={canSend ? sendBounce : undefined}
|
||||
className={`flex-shrink-0 w-10 h-10 rounded-xl
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
hover:shadow-[0_0_15px_rgba(46,139,122,0.25)]
|
||||
flex items-center justify-center transition-all
|
||||
disabled:opacity-50 cursor-pointer
|
||||
${canSend ? "breath-pulse" : ""}`}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</svg>
|
||||
</motion.button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<ModelSelector value={selectedModel} onChange={setSelectedModel} />
|
||||
|
||||
{/* 上传参考图按钮 */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled || hasActiveUpload}
|
||||
className={`relative flex-shrink-0 w-10 h-10 rounded-xl border
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
||||
hover:bg-[var(--accent)]/5
|
||||
flex items-center justify-center transition-all
|
||||
disabled:opacity-50 cursor-pointer ${
|
||||
doneUrls.length > 0
|
||||
? "border-[var(--accent)]/40 text-[var(--accent)]"
|
||||
: "border-[var(--border)]"
|
||||
}`}
|
||||
title="上传参考图(可多选)"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
{doneUrls.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--accent)]
|
||||
text-[var(--bg-primary)] text-[9px] flex items-center justify-center font-bold">
|
||||
{doneUrls.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* 文字输入框 */}
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="描述你想要的美术资源...(可粘贴图片)"
|
||||
title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图"
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="flex-1 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}
|
||||
/>
|
||||
|
||||
{/* 发送按钮 */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !text.trim() || hasActiveUpload}
|
||||
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)]
|
||||
flex items-center justify-center transition-all
|
||||
disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<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>
|
||||
{/* 涟漪层 */}
|
||||
<RippleLayer ripples={ripples} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
{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">
|
||||
{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)]" />
|
||||
{statusText}
|
||||
</div>
|
||||
)}
|
||||
{streamingText && (
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed">{streamingText}</div>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* AI 正在回复(流式) */}
|
||||
<AnimatePresence>
|
||||
{isLoading && (
|
||||
<motion.div
|
||||
key="streaming"
|
||||
variants={assistantEnter}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
className="flex justify-start"
|
||||
>
|
||||
<div className="max-w-[90%] md:max-w-[80%] rounded-2xl px-4 md:px-5 py-3 surface-2 border-l-2 border-l-[var(--accent)]/40">
|
||||
{statusText && (
|
||||
<div className="text-xs text-[var(--accent)] mb-2 flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
|
||||
shadow-[0_0_8px_rgba(46,139,122,0.4)]" />
|
||||
{statusText}
|
||||
</div>
|
||||
)}
|
||||
{streamingText && (
|
||||
<StreamingLines text={streamingText} />
|
||||
)}
|
||||
{streamingImages.length > 0 && <ImageGrid images={streamingImages} />}
|
||||
{!streamingText && !statusText && (
|
||||
<div className="flex items-end gap-1 h-5">
|
||||
<div className="wave-dot" style={{ animationDelay: "0s" }} />
|
||||
<div className="wave-dot" style={{ animationDelay: "0.15s" }} />
|
||||
<div className="wave-dot" style={{ animationDelay: "0.3s" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,21 +63,29 @@ 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"
|
||||
>
|
||||
<img
|
||||
src={getImageUrl(asset.url)}
|
||||
alt={`生成图片 ${i + 1}`}
|
||||
className="w-full aspect-square object-cover cursor-pointer
|
||||
transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
onClick={() => setDetailImage(asset)}
|
||||
/>
|
||||
<ImageReveal src={getImageUrl(asset.url)} alt={`生成图片 ${i + 1}`}>
|
||||
<img
|
||||
src={getImageUrl(asset.url)}
|
||||
alt={`生成图片 ${i + 1}`}
|
||||
className="w-full aspect-square object-cover cursor-pointer
|
||||
transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
onClick={() => setDetailImage(asset)}
|
||||
/>
|
||||
</ImageReveal>
|
||||
|
||||
{/* 底部渐变 + 操作栏 */}
|
||||
<div
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,49 +81,56 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-1.5 w-56 rounded-xl
|
||||
glass-panel shadow-xl shadow-black/40 overflow-hidden z-50"
|
||||
>
|
||||
{models.map((m) => {
|
||||
const isActive = m.id === value;
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => {
|
||||
onChange(m.id);
|
||||
localStorage.setItem(getModelStorageKey(), m.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3.5 py-2.5 flex flex-col gap-0.5
|
||||
transition-all cursor-pointer
|
||||
${isActive
|
||||
? "bg-[var(--accent)]/8 text-[var(--accent)]"
|
||||
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium flex items-center gap-1.5">
|
||||
{m.name}
|
||||
{m.supports_ref_image && (
|
||||
<span className="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>
|
||||
)}
|
||||
{isActive && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-secondary)]">{m.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
|
||||
className="absolute bottom-full left-0 mb-1.5 w-56 rounded-xl
|
||||
surface-3 overflow-hidden z-50"
|
||||
>
|
||||
{models.map((m) => {
|
||||
const isActive = m.id === value;
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => {
|
||||
onChange(m.id);
|
||||
localStorage.setItem(getModelStorageKey(), m.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3.5 py-2.5 flex flex-col gap-0.5
|
||||
transition-all cursor-pointer
|
||||
${isActive
|
||||
? "bg-[var(--accent)]/8 text-[var(--accent)]"
|
||||
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium flex items-center gap-1.5">
|
||||
{m.name}
|
||||
{m.supports_ref_image && (
|
||||
<span className="typo-micro px-1.5 py-0.5 rounded-full
|
||||
bg-[var(--accent)]/10 text-[var(--accent)] leading-none
|
||||
border border-[var(--accent)]/20"
|
||||
style={{ textTransform: "none", fontSize: "10px" }}>
|
||||
参考图
|
||||
</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className="typo-caption">{m.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
257
art-agent/frontend/src/components/chat/session-quick-picks.tsx
Normal file
257
art-agent/frontend/src/components/chat/session-quick-picks.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
|
||||
const ASSET_SPECS = [
|
||||
"1024×1024 · 通用",
|
||||
"768×1024 · 竖版卡牌",
|
||||
"1536×1024 · 横版横幅",
|
||||
"512×512 · Icon",
|
||||
"透明背景 PNG",
|
||||
];
|
||||
|
||||
interface PickerButtonProps {
|
||||
label: string;
|
||||
value?: string | null;
|
||||
placeholder: string;
|
||||
phaseBadge?: string;
|
||||
onClear?: () => void;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
function PickerButton({ label, value, placeholder, phaseBadge, onClear, onClick, icon }: PickerButtonProps) {
|
||||
const active = !!value;
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`h-7 pl-2 pr-2.5 rounded-lg text-xs flex items-center gap-1.5 cursor-pointer transition-all whitespace-nowrap ${
|
||||
active
|
||||
? "bg-[var(--accent)]/10 border border-[var(--accent)]/30 text-[var(--accent)]"
|
||||
: "border border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:border-[var(--accent)]/40 hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
title={`${label}:${value || placeholder}`}
|
||||
>
|
||||
{icon}
|
||||
<span className="typo-micro text-[10px]" style={{ textTransform: "none" }}>{label}</span>
|
||||
<span className="text-[11px] font-medium max-w-[120px] truncate">
|
||||
{value || placeholder}
|
||||
</span>
|
||||
{phaseBadge && (
|
||||
<span className="phase-chip" style={{ marginLeft: 2 }}>{phaseBadge}</span>
|
||||
)}
|
||||
</button>
|
||||
{active && onClear && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClear();
|
||||
}}
|
||||
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
text-[var(--text-secondary)] text-[10px] flex items-center justify-center
|
||||
opacity-0 group-hover:opacity-100 hover:text-[var(--hot)] hover:border-[var(--hot)]/40
|
||||
transition-opacity cursor-pointer"
|
||||
title="清除"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionQuickPicks() {
|
||||
const {
|
||||
stylePacks, characters,
|
||||
sessionStyleId, setSessionStyleId,
|
||||
sessionCharacterId, setSessionCharacterId,
|
||||
sessionAssetSpec, setSessionAssetSpec,
|
||||
} = useApp();
|
||||
|
||||
const [openMenu, setOpenMenu] = useState<null | "style" | "character" | "spec">(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMenu) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setOpenMenu(null);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [openMenu]);
|
||||
|
||||
const styleName = stylePacks.find((s) => s.id === sessionStyleId)?.name;
|
||||
const charName = characters.find((c) => c.id === sessionCharacterId)?.name;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative px-3 md:px-5 pt-2 pb-1 flex items-center gap-1.5 flex-wrap">
|
||||
<PickerButton
|
||||
label="风格"
|
||||
value={styleName}
|
||||
placeholder="选择风格"
|
||||
phaseBadge="P1"
|
||||
icon={
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<circle cx="12" cy="12" r="9" strokeDasharray="2 3" />
|
||||
</svg>
|
||||
}
|
||||
onClear={() => setSessionStyleId(null)}
|
||||
onClick={() => setOpenMenu(openMenu === "style" ? null : "style")}
|
||||
/>
|
||||
|
||||
<PickerButton
|
||||
label="角色"
|
||||
value={charName}
|
||||
placeholder="选择角色"
|
||||
phaseBadge="P1"
|
||||
icon={
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21a8 8 0 0 1 16 0" />
|
||||
</svg>
|
||||
}
|
||||
onClear={() => setSessionCharacterId(null)}
|
||||
onClick={() => setOpenMenu(openMenu === "character" ? null : "character")}
|
||||
/>
|
||||
|
||||
<PickerButton
|
||||
label="规格"
|
||||
value={sessionAssetSpec ?? undefined}
|
||||
placeholder="输出规格"
|
||||
icon={
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<path d="M4 12h16M12 4v16" />
|
||||
</svg>
|
||||
}
|
||||
onClear={() => setSessionAssetSpec(null)}
|
||||
onClick={() => setOpenMenu(openMenu === "spec" ? null : "spec")}
|
||||
/>
|
||||
|
||||
<span className="text-[10px] text-[var(--text-secondary)]/60 ml-auto hidden md:inline">
|
||||
快选占位 · 将作为提示词前缀参与生成(接入待 Phase 1)
|
||||
</span>
|
||||
|
||||
{/* 下拉菜单(向上展开,避免被下方输入栏遮挡) */}
|
||||
{openMenu && (
|
||||
<div
|
||||
className="absolute left-3 md:left-5 bottom-[calc(100%+4px)] z-50 w-80 max-w-[calc(100vw-24px)]
|
||||
rounded-xl surface-2 border border-[var(--border)] shadow-xl
|
||||
overflow-hidden backdrop-blur-xl"
|
||||
>
|
||||
{openMenu === "style" && (
|
||||
<>
|
||||
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
|
||||
<span className="typo-micro" style={{ textTransform: "none" }}>选择风格集</span>
|
||||
<Link href="/styles" className="text-[10px] text-[var(--accent)] hover:underline">
|
||||
管理 →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{stylePacks.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-[var(--text-secondary)]">
|
||||
暂无风格,
|
||||
<Link href="/styles/new" className="text-[var(--accent)] hover:underline">
|
||||
新建一个
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
stylePacks.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => { setSessionStyleId(s.id); setOpenMenu(null); }}
|
||||
className={`w-full px-3 py-2 text-left text-xs flex items-center gap-2 cursor-pointer transition-colors ${
|
||||
sessionStyleId === s.id
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)]"
|
||||
: "hover:bg-[var(--accent)]/5 text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-md bg-[var(--bg-tertiary)] flex-shrink-0 placeholder-card" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{s.name}</div>
|
||||
<div className="truncate text-[10px] text-[var(--text-secondary)]">
|
||||
{s.keywords.slice(0, 3).join(" · ") || "无关键词"}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{openMenu === "character" && (
|
||||
<>
|
||||
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
|
||||
<span className="typo-micro" style={{ textTransform: "none" }}>选择角色</span>
|
||||
<Link href="/characters" className="text-[10px] text-[var(--accent)] hover:underline">
|
||||
管理 →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{characters.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-[var(--text-secondary)]">
|
||||
暂无角色,
|
||||
<Link href="/characters/new" className="text-[var(--accent)] hover:underline">
|
||||
新建一个
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
characters.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => { setSessionCharacterId(c.id); setOpenMenu(null); }}
|
||||
className={`w-full px-3 py-2 text-left text-xs flex items-center gap-2 cursor-pointer transition-colors ${
|
||||
sessionCharacterId === c.id
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)]"
|
||||
: "hover:bg-[var(--accent)]/5 text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--bg-tertiary)] flex-shrink-0 placeholder-card" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{c.name}</div>
|
||||
<div className="truncate text-[10px] text-[var(--text-secondary)]">
|
||||
{c.visualHooks.slice(0, 3).join(" · ") || "无视觉锚点"}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{openMenu === "spec" && (
|
||||
<>
|
||||
<div className="px-3 py-2 border-b border-[var(--border)]">
|
||||
<span className="typo-micro" style={{ textTransform: "none" }}>输出规格(占位)</span>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{ASSET_SPECS.map((spec) => (
|
||||
<button
|
||||
key={spec}
|
||||
onClick={() => { setSessionAssetSpec(spec); setOpenMenu(null); }}
|
||||
className={`w-full px-3 py-2 text-left text-xs cursor-pointer transition-colors ${
|
||||
sessionAssetSpec === spec
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)]"
|
||||
: "hover:bg-[var(--accent)]/5 text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{spec}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-3 py-2 border-t border-[var(--border)] text-[10px] text-[var(--text-secondary)]">
|
||||
规格将在 Phase 1 接入真实参数。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
art-agent/frontend/src/components/chat/task-card.tsx
Normal file
108
art-agent/frontend/src/components/chat/task-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
165
art-agent/frontend/src/components/layout/project-switcher.tsx
Normal file
165
art-agent/frontend/src/components/layout/project-switcher.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { slideUp } from "@/components/ui/motion-presets";
|
||||
|
||||
export function ProjectSwitcher() {
|
||||
const { projects, activeProject, activeProjectId, switchProject, upsertProject } = useApp();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newType, setNewType] = useState("2D RPG");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handler(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!newName.trim()) return;
|
||||
const now = Date.now();
|
||||
upsertProject({
|
||||
id: `proj-${now.toString(36)}`,
|
||||
name: newName.trim(),
|
||||
type: newType,
|
||||
description: "",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
setNewName("");
|
||||
setCreating(false);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
if (!activeProject) return null;
|
||||
|
||||
return (
|
||||
<div className="relative hidden sm:block" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 text-xs rounded-lg
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]/60
|
||||
text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
||||
transition-all cursor-pointer"
|
||||
title="切换项目(Phase 1)"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-7l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span className="max-w-[100px] md:max-w-[160px] truncate font-medium">
|
||||
{activeProject.name}
|
||||
</span>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
|
||||
className="absolute left-0 top-full mt-1.5 w-72 rounded-xl surface-3 z-50 overflow-hidden"
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
|
||||
<span className="typo-micro" style={{ textTransform: "none" }}>
|
||||
项目(Phase 1 · 占位)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCreating(!creating)}
|
||||
className="text-[10px] text-[var(--accent)] hover:underline cursor-pointer"
|
||||
>
|
||||
{creating ? "取消" : "+ 新建"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div className="p-2.5 border-b border-[var(--border)] space-y-1.5">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="项目名称"
|
||||
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
rounded-md px-2 py-1.5 focus:outline-none focus:border-[var(--accent)]/50"
|
||||
/>
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value)}
|
||||
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
rounded-md px-2 py-1.5 focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option>2D RPG</option>
|
||||
<option>横版动作</option>
|
||||
<option>卡牌</option>
|
||||
<option>放置类</option>
|
||||
<option>其他</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!newName.trim()}
|
||||
className="w-full px-2 py-1.5 text-xs rounded-md bg-[var(--accent)]
|
||||
text-[var(--bg-primary)] font-medium cursor-pointer
|
||||
disabled:opacity-50"
|
||||
>
|
||||
创建(占位)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-64 overflow-y-auto py-1">
|
||||
{projects.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
switchProject(p.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm cursor-pointer
|
||||
hover:bg-[var(--bg-tertiary)] transition-colors flex items-center gap-2 ${
|
||||
p.id === activeProjectId ? "bg-[var(--accent)]/8" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`w-6 h-6 rounded-md flex-shrink-0 flex items-center justify-center text-[10px] font-bold ${
|
||||
p.id === activeProjectId
|
||||
? "bg-[var(--accent)] text-[var(--bg-primary)]"
|
||||
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]"
|
||||
}`}>
|
||||
{p.name.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-xs truncate ${
|
||||
p.id === activeProjectId ? "text-[var(--accent)] font-medium" : "text-[var(--text-primary)]"
|
||||
}`}>
|
||||
{p.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] truncate">
|
||||
{p.type}
|
||||
</div>
|
||||
</div>
|
||||
{p.id === activeProjectId && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" className="text-[var(--accent)]">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,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>
|
||||
{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"
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<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"
|
||||
>
|
||||
<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>}
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<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 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(--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>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
104
art-agent/frontend/src/components/sidebar/project-card.tsx
Normal file
104
art-agent/frontend/src/components/sidebar/project-card.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
|
||||
/**
|
||||
* 侧边栏项目信息卡(Phase 1)。
|
||||
*
|
||||
* 展示当前项目名、绑定的默认风格缩略图(占位),以及快捷入口:
|
||||
* 风格库 / 角色库 / 项目设置。数据来自 placeholder-store 的占位数据。
|
||||
*/
|
||||
export function ProjectCard() {
|
||||
const { activeProject, stylePacks } = useApp();
|
||||
|
||||
if (!activeProject) return null;
|
||||
|
||||
const defaultStyle = activeProject.defaultStyleId
|
||||
? stylePacks.find((s) => s.id === activeProject.defaultStyleId)
|
||||
: stylePacks[0];
|
||||
|
||||
return (
|
||||
<div className="mx-3 mb-2 rounded-xl placeholder-card p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="typo-micro" style={{ textTransform: "none" }}>
|
||||
当前项目
|
||||
</span>
|
||||
<span className="phase-chip">P1</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 mb-2.5">
|
||||
<div className="w-10 h-10 rounded-lg flex-shrink-0 flex items-center justify-center
|
||||
bg-gradient-to-br from-[var(--accent)]/15 to-[var(--accent-secondary)]/15
|
||||
border border-[var(--border-glow)]">
|
||||
{defaultStyle ? (
|
||||
<span className="text-[10px] text-[var(--accent)] font-semibold tracking-tight px-1 text-center">
|
||||
{defaultStyle.name.slice(0, 2)}
|
||||
</span>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.8">
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-7l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-[var(--text-primary)] font-medium truncate">
|
||||
{activeProject.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] truncate">
|
||||
{activeProject.type}
|
||||
{defaultStyle && <> · {defaultStyle.name}</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<Link
|
||||
href="/styles"
|
||||
className="flex flex-col items-center justify-center gap-1 py-1.5 rounded-md
|
||||
bg-[var(--bg-tertiary)]/60 text-[10px] text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] hover:bg-[var(--accent)]/8
|
||||
cursor-pointer transition-colors"
|
||||
title="风格库"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="13.5" cy="6.5" r=".5" />
|
||||
<circle cx="17.5" cy="10.5" r=".5" />
|
||||
<circle cx="8.5" cy="7.5" r=".5" />
|
||||
<circle cx="6.5" cy="12.5" r=".5" />
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.55 0 1-.45 1-1v-3.5c0-.55.45-1 1-1h2c2.76 0 5-2.24 5-5 0-5.5-4.5-9.5-10-9.5z" />
|
||||
</svg>
|
||||
风格
|
||||
</Link>
|
||||
<Link
|
||||
href="/characters"
|
||||
className="flex flex-col items-center justify-center gap-1 py-1.5 rounded-md
|
||||
bg-[var(--bg-tertiary)]/60 text-[10px] text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] hover:bg-[var(--accent)]/8
|
||||
cursor-pointer transition-colors"
|
||||
title="角色库"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
角色
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="flex flex-col items-center justify-center gap-1 py-1.5 rounded-md
|
||||
bg-[var(--bg-tertiary)]/60 text-[10px] text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] hover:bg-[var(--accent)]/8
|
||||
cursor-pointer transition-colors"
|
||||
title="项目设置"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
|
||||
</svg>
|
||||
设置
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
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,95 +218,100 @@ export function SessionList() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 下拉菜单:在会话项正下方展开 */}
|
||||
{menuSessionId === session.id && menuSession && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="mx-1 mt-1 glass-panel rounded-xl shadow-xl shadow-black/30 py-1.5
|
||||
overflow-hidden z-10 relative"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 模型选择(折叠/展开式) */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModelSub(!showModelSub);
|
||||
}}
|
||||
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 justify-between"
|
||||
{/* 下拉菜单 */}
|
||||
<AnimatePresence>
|
||||
{menuSessionId === session.id && menuSession && (
|
||||
<motion.div
|
||||
ref={menuRef}
|
||||
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()}
|
||||
>
|
||||
<span>对话模型</span>
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2"
|
||||
className={`transition-transform ${showModelSub ? "rotate-90" : ""}`}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModelSub(!showModelSub);
|
||||
}}
|
||||
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 justify-between"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<span>对话模型</span>
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2"
|
||||
className={`transition-transform ${showModelSub ? "rotate-90" : ""}`}
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showModelSub && (
|
||||
<div className="border-t border-[var(--border)] mx-2 mt-1 pt-1 max-h-[240px] overflow-y-auto">
|
||||
{llmModels.map((model) => {
|
||||
const isActive = menuSession.llmModel
|
||||
? menuSession.llmModel === model.id
|
||||
: model.id === defaultLlmModel;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelectModel(menuSessionId!, model.id);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm cursor-pointer transition-colors
|
||||
rounded-lg flex items-center gap-2 ${
|
||||
isActive
|
||||
? "text-[var(--accent)] bg-[var(--accent)]/5"
|
||||
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 flex-shrink-0 text-center">
|
||||
{isActive && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{model.name}</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] truncate">{model.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{showModelSub && (
|
||||
<div className="border-t border-[var(--border)] mx-2 mt-1 pt-1 max-h-[240px] overflow-y-auto">
|
||||
{llmModels.map((model) => {
|
||||
const isActive = menuSession.llmModel
|
||||
? menuSession.llmModel === model.id
|
||||
: model.id === defaultLlmModel;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelectModel(menuSessionId!, model.id);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm cursor-pointer transition-colors
|
||||
rounded-lg flex items-center gap-2 ${
|
||||
isActive
|
||||
? "text-[var(--accent)] bg-[var(--accent)]/5"
|
||||
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 flex-shrink-0 text-center">
|
||||
{isActive && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{model.name}</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] truncate">{model.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-2 my-1 border-t border-[var(--border)]" />
|
||||
<div className="mx-2 my-1 border-t border-[var(--border)]" />
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (menuSession) startRename(menuSession.id, menuSession.title);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
重命名
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (menuSession) startRename(menuSession.id, menuSession.title);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
重命名
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteSession(menuSessionId!);
|
||||
closeMenu();
|
||||
}}
|
||||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--hot)]
|
||||
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteSession(menuSessionId!);
|
||||
closeMenu();
|
||||
}}
|
||||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--hot)]
|
||||
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
78
art-agent/frontend/src/components/ui/ambient-particles.tsx
Normal file
78
art-agent/frontend/src/components/ui/ambient-particles.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface Particle {
|
||||
id: number;
|
||||
left: string;
|
||||
top: string;
|
||||
size: number;
|
||||
delay: string;
|
||||
duration: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
function generateParticles(count: number): Particle[] {
|
||||
const particles: Particle[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
particles.push({
|
||||
id: i,
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: `${Math.random() * 100}%`,
|
||||
size: 2 + Math.random() * 3,
|
||||
delay: `${Math.random() * 20}s`,
|
||||
duration: `${15 + Math.random() * 25}s`,
|
||||
opacity: 0.12 + Math.random() * 0.18,
|
||||
});
|
||||
}
|
||||
return particles;
|
||||
}
|
||||
|
||||
interface AmbientParticlesProps {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export function AmbientParticles({ count = 18 }: AmbientParticlesProps) {
|
||||
const particles = useMemo(() => generateParticles(count), [count]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none z-0 overflow-hidden" aria-hidden="true">
|
||||
{/* 远景雾 */}
|
||||
<div className="fog-layer-far" />
|
||||
{/* 近景雾 */}
|
||||
<div className="fog-layer-near" />
|
||||
{/* 萤火粒子 */}
|
||||
{particles.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
left: p.left,
|
||||
top: p.top,
|
||||
width: p.size,
|
||||
height: p.size,
|
||||
backgroundColor: "var(--accent)",
|
||||
opacity: p.opacity,
|
||||
animation: `particleDrift ${p.duration} ease-in-out infinite, particleFlicker 4s ease-in-out infinite`,
|
||||
animationDelay: `${p.delay}, ${parseFloat(p.delay) + 1}s`,
|
||||
boxShadow: `0 0 ${p.size * 3}px rgba(46, 139, 122, ${p.opacity * 0.6})`,
|
||||
willChange: "transform, opacity",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<style>{`
|
||||
@keyframes particleDrift {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(${20 + Math.random() * 30}px, -${15 + Math.random() * 20}px); }
|
||||
50% { transform: translate(-${10 + Math.random() * 20}px, ${10 + Math.random() * 25}px); }
|
||||
75% { transform: translate(${15 + Math.random() * 20}px, ${5 + Math.random() * 15}px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
@keyframes particleFlicker {
|
||||
0%, 100% { opacity: var(--particle-base-opacity, 0.15); }
|
||||
50% { opacity: calc(var(--particle-base-opacity, 0.15) * 2.2); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
art-agent/frontend/src/components/ui/data-stream.tsx
Normal file
20
art-agent/frontend/src/components/ui/data-stream.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
interface DataStreamProps {
|
||||
active: boolean;
|
||||
speed?: "slow" | "normal" | "fast";
|
||||
}
|
||||
|
||||
export function DataStream({ active, speed = "normal" }: DataStreamProps) {
|
||||
if (!active) return null;
|
||||
|
||||
const speedClass =
|
||||
speed === "slow" ? "data-stream-slow" :
|
||||
speed === "fast" ? "data-stream-fast" : "";
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-hidden" aria-hidden="true">
|
||||
<div className={`data-stream ${speedClass}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
art-agent/frontend/src/components/ui/firefly-burst.tsx
Normal file
62
art-agent/frontend/src/components/ui/firefly-burst.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { sparkleAnim } from "./motion-presets";
|
||||
|
||||
interface Spark {
|
||||
id: number;
|
||||
angle: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
let _sparkId = 0;
|
||||
|
||||
export function useFireflyBurst() {
|
||||
const [sparks, setSparks] = useState<Spark[]>([]);
|
||||
|
||||
const burst = useCallback(() => {
|
||||
const count = 5 + Math.floor(Math.random() * 3);
|
||||
const newSparks: Spark[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
newSparks.push({
|
||||
id: ++_sparkId,
|
||||
angle: (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.5,
|
||||
distance: 12 + Math.random() * 18,
|
||||
});
|
||||
}
|
||||
setSparks(newSparks);
|
||||
setTimeout(() => setSparks([]), 1000);
|
||||
}, []);
|
||||
|
||||
return { sparks, burst };
|
||||
}
|
||||
|
||||
interface FireflyBurstProps {
|
||||
sparks: Spark[];
|
||||
}
|
||||
|
||||
export function FireflyBurst({ sparks }: FireflyBurstProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{sparks.map((s) => (
|
||||
<motion.div
|
||||
key={s.id}
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--gold)",
|
||||
boxShadow: "0 0 6px var(--gold)",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
marginLeft: -2,
|
||||
marginTop: -2,
|
||||
}}
|
||||
animate={sparkleAnim(s.angle, s.distance)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
113
art-agent/frontend/src/components/ui/motion-presets.ts
Normal file
113
art-agent/frontend/src/components/ui/motion-presets.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { Transition, Variants } from "framer-motion";
|
||||
|
||||
/* ===== Spring 物理参数 ===== */
|
||||
export const spring: Transition = { type: "spring", stiffness: 400, damping: 25 };
|
||||
export const springGentle: Transition = { type: "spring", stiffness: 200, damping: 20 };
|
||||
|
||||
/* ===== 通用入场变体 ===== */
|
||||
export const fadeIn: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { duration: 0.2 } },
|
||||
};
|
||||
|
||||
export const slideUp: Variants = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.2, ease: "easeOut" } },
|
||||
};
|
||||
|
||||
export const mistReveal: Variants = {
|
||||
hidden: { opacity: 0, filter: "blur(8px)" },
|
||||
visible: { opacity: 1, filter: "blur(0px)", transition: { duration: 0.4, ease: "easeOut" } },
|
||||
};
|
||||
|
||||
/* ===== 消息入场 ===== */
|
||||
export const messageEnter: Variants = {
|
||||
hidden: { opacity: 0, y: 12, scale: 0.98 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0.25, ease: "easeOut" },
|
||||
},
|
||||
};
|
||||
|
||||
export const assistantEnter: Variants = {
|
||||
hidden: { opacity: 0, filter: "blur(8px)", y: 6 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
y: 0,
|
||||
transition: { duration: 0.4, ease: "easeOut" },
|
||||
},
|
||||
};
|
||||
|
||||
/* ===== 按钮反馈 ===== */
|
||||
export const buttonTap = { scale: 0.85 };
|
||||
export const sendBounce = {
|
||||
scale: [1, 0.85, 1.08, 1],
|
||||
transition: { duration: 0.35, ease: "easeInOut" },
|
||||
};
|
||||
|
||||
/* ===== 涟漪 ===== */
|
||||
export const rippleAnim = {
|
||||
scale: [0, 2.5],
|
||||
opacity: [0.4, 0],
|
||||
transition: { duration: 0.6, ease: "easeOut" as const },
|
||||
};
|
||||
|
||||
/* ===== 萤火粒子 ===== */
|
||||
export const sparkleAnim = (angle: number, distance: number) => ({
|
||||
x: [0, Math.cos(angle) * distance],
|
||||
y: [0, Math.sin(angle) * distance],
|
||||
scale: [0, 1.2, 0],
|
||||
opacity: [0, 1, 0],
|
||||
transition: { duration: 0.7 + Math.random() * 0.3, ease: "easeOut" as const },
|
||||
});
|
||||
|
||||
/* ===== 滚动到底部按钮 — 落叶归根 ===== */
|
||||
export const scrollBtnEnter: Variants = {
|
||||
hidden: { opacity: 0, y: 20, scale: 0.8 },
|
||||
visible: { opacity: 1, y: 0, scale: 1, transition: { ...springGentle } },
|
||||
exit: { opacity: 0, y: 10, scale: 0.8, transition: { duration: 0.15 } },
|
||||
};
|
||||
export const scrollBtnClick = {
|
||||
y: [-0, -6, 20],
|
||||
opacity: [1, 1, 0],
|
||||
scale: [1, 1.1, 0.8],
|
||||
transition: { duration: 0.35, ease: "easeIn" as const },
|
||||
};
|
||||
|
||||
/* ===== 会话切换 — 翻山越岭 ===== */
|
||||
export const sessionSlide: Variants = {
|
||||
enter: (dir: number) => ({ opacity: 0, y: dir > 0 ? 30 : -30 }),
|
||||
center: { opacity: 1, y: 0, transition: { duration: 0.25, ease: "easeOut" } },
|
||||
exit: (dir: number) => ({ opacity: 0, y: dir > 0 ? -30 : 30, transition: { duration: 0.2, ease: "easeIn" } }),
|
||||
};
|
||||
|
||||
/* ===== Error 飘散消失 ===== */
|
||||
export const errorFloat: Variants = {
|
||||
visible: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -12, transition: { duration: 0.5, ease: "easeOut" } },
|
||||
};
|
||||
|
||||
/* ===== stagger 容器 ===== */
|
||||
export const staggerContainer: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: { staggerChildren: 0.06 },
|
||||
},
|
||||
};
|
||||
|
||||
export const staggerItem: Variants = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.2 } },
|
||||
};
|
||||
|
||||
/* ===== 水墨揭示 ===== */
|
||||
export const inkReveal: Variants = {
|
||||
hidden: { clipPath: "circle(0% at 50% 50%)" },
|
||||
visible: {
|
||||
clipPath: "circle(75% at 50% 50%)",
|
||||
transition: { duration: 0.6, ease: "easeOut" },
|
||||
},
|
||||
};
|
||||
53
art-agent/frontend/src/components/ui/ripple-effect.tsx
Normal file
53
art-agent/frontend/src/components/ui/ripple-effect.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { rippleAnim } from "./motion-presets";
|
||||
|
||||
interface Ripple {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
let _rippleId = 0;
|
||||
|
||||
export function useRipple() {
|
||||
const [ripples, setRipples] = useState<Ripple[]>([]);
|
||||
|
||||
const trigger = useCallback((x: number, y: number) => {
|
||||
const id = ++_rippleId;
|
||||
setRipples((prev) => [...prev, { id, x, y }]);
|
||||
setTimeout(() => {
|
||||
setRipples((prev) => prev.filter((r) => r.id !== id));
|
||||
}, 700);
|
||||
}, []);
|
||||
|
||||
return { ripples, trigger };
|
||||
}
|
||||
|
||||
interface RippleLayerProps {
|
||||
ripples: Ripple[];
|
||||
}
|
||||
|
||||
export function RippleLayer({ ripples }: RippleLayerProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{ripples.map((r) => (
|
||||
<motion.div
|
||||
key={r.id}
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: r.x - 10,
|
||||
top: r.y - 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: "50%",
|
||||
border: "2px solid var(--accent)",
|
||||
}}
|
||||
animate={rippleAnim}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
/**
|
||||
* 高级控制面板(Phase 2 占位)
|
||||
*
|
||||
* 对应 INFOLAYER.md 3.3 节「高级控制」:提供给高级用户的参数调节抽屉。
|
||||
* 当前所有参数均为占位,不会真实下发到后端。
|
||||
*/
|
||||
export function AdvancedControls() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [cfg, setCfg] = useState(7.5);
|
||||
const [steps, setSteps] = useState(28);
|
||||
const [sampler, setSampler] = useState("DPM++ 2M Karras");
|
||||
const [seed, setSeed] = useState("");
|
||||
const [styleWeight, setStyleWeight] = useState(0.8);
|
||||
const [charWeight, setCharWeight] = useState(0.85);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="h-7 px-2 rounded-lg text-xs flex items-center gap-1.5
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
|
||||
title="高级控制(占位)"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
高级
|
||||
<span className="phase-chip">P2</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
className="absolute bottom-full mb-2 right-0 w-80 max-w-[calc(100vw-24px)]
|
||||
rounded-xl surface-2 border border-[var(--border)] shadow-xl
|
||||
backdrop-blur-xl z-40 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="typo-strong text-xs">高级控制(占位)</span>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Slider label="CFG Scale" value={cfg} min={1} max={20} step={0.5} onChange={setCfg} />
|
||||
<Slider label="Steps" value={steps} min={10} max={60} step={1} onChange={setSteps} />
|
||||
<Slider label="风格强度" value={styleWeight} min={0} max={1.5} step={0.05} onChange={setStyleWeight} />
|
||||
<Slider label="角色一致性" value={charWeight} min={0} max={1.5} step={0.05} onChange={setCharWeight} />
|
||||
|
||||
<div>
|
||||
<label className="block typo-micro mb-1" style={{ textTransform: "none" }}>采样器</label>
|
||||
<select
|
||||
value={sampler}
|
||||
onChange={(e) => setSampler(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-xs rounded-md bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||
>
|
||||
{["DPM++ 2M Karras", "Euler a", "DDIM", "UniPC"].map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block typo-micro mb-1" style={{ textTransform: "none" }}>Seed</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
value={seed}
|
||||
onChange={(e) => setSeed(e.target.value)}
|
||||
placeholder="留空为随机"
|
||||
className="flex-1 px-2 py-1.5 text-xs rounded-md bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setSeed(Math.floor(Math.random() * 9_999_999).toString())}
|
||||
className="px-2 py-1.5 text-xs rounded-md border border-[var(--border)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
cursor-pointer"
|
||||
>
|
||||
🎲
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-[var(--border)] text-[10px] text-[var(--text-secondary)]">
|
||||
参数为占位,真实生效链路待 Phase 2 交付。
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Slider({
|
||||
label, value, min, max, step, onChange,
|
||||
}: { label: string; value: number; min: number; max: number; step: number; onChange: (v: number) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="typo-micro" style={{ textTransform: "none" }}>{label}</label>
|
||||
<span className="text-[11px] text-[var(--accent)] font-mono">{value}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||
className="w-full accent-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
art-agent/frontend/src/components/workbench/candidate-panel.tsx
Normal file
110
art-agent/frontend/src/components/workbench/candidate-panel.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ImageAsset } from "@/lib/types";
|
||||
import { getImageUrl } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* 候选评估面板(Phase 1 占位)
|
||||
*
|
||||
* 对应 INFOLAYER.md 3.3.3 节:同轮生成的一组候选图的并排比较、
|
||||
* 快速评分、标注差异、挑选定稿。
|
||||
*/
|
||||
interface CandidatePanelProps {
|
||||
candidates: ImageAsset[];
|
||||
onPick?: (id: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function CandidatePanel({ candidates, onPick, onClose }: CandidatePanelProps) {
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-y-0 right-0 w-[min(680px,100vw)] z-40
|
||||
bg-[var(--bg-secondary)]/95 backdrop-blur-xl
|
||||
border-l border-[var(--border)] shadow-2xl
|
||||
flex flex-col animate-in slide-in-from-right">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="typo-h2">候选评估</h2>
|
||||
<span className="phase-chip">Phase 1</span>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
共 {candidates.length} 张
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-all"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{candidates.map((c, i) => (
|
||||
<div key={c.id} className="rounded-xl surface-2 overflow-hidden">
|
||||
<div className="relative aspect-square bg-[var(--bg-tertiary)]">
|
||||
{c.url ? (
|
||||
<img src={getImageUrl(c.url)} alt={`候选 ${i + 1}`} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
||||
占位候选 {i + 1}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-2 left-2 px-2 py-0.5 rounded-md
|
||||
bg-[var(--bg-primary)]/70 backdrop-blur-sm
|
||||
text-[10px] font-mono text-[var(--text-primary)]">
|
||||
#{i + 1}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2.5">
|
||||
<div className="flex gap-0.5 mb-1.5">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() =>
|
||||
setRatings((prev) => ({ ...prev, [c.id]: prev[c.id] === n ? 0 : n }))
|
||||
}
|
||||
className={`text-base leading-none cursor-pointer transition-colors ${
|
||||
(ratings[c.id] ?? 0) >= n ? "text-[var(--accent)]" : "text-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
value={notes[c.id] ?? ""}
|
||||
onChange={(e) => setNotes((prev) => ({ ...prev, [c.id]: e.target.value }))}
|
||||
placeholder="点评(占位)"
|
||||
className="w-full px-2 py-1 text-[11px] rounded-md bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => onPick?.(c.id)}
|
||||
className="mt-1.5 w-full text-[11px] py-1 rounded-md
|
||||
bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/30
|
||||
hover:bg-[var(--accent)]/20 cursor-pointer transition-all"
|
||||
>
|
||||
选为定稿
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="placeholder-card rounded-lg p-3 text-xs text-[var(--text-secondary)]">
|
||||
<div className="typo-strong text-xs mb-1 text-[var(--text-primary)]">自动差异对比(占位)</div>
|
||||
构图 / 配色 / 细节的自动化差分尚未接入(Phase 2)。用户可在此给出评分与点评,
|
||||
Agent 将根据选中项做后续迭代。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
347
art-agent/frontend/src/lib/placeholder-store.ts
Normal file
347
art-agent/frontend/src/lib/placeholder-store.ts
Normal 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));
|
||||
}
|
||||
@@ -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 模型 ID(Phase 2 占位) */
|
||||
loraId?: string;
|
||||
/** Fork 来源的风格 ID(占位) */
|
||||
forkFromId?: string;
|
||||
/** 使用次数(占位) */
|
||||
usageCount: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** 角色参考组中的一张参考图(占位) */
|
||||
export interface CharacterRefImage {
|
||||
id: string;
|
||||
url: string;
|
||||
/** 视角分类:正面 / 半侧面 / 半身 / 表情 / 服饰细节 等 */
|
||||
angle: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
/** 角色卡(Phase 1) */
|
||||
export interface CharacterCard {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** 性别 */
|
||||
gender?: string;
|
||||
/** 年龄范围 */
|
||||
ageRange?: string;
|
||||
/** 体型 */
|
||||
body?: string;
|
||||
/** 性格关键词 */
|
||||
personality: string[];
|
||||
/** 背景故事摘要 */
|
||||
story?: string;
|
||||
/** 关键视觉要素(发色、瞳色、标志性饰品、武器等) */
|
||||
visualHooks: string[];
|
||||
/** 默认一致性级别 */
|
||||
consistency: "low" | "medium" | "high";
|
||||
/** 参考图组 */
|
||||
refImages: CharacterRefImage[];
|
||||
/** 绑定的 LoRA 模型 ID(Phase 2 占位) */
|
||||
loraId?: string;
|
||||
/** 所属项目 */
|
||||
projectId?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** 训练任务类型 */
|
||||
export type TrainingTaskType = "style_lora" | "character_lora";
|
||||
/** 训练任务状态 */
|
||||
export type TrainingTaskStatus = "queued" | "running" | "completed" | "failed" | "deprecated";
|
||||
|
||||
/** 训练任务(Phase 2) */
|
||||
export interface TrainingTask {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TrainingTaskType;
|
||||
status: TrainingTaskStatus;
|
||||
/** 0–100 */
|
||||
progress: number;
|
||||
/** 来源风格或角色 ID */
|
||||
sourceId?: string;
|
||||
/** 训练模板(如 sdxl_lora / flux_lora) */
|
||||
template: string;
|
||||
/** 训练集资产 ID 列表(占位) */
|
||||
trainingSet: string[];
|
||||
/** 训练日志片段(占位) */
|
||||
logs: string[];
|
||||
/** 当前 step 信息(占位) */
|
||||
currentStep?: string;
|
||||
/** 预估剩余时间(秒,占位) */
|
||||
etaSeconds?: number;
|
||||
/** 预估成本(占位) */
|
||||
estCost?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** 任务说明卡 IR(Phase 1,对应 3.3.2 节 Task Card) */
|
||||
export interface TaskIR {
|
||||
/** 任务类型,如「角色立绘(全身)」 */
|
||||
taskType: string;
|
||||
styleId?: string;
|
||||
characterId?: string;
|
||||
/** 输出规格,如「1024×1536 · 透明背景可选」 */
|
||||
outputSpec: string;
|
||||
/** 模板名,如 character_consistent_v1 */
|
||||
template: string;
|
||||
/** 候选数 */
|
||||
candidateCount: number;
|
||||
/** 用户原始指令,仅展示 */
|
||||
userPrompt?: string;
|
||||
}
|
||||
|
||||
/** 标注数据 */
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
|
||||
@@ -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
614
docs/art-agent/INFOLAYER.md
Normal file
@@ -0,0 +1,614 @@
|
||||
# EpeeKit — 前端信息层级与导航流程
|
||||
|
||||
> 本文档描述产品**最终形态**的前端信息架构。
|
||||
> 标注 `[Phase 0]` 的部分表示当前已实现或即将实现;
|
||||
> 标注 `[Phase 1]`、`[Phase 2]`、`[Phase 3]` 的部分对应方案文档的阶段路线。
|
||||
|
||||
---
|
||||
|
||||
## 1. 全局导航结构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ TopNav │
|
||||
│ ┌──────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │ Logo │ │ 工作台 │ │ 资源库 │ │ 风格库 │ │ 训练中心│ ... │
|
||||
│ └──────┘ └────────┘ └────────┘ └────────┘ └────────┘ │
|
||||
│ ┌──────────┐ │
|
||||
│ │ 用户菜单 │ │
|
||||
│ └──────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 顶部导航(TopNav)
|
||||
|
||||
| 元素 | 说明 |
|
||||
|------|------|
|
||||
| Logo / 品牌名 | 点击回到当前项目的工作台 |
|
||||
| **工作台** | 对话 + 任务 + 高级控制的主工作区域(`/`) |
|
||||
| **资源库** | 项目级资产浏览、搜索、管理(`/gallery`) |
|
||||
| **风格库** | 风格集 + 角色库的管理入口(`/styles`)`[Phase 1]` |
|
||||
| **训练中心** | LoRA 训练任务的管理入口(`/training`)`[Phase 2]` |
|
||||
| 项目切换器 | 当前项目名 + 下拉切换/新建项目 `[Phase 1]` |
|
||||
| 用户菜单 | 头像 → 个人信息、记忆查看、设置、退出 |
|
||||
|
||||
### 路由总览
|
||||
|
||||
| 路由 | 页面 | 阶段 |
|
||||
|------|------|------|
|
||||
| `/login` | 登录页 | Phase 0 |
|
||||
| `/` | 工作台(对话 + 任务 + 控制面板) | Phase 0 |
|
||||
| `/gallery` | 资源库 | Phase 0 |
|
||||
| `/styles` | 风格库 | Phase 1 |
|
||||
| `/styles/[id]` | 风格详情 / 编辑 | Phase 1 |
|
||||
| `/characters` | 角色库 | Phase 1 |
|
||||
| `/characters/[id]` | 角色详情 / 编辑 | Phase 1 |
|
||||
| `/training` | 训练中心(任务列表) | Phase 2 |
|
||||
| `/training/[id]` | 训练任务详情 | Phase 2 |
|
||||
| `/settings` | 项目设置 | Phase 1 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 登录页 `/login`
|
||||
|
||||
### 信息内容
|
||||
|
||||
- 品牌 Logo + 产品名称
|
||||
- 用户名输入框
|
||||
- 密码输入框
|
||||
- 登录按钮
|
||||
- 错误提示(用户名/密码错误、网络异常)
|
||||
|
||||
### 功能
|
||||
|
||||
- 提交登录 → `POST /api/auth/login` → 成功后跳转 `/`
|
||||
- 已登录状态自动重定向至 `/`
|
||||
- Token 存 `localStorage`,后续请求带 `Bearer`
|
||||
|
||||
### 跳转
|
||||
|
||||
| 操作 | 目标 |
|
||||
|------|------|
|
||||
| 登录成功 | → `/`(工作台) |
|
||||
| 已登录访问 | → `/`(自动重定向) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 工作台 `/`(核心页面)
|
||||
|
||||
工作台是产品最核心的界面,采用**三栏布局**。
|
||||
|
||||
### 3.1 整体布局
|
||||
|
||||
```
|
||||
┌──────────┬──────────────────────────────┬──────────────┐
|
||||
│ │ │ │
|
||||
│ 侧栏 │ 对话 + 任务区 │ 上下文面板 │
|
||||
│ Sidebar │ Main Area │ Context │
|
||||
│ │ │ Panel │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌────────────────────────┐ │ │
|
||||
│ │ │ 输入区 │ │ │
|
||||
│ │ └────────────────────────┘ │ │
|
||||
└──────────┴──────────────────────────────┴──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 左侧栏(Sidebar)
|
||||
|
||||
#### 3.2.1 会话列表区
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| 新建会话按钮 | 创建空白对话会话 |
|
||||
| 会话条目 | 缩略图 + 标题 + 最后活动时间 |
|
||||
| 会话标签 | 每个会话可绑定多标签,标签色块显示 |
|
||||
| 标签筛选栏 | 顶部标签多选过滤器,只显示匹配会话 |
|
||||
| 会话三点菜单 | 重命名 / 删除 / LLM 模型切换 / 管理标签 |
|
||||
|
||||
#### 3.2.2 项目信息卡 `[Phase 1]`
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| 当前项目名 | 显示当前工作项目 |
|
||||
| 项目风格缩略图 | 当前绑定的风格集代表图 |
|
||||
| 快捷入口 | → 风格库 / → 角色库 / → 项目设置 |
|
||||
|
||||
#### 3.2.3 侧栏折叠
|
||||
|
||||
- 汉堡菜单切换折叠/展开
|
||||
- 移动端默认折叠,点击展开 + 遮罩覆盖
|
||||
|
||||
---
|
||||
|
||||
### 3.3 中间区:对话 + 任务区(Main Area)
|
||||
|
||||
#### 3.3.1 对话消息流
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| **用户消息气泡** | 文本 + 参考图缩略图(多图) |
|
||||
| **助手消息气泡** | Markdown 文本 + 生成图片网格 |
|
||||
| 流式文本 | SSE 流式渲染,逐字显示 |
|
||||
| 图片网格(ImageGrid) | 候选图缩略图阵列,每张可:点击查看详情 / 下载 / 收藏 |
|
||||
| 任务说明卡 `[Phase 1]` | Agent 理解到的结构化信息卡,显示:任务类型、风格、角色、输出规格、使用模板 |
|
||||
| 工具执行状态 | 生图进行中的 Loading 状态、模型名称、进度 |
|
||||
| 错误提示 | 工具调用失败时的错误信息展示 |
|
||||
| 复制按钮 | 助手消息 hover 显示复制文本按钮 |
|
||||
| 回到底部按钮 | 不在底部时浮动箭头,点击平滑滚动 |
|
||||
|
||||
#### 3.3.2 任务说明卡(Task Card)`[Phase 1]`
|
||||
|
||||
Agent 执行生图前,在对话流中插入一张结构化卡片,展示系统对用户意图的理解:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 📋 任务说明 │
|
||||
│ │
|
||||
│ 任务类型:角色立绘(全身) │
|
||||
│ 风格:anime_dark_fantasy_v1 │
|
||||
│ 角色:剑士·艾丽卡 │
|
||||
│ 输出规格:1024×1536 · 透明背景可选 │
|
||||
│ 模板:character_consistent_v1 │
|
||||
│ 候选数:4 │
|
||||
│ │
|
||||
│ [ ✏️ 调整参数 ] [ ▶ 确认生成 ] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 调整参数 | 展开编辑各字段,或打开右侧上下文面板的高级控制 |
|
||||
| 确认生成 | 按当前参数执行生成 |
|
||||
| 自动执行 | 模式 A 下用户不需要确认,Agent 自动执行 |
|
||||
|
||||
#### 3.3.3 输入区(ChatInput)
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| 文本输入框 | 多行输入,Enter 发送,Shift+Enter 换行 |
|
||||
| 参考图上传 | 点击按钮选图 / 拖拽到整个对话区域 / 粘贴图片 |
|
||||
| 参考图预览 | 已上传参考图缩略图列表(多图),可单独删除 |
|
||||
| 参考图沿用提示 | 上次发送的参考图可沿用,显示提示条 + 可清除 |
|
||||
| 生图模型选择器 | 下拉选择当前生图模型(Flux / SDXL / GPT Image 等) |
|
||||
| 风格快选 `[Phase 1]` | 快捷选取当前风格,不用打字描述 |
|
||||
| 角色快选 `[Phase 1]` | 快捷选取当前角色 |
|
||||
| 资产规格快选 `[Phase 2]` | 选择输出类型(Icon / 立绘 / 场景等),自动套用规格 |
|
||||
| 发送按钮 | 涟漪动画反馈 |
|
||||
|
||||
---
|
||||
|
||||
### 3.4 右侧栏:上下文面板(Context Panel)
|
||||
|
||||
右侧面板**默认收起**,用户可通过以下方式唤出:
|
||||
|
||||
- 点击图片网格中的某张图 → 打开**图片详情**
|
||||
- 点击任务说明卡的"调整参数" → 打开**高级控制**
|
||||
- 手动点击展开按钮
|
||||
|
||||
#### 3.4.1 图片详情模式(Image Detail)
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| 大图预览 | 缩放、平移查看 |
|
||||
| Prompt 信息 | 生成时使用的提示词,可复制 |
|
||||
| 模型信息 | 生成该图使用的模型名称 |
|
||||
| 元数据 | 分辨率、生成时间、风格 ID、角色 ID |
|
||||
| 下载 | 原图下载 |
|
||||
| 收藏 | 收藏/取消收藏 |
|
||||
| 删除 | 从资源库移除 |
|
||||
| 标注(Annotation) | 进入标注画布:矩形 / 箭头 / 自由画笔 / 文字 |
|
||||
| 标注回流 | 标注完成后将标注数据和截图回流到对话输入,辅助修改描述 |
|
||||
| 以此为基础修改 | 将此图设为下一轮对话的参考图 |
|
||||
| 局部编辑(Inpaint)`[Phase 1]` | 框选区域 + 描述修改意图,局部重绘 |
|
||||
|
||||
#### 3.4.2 高级控制模式(Advanced Control)`[Phase 1]`
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| Task IR 预览 | 当前任务的结构化 JSON 预览(只读或可编辑) |
|
||||
| 模板选择器 | 从 Pipeline Registry 中选择/切换工作流模板 |
|
||||
| 参数面板 | 模板暴露的可调参数(分辨率、候选数、一致性级别、控制类型等) |
|
||||
| 结构控制图输入 | 上传 Pose / Canny / Depth 控制图 |
|
||||
| 风格约束 | 当前绑定的风格集,可临时覆盖 |
|
||||
| 角色约束 | 当前绑定的角色参考组,可临时覆盖 |
|
||||
| 成本预估 | 当前参数下的预估成本/时间 |
|
||||
| 应用 | 将调整后的参数应用到当前任务 |
|
||||
|
||||
#### 3.4.3 候选评估面板 `[Phase 2]`
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| 候选图排列 | 所有候选图并排对比 |
|
||||
| 评分信息 | 语义匹配 / 风格相似度 / 角色一致性 / 规则检查 各项评分 |
|
||||
| 自动推荐 | 系统推荐的最佳候选高亮 |
|
||||
| 人工确认 | 用户选择采纳哪张,或全部重试 |
|
||||
| 局部修补入口 | 对候选图做局部修补后再确认 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 资源库 `/gallery`
|
||||
|
||||
### 4.1 整体布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ TopNav │
|
||||
├──────────┬──────────────────────────────────────────────┤
|
||||
│ 筛选栏 │ │
|
||||
│ │ 资产网格 / 列表 │
|
||||
│ Filter │ Asset Grid │
|
||||
│ Panel │ │
|
||||
│ │ │
|
||||
│ ├──────────────────────────────────────────────┤
|
||||
│ │ 资产详情(选中时) │
|
||||
│ │ Asset Detail │
|
||||
└──────────┴──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 筛选栏(Filter Panel)
|
||||
|
||||
| 筛选维度 | 说明 |
|
||||
|----------|------|
|
||||
| 搜索框 | 按 Prompt 文本 / 语义搜索 `[Phase 2: 语义搜索]` |
|
||||
| 标签筛选 | 多标签 AND/OR 过滤 |
|
||||
| 资产类型 | 角色立绘 / 道具 / 场景 / Icon / 参考图 / 控制图 等 |
|
||||
| 收藏过滤 | 只看已收藏 |
|
||||
| 模型筛选 | 按生成模型过滤 |
|
||||
| 风格筛选 `[Phase 1]` | 按风格集过滤 |
|
||||
| 角色筛选 `[Phase 1]` | 按角色过滤 |
|
||||
| 日期范围 | 按生成时间过滤 |
|
||||
| 来源会话 | 按生成时所在会话过滤 |
|
||||
|
||||
### 4.3 资产网格
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| 缩略图 | 预览图,hover 显示基本信息 |
|
||||
| 网格/列表切换 | 视图模式切换 |
|
||||
| 排序 | 按时间 / 名称 / 收藏 排序 |
|
||||
| 多选 | Checkbox 多选 → 批量下载 / 批量删除 / 批量打标签 |
|
||||
| 拖拽选区 `[Phase 1]` | 框选多张资产 |
|
||||
|
||||
### 4.4 资产详情(选中某张图时)
|
||||
|
||||
复用工作台的图片详情组件,额外增加:
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| 来源信息 | 来自哪个会话、哪次对话 |
|
||||
| 所属项目 `[Phase 1]` | 归属项目 |
|
||||
| 标签编辑 | 添加/移除标签 |
|
||||
| 版本历史 `[Phase 1]` | 该资产的编辑/修改版本链 |
|
||||
| 导出 | 按规格导出(多尺寸 @1x @2x @3x)`[Phase 2]` |
|
||||
| 发送到工作台 | 将此图作为参考图发送回对话输入 |
|
||||
|
||||
### 4.5 跳转关系
|
||||
|
||||
| 操作 | 目标 |
|
||||
|------|------|
|
||||
| 点击缩略图 | → 展开资产详情(右侧面板或 Modal) |
|
||||
| "发送到工作台" | → `/`(工作台),自动填充为参考图 |
|
||||
| 点击"来源会话" | → `/`(工作台),切换到对应会话 |
|
||||
| 资产详情中"标注" | → 进入标注画布 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 风格库 `/styles` `[Phase 1]`
|
||||
|
||||
### 5.1 风格库列表页
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ TopNav │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ 风格卡片 │ │ 风格卡片 │ │ 风格卡片 │ │ + 新建 │ │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ │ 代表图 │ │ 代表图 │ │ 代表图 │ │ │ │
|
||||
│ │ 名称 │ │ 名称 │ │ 名称 │ │ │ │
|
||||
│ │ 标签 │ │ 标签 │ │ 标签 │ │ │ │
|
||||
│ │ 使用次数 │ │ 使用次数 │ │ 使用次数 │ │ │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| 风格卡片 | 代表图(风格参考集首图) + 名称 + 标签 + 使用计数 |
|
||||
| 分类筛选 | 按标签分类(写实 / 二次元 / 像素 / 水墨 / 赛博 等) |
|
||||
| 来源筛选 | 平台预设 / 项目自建 / 社区共享 |
|
||||
| 新建风格 | → 进入风格编辑页 |
|
||||
| 搜索 | 按名称/标签搜索 |
|
||||
|
||||
### 5.2 风格详情/编辑页 `/styles/[id]`
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| 风格名称 | 可编辑 |
|
||||
| 风格描述 | 文字描述 + 关键词标签 |
|
||||
| **风格参考集** | 3-10 张代表该风格核心特征的参考图,可增删替换 |
|
||||
| 风格 LoRA | 绑定的 LoRA 模型(如有)`[Phase 2]` |
|
||||
| 风格 Embedding | 自动计算的风格向量表示 `[Phase 2]` |
|
||||
| Prompt 模板 | 该风格关联的正向/负向 Prompt 模板 |
|
||||
| 预览效果 | 使用该风格生成的历史图片(从资源库自动聚合) |
|
||||
| 使用统计 | 被多少会话/任务引用过 |
|
||||
| Fork 来源 | 如果是从官方模板 Fork 的,显示来源 |
|
||||
| 操作 | 应用到当前会话 / 复制 / Fork / 删除 |
|
||||
|
||||
### 5.3 跳转关系
|
||||
|
||||
| 操作 | 目标 |
|
||||
|------|------|
|
||||
| 点击风格卡片 | → `/styles/[id]`(风格详情) |
|
||||
| "应用到当前会话" | → `/`(工作台),注入该风格到当前会话上下文 |
|
||||
| "新建风格" | → `/styles/new`(风格编辑页) |
|
||||
| "训练风格 LoRA" | → `/training/new?type=style&source=[id]` `[Phase 2]` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 角色库 `/characters` `[Phase 1]`
|
||||
|
||||
### 6.1 角色库列表页
|
||||
|
||||
与风格库类似的卡片式布局。
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| 角色卡片 | 头像图 + 名称 + 简要描述 + 所属项目 |
|
||||
| 分类 | 按项目分组,或自定义标签分类 |
|
||||
| 新建角色 | → 进入角色编辑页 |
|
||||
| 搜索 | 按名称/描述搜索 |
|
||||
|
||||
### 6.2 角色详情/编辑页 `/characters/[id]`
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| 角色名称 | 可编辑 |
|
||||
| 角色描述元数据 | 性别、年龄范围、体型、性格关键词、背景故事摘要 |
|
||||
| **角色参考组** | 多角度参考图集合:正面 / 半侧面 / 半身 / 表情变化 / 关键服饰细节 |
|
||||
| 关键视觉要素 | 标注角色的识别性特征(发色、瞳色、标志性饰品、武器等) |
|
||||
| 角色 LoRA | 绑定的 LoRA 模型(如有)`[Phase 2]` |
|
||||
| 生成历史 | 使用该角色生成的所有图片(从资源库自动聚合) |
|
||||
| 一致性级别 | 默认一致性要求(高/中/低) |
|
||||
| 操作 | 应用到当前会话 / 复制 / 删除 |
|
||||
|
||||
### 6.3 跳转关系
|
||||
|
||||
| 操作 | 目标 |
|
||||
|------|------|
|
||||
| 点击角色卡片 | → `/characters/[id]`(角色详情) |
|
||||
| "应用到当前会话" | → `/`(工作台),注入该角色到当前会话上下文 |
|
||||
| "新建角色" | → `/characters/new`(角色编辑页) |
|
||||
| "训练角色 LoRA" | → `/training/new?type=character&source=[id]` `[Phase 2]` |
|
||||
| 参考图中某张图 | → 图片详情面板 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 训练中心 `/training` `[Phase 2]`
|
||||
|
||||
### 7.1 训练任务列表页
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ TopNav │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ [ + 新建训练任务 ] │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────┐ │
|
||||
│ │ 任务名称 类型 状态 进度 创建时间 │ │
|
||||
│ ├───────────────────────────────────────────────────┤ │
|
||||
│ │ 暗黑幻想风格v2 风格LoRA 训练中 67% 04-16 │ │
|
||||
│ │ 剑士·艾丽卡v1 角色LoRA 已完成 100% 04-15 │ │
|
||||
│ │ 水墨武侠v1 风格LoRA 排队中 — 04-16 │ │
|
||||
│ │ 枪手·马克v1 角色LoRA 已失败 — 04-14 │ │
|
||||
│ └───────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| 任务列表 | 名称 + 类型 + 状态(排队/训练中/已完成/已失败/已弃用)+ 进度 + 时间 |
|
||||
| 状态筛选 | 按状态过滤 |
|
||||
| 新建训练任务 | → 训练向导流程 |
|
||||
| 点击某任务 | → 训练任务详情页 |
|
||||
|
||||
### 7.2 新建训练任务(向导流程)
|
||||
|
||||
分步引导,降低用户门槛:
|
||||
|
||||
| 步骤 | 内容 |
|
||||
|------|------|
|
||||
| **Step 1: 选择类型** | 风格 LoRA / 角色 LoRA |
|
||||
| **Step 2: 选择来源** | 从风格库/角色库中选择(自动带入参考组),或从资源库批量选图 |
|
||||
| **Step 3: 审核训练集** | 展示所有候选图,自动质量检查(模糊 / 低分辨率 / 重复标记),用户确认/剔除 |
|
||||
| **Step 4: 选择模板** | 选择训练模板(SDXL LoRA / Flux LoRA 等),显示推荐参数 |
|
||||
| **Step 5: 确认并提交** | 汇总信息 + 成本预估 → 提交训练 |
|
||||
|
||||
### 7.3 训练任务详情页 `/training/[id]`
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| 基本信息 | 名称、类型、来源风格/角色、创建时间 |
|
||||
| 训练状态 | 进度条 + 当前 Step / Epoch |
|
||||
| 训练日志 | 实时日志输出(可折叠) |
|
||||
| 训练集预览 | 使用的图片缩略图列表 |
|
||||
| **训练结果对比** | 训练前后的效果对比(同一 Prompt 下的生成对比图) |
|
||||
| 模型版本 | 版本号 + 可设为当前使用版本 |
|
||||
| 操作 | 使用该模型 / 回滚到上一版本 / 弃用 / 重新训练 |
|
||||
|
||||
### 7.4 跳转关系
|
||||
|
||||
| 操作 | 目标 |
|
||||
|------|------|
|
||||
| "使用该模型" | → `/`(工作台),自动绑定该 LoRA 到当前会话 |
|
||||
| "查看来源风格" | → `/styles/[id]` |
|
||||
| "查看来源角色" | → `/characters/[id]` |
|
||||
| 训练集中某张图 | → 图片详情面板 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 项目设置 `/settings` `[Phase 1]`
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| 项目名称 | 可编辑 |
|
||||
| 项目描述 | 项目简介、类型(2D RPG / 横版动作 / 卡牌 等) |
|
||||
| 默认风格 | 项目级默认风格集绑定 |
|
||||
| 默认输出规格 | 项目级默认分辨率、背景要求等 |
|
||||
| 约束规则 | 项目级约束(如:只能使用指定风格、候选数上限、成本限制等) |
|
||||
| 成员管理 `[Phase 3]` | 团队成员和权限 |
|
||||
| 导出设置 | 默认导出格式、命名规则 |
|
||||
| 危险区 | 归档项目 / 删除项目 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 用户菜单(全局右上角下拉)
|
||||
|
||||
| 信息 / 功能 | 说明 |
|
||||
|-------------|------|
|
||||
| 用户名 + 头像 | 当前登录用户 |
|
||||
| 个人信息 | 打开个人信息弹窗 |
|
||||
| 记忆查看 | 打开 Mem0 记忆列表弹窗(按时间分组,只读) |
|
||||
| 全局设置 `[Phase 2]` | API Key 配置、界面偏好、通知设置 |
|
||||
| 退出登录 | 清除 Token,跳转 `/login` |
|
||||
|
||||
---
|
||||
|
||||
## 10. 完整导航流程图
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ /login │
|
||||
└────┬─────┘
|
||||
│ 登录成功
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ / (工作台) │
|
||||
│ │
|
||||
│ ┌────────┐ ┌──────────────┐ │
|
||||
│ │ 侧栏 │ │ 对话 + 任务区 │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ 会话列表 │ │ 消息流 │──── 点击生成图 ──→ 右侧面板:图片详情
|
||||
│ │ │ │ │ │
|
||||
│ │ 项目卡片 │ │ 任务说明卡 │──── 调整参数 ──→ 右侧面板:高级控制
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ 输入区 │ │
|
||||
│ │ │ │ · 风格快选 ─┼──── 更多风格 ──→ /styles
|
||||
│ │ │ │ · 角色快选 ─┼──── 更多角色 ──→ /characters
|
||||
│ └────────┘ └──────────────┘ │
|
||||
└──────────────┬───────────────────────────-┘
|
||||
│
|
||||
┌─────────────┼──────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────────┐ ┌──────────┐ ┌──────────────┐
|
||||
│ /gallery │ │ /styles │ │ /characters │
|
||||
│ 资源库 │ │ 风格库 │ │ 角色库 │
|
||||
│ │ │ │ │ │
|
||||
│ 筛选+网格 │ │ 卡片列表 │ │ 卡片列表 │
|
||||
│ │ │ │ │ │
|
||||
│ 发送到工作台 ─┼→│ 应用到会话─┼→ │ 应用到会话 ──┼→ 回到 /
|
||||
│ 查看来源会话 ─┼→│ 训练LoRA ─┼→ │ 训练LoRA ───┼→ /training/new
|
||||
└──────┬─────┘ └─────┬────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ ┌─────────────┐ ┌─────────────────┐
|
||||
│ │/styles/[id] │ │/characters/[id] │
|
||||
│ │ 风格详情/编辑 │ │ 角色详情/编辑 │
|
||||
│ └─────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ /training │
|
||||
│ 训练中心 │
|
||||
│ │
|
||||
│ 任务列表 │
|
||||
│ 新建→向导流程 │
|
||||
│ │
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ /training/[id] │
|
||||
│ 训练任务详情 │
|
||||
│ │
|
||||
│ 使用该模型 → 回到 / │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 跨页面数据流转总结
|
||||
|
||||
以下是各页面之间的数据流转关系,说明资产和上下文如何在页面间传递:
|
||||
|
||||
| 起点 | 动作 | 终点 | 携带数据 |
|
||||
|------|------|------|----------|
|
||||
| 工作台 | 生成图片并沉淀 | 资源库 | `ImageAsset` 自动入库 |
|
||||
| 资源库 | "发送到工作台" | 工作台 | 图片 URL 填入参考图输入 |
|
||||
| 资源库 | "查看来源会话" | 工作台 | 切换到对应 `sessionId` |
|
||||
| 工作台输入区 | "更多风格" | 风格库 | 当前项目 ID 作为筛选条件 |
|
||||
| 风格库 | "应用到当前会话" | 工作台 | `styleId` + Prompt 模板 + 参考集注入会话上下文 |
|
||||
| 风格库 | "训练风格 LoRA" | 训练中心 | 风格 ID + 参考集作为训练源 |
|
||||
| 角色库 | "应用到当前会话" | 工作台 | `characterId` + 参考组注入会话上下文 |
|
||||
| 角色库 | "训练角色 LoRA" | 训练中心 | 角色 ID + 参考组作为训练源 |
|
||||
| 训练中心 | "使用该模型" | 工作台 | LoRA 模型 ID 绑定到当前会话 |
|
||||
| 训练中心 | "查看来源" | 风格库/角色库 | 跳转到对应风格/角色详情 |
|
||||
| 工作台(标注) | 标注完成 | 工作台输入区 | `AnnotationData` + snapshot 回流到 Prompt |
|
||||
| 工作台(高级控制) | 调整 Task IR 参数 | 工作台对话流 | 更新后的 Task IR → 执行生成 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 响应式策略
|
||||
|
||||
| 设备 | 布局适配 |
|
||||
|------|----------|
|
||||
| **桌面(≥1280px)** | 三栏布局完整展开:侧栏 + 对话区 + 右侧面板 |
|
||||
| **平板(768-1279px)** | 侧栏可折叠,右侧面板改为 Overlay 浮层 |
|
||||
| **手机(<768px)** | 侧栏抽屉式,右侧面板改为全屏 Modal,底部 Tab 导航替代顶部导航 |
|
||||
|
||||
### 手机端底部 Tab 导航
|
||||
|
||||
```
|
||||
┌────────┬────────┬────────┬────────┐
|
||||
│ 对话 │ 资源库 │ 风格库 │ 更多 │
|
||||
└────────┴────────┴────────┴────────┘
|
||||
```
|
||||
|
||||
"更多"展开:训练中心 / 项目设置 / 个人信息
|
||||
|
||||
---
|
||||
|
||||
## 13. 三层交互模式对照
|
||||
|
||||
方案文档定义了三层使用模式,对应到 UI 的映射:
|
||||
|
||||
| 模式 | 用户类型 | UI 表现 |
|
||||
|------|---------|---------|
|
||||
| **模式 A:自然语言驱动** | 普通用户 | 只看到对话区和输入框,Agent 自动处理一切,任务说明卡自动确认 |
|
||||
| **模式 B:引导式修订** | 中级用户 | 对话区 + 任务说明卡可展开查看,自然语言修改后 Agent 自动映射参数 |
|
||||
| **模式 C:高级控制** | 专业用户 | 右侧面板展开高级控制,可直接编辑 Task IR、切换模板、上传控制图 |
|
||||
|
||||
用户不需要显式"切换模式"——三层是**渐进式暴露**的:
|
||||
|
||||
1. 默认只看到对话框 → 模式 A
|
||||
2. 想了解 Agent 在做什么 → 点开任务说明卡 → 模式 B
|
||||
3. 想精细控制 → 点开高级控制面板 → 模式 C
|
||||
|
||||
---
|
||||
|
||||
## 14. Phase 0 → 最终形态的演进路径
|
||||
|
||||
| 阶段 | 新增页面 / 模块 | 关键变化 |
|
||||
|------|----------------|----------|
|
||||
| **Phase 0** | `/login`、`/`(对话+图片详情+标注)、`/gallery` | 当前状态:对话生图 + 资源库 + 用户系统 |
|
||||
| **Phase 1** | `/styles`、`/characters`、`/settings`;工作台增加任务说明卡 + 高级控制面板 + 输入区快选 | 引入项目概念、风格系统、角色系统、Task IR 可视化 |
|
||||
| **Phase 2** | `/training`;资源库增加语义搜索和多尺寸导出;工作台增加候选评估面板 | 训练能力产品化、评估层可视化、资产规格系统 |
|
||||
| **Phase 3** | 扩展 3D / 动画 / 音频的任务类型和资产类型 | 工作台支持新任务类型,资源库支持新资产类型,训练中心支持新模型类型 |
|
||||
1077
docs/art-agent/THE-GREATE-EPEEKIT.md
Normal file
1077
docs/art-agent/THE-GREATE-EPEEKIT.md
Normal file
File diff suppressed because it is too large
Load Diff
417
docs/art-agent/UX-STYLE-GUIDE.md
Normal file
417
docs/art-agent/UX-STYLE-GUIDE.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# EPEEKit 交互体验指南
|
||||
|
||||
> 设计语言:**青绿山水 · 科技幽谷**
|
||||
> 核心意境:深山幽谷中的科技圣地 — 自然的宁静与数字的精密共存
|
||||
> 更新日期:2026-04-16
|
||||
|
||||
## 实现状态说明
|
||||
|
||||
每个特性标注实现状态:
|
||||
- ✅ **已实现** — 代码完整落地,与文档描述一致
|
||||
- 🔶 **基本实现** — 核心功能已落地,细节有微小偏差(不影响体验)
|
||||
- ⬜ **未实现** — 仅有文档描述,无对应代码
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计哲学
|
||||
|
||||
每一次交互都是一次"与山水对话":
|
||||
|
||||
| 操作 | 意象 | 动效语言 | 状态 |
|
||||
|------|------|----------|------|
|
||||
| 发送消息 | 投石入水 | 按钮弹缩 → 气泡飞入 → 涟漪扩散 | ✅ |
|
||||
| AI 回复 | 云雾凝聚 | 模糊→清晰 → 文字逐行显现 | ✅ |
|
||||
| 收藏图片 | 萤火点亮 | 星标亮起 → 金色粒子飘散 | ✅ |
|
||||
| 滚动浏览 | 穿行雾中 | 内容在雾气边缘隐现 | ✅ |
|
||||
| 等待加载 | 溪水潺潺 | 石青流光 + 水波纹脉冲 | ✅ |
|
||||
| 图片揭示 | 水墨晕染 | clip-path 圆形从中心扩展 | ✅ |
|
||||
| 会话切换 | 翻山越岭 | 内容滑出/滑入 | ✅ |
|
||||
|
||||
科技感不通过赛博朋克的锐利线条传递,而是通过"精密点阵 × 自然粒子"的融合 —
|
||||
就像在深山中发现了一座用水晶和苔藓构建的数据中心。
|
||||
|
||||
---
|
||||
|
||||
## 2. 色彩体系 ✅
|
||||
|
||||
### 主色调
|
||||
|
||||
| 语义 | 变量名 | 色值 | 色彩隐喻 | 状态 |
|
||||
|------|--------|------|---------|------|
|
||||
| 背景主色 | `--bg-primary` | `#0C1210` | 深山暗处 | ✅ |
|
||||
| 背景次级 | `--bg-secondary` | `#131E1A` | 苔藓暗绿 / 山石基底 | ✅ |
|
||||
| 背景三级 | `--bg-tertiary` | `#1C2A24` | 松烟绿 / 远山中层 | ✅ |
|
||||
| 卡片表面 | `--bg-card` | `rgba(16, 30, 24, 0.65)` | 半透明山岩 | ✅ |
|
||||
| 主高亮色 | `--accent` | `#4DB8A4` | 石青绿 — 千里江山图的石青 | ✅ |
|
||||
| 高亮 hover | `--accent-hover` | `#6CD4BE` | 石青亮态 | ✅ |
|
||||
| 辅助高亮 | `--accent-secondary` | `#3A8FB7` | 石绿蓝 — 千里江山图的石绿 | ✅ |
|
||||
| 热门/警告 | `--hot` | `#C4654A` | 赭石红 — 点景人物/宫殿暖色 | ✅ |
|
||||
| 文字主色 | `--text-primary` | `#E0E8E2` | 微泛绿的月白 | ✅ |
|
||||
| 文字次级 | `--text-secondary` | `#7A9485` | 青灰绿 | ✅ |
|
||||
| 边框色 | `--border` | `rgba(77, 184, 164, 0.12)` | 石青半透明 | ✅ |
|
||||
| 发光边框 | `--border-glow` | `rgba(77, 184, 164, 0.25)` | 石青发光态 | ✅ |
|
||||
| 雾气基础 | `--mist` | `rgba(77, 184, 164, 0.06)` | 云雾底色 | ✅ |
|
||||
| 赭金点缀 | `--gold` | `#B8935A` | 收藏星标等极少量暖色 | ✅ |
|
||||
|
||||
### 色彩来源
|
||||
|
||||
石青(`#4DB8A4`)和石绿蓝(`#3A8FB7`)取自《千里江山图》中使用的矿物质颜料色,
|
||||
经现代演绎降低饱和度,在深色 UI 中不过于刺眼。
|
||||
赭石红(`#C4654A`)来源于画中点景人物和宫殿使用的暖色颜料。
|
||||
|
||||
**实现文件**: `globals.css` `:root` 第 4-18 行
|
||||
|
||||
---
|
||||
|
||||
## 3. 信息层级体系
|
||||
|
||||
### 3.1 排版重量体系(7 级) ✅
|
||||
|
||||
| 层级 | CSS 类 | 大小 | 字重 | 字间距 | 视觉手段 | 状态 |
|
||||
|------|--------|------|------|--------|----------|------|
|
||||
| Display | `.typo-display` | 32px | 300 | -0.02em | 石青→石绿蓝渐变填色 | ✅ |
|
||||
| H1 | `.typo-h1` | 24px | 700 | -0.01em | 底部石青细线装饰 | ✅ |
|
||||
| H2 | `.typo-h2` | 18px | 600 | 0 | 左侧 3px 石青竖线标记 | ✅ |
|
||||
| Body | 默认 | 14px | 400 | 0.01em | 标准 | ✅ |
|
||||
| Body Strong | `.typo-strong` | 14px | 600 | 0 | 石青色 | ✅ |
|
||||
| Caption | `.typo-caption` | 12px | 400 | 0.02em | text-secondary | ✅ |
|
||||
| Micro | `.typo-micro` | 11px | 600 | 0.04em | 大写 + 加宽字间距 | ✅ |
|
||||
|
||||
关键区分手段:Display 使用渐变文字而非纯色,H1 与 H2 各有独立视觉标记(底线 vs 竖线),
|
||||
不仅靠字号大小区分层级。
|
||||
|
||||
**实现文件**: `globals.css` 第 33-102 行
|
||||
**使用位置**: `typo-display`(欢迎页/登录页)、`typo-h1`(ProfileModal 用户名)、`typo-h2`(Gallery 详情标题)、`typo-strong`(侧栏/Gallery 区域标题、ProfileModal 记忆标题)、`typo-caption`(时间戳/记忆条数)、`typo-micro`(标签/分组标签/New 标记)
|
||||
|
||||
### 3.2 表面层级系统(Surface Elevation) ✅
|
||||
|
||||
4 级表面,每升一级 backdrop-blur +8px、border 亮度 +0.06、box-shadow 扩展 +4px:
|
||||
|
||||
| 层级 | CSS 类 | 用途 | 隐喻 | 状态 |
|
||||
|------|--------|------|------|------|
|
||||
| Surface-0 | — | 页面背景 `#0C1210` | 深渊 | ✅ |
|
||||
| Surface-1 | `.surface-1` | 侧栏/主区域 | 山脚(bg-secondary + 微弱内发光) | ✅ |
|
||||
| Surface-2 | `.surface-2` | 卡片/消息气泡 | 山腰(glass-panel + 更强模糊 + 微上移阴影) | ✅ |
|
||||
| Surface-3 | `.surface-3` | 弹窗/下拉/tooltip | 山顶(强模糊 + 明显阴影 + 更亮边框) | ✅ |
|
||||
|
||||
**实现文件**: `globals.css` 第 106-133 行
|
||||
**使用位置**: 侧栏(surface-1)、消息气泡/回到底部按钮(surface-2)、下拉菜单/弹窗(surface-3)
|
||||
|
||||
> 注:文档定义的 `.glass-panel` 与 `.surface-2` 视觉上等价,实际组件统一使用 surface-* 系列。`glass-panel` 保留为兼容。
|
||||
|
||||
### 3.3 用户消息 vs 助手消息 ✅
|
||||
|
||||
| 维度 | 用户消息 | 助手消息 | 状态 |
|
||||
|------|----------|----------|------|
|
||||
| 对齐 | 右对齐 | 左对齐 | ✅ |
|
||||
| 背景 | 石青渐变(accent/8 → accent/15) | glass-panel(Surface-2) | ✅ |
|
||||
| 圆角 | 左上方切角(几何感) | 标准圆角 | ✅ |
|
||||
| 边框 | 无 | 左侧 2px 石青竖线(山泉流水) | ✅ |
|
||||
| 入场动画 | 投石入水(从下方飞入) | 云雾凝聚(blur 消散) | ✅ |
|
||||
|
||||
**实现文件**: `chat-messages.tsx` 第 69-74 行、`globals.css` `.user-bubble` 类
|
||||
|
||||
---
|
||||
|
||||
## 4. 科技质感
|
||||
|
||||
### 4.1 环境粒子(Ambient Particles) ✅
|
||||
|
||||
背景云雾之上叠加"深山萤火"粒子层:
|
||||
|
||||
- 数量:15-20 个小光点
|
||||
- 颜色:石青色,opacity 0.15-0.3
|
||||
- 行为:缓慢随机漂浮 + 偶尔闪烁(opacity 脉冲)
|
||||
- 实现:React 组件 + useMemo 随机化 + CSS animation
|
||||
- 空态时粒子增多至 ~28 个,营造"未探索的深山"感
|
||||
|
||||
**实现文件**: `components/ui/ambient-particles.tsx`
|
||||
**接入位置**: `page.tsx`(首页,count 根据空态切换)、`login/page.tsx`、`gallery/page.tsx`
|
||||
|
||||
### 4.2 网格底纹(Subtle Grid) ✅
|
||||
|
||||
Surface-0 背景叠加极淡的点阵网格(opacity: 0.03),
|
||||
几乎不可见但传递"精密、数字化"的潜意识感知,与云雾层形成"自然 × 科技"张力。
|
||||
|
||||
**实现文件**: `globals.css` `body::before` 第 136-145 行
|
||||
|
||||
### 4.3 数据流光(Data Stream) ✅
|
||||
|
||||
AI 思考/生成时,输入栏上方出现一条横向流光:
|
||||
|
||||
- 石青色光带从左向右流动,高度 2px
|
||||
- 思考时慢速、生成图片时加速
|
||||
- 意象:山间溪流 / 数据脉搏
|
||||
|
||||
**实现文件**: `components/ui/data-stream.tsx`、`globals.css` `.data-stream` / `-slow` / `-fast`
|
||||
**接入位置**: `chat-input.tsx` 第 202 行
|
||||
|
||||
---
|
||||
|
||||
## 5. 微交互图鉴
|
||||
|
||||
所有微交互使用 framer-motion 实现,统一动画参数定义在 `motion-presets.ts` 中。
|
||||
|
||||
### 5.1 发送消息 — "投石入水" ✅
|
||||
|
||||
1. 发送按钮 `scale(0.85)` → spring 弹回(`sendBounce`)
|
||||
2. 消息气泡从输入栏位置飞入对话区(translateY + opacity)
|
||||
3. 到达目标位置时涟漪扩散(`RippleLayer` 石青色圆环 scale + fade out)
|
||||
4. 输入栏清空后边框发光脉冲一次(水面恢复平静)
|
||||
|
||||
**实现文件**: `chat-input.tsx`(sendBounce + useRipple)、`ripple-effect.tsx`、`motion-presets.ts`
|
||||
|
||||
### 5.2 AI 回复 — "云雾凝聚" ✅
|
||||
|
||||
1. 消息气泡 `blur(8px)` → `blur(0)` 凝聚显现
|
||||
2. 文字逐行显示时 opacity 阶梯(新行更亮,旧行渐稳)
|
||||
3. 图片结果:石青色模糊占位 → blur 消散 + scale 1.02→1.0(拨开云雾)
|
||||
|
||||
**实现文件**: `motion-presets.ts` `assistantEnter`、`chat-messages.tsx` `StreamingLines` 组件
|
||||
|
||||
### 5.3 收藏图片 — "萤火点亮" ✅
|
||||
|
||||
1. 星标 outline → filled,赭金色
|
||||
2. 点击瞬间散出 5-7 个微小金色粒子(萤火被惊起)
|
||||
3. 粒子向四周飘散并淡出(stagger + random 偏移)
|
||||
|
||||
**实现文件**: `components/ui/firefly-burst.tsx`、`image-grid.tsx` `FavoriteButton`
|
||||
|
||||
### 5.4 图片加载 — "水墨晕染" ✅
|
||||
|
||||
1. 占位:石青渐变块 + 呼吸脉冲(`.stone-placeholder`)
|
||||
2. 加载完成:`clip-path: circle()` 从中心扩展到全图(水墨滴入纸面)
|
||||
|
||||
**实现文件**: `globals.css` `.stone-placeholder`、`motion-presets.ts` `inkReveal`、`image-grid.tsx` `ImageReveal`
|
||||
|
||||
### 5.5 输入框 — "水面感应" ✅
|
||||
|
||||
- 空态:底部淡石青线
|
||||
- 获焦:边框从中心向两端扩展点亮(水面波纹展开)
|
||||
- 输入时:边框亮度随文字长度微增(蓄力)
|
||||
- 可发送态:发送按钮缓慢脉冲发光(准备就绪)
|
||||
|
||||
**实现文件**: `globals.css` `.water-focus-line` / `.water-focus-active`、`chat-input.tsx` boxShadow 联动
|
||||
|
||||
### 5.6 Hover 状态 ✅
|
||||
|
||||
- 按钮:`translateY(-1px)` + shadow 加深(浮起感)
|
||||
- 卡片:border-glow + 底部向上内发光渐变
|
||||
- 侧栏会话项:左侧 2px 石青竖线 slide-in
|
||||
|
||||
**实现文件**: `globals.css` `.btn-hover-lift`(第 466-472 行)、`.neon-border`(第 195-204 行)、`.session-hover-line`(第 474-493 行)
|
||||
|
||||
### 5.7 滚动到底部 — "落叶归根" ✅
|
||||
|
||||
- 形状:水滴形(椭圆切割 border-radius)
|
||||
- 出现:spring 从下方弹入
|
||||
- 石青半透明背景 + 雾气边框
|
||||
|
||||
**实现文件**: `page.tsx` scroll-btn、`motion-presets.ts` `scrollBtnEnter`
|
||||
|
||||
### 5.8 会话切换 — "翻山越岭" ✅
|
||||
|
||||
- 当前对话向上/向下滑出
|
||||
- 新对话从对应方向滑入
|
||||
- 过渡 250ms ease-out
|
||||
- 使用 `AnimatePresence mode="wait"` + `sessionSlide` 变体
|
||||
|
||||
**实现文件**: `page.tsx` AnimatePresence 包裹聊天内容区、`motion-presets.ts` `sessionSlide`
|
||||
|
||||
---
|
||||
|
||||
## 6. 状态与反馈
|
||||
|
||||
### 6.1 Loading 体系 ✅
|
||||
|
||||
| 场景 | 表现 | 状态 |
|
||||
|------|------|------|
|
||||
| 骨架屏 | shimmer 渐变(bg-tertiary → bg-secondary),左→右扫描 | ✅ |
|
||||
| 内联加载 | 三个石青圆点"水波纹"式依次涌起(scale Y 变化) | ✅ |
|
||||
| 全屏加载 | 中心放射状云雾凝聚(涟漪环 + 核心光点 + 呼吸脉冲) | ✅ |
|
||||
| AI 生成中 | 数据流光(输入栏上方 2px 流动光带) | ✅ |
|
||||
|
||||
**实现文件**: `globals.css` `.skeleton-shimmer`、`.wave-dot`、`.mist-loader`、`.data-stream`
|
||||
**接入位置**: `image-grid.tsx`、`chat-messages.tsx`、`app-context.tsx`、`chat-input.tsx`
|
||||
|
||||
### 6.2 空态设计 ✅
|
||||
|
||||
- Display 级渐变文字欢迎语
|
||||
- "快速开始"建议卡片(stagger 入场)
|
||||
- 萤火粒子增多营造氛围
|
||||
|
||||
**实现文件**: `page.tsx` isEmptyState 分支
|
||||
|
||||
### 6.3 Error 状态 ✅
|
||||
|
||||
- 赭石红色调但保持优雅(非大红色块)
|
||||
- 微弱横向震动(translateX ±2px,2 次)
|
||||
- 自动消失时向上飘散淡出(`errorFloat` 变体 + 4s 定时清除)
|
||||
|
||||
**实现文件**: `globals.css` `.error-shake`、`motion-presets.ts` `errorFloat`、`login/page.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 7. 动效语言
|
||||
|
||||
### 7.1 时序原则 ✅
|
||||
|
||||
| 类别 | 时长 | 缓动 |
|
||||
|------|------|------|
|
||||
| 即时反馈(hover/press) | 150-200ms | ease |
|
||||
| 状态过渡(展开/折叠) | 250ms | ease-in-out |
|
||||
| 云烟效果(淡入/淡出) | 400-600ms | ease |
|
||||
| 环境动效(背景漂移) | 45-60s | ease-in-out infinite |
|
||||
| Spring 物理(弹缩) | stiffness: 400, damping: 25 | spring |
|
||||
|
||||
### 7.2 编排原则 ✅
|
||||
|
||||
- **先快后慢**:触发瞬间最快,收尾最慢(模拟自然物理)
|
||||
- **层次递进**:多元素入场使用 stagger(50-80ms 间隔),从近到远
|
||||
- **克制优先**:微交互幅度小(1-3px 位移、0.95-1.05 缩放),避免分散注意力
|
||||
- **自然衰减**:所有粒子效果都应以 opacity → 0 结束,不突兀消失
|
||||
|
||||
### 7.3 统一动画参数(motion-presets.ts) ✅
|
||||
|
||||
```typescript
|
||||
// Spring 物理参数
|
||||
spring: { stiffness: 400, damping: 25 }
|
||||
springGentle: { stiffness: 200, damping: 20 }
|
||||
|
||||
// 入场动画
|
||||
fadeIn: { opacity: [0, 1], transition: { duration: 0.2 } }
|
||||
slideUp: { opacity: [0, 1], y: [8, 0], transition: { duration: 0.2 } }
|
||||
mistReveal: { filter: ["blur(8px)", "blur(0px)"], opacity: [0, 1], duration: 0.4 }
|
||||
|
||||
// 按钮反馈
|
||||
buttonTap: { scale: 0.85 }
|
||||
sendBounce: { scale: [1, 0.85, 1.05, 1], transition: { duration: 0.3 } }
|
||||
|
||||
// 涟漪/粒子
|
||||
ripple: { scale: [0, 2.5], opacity: [0.4, 0], duration: 0.6 }
|
||||
sparkle: { scale: [0, 1, 0], opacity: [0, 1, 0], duration: 0.8 }
|
||||
|
||||
// 会话切换
|
||||
sessionSlide: { enter → center → exit, y ±30, duration: 0.25 }
|
||||
|
||||
// Error 飘散
|
||||
errorFloat: { exit → opacity: 0, y: -12, duration: 0.5 }
|
||||
```
|
||||
|
||||
**实现文件**: `components/ui/motion-presets.ts`
|
||||
|
||||
---
|
||||
|
||||
## 8. 视觉效果
|
||||
|
||||
### 8.1 山雾玻璃面板 ✅
|
||||
|
||||
```css
|
||||
.glass-panel {
|
||||
background:
|
||||
linear-gradient(165deg, rgba(16, 30, 24, 0.15) 0%, transparent 50%),
|
||||
rgba(16, 30, 24, 0.55);
|
||||
backdrop-filter: blur(24px) saturate(1.1);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
```
|
||||
|
||||
**实现文件**: `globals.css` 第 185-192 行(定义)
|
||||
> 注:组件统一使用 `.surface-2`(等价视觉),`.glass-panel` 保留为兼容别名。
|
||||
|
||||
### 8.2 云烟缭绕边框 ✅
|
||||
|
||||
选中态组件的动态边框:
|
||||
- 常态:石青微弱均匀静态发光
|
||||
- hover:conic-gradient 6s 缓慢旋转(40-50% 宽弧段),模拟云烟缭绕
|
||||
- 移开:opacity 600ms 平滑淡散
|
||||
|
||||
**实现文件**: `globals.css` `.glow-border` + `@property --glow-angle` + `mistSpin`(第 206-285 行)
|
||||
**使用位置**: `top-nav.tsx` 活跃 Tab、`session-list.tsx` 当前会话
|
||||
|
||||
### 8.3 山雾背景 ✅
|
||||
|
||||
多层缓慢漂移云雾(远景层 60s / 近景层 45s),
|
||||
两层不同速率和方向模拟远山/近山视差。
|
||||
|
||||
**实现文件**: `globals.css` `.fog-layer-far` / `.fog-layer-near`(第 162-182 行)
|
||||
**挂载位置**: `ambient-particles.tsx` 组件内渲染两个雾层 div
|
||||
|
||||
### 8.4 雾气滚动边缘 ✅
|
||||
|
||||
主要滚动区域顶底渐变遮罩(36px 渐变区域),内容消失在云雾中。
|
||||
|
||||
**实现文件**: `globals.css` `.fog-scroll`(第 288-303 行)
|
||||
**使用位置**: `page.tsx` 对话滚动区、`session-list.tsx` 会话列表
|
||||
|
||||
---
|
||||
|
||||
## 9. 布局架构
|
||||
|
||||
### 整体结构 ✅
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────┐
|
||||
│ Top Nav (56px) — Surface-1 + 毛玻璃 │
|
||||
├──────────┬────────────────────────────────────────────┤
|
||||
│ 侧边栏 │ 主内容区(对话 / 资源库) │
|
||||
│ (280px) │ (flex-1) │
|
||||
│ Surface-1 │ ┌──────────────────────────────────────┐ │
|
||||
│ 会话列表 │ │ 对话消息 / 图片网格 │ │
|
||||
│ 标签筛选 │ │ (fog-scroll 雾气边缘) │ │
|
||||
│ │ └──────────────────────────────────────┘ │
|
||||
│ │ 数据流光条(AI 活动时可见) │
|
||||
│ │ 输入区 — 水面感应 │
|
||||
└──────────┴────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**实现文件**: `page.tsx`(首页布局)
|
||||
|
||||
### 间距系统 ✅
|
||||
|
||||
- 面板间间距:0(紧贴,用边框分隔)
|
||||
- 卡片网格间距:16px
|
||||
- 内容区内边距:20px
|
||||
- 面板内边距:16px
|
||||
|
||||
### 响应式断点 ✅
|
||||
|
||||
| 断点 | 布局 |
|
||||
|------|------|
|
||||
| ≥768px (md) | 双栏:侧边栏(280px) + 主内容区(flex-1) |
|
||||
| <768px | 单栏:侧边栏为固定抽屉 + 主内容区全宽 |
|
||||
|
||||
**实现文件**: `sidebar.tsx`(md 断点切换固定/内联)、`app-context.tsx`(`window.innerWidth < 768` 初始折叠)
|
||||
|
||||
---
|
||||
|
||||
## 10. 技术实现
|
||||
|
||||
### 依赖
|
||||
|
||||
- `framer-motion`:微交互动画引擎(spring 物理、stagger、AnimatePresence)
|
||||
- `tailwindcss v4`:工具类样式
|
||||
- CSS 自定义属性:设计 token
|
||||
- CSS `@property`:可动画角度属性(云烟边框)
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ui/
|
||||
│ ├── ambient-particles.tsx — 环境萤火粒子 ✅
|
||||
│ ├── data-stream.tsx — 数据流光条 ✅
|
||||
│ ├── ripple-effect.tsx — 涟漪效果 ✅
|
||||
│ ├── firefly-burst.tsx — 萤火粒子爆发 ✅
|
||||
│ └── motion-presets.ts — 统一动画参数 ✅
|
||||
├── app/globals.css — 设计 token + 工具类 ✅
|
||||
└── ...existing components — 集成微交互
|
||||
```
|
||||
|
||||
### 性能约束
|
||||
|
||||
- 环境粒子使用 React + CSS animation,不引入 Canvas
|
||||
- 所有动画使用 transform/opacity,避免触发 layout
|
||||
- 粒子总数不超过 30 个
|
||||
- 背景动效使用 `will-change: transform` 提示 GPU 加速
|
||||
- framer-motion 使用 `layout` 动画时限制在必要组件
|
||||
@@ -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 设计融合:
|
||||
- **绢本留白**:白底如绢纸铺展,给青绿色块留出呼吸空间
|
||||
- **青绿设色**:石青石绿只用于框体边框、高亮和交互反馈,克制而鲜明
|
||||
- **自然动效**:所有动画模拟自然现象(云烟飘动),而非机械闪烁
|
||||
- **克制用色**:石青石绿只用于高亮和交互反馈,不滥用;赭金极少量点缀
|
||||
- **墨色可读**:正文使用接近纯黑的墨色,在白底上对比度极佳
|
||||
- **雾气意境**:从边框云烟到背景漂移到滚动渐隐,统一的"雾"意象贯穿全站
|
||||
|
||||
Reference in New Issue
Block a user