Architech #1

Merged
shiyong.qin merged 8 commits from Architech into main 2026-04-16 11:16:48 +08:00
80 changed files with 13787 additions and 9 deletions

View File

View File

@@ -0,0 +1,921 @@
# Dev Changelog — Full
完整的开发改动记录,按时间倒序排列。作为主动 RAG 的数据源,用户手动唤醒时读取。
## 记录
### [CL-20260416-0830] 2026-04-16 08:30 — 生图按模型预处理 prompt官网/社区策略对齐)
- **tags**: 后端, 生图, prompt, SDXL, Flux, GPT Image, Gemini, Replicate, IP-Adapter, image_gen, Agent
- **affected_files**:
- art-agent/backend/app/services/image_prompt_strategy.py
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/agent/tools.py
- **what**: 为当前注册的全部 7 个生图模型增加「进入 provider 前」的 prompt 预处理层SDXL 向 Replicate 传入独立 `negative_prompt` 字段(与 stability-ai/sdxl API 一致)
- **why**: 各后端对 prompt 的惯例不同(如 SDXL 双通道、FLUX 无 negative、IP-Adapter 重参考图语义),原先同一字符串直传难以发挥模型能力
- **decisions**:
- 新模块 `image_prompt_strategy`:策略按 `IMAGE_MODELS``id` 分发docstring 注明依据OpenAI Image 指南、BFL FLUX 文档、Replicate API 字段、Stability 系默认负向词)
- SDXL未拆分时使用模块内 `DEFAULT_SDXL_NEGATIVE`;可选 `default_params.negative_prompt` 覆盖;支持正文内 `---NEGATIVE---``|||NEG|||`
- Flux剥除误粘贴的 `negative prompt:` 等段,符合 BFL「用正向描述替代负向」的指引
- `GenerateResult` 携带 `effective_prompt`/`negative_prompt` 供调试与工具回传
- **notes**: 未引入二次 LLM 调用;复杂措辞重排仍依赖对话模型在工具参数里写好英文描述
### [CL-20260416-0810] 2026-04-16 08:10 — 对话栏支持剪贴板粘贴图片为参考图
- **tags**: 前端, ChatInput, 剪贴板, paste, 参考图, UX
- **affected_files**:
- art-agent/frontend/src/components/chat/chat-input.tsx
- **what**: 在输入区通过 Ctrl+V 或右键粘贴时,若剪贴板含图片文件则自动上传为参考图(与拖拽/点选上传同一路径)
- **why**: 截图或复制图片后直接粘贴比保存再选文件更快
- **decisions**: 使用根容器 `onPasteCapture` 在文本插入前拦截;仅当 `clipboardData.items` 中存在 `kind==="file"``type``image/` 开头时才 `preventDefault`,避免影响纯文本粘贴
- **notes**: 依赖浏览器将剪贴板图片暴露为 file item多图时逐项上传
### [CL-20260416-0745] 2026-04-16 07:45 — Gemini 原生生图:延长超时 + 断连类错误自动重试
- **tags**: 后端, Gemini, httpx, 超时, 重试, RemoteProtocolError, 向量引擎, 稳定性
- **affected_files**:
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/.env.example
- **what**: 缓解 Gemini 3.1 Flash Image 调用时出现 `Server disconnected without sending a response` 的失败率
- **why**: 多参考图时请求体大、生图耗时长上游或本地代理可能在响应返回前关闭连接httpx 抛出 `RemoteProtocolError`;原先 read 仅 180s 且单次失败即返回
- **decisions**: 默认 read=600s、write=180s、connect/pool=60s`RemoteProtocolError``ConnectError``Read/Write/Connect/PoolTimeout` 最多重试 3 次、间隔 1s/2s可通过 `VECTORENGINE_GEMINI_READ_TIMEOUT` 等环境变量覆盖;`.env.example` 增加注释说明
- **notes**: 若仍频繁断连,需排查代理 idle 超时或向量引擎侧限流;用户可暂时换 GPT Image 1.5 对比
- **source_chat**: [Gemini断连重试](eb8bd100-5a4b-439f-bdc9-077311a456ad)
### [CL-20260416-0720] 2026-04-16 07:20 — 修复拖拽参考图到 ChatInput 区域后覆盖层卡住不消失
- **tags**: bug修复, 拖拽, 覆盖层, ChatInput, stopPropagation, UX, 事件冒泡
- **affected_files**:
- art-agent/frontend/src/components/chat/chat-input.tsx
- art-agent/frontend/src/app/page.tsx
- **what**: 修复拖拽图片到 ChatInput 区域松开后,全屏拖拽覆盖层("松开以添加参考图")不消失的 bug
- **why**: CL-20260416-0620 为防止重复上传在 ChatInput.handleDrop 中加了 stopPropagation但这也阻止了事件冒泡到 page.tsx 的 main.onDrop后者负责清除 mainDragging 状态和 dragCounter。覆盖层有 pointer-events-none不直接接收事件但它的显示由 mainDragging 控制
- **decisions**: 新增 onFileDrop 回调 prop 让 ChatInput 在 handleDrop 中通知父组件,而非移除 stopPropagation移除会导致 CL-20260416-0620 修复的重复上传问题复发)
- **notes**: 这是 stopPropagation 的典型副作用 — 解决了一个冒泡问题的同时切断了另一个需要冒泡的事件链。onFileDrop 回调模式是解决此类问题的标准方式
- **source_chat**: [拖拽覆盖层卡住修复](eb8bd100-5a4b-439f-bdc9-077311a456ad)
### [CL-20260416-0700] 2026-04-16 07:00 — 新增 GeminiNativeImageProviderGemini 原生 generateContent 接口对接
- **tags**: 后端, Gemini, Provider, 原生API, generateContent, 多图参考, 架构, 向量引擎
- **affected_files**:
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/config.py
- **what**: 新增 `GeminiNativeImageProvider` 类,对接 Gemini 原生 `generateContent` 接口,使 Gemini 3.1 Flash Image 恢复多图参考能力
- **why**: 向量引擎对 Gemini 不支持 OpenAI 兼容的 `images/edit` 端点CL-20260416-0620 回退了此方案。Gemini 原生 API 通过 `generateContent` + `inline_data` 支持文字+图片混合输入,这是唯一可行的路径
- **decisions**:
- 选择直接 HTTP 调用向量引擎中转的 `/v1beta/models/{model}:generateContent` 端点,而非引入 `google-genai` SDK 新依赖——避免依赖膨胀且向量引擎已提供中转
- 新增 `_resolve_image_base64` 公共工具函数统一 data URI / 本地路径到 (mime, base64) 的解析Gemini inline_data 和未来其他 provider 都可复用
- Gemini 响应中同时处理 `inlineData`camelCase`inline_data`snake_case两种字段名因为向量引擎中转可能改变命名风格
- **notes**:
- Provider 注册表现有三个:`replicate``openai``gemini_native`,分发逻辑无需改动
- `generate_images` 中 Replicate 独有的"无参考图报错"逻辑已有 `provider_name == "replicate"` 限定Gemini 无参考图时正常走纯文生图
- API key 复用 `VECTORENGINE_API_KEY`base_url 从 `VECTORENGINE_BASE_URL` 推导(去掉 `/v1` 后拼 `/v1beta/...`
- **source_chat**: [Gemini原生Provider实现](eb8bd100-5a4b-439f-bdc9-077311a456ad)
### [CL-20260416-0620] 2026-04-16 06:20 — 修复拖拽上传重复 + Gemini 改回不支持参考图 + images.edit 参数修正
- **tags**: bug修复, 拖拽, 事件冒泡, Gemini, 向量引擎, images.edit, config, stopPropagation
- **affected_files**:
- art-agent/frontend/src/components/chat/chat-input.tsx
- art-agent/backend/app/config.py
- art-agent/backend/app/services/image_gen.py
- **what**: 三个问题修复拖拽上传重复、Gemini 500 错误、images.edit quality 参数不支持
- **why**: 用户测试多图上传时发现拖 1 张图显示 2 张Gemini 使用参考图时 500 报错
- **decisions**:
- 拖拽重复ChatInput 的 handleDrop/handleDragOver 加 e.stopPropagation(),阻止事件冒泡到 page.tsx 的 onDrop
- Gemini 500向量引擎的 Gemini 图片编辑走原生 `/v1beta/models/xxx:generateContent` 端点,不支持 OpenAI 兼容的 `/v1/images/edits`。Gemini 改回 supports_ref_image=False有参考图时走纯文生图
- images.edit 的 quality 参数OpenAI SDK images.edit 不接受 quality 参数(那是 images.generate 的),移除避免报错
- **notes**:
- Gemini 图片编辑需要对接原生 generateContent 接口才能支持参考图,作为未来方向
- GPT Image 1.5 通过向量引擎 /v1/images/edits 走通的前提是向量引擎正确转发 multipart/form-data
### [CL-20260416-0600] 2026-04-16 06:00 — 多张参考图全链路支持
- **tags**: 前端, 后端, 参考图, 多图, OpenAI, images.edit, Provider, API, Agent Loop, UX
- **affected_files**:
- art-agent/frontend/src/components/chat/chat-input.tsx
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/components/chat/chat-messages.tsx
- art-agent/frontend/src/lib/api.ts
- art-agent/frontend/src/lib/types.ts
- art-agent/backend/app/api/chat.py
- art-agent/backend/app/agent/loop.py
- art-agent/backend/app/agent/tools.py
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/config.py
- **what**: 从单张参考图扩展为多张参考图全链路支持(前端上传/显示 + API + Agent Loop + 生图 Provider
- **why**: GPT Image 1.5 支持最多 16 张参考图images.edit 端点Gemini 支持 14 张,用户需要"保留图1主体+图2视角"等多图组合控制能力
- **decisions**:
- OpenAI Provider 有参考图时从 images.generate 切换到 images.edit 端点(而非统一用 edit
- Replicate IP-Adapter 模型仍只支持单张,取 ref_image_urls[0] 兼容
- 后端 API 新增 ref_image_urls JSON 数组字段,保留旧 ref_image_url 单值字段向后兼容
- 前端 ChatMessage 新增 refImageUrls 数组字段,保留旧 refImageUrl 兼容已有聊天记录
- System Prompt 更新LLM 需要理解多张参考图各自的角色并在 prompt 中传达
- **notes**:
- 向量引擎中转需支持 /v1/images/edits 端点才能真正生效,待验证
- Gemini 走 OpenAI 兼容 API 中转images.edit 是否被正确转发取决于中转层实现
- _load_image_bytes 新函数用于将本地路径/data URI 转为 bytes 供 images.edit 使用
### [CL-20260416-0510] 2026-04-16 05:10 — 生图模型新增 Gemini 3.1 Flash Image
- **tags**: 后端, 生图模型, Gemini, 向量引擎, config
- **affected_files**:
- art-agent/backend/app/config.py
- art-agent/backend/.env
- **what**: IMAGE_MODELS 注册表新增 gemini-3.1-flash-image 条目model_id 为 gemini-3.1-flash-image-previewprovider 为 openai
- **why**: 用户希望使用 Google Gemini 3.1 Flash 的原生生图能力,速度快价格低
- **decisions**: 复用已有的 OpenAIImageProvider向量引擎中转兼容 OpenAI images/generations 端点),零代码改动
- **notes**: 如向量引擎对 Gemini 生图的返回格式有差异(如只返回 b64 不返回 URLOpenAIImageProvider 已兼容两种格式
### [CL-20260416-0500] 2026-04-16 05:00 — 全区域拖拽添加参考图
- **tags**: 前端, UX, 拖拽, 参考图, 上传
- **affected_files**:
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/components/chat/chat-input.tsx
- **what**: 将拖拽上传参考图的区域从底部输入栏扩展到整个对话区域main拖入时显示全屏半透明覆盖层提示"松开以添加参考图"
- **why**: 原先拖拽事件只绑在 ChatInput 组件(底部一小条),用户拖图片到对话区域无反应,体验不直觉
- **decisions**: ChatInput 改为 forwardRef暴露 `uploadFile` 方法ChatInputHandle 接口。page.tsx 的 `<main>` 处理 dragEnter/dragOver/dragLeave/dropdrop 时调用 `chatInputRef.current.uploadFile(file)` 复用已有上传逻辑。用 dragCounter ref 解决子元素 dragEnter/dragLeave 冒泡导致覆盖层闪烁的经典问题
- **notes**: ChatInput 自身的拖拽处理保留(作为 fallback两套不冲突
### [CL-20260416-0430] 2026-04-16 04:30 — 接入 GPT Image 1.5 生图模型(向量引擎中转)
- **tags**: 后端, 生图模型, GPT-Image, OpenAI, 向量引擎, provider
- **affected_files**:
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/config.py
- art-agent/backend/.env
- **what**: 新增 OpenAIImageProvider通过向量引擎 API 中转调用 GPT Image 1.5 模型生图。IMAGE_MODELS 注册表新增 gpt-image-1.5 条目,放在列表首位
- **why**: 用户希望使用 OpenAI 最新的 GPT Image 1.5 模型,该模型在 prompt 理解和文字渲染方面表现最好。通过向量引擎中转可复用已有的 API Key
- **decisions**: 新建 `OpenAIImageProvider` 类(与 `ReplicateProvider` 并列),使用 OpenAI Python SDK 的 `images.generate` 端点,懒初始化客户端。支持 URL 和 base64 两种返回格式。Provider 注册键为 `"openai"`
- **notes**: GPT Image 系列不支持参考图IP-Adapter`supports_ref_image=False`。如果后续需要接入 gpt-image-1 或 gpt-image-1-mini只需在注册表新增条目Provider 代码无需改动
### [CL-20260416-0400] 2026-04-16 04:00 — Session 级参考图自动沿用 + 缺参考图前置校验
- **tags**: 前端, 后端, 参考图, UX, InstantStyle, 风格迁移
- **affected_files**:
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/components/chat/chat-input.tsx
- art-agent/backend/app/services/image_gen.py
- **what**: 发送消息使用参考图后,在 session 级别记住该参考图 URL后续消息自动沿用。输入框上方显示"沿用上次参考图"提示条(含缩略图+清除按钮。后端对需要参考图但未收到的模型InstantStyle/Kolors 等)返回友好错误而非发给模型得到诡异结果
- **why**: 用户切换到 InstantStyle 模型后输入"再执行一次前面的任务",因前端每次发送后清除参考图状态,导致 InstantStyle 收不到 style_image 参数,模型返回 "No input, Save money" 文本。高频场景(换模型重试同任务)不应要求重新上传参考图
- **decisions**: 参考图以 per-session `Map<sessionId, serverUrl>` 存在 `useRef` 中(不持久化到 localStorage切换 session 时恢复对应参考图。UI 提示条仅在无主动上传且有历史参考图时显示,用户可一键清除。后端在 `generate_images` 统一入口处加前置校验,比在各 provider 内部检查更统一
- **notes**: 参考图信息不写入 Session 类型定义(不持久化到 localStorage因为服务端文件可能被清理如果将来需要持久化需考虑文件存在性校验
### [CL-20260416-0300] 2026-04-16 03:00 — System Prompt 禁止 LLM 在回复中嵌入图片 Markdown 链接
- **tags**: 后端, agent-loop, system-prompt, LLM行为约束
- **affected_files**:
- art-agent/backend/app/agent/loop.py
- **what**: 在 System Prompt 注意事项中新增约束,禁止 LLM 在回复文字中使用 `![](...)``sandbox:` 等 Markdown 图片语法
- **why**: GPT 系列模型在收到 generate_image 工具返回的本地路径后,会自行拼凑 `sandbox:/generated/xxx.png` 格式的 Markdown 图片链接嵌入回复。该链接在前端不可渲染(`sandbox:` 不是有效协议),且图片展示已由 `image_result` SSE 事件独立处理,文字中的链接纯属多余
- **decisions**: 通过 System Prompt 约束解决(而非后端过滤),因为这是 LLM 行为问题,从源头阻止更干净
- **notes**: 同时排查发现 gpt-5.4 在向量引擎中转后实际调用了 gpt-4o-mini该问题属于向量引擎 API 侧的模型映射/降级,非代码 bug
### [CL-20260416-0245] 2026-04-16 02:45 — 修复三点菜单被右侧对话区遮挡
- **tags**: 前端, UI, sidebar, 菜单, z-index
- **affected_files**:
- art-agent/frontend/src/components/sidebar/session-list.tsx
- **what**: 三点菜单从 fixed 右侧弹出改为 inline 下方展开,模型子菜单改为折叠式内联
- **why**: 菜单用 fixed 定位弹到侧栏右侧,被主内容区的 stacking context 遮挡
- **decisions**: 放弃 fixed+绝对坐标的弹出菜单方案,改为在会话项下方 inline 渲染。模型子菜单从右侧 absolute 弹出改为折叠展开式,带 max-h 240px 滚动防止过长
- **notes**: 移除了 menuPos 状态,简化了定位逻辑
### [CL-20260416-0230] 2026-04-16 02:30 — LLM 模型注册表扩充:新增 5 个前沿模型,默认改为 GPT-5.4
- **tags**: 后端, config, LLM, 模型注册表
- **affected_files**:
- art-agent/backend/app/config.py
- art-agent/backend/.env
- **what**: LLM_MODELS 注册表从 3 个模型扩充到 7 个,默认模型从 gpt-4o-mini 改为 gpt-5.4
- **why**: 用户需要使用 2026 年前沿模型GPT-5.4, Claude Sonnet/Opus 4.6, Gemini 3.1 Pro, GLM-4.7
- **decisions**: 所有新增模型走 vectorengine providermodel_id 使用向量引擎确认的名称gpt-4o-mini 和 deepseek-chat 保留作为轻量/直连选项
- **notes**: 新增模型的 model_id 需向量引擎实际支持,如有出入需调整
### [CL-20260416-0200] 2026-04-16 02:00 — 接入向量引擎中转 API + 对话级 LLM 模型切换
- **tags**: 后端, 前端, LLM, 向量引擎, 中转API, 模型切换, config, UI, session
- **affected_files**:
- art-agent/backend/app/config.py
- art-agent/backend/app/agent/loop.py
- art-agent/backend/app/api/chat.py
- art-agent/backend/.env
- art-agent/frontend/src/lib/types.ts
- art-agent/frontend/src/lib/api.ts
- art-agent/frontend/src/lib/app-context.tsx
- art-agent/frontend/src/components/sidebar/session-list.tsx
- art-agent/frontend/src/app/page.tsx
- **what**: 接入向量引擎中转 APIOpenAI 兼容格式),实现对话级 LLM 模型切换
- **why**: 用户需要通过中转 API 使用 OpenAI 系模型GPT-4o/4o-mini同时保留 DeepSeek 直连。每条对话应可独立选择不同模型
- **decisions**:
- 后端采用 LLM_MODELS 注册表 + provider 分发的架构(参考已有 IMAGE_MODELS 模式)
- provider 分为 vectorengine向量引擎中转 OpenAI 系)和 deepseek直连各有独立的 API key 和 base_url
- 移除旧的 OPENAI_API_KEY/OPENAI_BASE_URL 全局配置,改为 provider 级别
- vision 能力检测改为从注册表读取,不再子串匹配模型名
- 前端对话列表的右键菜单改为显式三点按钮菜单,模型选择作为子菜单项
- Session 类型新增 llmModel 字段llmModel 为空时使用后端默认
- **notes**:
- .env 中 VECTORENGINE_API_KEY 需要用户填入自己的 key
- Mem0 记忆系统仍使用 DEEPSEEK_API_KEY不受影响
- 三点菜单预留了分享、归档等功能的扩展位
- 旧 localStorage 中没有 llmModel 字段的 Session 自动走默认模型
### [CL-20260416-0030] 2026-04-16 00:30 — 青绿山水风格改造:从 Cyberpunk 霓虹转为千里江山图色调 + 云烟雾气动效
- **tags**: 前端, UI改造, 视觉风格, 青绿山水, 千里江山图, CSS动画, 色彩体系, 云烟, 雾气
- **affected_files**:
- art-agent/frontend/src/app/globals.css
- 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/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/detail/image-detail-panel.tsx
- art-agent/frontend/src/components/profile-modal.tsx
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/app/gallery/page.tsx
- art-agent/frontend/src/app/login/page.tsx
- docs/art-agent/VISUAL-STYLE-GUIDE.md
- **what**: 全站视觉风格从 Cyberpunk Dark Neon 改为"青绿山水"——以《千里江山图》矿物质颜料色为灵感的现代演绎。色彩体系、动画效果、背景光晕全面重做
- **why**: 用户希望美术风格从赛博朋克调整为中国传统青绿山水的现代演绎,发光效果添加"雾气感"
- **decisions**:
- 色彩体系:深墨绿底色(#0C1210+ 石青绿高亮(#4DB8A4+ 石绿蓝辅助(#3A8FB7+ 赭石红(#C4654A+ 赭金(#B8935A
- 新增 --mist 和 --gold CSS 变量
- 背景改为双层漂移云雾body::before 60s + body::after 45sinset 负值防漂移露白
- .glow-border hover 效果conic-gradient 弧段从 20% 扩大到 40-50%(更柔和),旋转从 3s 减慢到 6sblur 从 6px 增到 12pxtransition 从 500ms 增到 600ms
- .glass-panel 加内层渐变 + blur 从 20px 增到 24px + saturate(1.1)
- 新增 .fog-scroll 类mask-image 渐变遮罩),应用到聊天区和会话列表
- 11 个组件文件中的硬编码 rgba(0,229,160,...) 全部替换为 rgba(77,184,164,...)
- 动画命名从 glowSpin 改为 mistSpin
- **notes**:
- profile-modal.tsx 也有硬编码颜色,在计划外被一并修复
- Logo SVG 中的 #0B0E14 替换为新的 --bg-primary 色值 #0C1210
- VISUAL-STYLE-GUIDE.md 完全重写,包含新旧风格对比表
- **source_chat**: [青绿山水风格改造]
### [CL-20260415-2330] 2026-04-15 23:30 — 用户个人信息 + 记忆查看面板:右上角菜单弹窗
- **tags**: 前端, 后端, 用户信息, 记忆系统, Mem0, Modal, ProfileModal, API
- **affected_files**:
- art-agent/backend/app/api/memory.py
- art-agent/backend/app/main.py
- art-agent/frontend/src/components/profile-modal.tsx
- art-agent/frontend/src/components/layout/top-nav.tsx
- **what**: 新增用户个人信息弹窗,展示用户基本信息和 Mem0 记忆系统中按时间分组的记忆列表
- **why**: 用户希望能查看记忆系统为自己记录了哪些信息,右上角菜单是最自然的入口
- **decisions**:
- 后端新建独立路由文件 api/memory.py职责分离暴露 GET /api/memory/list 端点
- 使用 Mem0 OSS `Memory.get_all(user_id=..., limit=200)` 获取全部记忆
- 同步调用通过 `run_in_executor` 包装避免阻塞事件循环
- 前端用居中 Modal非 Drawer/独立页),复用 glass-panel + Cyberpunk 风格
- 记忆按 created_at 时间分组(今天/最近7天/更早),不依赖 Mem0 不稳定的 categories 字段
- 只读模式,不实现删除功能
- **notes**:
- TopNav 的 username 行从静态文本改为可点击按钮,点击打开 ProfileModal
- TopNav return 改为 Fragment 以容纳 header + Modal 两个顶级元素
- ESC 键可关闭弹窗,点击遮罩层也可关闭
- **source_chat**: [用户个人信息记忆面板]
### [CL-20260415-2230] 2026-04-15 22:30 — 光影流动边框效果:导航活动项 + 活动会话项
- **tags**: 前端, CSS动画, 视觉效果, conic-gradient, 霓虹边框, 导航, 侧边栏
- **affected_files**:
- art-agent/frontend/src/app/globals.css
- art-agent/frontend/src/components/layout/top-nav.tsx
- art-agent/frontend/src/components/sidebar/session-list.tsx
- art-agent/frontend/src/components/sidebar/sidebar.tsx
- **what**: 实现 Dribbble 设计图中的边框光影流动效果,应用到导航活动 Tab 和活动会话项
- **why**: 用户指出按钮/标签缺少设计图中的发光+光影流动感。需要用 CSS 动画实现旋转锥形渐变边框
- **decisions**:
-`@property --glow-angle` + `conic-gradient` 实现旋转光线3s 一圈
- 双伪元素方案:`::before` 做精确渐变边框mask-composite 裁掉内部),`::after` 做外层模糊扩散光晕
- 提供两个 CSS 类:`.glow-border`(动画旋转,用于活动态)、`.glow-border-static`(静态发光,用于 hover
- 导航非活动项 hover 时用静态发光,不抢活动项视觉焦点
- **notes**:
- `@property` 需要浏览器支持 CSS HoudiniChrome 85+, Edge 85+, Safari 15.4+),对目标用户群体覆盖足够
- 光影流动 3s 周期可通过修改 `glowSpin` 动画时长调整
- **source_chat**: [光影流动边框效果实现]
### [CL-20260415-2200] 2026-04-15 22:00 — 全站 UI 风格改造Cyberpunk Dark Neon 主题
- **tags**: 前端, UI改造, 视觉风格, Cyberpunk, Neon, 毛玻璃, 全局样式, 组件重构
- **affected_files**:
- 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/login/page.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/sidebar/tag-filter.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/detail/image-detail-panel.tsx
- docs/art-agent/VISUAL-STYLE-GUIDE.md
- **what**: 参考 SolCasino Dribbble 设计,将全站 UI 从朴素暗色主题改造为 Cyberpunk Dark Neon 风格
- **why**: 用户希望产品页面更具视觉冲击力,参考了 Dribbble 上的加密赌场 Cases 页面设计,提取其核心视觉语言(霓虹发光、毛玻璃、环境光晕、卡片网格)应用到美术 Agent 工具
- **decisions**:
- 主色从紫色(#6366f1)改为青绿霓虹(#00E5A0),更符合 Cyberpunk 调性
- 新增 CSS 变量:`--bg-card`, `--hot`, `--accent-secondary`, `--border-glow`
- 背景加 body::before 环境光晕(两团 radial-gradient 光斑)
- 全部面板改为 backdrop-blur 毛玻璃效果
- 圆角从 rounded-lg/2xl 统一升级为 rounded-xl/2xl
- 新增 `.glass-panel``.neon-border` 全局 CSS 类
- 图片卡片 hover 时有微缩放(1.03) + 霓虹边框发光
- 登录页加独立环境光背景 + 毛玻璃表单容器
- Gallery 卡片改为 neon-border 风格,选中时霓虹发光阴影
- 收藏色从金色改为 accent 青绿色,保持色彩一致性
- **notes**:
- 同时编写了视觉风格文档 `docs/art-agent/VISUAL-STYLE-GUIDE.md`,记录完整的设计规范
- annotation-canvas.tsx 未做改动(工具型组件,暗色已适配)
- 所有组件保留原有功能和交互逻辑,仅变更视觉层
- **source_chat**: [Cyberpunk UI 风格改造]
### [CL-20260414-2350] 2026-04-14 23:50 — 修复登录后 useApp must be used within AppProvider 报错
- **tags**: bug修复, 前端, 认证, AppProvider, 初始化时序
- **affected_files**:
- art-agent/frontend/src/lib/app-context.tsx
- **what**: 修复登录成功跳转到主页时 useApp() 拿到 null context 报错
- **why**: AppProvider 在 `!initialized` 时直接渲染 `<>{children}</>`(不提供 AppContext但已登录用户跳转到 `/``page.tsx` 立即调用 `useApp()`,而 `useEffect` 中的 `setInitialized(true)` 还没执行,导致 context 为 null
- **decisions**: 区分两种 `!initialized` 场景——已认证时显示加载状态(不渲染需要 context 的子组件),未认证时渲染 children供 /login 页面使用)
- **notes**: 这是 React 的 useEffect 异步特性导致的时序问题,登录跳转瞬间路由已变但 state 还没更新
- **source_chat**: [修复 AppProvider 初始化时序](a33b7af6-a8c0-4e88-8372-ec4b5b9dd703)
### [CL-20260414-2340] 2026-04-14 23:40 — 用 pwdlib (Argon2id) 替换 passlib (bcrypt),彻底解决兼容性问题
- **tags**: 重构, 安全, 密码哈希, Argon2, pwdlib, passlib, 依赖升级
- **affected_files**:
- art-agent/backend/app/auth.py
- art-agent/backend/requirements.txt
- docs/art-agent/USER-SYSTEM.md
- **what**: 将密码哈希从 passlib+bcrypt 替换为 pwdlib+Argon2id
- **why**: passlib 已停止维护Python 3.13+ 不可用,且与 bcrypt 5.x 有兼容性问题。pwdlib 是 passlib 的现代替代,原生支持 bcrypt 5.x
- **decisions**: 选择 pwdlib[argon2,bcrypt] 而非纯 argon2-cffipwdlib 提供与 passlib 类似的统一接口,同时内置 BcryptHasher 兼容已有旧哈希。新密码一律 Argon2id旧 bcrypt 哈希仍可验证(过渡期)
- **notes**: 已有 admin 用户的 bcrypt 哈希无需迁移,下次改密时自动升级为 Argon2id 格式。不再需要锁定 bcrypt 版本
- **source_chat**: [替换 passlib 为 pwdlib](a33b7af6-a8c0-4e88-8372-ec4b5b9dd703)
### [CL-20260414-2330] 2026-04-14 23:30 — 修复 passlib + bcrypt 5.x 兼容性问题,锁定 bcrypt<4.1
- **tags**: bug修复, 依赖, bcrypt, passlib, 用户系统
- **affected_files**:
- art-agent/backend/requirements.txt
- docs/art-agent/USER-SYSTEM.md
- **what**: 修复 passlib 1.7.4 与 bcrypt 5.0.0 不兼容导致后端启动失败
- **why**: passlib 已停止维护bcrypt 4.1+ 移除了 `__about__` 属性且改变了密码长度校验行为,导致 passlib 加载后端报 `AttributeError` + `ValueError`
- **decisions**: 降级 bcrypt 到 4.0.1 并锁定 `>=4.0.1,<4.1`,而非替换 passlib改动最小
- **notes**: 如果未来需要升级 bcrypt需替换 passlib 为 bcrypt 直接调用或改用 argon2-cffi 等不依赖 passlib 的方案
- **source_chat**: [修复 bcrypt 兼容性](a33b7af6-a8c0-4e88-8372-ec4b5b9dd703)
### [CL-20260414-2300] 2026-04-14 23:00 — 用户系统:管理员邀请制 + JWT 认证 + 数据按用户隔离
- **tags**: 用户系统, 认证, JWT, SQLite, 数据隔离, 多用户, 安全
- **affected_files**:
- art-agent/backend/requirements.txt
- art-agent/backend/app/db.py新建
- art-agent/backend/app/auth.py新建
- art-agent/backend/app/api/auth.py新建
- art-agent/backend/app/api/admin.py新建
- art-agent/backend/app/api/chat.py
- art-agent/backend/app/main.py
- art-agent/backend/app/agent/loop.py
- art-agent/backend/.env.example
- art-agent/frontend/src/lib/auth-context.tsx新建
- art-agent/frontend/src/lib/auth-guard.tsx新建
- art-agent/frontend/src/lib/api.ts
- art-agent/frontend/src/lib/store.ts
- art-agent/frontend/src/lib/app-context.tsx
- art-agent/frontend/src/app/layout.tsx
- art-agent/frontend/src/app/login/page.tsx新建
- art-agent/frontend/src/components/layout/top-nav.tsx
- art-agent/frontend/src/components/chat/model-selector.tsx
- **what**: 为 EPEEKit 添加完整用户系统管理员创建账号、JWT 登录、所有 API 鉴权、Mem0 记忆按用户隔离、前端 localStorage 按用户隔离
- **why**: 之前无认证,所有访问者共享 `default_user` 的记忆,多人通过穿透使用时数据互相混杂
- **decisions**:
- SQLitedata/epeekit.db存用户表零部署成本与 Qdrant 数据目录在同一 data/ 下
- JWT access token (30min) + refresh token (7d)passlib bcrypt 密码哈希
- 不开放注册,管理员通过 /api/admin/users 创建账号
- 首次启动自动创建 admin 账号(密码从 ADMIN_DEFAULT_PASSWORD 读取或随机生成打印到控制台)
- 前端 localStorage 键名加 user_id 前缀(如 `epeekit-{userId}-sessions`)实现数据隔离
- Mem0 的 user_id 从 JWT 中解析的真实用户 ID 传入,替代硬编码 `default_user`
- AppProvider 未登录时返回 `<>{children}</>` 而非 null确保 /login 页面可渲染
- **notes**:
- 旧 Mem0 数据仍在 `default_user` 下,不自动迁移,新用户从零积累
- 前端 401 响应自动跳转 /loginXHR上传和 fetch 均处理
- TopNav 右侧新增用户菜单(显示名称 + 退出登录)
- 需在 .env 中配置 JWT_SECRET否则启动时 RuntimeError
### [CL-20260414-2230] 2026-04-14 22:30 — 对话区域新增"回到底部"浮动按钮
- **tags**: 前端, UX, 滚动, 浮动按钮
- **affected_files**:
- art-agent/frontend/src/app/page.tsx
- **what**: 当用户在对话中间位置时,显示一个浮动的向下箭头按钮,点击平滑滚到最新消息
- **why**: 长对话中浏览历史消息后,缺少快速回到最新内容的入口
- **decisions**: 复用已有的 scroll 事件监听器检测距底部距离(阈值 200px按钮定位在输入框上方右侧。切换会话恢复位置后也主动检查按钮显示状态。使用已有的 `fadeIn` keyframes 动画
- **notes**: 按钮使用 `absolute` 定位在 `main` 容器内,`bottom-16` 避开输入框
### [CL-20260414-2220] 2026-04-14 22:20 — 修复长对话切换回来滚动位置上移scroll 事件实时保存取代 effect 延迟保存
- **tags**: 前端, bug修复, 滚动, 多会话, UX, scroll 事件
- **affected_files**:
- art-agent/frontend/src/app/page.tsx
- **what**: 修复长对话切换回来后滚动位置上移的 bug
- **why**: 之前在 useEffect 中保存旧会话的 scrollTop但 effect 执行时 DOM 已经渲染了新会话短对话的内容scrollHeight 变小,浏览器自动将 scrollTop 钳位clamp`scrollHeight - clientHeight`,导致保存的值远小于真实位置。最长的对话受影响最大,因为它的 scrollTop 差值最大
- **decisions**: 改用 scroll 事件监听器实时保存当前会话的 scrollTop 到 Map 中。这样在任何 state 变更或 DOM 重渲染之前,最新的滚动位置已经被记录。切换期间(`isSwitching=true`)跳过 scroll handler 写入,避免恢复过程中的中间值覆盖正确值
- **notes**: 这是 CL-20260414-2210 的后续修复。scroll 事件加了 `{ passive: true }` 避免影响滚动性能。cleanup 函数在 `activeSessionId` 变化时正确移除旧 listener
### [CL-20260414-2210] 2026-04-14 22:10 — 修复会话切换时滚动位置跳动:解决时序竞争 + 抑制切换期间 smooth scroll
- **tags**: 前端, bug修复, 滚动, 多会话, UX, 时序
- **affected_files**:
- art-agent/frontend/src/app/page.tsx
- **what**: 修复会话切换时滚动条频繁跳动的问题
- **why**: 上一版修复CL-20260414-2200存在时序竞争`useEffect``requestAnimationFrame` 执行时 React 可能还没将新会话的 messages 渲染到 DOM 中,导致 `scrollHeight` 是旧值。另外切换期间 `scrollToBottom(smooth)` 没有被抑制smooth 动画与位置恢复互相干扰
- **decisions**:
- 拆为两个 effect第一个依赖 `activeSessionId`)只做保存旧位置 + 设 `isSwitching` 标记;第二个(依赖 `activeSessionId` + `messages.length`)在 messages 内容更新后才恢复位置
- 新增 `isSwitching` ref切换期间抑制非 instant 的 `scrollToBottom` 调用,避免 smooth 动画干扰
- instant 模式改为直接赋值 `el.scrollTop = ...`,不再走 setTimeout消除额外异步
- `messages` 声明提前到 effect 之前,确保 `messages.length` 可在 effect 依赖中使用
- **notes**: 图片异步加载仍可能导致 scrollHeight 变化使恢复位置偏移,但这是更深层问题,当前改动先解决核心的切换跳动
### [CL-20260414-2200] 2026-04-14 22:00 — 修复多会话滚动位置共享 bugper-session 独立记录 + 切换恢复
- **tags**: 前端, bug修复, 滚动, 多会话, UX
- **affected_files**:
- art-agent/frontend/src/app/page.tsx
- **what**: 修复多会话切换时滚动条位置共享的 bug实现 per-session 滚动位置独立记录与恢复
- **why**: 所有会话共用同一个 scrollRef DOM 元素,切换会话时既不保存旧会话的 scrollTop也不恢复目标会话的位置。导致从长会话切到短会话可能看到空白底部从短会话切到长会话不会停在最新消息处
- **decisions**: 用 `useRef<Map<string, number>>` 在内存中记录每个会话的 scrollTop而非 localStorage因为滚动位置是临时状态刷新页面后重新滚到底部是合理的默认行为
- **notes**:
- 切换会话时,先保存离开会话的 scrollTop再用 `requestAnimationFrame` 等 DOM 渲染完新消息后恢复目标位置
- 无记录的会话(首次进入或新建)使用 `scrollToBottom(true)` 即时滚到底部
- `scrollToBottom` 改为 `useCallback` 并新增 `instant` 参数,恢复位置时用 instant 避免视觉跳动,正常对话流用 smooth
-`scrollToBottom` 加入 `handleSend` 的依赖数组,修复潜在的陈旧闭包问题
### [CL-20260413-2320] 2026-04-13 23:20 — 集成 Mem0 记忆系统:滑动窗口 + 跨会话长期记忆
- **tags**: Mem0, 记忆系统, 上下文管理, 长期记忆, Ollama, embedding, 滑动窗口
- **affected_files**:
- art-agent/backend/app/memory.py新建
- art-agent/backend/app/agent/loop.py
- art-agent/backend/app/config.py
- art-agent/backend/app/api/chat.py
- art-agent/backend/requirements.txt
- art-agent/backend/.env
- art-agent/frontend/src/lib/api.ts
- art-agent/frontend/src/app/page.tsx
- .gitignore
- **what**: 集成 Mem0 开源版作为统一记忆方案,一次性解决两个延期方案:上下文管理(滑动窗口截断)和跨会话长期记忆(事实提取+语义检索)
- **why**: 当前对话全量透传无截断,长对话会超 token 上限且费用线性增长;且每次会话从零开始无法记住用户偏好
- **decisions**:
- 选择 Mem0 OSS 自部署(非平台托管),完全本地化,数据在 ./data/qdrant
- DeepSeek 作为事实提取 LLM复用现有 key成本极低
- Ollama nomic-embed-text 作为本地 embedding免费768维性能足够
- 不做静默降级Ollama 不可用 → 阻止启动search 失败 → SSE error 中断对话add 失败 → SSE warning 告知用户
- 用 asyncio.run_in_executor 异步执行 memory.add(),不阻塞用户体验
- **notes**:
- 测试时创建了 data/qdrant 目录(含测试记忆数据),已加入 .gitignore
- Ollama 需要作为后台服务保持运行
- 首次 Mem0 初始化约需 5-8 秒(创建 Qdrant collection
- 解决了延期方案 [context-window-management] 和 [mem0-long-term-memory]
- **source_chat**: [Mem0 记忆系统集成](mem0-memory-integration)
### [CL-20260413-1130] 2026-04-13 11:30 — 修复 SDXL 模型 404 错误:补全 Replicate 版本 hash
- **tags**: bug修复, Replicate, SDXL, 模型配置, 404
- **affected_files**:
- art-agent/backend/app/config.py
- **what**: 修复 Stable Diffusion XL 模型调用 Replicate API 返回 404 的问题
- **why**: 与之前 Kolors IP-AdapterCL-20260413-0440完全相同的根因——Replicate 对非 Black Forest Labs 官方模型需要 `owner/model:version_hash` 完整格式,短格式 `stability-ai/sdxl` 无法定位到具体版本
- **decisions**: 使用 Replicate 官方文档中的最新推荐版本 hash `39ed52f2...e08b`
- **notes**: Flux 系列black-forest-labs/flux-schnell、flux-dev不受此问题影响Replicate 对其支持短格式。后续新增模型时应默认使用完整的 `owner/model:version_hash` 格式
### [CL-20260413-1100] 2026-04-13 11:00 — 修复切换模型后 LLM 仍声称使用旧模型名
- **tags**: Agent Loop, system prompt, 模型选择, LLM 幻觉, 模型名
- **affected_files**:
- art-agent/backend/app/agent/loop.py
- **what**: System Prompt 中动态注入当前生图模型名称,防止 LLM 从对话历史幻觉旧模型名
- **why**: 用户在同一会话内切换模型(如从 InstantStyle 切到 KolorsLLM 在回复中仍声称使用旧模型。根因是 System Prompt 不包含模型信息LLM 从对话历史中的工具返回结果/文字记录推断模型名,导致幻觉
- **decisions**: 在 system prompt 末尾动态拼接当前模型名 + 强调忽略旧记录。同时在假生成重试的纠正消息中也附带模型名
- **notes**: 通过 debug 日志验证前端→后端→工具执行的模型传递链路完全正确kolors-ipadapter 一路贯穿),问题仅在 LLM 文字输出层面。修复后验证通过
### [CL-20260413-1030] 2026-04-13 10:30 — Agent Loop 假生成检测 + 自动重试机制
- **tags**: Agent Loop, DeepSeek, function calling, 防幻觉, 自动修复
- **affected_files**:
- art-agent/backend/app/agent/loop.py
- **what**: 当 LLM 返回纯文字(无 tool_calls但内容包含"已生成"等模拟生图关键词时,自动注入纠正消息强制 LLM 重新调用工具
- **why**: DeepSeek 即使在 System Prompt 中明确禁止模拟工具调用CL-0930仍会在复杂多步任务中用文字"扮演"生图过程,导致前端收不到 image_result 事件。Prompt 约束对 DeepSeek 无效,必须在代码层面硬性拦截
- **decisions**: 用正则匹配"已生成"、"生成完成"、"图片已"等关键词,命中 2 次以上且文本 >= 50 字时判定为假生成。检测到后将 LLM 的回复保留在历史中,追加一条纠正消息"你没有调用工具,请立即调用",然后 continue 回到循环让 LLM 重试。重试消耗一次迭代配额,不影响正常对话
- **notes**: 此机制主要针对 DeepSeek 的 function calling 纪律问题。GPT-4o 等模型通常不会触发。如果 DeepSeek 重试后仍然不调用工具,会在 max_iterations 用尽后正常结束
### [CL-20260413-1000] 2026-04-13 10:00 — 助手消息气泡添加复制文本按钮
- **tags**: 前端, UX, chat-messages, 复制
- **affected_files**:
- art-agent/frontend/src/components/chat/chat-messages.tsx
- **what**: 助手回复气泡下方新增复制文本按钮hover 时显示,点击复制文本内容并显示对勾反馈
- **why**: LLM 生成的 prompt 思路、风格描述等文字内容用户经常需要复制,之前只能手动选中
- **decisions**: 按钮放在气泡下方左侧而非内部,避免干扰阅读。只对 assistant 消息显示(用户自己的消息不需要复制)。使用 navigator.clipboard APIhover 显示 + 1.5s 对勾反馈
- **notes**: 仅复制纯文本内容,不含图片信息
### [CL-20260413-0930] 2026-04-13 09:30 — System Prompt 加入"禁止模拟工具调用"约束
- **tags**: prompt 工程, system prompt, DeepSeek, function calling, 防幻觉
- **affected_files**:
- art-agent/backend/app/agent/loop.py
- **what**: System Prompt 注意事项中新增"禁止模拟工具调用"硬性约束
- **why**: DeepSeek 在复杂多步任务(如一次生成 3 种不同建筑)中,有时不通过 function calling 调用 generate_image 工具,而是用文字"模拟"生成过程(如"已生成"),导致 Agent Loop 第一轮就因 tool_calls_data 为空而 yield done前端收不到 image_result 事件,用户看不到图片
- **decisions**: 在 System Prompt 中加一条行为红线,告诉 LLM 必须实际调用工具。轻量级防御(检测文字中包含"已生成"但无工具调用时发警告)暂不实现,先观察 prompt 约束的效果
- **notes**: 这是 DeepSeek function calling 纪律问题GPT-4o 等模型通常不会出现。如果 prompt 约束不够,后续可加检测机制
### [CL-20260413-0900] 2026-04-13 09:00 — 非 vision + 参考图场景:尊重用户主动指定的风格意图
- **tags**: prompt 工程, IP-Adapter, 风格, system prompt, UX
- **affected_files**:
- art-agent/backend/app/agent/loop.py
- **what**: 将"绝对禁止风格关键词"改为"不自行猜测,但保留用户明确指定的风格"
- **why**: 之前的 system prompt 和非 vision hint 中对风格关键词的禁止是绝对性的("绝对不要"、"不要添加任何"),导致 LLM 在用户主动输入了风格描述(如"赛博朋克风"、"水彩风")时也会丢弃用户意图。系统不应替用户做决定
- **decisions**: 措辞从"绝对禁止"改为"不自行猜测,但用户明确指定则保留"。两处同步修改SYSTEM_PROMPT 第 47 行 + 非 vision 模型的 hint 文本。IP-Adapter 风格迁移与用户文字风格描述可叠加不冲突
- **notes**: 这是对 CL-20260413-0540 策略的修正——当时为了防止 DeepSeek 脑补画风而加了绝对禁止,现在细化为区分 LLM 猜测 vs 用户意图
### [CL-20260413-0800] 2026-04-13 08:00 — 修复 InstantStyle ReadTimeout + 多次生成丢失参考图
- **tags**: bug修复, Replicate, InstantStyle, timeout, 参考图, Agent Loop, wait, SDK
- **affected_files**:
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/agent/loop.py
- **what**: 修复 InstantStyle 首次调用 ReadTimeout (61.4s) + 后续调用丢失参考图返回 "No input, Save money"
- **why**: 两个运行时日志证实的独立问题:(1) `wait=60` 时 SDK `_create_prediction_timeout` 生成 `httpx.Timeout(5.0, read=60.5)` 作为 per-request timeout 传给 `httpx.AsyncClient.request()`,优先级高于客户端级 300s timeout(2) 成功生成后执行 `ref_image_url = None`,但 LLM 可能在后续轮次继续调用工具,此时 InstantStyle 因没有 `style_image` 返回 "No input, Save money"
- **decisions**: (1) `wait=60``wait=False`:不发 `Prefer: wait` headercreate 请求立即返回 prediction IDSDK 自动进入 `prediction.async_wait()` 轮询路径。轮询请求不带 per-request timeout使用客户端级 `read=300s`。代价:比 `Prefer: wait` 模式多几秒轮询延迟,但绝不会 timeout。(2) 完全移除 `ref_image_url = None`:消息构建(附加参考图到 LLM 消息在外层循环前一次性完成不受影响。ref_image_url 只在 `execute_tool()` 中使用,整个对话期间都应该传递
- **notes**: 迭代了 CL-0700 和 CL-0730 的方案。wait 参数完整语义:`True` = `Prefer: wait` + read=60.5s`int(n)` = `Prefer: wait={n}` + read={n+0.5}sn 必须 1-60`False` = 不等待、纯轮询。对慢模型InstantStyle ~128s配合大 payload1.6MB base64`wait=False` 是唯一可靠选项
### [CL-20260413-0730] 2026-04-13 07:30 — 修复生图失败后 LLM 重试丢失参考图
- **tags**: bug修复, Agent Loop, 参考图, InstantStyle, ref_image_url
- **affected_files**:
- art-agent/backend/app/agent/loop.py
- **what**: 修复 LLM 第一次生图工具调用失败后重试时参考图丢失InstantStyle 返回 "No input, Save money"
- **why**: `loop.py` 在每轮工具调用循环结束后无条件执行 `ref_image_url = None`。当第一次调用超时失败后LLM 决定重试,但此时 ref_image_url 已被清空第二次工具调用没有参考图。InstantStyle 模型必须有 `style_image` 才能工作
- **decisions**: 改为条件性清除:在工具调用循环中标记 `tc_data["_had_images"] = True`,循环结束后检查是否有任何成功产出,只有 `any_success` 时才清除 ref_image_url。失败时保留参考图供后续重试
- **notes**: 这个改动对所有参考图模型生效Kolors、InstantStyle 等。只要本轮所有工具调用都失败了ref_image_url 会一直保留直到某次成功为止
### [CL-20260413-0700] 2026-04-13 07:00 — 修复 InstantStyle 生图 422 + ReadTimeoutwait 参数超限与 SDK 内部 timeout 覆盖
- **tags**: bug修复, Replicate, InstantStyle, API参数, async_run, Prefer header, timeout
- **affected_files**:
- art-agent/backend/app/services/image_gen.py
- **what**: 修复 InstantStyle 生图 422`Prefer: wait=x must be 1-60`)和后续 ReadTimeout61.4s 超时)
- **why**: 上次修超时时将 `async_run(wait=300)` 设为 300 秒。Replicate SDK 将 int 类型的 wait 值设为 `Prefer: wait=300` header → API 返回 422。改为 `wait=True` 后 SDK 生成内部 `httpx.Timeout(read=60.5)` 作为请求级 timeout 覆盖了客户端级 300s timeout → 上传 1.6MB base64 style_image + 等待响应超过 60s → ReadTimeout
- **decisions**: 最终改为 `wait=60`API 允许的最大 int 值。SDK 行为:(1) `Prefer: wait=60` header合法(2) 内部 timeout `read=60.5s` 用于初始 create prediction 请求;(3) 60s 内未完成自动 fallback 到 `prediction.async_wait()` 轮询;(4) 轮询请求用客户端级 `httpx.Timeout(read=300s)`
- **notes**: Replicate SDK wait 参数语义:`True` = `Prefer: wait` + read=60.5s`int(n)` = `Prefer: wait={n}` + read={n+0.5}sn 必须 1-60`False` = 不等待。对慢模型用 `wait=60` + 长客户端 timeout 是最优组合
### [CL-20260413-0630] 2026-04-13 06:30 — 修复 Replicate SDK 不走代理 + 超时:自定义 Client 注入代理和长超时
- **tags**: bug修复, Replicate, InstantStyle, 代理, httpx, 超时, 图像生成
- **affected_files**:
- art-agent/backend/app/services/image_gen.py
- **what**: 修复 InstantStyle 调用时 ConnectTimeout / ReadTimeout + "URL missing protocol" 连环错误
- **why**: Replicate Python SDK 的 `_build_httpx_client` 显式传入 `transport=AsyncHTTPTransport()`,导致 httpx 跳过环境变量中的 `HTTPS_PROXY` 代理配置直连 Replicate API。网络不稳定时直连超时SDK 内部状态异常导致后续请求报 "URL missing protocol"。同时默认 read timeout 30s 不够 InstantStyle 的 ~128s 生成时间
- **decisions**: 创建 `_make_replicate_client()` 工厂函数,从环境变量读取代理并注入到 `AsyncHTTPTransport(proxy=...)` 中传给 `ReplicateClient`。read timeout 300s、connect timeout 30s。`_download_image` 也加了代理和 60s 超时
- **notes**: Replicate SDK 的代理绕过是个已知设计缺陷(显式 transport 覆盖了 httpx 的代理自动检测)。此修复对所有通过 `_replicate_client` 的 Replicate API 调用生效。无代理环境下也兼容(`proxy=None` 时 httpx 不走代理)
### [CL-20260413-0600] 2026-04-13 06:00 — 新增 InstantStyle 模型:强风格迁移选项
- **tags**: 模型注册, InstantStyle, 风格迁移, IP-Adapter, 模型配置
- **affected_files**:
- art-agent/backend/app/config.py
- **what**: 在模型注册表中新增 InstantStyle`jyoung105/instant-style`),专门做风格分离迁移,与 Kolors IP-Adapter 并列为两个参考图模型选项
- **why**: Kolors IP-Adapter 的风格迁移弱(内容+风格+构图混合提取),经调研 InstantStyle 是 Replicate 上风格迁移能力最强的可用模型,能从参考图中分离出纯画风(线条、着色、色调)
- **decisions**: 利用现有 `supports_ref_image` + `ref_image_param` 抽象机制InstantStyle 的 `style_image` 参数名通过配置映射,无需修改 `_build_input` 逻辑。默认 `block_mode: "style-only"`(只迁移风格不迁移构图),`style_strength: 1.0`
- **notes**: InstantStyle 较贵(~$0.12/次)且慢(~128s适合风格定调阶段使用Kolors 保留为低成本快速测试选项。社区模型需版本 hash
### [CL-20260413-0540] 2026-04-13 05:40 — 非 vision LLM + 参考图时禁止猜测风格,避免 prompt 与参考图风格冲突
- **tags**: prompt 工程, IP-Adapter, 风格一致性, system prompt, 非vision模型
- **affected_files**:
- art-agent/backend/app/agent/loop.py
- **what**: 修改 system prompt 和非 vision 模型的参考图文字提示,明确要求 LLM 不要猜测/指定画风关键词,风格完全交给 IP-Adapter 从参考图提取
- **why**: DeepSeek 等非 vision LLM 看不到参考图内容,会根据"2D 游戏"等关键词脑补风格(如 pixel art导致 prompt 中的风格描述与参考图实际风格严重冲突。IP-Adapter 优先服从 prompt 的显式风格指令,参考图的风格影响被稀释
- **decisions**: 双重约束——system prompt 中加入"看不到参考图时不猜风格"的通用指引 + 注入的文字提示中用编号列表明确三条规则。保留了 vision 模型路径不变vision 模型能看图,可以写风格词)
- **notes**: 这是过渡方案,等切换到 vision LLM如 GPT-4oLLM 可直接看图描述风格,此约束自动失效(走 vision_capable 分支)
### [CL-20260413-0520] 2026-04-13 05:20 — 多图生成改为单次 API 调用 + 修复空错误信息
- **tags**: 性能优化, Replicate, 速率限制, 错误处理, 模型配置
- **affected_files**:
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/config.py
- **what**: 多图生成从 for 循环多次 API 调用改为单次调用(利用模型原生批量参数),同时修复异常 str() 为空时的错误信息丢失
- **why**: LLM 传 num_images=3 时会发 3 个独立 API 请求在低余额账户的严格速率限制burst=1下后续请求会被 429 拦截。改为单次调用后只消耗 1 次请求配额。空错误信息(`[生成失败: ]`)导致用户无法诊断失败原因
- **decisions**: 新增 `num_images_param` 配置字段Flux 用 `num_outputs`Kolors 用 `number_of_images`),由 `_build_input` 统一注入。从 kolors 的 default_params 中移除了硬编码的 `number_of_images: 1`,改为动态设置
- **notes**: 空异常现在显示为 `{TypeName}: {repr(e)}`,确保始终有诊断信息
### [CL-20260413-0500] 2026-04-13 05:00 — 生成图片后显示使用的模型名称
- **tags**: 模型溯源, SSE, 图片生成, 前端展示
- **affected_files**:
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/agent/tools.py
- art-agent/backend/app/agent/loop.py
- art-agent/frontend/src/lib/types.ts
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/components/chat/chat-messages.tsx
- **what**: 图片生成后在对话中显示实际使用的模型名称(如"由 Kolors IP-Adapter 生成"),失败时也附带模型名称
- **why**: 用户无法确认前端选择的模型是否真的被用于生图,特别是在多模型切换场景下需要明确溯源
- **decisions**: `generate_images` 返回值从 `list[str]` 改为 `GenerateResult` 数据类(包含 urls + model_name + model_id模型名称通过 SSE `image_result``tool_error` 事件透传到前端。ChatMessage 类型新增 `modelName` 可选字段,消息归档后仍可查看模型来源
- **notes**: 模型名称来自 config.py 注册表中的 `name` 字段,所以显示的是友好名称而非 Replicate 的 model_id
### [CL-20260413-0440] 2026-04-13 04:40 — 修复 Kolors IP-Adapter 模型 404 错误:补全 Replicate 版本 hash
- **tags**: bug修复, Replicate, Kolors, IP-Adapter, 模型配置
- **affected_files**:
- art-agent/backend/app/config.py
- **what**: 在 kolors-ipadapter 的 model_id 中补全版本 hash`fofr/kolors-with-ipadapter` 改为 `fofr/kolors-with-ipadapter:5a1a92b2...`
- **why**: Replicate SDK `async_run` 对非 official 模型(社区模型)只用 `owner/model` 格式调用时,会尝试 official model predictions 端点,该端点返回 404。必须使用 `owner/model:version_hash` 格式才能正确创建 prediction
- **decisions**: 只修复 kolors 模型唯一报错的其他三个模型flux-schnell、flux-dev、sdxl均为 official/热门模型,`owner/model` 格式能正常工作
- **notes**: 未来新增社区模型时,需确保 model_id 包含版本 hash。经实测加版本 hash 后生图成功
### [CL-20260413-0430] 2026-04-13 04:30 — 图片生成失败时将具体错误信息透传到前端
- **tags**: 错误处理, Agent Loop, SSE, 调试体验
- **affected_files**:
- art-agent/backend/app/agent/loop.py
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/lib/api.ts
- **what**: 新增 `tool_error` SSE 事件类型,将图片生成工具的具体错误信息(如 Replicate API 报错详情)直接透传到前端展示
- **why**: 之前工具调用失败时,错误信息只传回给 LLMLLM 会自行"翻译"错误(如说成"速率限制"),用户无法看到真实报错,排查困难。典型场景:使用 Kolors IP-Adapter 生图失败,前端只看到 LLM 的模糊描述,无法定位是参数错误、超时还是 API 限制
- **decisions**: 错误信息同时发给前端展示和 LLM 继续对话,不互斥。前端用 ⚠️ 前缀醒目显示错误详情
- **notes**: 此改动不影响 LLM 的重试机制(工具结果仍然回传给 LLM只是额外增加了一条面向用户的 SSE 事件
### [CL-20260413-0330] 2026-04-13 03:30 — README 补充访问地址汇总 + 穿透管理文档
- **tags**: 文档, README, 穿透, Cloudflare, cloudflared
- **affected_files**:
- art-agent/README.md (新增"访问地址"章节 + 重写"远程访问"章节)
- **what**: README 新增三种访问场景(本机/局域网/外网)的地址汇总表,重写远程访问章节补充 cloudflared 安装指引、穿透管理操作表、代理配置排错
- **why**: 原 README 远程访问章节过于简略,缺少 cloudflared 安装方式、穿透的启动/停止/排错说明,用户实际操作时需要这些信息
- **decisions**:
- 访问地址章节放在"使用"之后、"配置说明"之前,让用户启动后立即看到访问方式
- cloudflared 安装提供 winget 和手动下载两种方式winget 安装后可能不在 PATH 中)
- 穿透管理用表格呈现(启动/停止/查看状态/重启),简洁直观
- 代理排错放在注意事项中,因为国内环境常需代理才能连上 Cloudflare
- **notes**: 无
### [CL-20260412-2345] 2026-04-12 23:45 — 参考图上传独立化:进度条 + 状态反馈 + 失败重试
- **tags**: 前端, 后端, 参考图, 上传, UX, 进度反馈
- **affected_files**:
- art-agent/backend/app/api/chat.py (新增 POST /api/upload-ref-image 独立上传端点chat 端点新增 ref_image_url 字段)
- art-agent/frontend/src/lib/api.ts (新增 uploadRefImage 函数XMLHttpRequest 实现进度回调sendChat 参数从 File 改为服务端路径字符串)
- art-agent/frontend/src/components/chat/chat-input.tsx (重写上传流程选图即上传、环形进度条、状态角标、失败重试、input value 重置)
- art-agent/frontend/src/app/page.tsx (handleSend 适配新签名:接收服务端路径而非 File标注截图也走独立上传)
- **what**: 将参考图从"随消息一起提交"改为"选中后立即独立上传",全程有进度条、状态提示、失败重试入口
- **why**: 原方案图片上传绑定在发送消息时才执行,无任何进度反馈。大图或走 Cloudflare Tunnel 时用户完全无法判断上传是否开始、是否成功、是否卡住。多次上传参考图后 file input 不重置也导致交互异常
- **decisions**: 使用 XMLHttpRequest 而非 fetchfetch 不支持 upload progress 事件);后端同时保留 ref_image 文件上传和 ref_image_url 路径两种方式,向后兼容
- **notes**: 上传超时设为 120 秒;支持 AbortController 取消file input 每次选择后立即重置 value确保同一文件可重复选择
### [CL-20260412-2315] 2026-04-12 23:15 — 修复 Cloudflare Tunnel 跨域开发警告
- **tags**: 前端, 配置, Next.js, Cloudflare, 跨域
- **affected_files**:
- art-agent/frontend/next.config.ts (新增 allowedDevOrigins)
- **what**: 在 next.config.ts 中添加 `allowedDevOrigins: ["*.trycloudflare.com"]`,消除 Cloudflare Tunnel 穿透时的跨域警告
- **why**: Next.js 检测到来自 trycloudflare.com 的跨域请求,发出警告提示未来版本将强制要求配置
- **decisions**: 使用通配符 `*.trycloudflare.com` 覆盖所有 Quick Tunnel 随机域名
- **notes**: 仅影响开发模式,生产构建不受影响
### [CL-20260412-2300] 2026-04-12 23:00 — 集成 Kolors IP-Adapter 模型,参考图可直接用于风格生成
- **tags**: 后端, 前端, IP-Adapter, 风格迁移, 参考图, 模型注册, Replicate, 架构
- **affected_files**:
- art-agent/backend/app/config.py (新增 kolors-ipadapter 模型注册 + supports_ref_image / ref_image_param 通用字段)
- art-agent/backend/app/services/image_gen.py (ReplicateProvider._build_input 支持参考图注入 + to_data_uri 公共工具函数)
- art-agent/backend/app/agent/loop.py (复用 to_data_uri 替代原 _to_vision_url移除冗余 import)
- art-agent/frontend/src/lib/types.ts (ImageModelInfo 新增 supports_ref_image 字段)
- art-agent/frontend/src/components/chat/model-selector.tsx (模型列表中显示"参考图"标记)
- **what**: 集成 Kolors IP-Adapter 模型fofr/kolors-with-ipadapter参考图可真正参与图像生成的风格控制架构上为后续切换其他 IP-Adapter 模型预留了通用扩展机制
- **why**: 之前参考图仅由 LLM 侧理解(且 DeepSeek 看不到图),生图工具侧完全忽略了参考图。用户希望参考图能真正影响生成结果的风格
- **decisions**:
- 模型注册表新增两个通用字段supports_ref_image布尔标记和 ref_image_param参考图参数名不同模型可能不同
- _build_input 根据 supports_ref_image 自动走不同构建逻辑,新增 IP-Adapter 模型只需加注册表配置
- 参考图通过 base64 data URI 传递给 Replicate避免本地 URL 不可达的问题)
- to_data_uri 抽为 image_gen.py 中的公共函数loop.py 复用(替代原 _to_vision_url
- Kolors IP-Adapter 默认 ip_adapter_weight=0.8(非 1.0),给 prompt 文字留一些影响空间
- 前端模型列表中为支持参考图的模型显示绿色"参考图"徽章
- get_image_models_list 返回 supports_ref_image 字段,前端据此渲染标记
- **notes**:
- Kolors IP-Adapter 成本极低(~$0.004/次),速度快(~4s
- 后续要换其他 IP-Adapter 模型(如 Flux IP-Adapter v2只需在 IMAGE_MODELS 中新增配置
- 分层组合产线方案IP-Adapter → LoRA → ControlNet已记录为延期方案 [style-pipeline-layered]
### [CL-20260412-2230] 2026-04-12 22:30 — 用户消息气泡中显示参考图缩略图
- **tags**: 前端, 参考图, UX, chat-messages
- **affected_files**:
- art-agent/frontend/src/lib/types.ts (ChatMessage 新增 refImageUrl 字段)
- art-agent/frontend/src/app/page.tsx (构造 userMessage 时保存参考图 Blob URL)
- art-agent/frontend/src/components/chat/chat-messages.tsx (用户消息气泡中渲染参考图缩略图)
- **what**: 用户上传参考图发送后,在聊天气泡中显示参考图缩略图,让用户确认图片确实被附带发送了
- **why**: 之前用户上传参考图后,发送消息后气泡中看不到参考图,无法确认是否上传成功,体验不好
- **decisions**:
- 使用 URL.createObjectURL 创建 Blob URL 用于当前会话内显示(轻量、即时)
- Blob URL 不持久化到 localStorage刷新后失效避免存储 base64 data URI 的空间开销
- 参考图缩略图显示在消息文本上方,最大尺寸 160×120px
- **notes**:
- Blob URL 仅在当前标签页有效刷新页面后旧消息中的参考图缩略图不可见MVP 可接受)
- 如需持久化,后续可改为存储后端上传路径(需要等后端返回 upload URL 后再构造 userMessage
### [CL-20260412-2200] 2026-04-12 22:00 — 修复 DeepSeek 不支持 image_url 导致参考图上传报错
- **tags**: bug修复, 后端, Agent Loop, vision, DeepSeek, 多模态
- **affected_files**:
- art-agent/backend/app/agent/loop.py (新增 vision 能力检测,非 vision 模型走文字提示)
- **what**: 修复使用 DeepSeek 作为 LLM 时,上传参考图后报 400 错误 `unknown variant 'image_url', expected 'text'`
- **why**: 代码原本假设所有 LLM 都支持 OpenAI Vision API 的 `image_url` content type但 DeepSeek 的 Chat API 只支持 `text` 类型
- **decisions**:
- 通过模型名称关键词检测 vision 能力gpt-4o / gpt-4-vision / claude 视为支持)
- 不支持 vision 的模型:用文字提示告知 LLM 有参考图存在,参考图仍由图像生成工具侧处理
- 支持 vision 的模型:保持原有 image_url 格式不变
- **notes**:
- 此方案确保参考图功能在任何 LLM 下都不会报错
- 非 vision 模型无法"看到"参考图,但生图工具仍会收到参考图用于 img2img 场景
- 如需更精确的 vision 能力检测,可后续改为在 config.py 中按模型注册 vision 标志
### [CL-20260412-2130] 2026-04-12 21:30 — 修复图片生成失败时前端显示破碎图标
- **tags**: bug修复, 前端, 后端, 图像生成, 错误处理
- **affected_files**:
- art-agent/backend/app/agent/tools.py (分离有效 URL 和错误信息)
- art-agent/frontend/src/app/page.tsx (过滤无效 URL空列表时不渲染图片网格)
- **what**: 修复 Replicate API 失败时前端出现 4 个破碎图标框的问题
- **why**: 用户在 Replicate 余额不足402时生图错误字符串被当作图片 URL 传给 `<img>` 标签
- **decisions**:
- 后端 tools.py将 generate_images 返回值分为 images有效 URL和 errors错误信息两个字段
- 前端 page.tsx过滤以 `[` 开头的无效 URL无有效图片时直接 break 不创建 ImageAsset
- **notes**:
- 根因是 Replicate 账户余额不足,需用户到 https://replicate.com/account/billing#billing 充值
- 此修复确保任何 API 错误场景下前端都不会显示破碎图标
### [CL-20260412-2100] 2026-04-12 21:00 — 生图模型动态切换Provider 抽象 + 前端模型选择器
- **tags**: 后端, 前端, 图像生成, 模型切换, Provider, 重构, Replicate
- **affected_files**:
- art-agent/backend/app/config.py (新增 IMAGE_MODELS 注册表 + get_image_model_config / get_image_models_list / get_default_image_model_id)
- art-agent/backend/app/services/image_gen.py (重构ImageProvider 抽象基类 + ReplicateProvider 实现 + generate_images 接受 model_id)
- art-agent/backend/app/api/chat.py (新增 GET /api/models 端点 + POST /api/chat 增加 image_model 参数)
- art-agent/backend/app/agent/loop.py (run_agent_loop 增加 image_model 参数透传)
- art-agent/backend/app/agent/tools.py (execute_tool 增加 image_model 参数透传)
- art-agent/backend/.env (IMAGE_MODEL 改为短 ID 格式)
- art-agent/backend/.env.example (同步更新)
- art-agent/frontend/src/lib/types.ts (新增 ImageModelInfo 类型)
- art-agent/frontend/src/lib/api.ts (新增 fetchModels() + sendChat 增加 imageModel 参数)
- art-agent/frontend/src/components/chat/model-selector.tsx (新建:模型选择下拉组件)
- art-agent/frontend/src/components/chat/chat-input.tsx (集成 ModelSelector + onSend 签名扩展)
- art-agent/frontend/src/app/page.tsx (handleSend 透传 imageModel 到 sendChat)
- **what**: 将图像生成服务从 Replicate 硬绑定重构为 Provider 抽象架构,支持 Replicate 上多模型flux-schnell / flux-dev / SDXL按消息粒度切换
- **why**: 用户希望能在对话中随时切换生图模型来对比不同模型的效果
- **decisions**:
- Provider 模式:抽象基类 ImageProvider + 具体实现(当前只有 ReplicateProvider预留 DalleProvider/ComfyUIProvider 等扩展点
- 模型注册表放在 config.py 中Python dict新增模型只需加一条配置
- 前端模型列表由后端 API 驱动GET /api/models不在前端硬编码
- 切换粒度为每条消息级(最灵活),通过 FormData 传递 image_model 参数
- 模型选择持久化到 localStorage作为后续消息的默认值
- Flux 系列使用 aspect_ratio 参数SDXL 使用 width/height 参数,由 ReplicateProvider._build_input 内部分流处理
- **notes**:
- 向后兼容image_model 参数可选,不传时使用 .env 中的默认模型
- .env 中 IMAGE_MODEL 支持短 IDflux-schnell和完整 Replicate IDblack-forest-labs/flux-schnell自动转换
### [CL-20260412-1800] 2026-04-12 18:00 — 移动端适配 + Cloudflare Tunnel 内网穿透
- **tags**: 前端, 响应式, 移动端, 部署, Cloudflare, 穿透, 配置
- **affected_files**:
- art-agent/frontend/src/app/layout.tsx (viewport meta 配置)
- art-agent/frontend/src/app/globals.css (iOS bounce 防护、tap highlight、dvh 支持、遮罩动画)
- art-agent/frontend/src/app/page.tsx (桌面端展开按钮加 hidden md:flex)
- art-agent/frontend/src/app/gallery/page.tsx (网格 gap/列数适配、hover 操作移动端始终可见、选择框可见)
- art-agent/frontend/src/components/sidebar/sidebar.tsx (移动端固定定位抽屉 + 遮罩)
- art-agent/frontend/src/components/layout/top-nav.tsx (新增汉堡菜单按钮、间距微调)
- art-agent/frontend/src/components/detail/image-detail-panel.tsx (移动端全屏 overlay)
- art-agent/frontend/src/components/chat/chat-messages.tsx (气泡宽度 90%、间距收紧)
- art-agent/frontend/src/components/chat/chat-input.tsx (padding 收紧)
- art-agent/frontend/src/components/chat/image-grid.tsx (max-width 适配、操作栏移动端可见)
- art-agent/frontend/src/lib/app-context.tsx (小屏默认折叠侧边栏、切换会话自动收起)
- art-agent/frontend/next.config.ts (trycloudflare.com + replicate.delivery 白名单)
- art-agent/start-tunnel.ps1 (新建:一键穿透脚本)
- art-agent/README.md (新增远程访问章节、uvicorn 加 --host 0.0.0.0)
- **what**: 为实现手机浏览器远程访问完成移动端响应式适配9 个组件)和 Cloudflare Quick Tunnel 穿透方案
- **why**: 用户希望把项目发给自己和朋友在手机上通过浏览器使用
- **decisions**:
- 移动端断点统一用 `md:` (768px),与 Tailwind 默认保持一致
- 侧边栏移动端用 fixed + overlay 抽屉模式,桌面端保持原有 w-0/w-[280px] 切换
- 图片详情面板移动端全屏fixed inset-0不做半屏因为手机屏幕空间有限
- 穿透方案选 Cloudflare Quick Tunnel免费、免注册不选 ngrok有连接限制
- 穿透脚本自动管理 .env.local 的更新和恢复,减少手动操作
- hover 操作在移动端始终显示(无 hover 事件),避免操作不可达
- 路线 B云服务器正式部署记录为延期方案待后续实施
- **notes**:
- 构建验证通过,无 lint 错误
- cloudflared 通过 winget 安装,版本 2025.8.1
- Quick Tunnel 每次启动分配随机域名xxx.trycloudflare.com非固定
- 穿透启动后需重启前端才能读取新的 NEXT_PUBLIC_API_URL
- project-launcher Skill 中的 uvicorn 命令也同步加了 --host 0.0.0.0
### [CL-20260412-1530] 2026-04-12 15:30 — 修复 .env 配置加载时序问题
- **tags**: 后端, bug修复, 配置, dotenv, Python
- **affected_files**:
- art-agent/backend/app/main.py (load_dotenv 前置 + override=True)
- art-agent/backend/app/config.py (模块级常量 → 函数式懒读取)
- art-agent/backend/app/agent/loop.py (引用改为函数调用)
- art-agent/backend/app/services/image_gen.py (引用改为函数调用)
- art-agent/backend/.env (OPENAI_BASE_URL 去掉 /v1)
- **what**: 修复切换 DeepSeek API 后报 "Model Not Exist" 400 错误
- **why**: 用户修改 .env 切换到 DeepSeek但实际发出的请求仍使用默认模型名 gpt-4o-mini
- **decisions**:
- load_dotenv(override=True) 放在 main.py 所有业务 import 之前,确保环境变量在任何模块读取前就位
- config.py 从模块级常量改为函数get_llm_model() 等),避免 import 时求值被固化
- 不使用 lru_cache避免首次调用在 load_dotenv 前就缓存了空值)
- **notes**:
- 根因有两层import 时序config.py 在 load_dotenv 前被 import+ override 默认行为(不覆盖已有环境变量)
- 通过在 uvicorn 同环境中运行 Python 脚本成功复现了 API 调通,证明问题在进程内加载顺序而非 API 本身
- DeepSeek base_url 使用 https://api.deepseek.com不带 /v1与官方文档一致
### [CL-20260412-1500] 2026-04-12 15:00 — 品牌重命名 EPEEKit + API 配置集中化
- **tags**: EPEEKit, 品牌, 配置, 后端, 前端, 重构
- **affected_files**:
- art-agent/frontend/src/app/layout.tsx (title/description)
- art-agent/frontend/src/components/layout/top-nav.tsx (Logo SVG + 品牌名)
- art-agent/frontend/src/app/page.tsx (欢迎文案)
- art-agent/frontend/src/lib/store.ts (localStorage 键名)
- art-agent/frontend/src/app/gallery/page.tsx (下载前缀)
- art-agent/frontend/src/components/detail/image-detail-panel.tsx (下载前缀)
- art-agent/frontend/src/components/chat/image-grid.tsx (下载前缀)
- art-agent/frontend/package.json (项目名)
- art-agent/frontend/package-lock.json (项目名)
- art-agent/backend/app/main.py (FastAPI title)
- art-agent/backend/app/config.py (新建:集中配置)
- art-agent/backend/app/agent/loop.py (引用集中配置)
- art-agent/backend/app/services/image_gen.py (引用集中配置 + 简化重复分支)
- art-agent/backend/.env (重组为分组结构 + 新增 LLM_MODEL/IMAGE_MODEL 等)
- art-agent/backend/.env.example (新建)
- art-agent/README.md (全文更新)
- **what**: 将应用从 "Art Agent" 正式命名为 "EPEEKit",同时将后端所有硬编码的 API/模型配置抽取到 .env 文件,通过 config.py 集中管理
- **why**: 用户要求正式命名应用并希望能方便地切换 LLM 和图像生成模型进行测试
- **decisions**:
- 品牌名全部统一为 "EPEEKit"包括页面标题、Logo、下载文件名、localStorage 键名
- Logo 采用内联 SVG 设计(主题色圆角方块 + A/i 组合),不引入外部图片资源
- API 配置走 .env + config.py 方案(非 settings.py 类模式),保持极简
- 切换 LLM 只需改 .env 中 3 个值OPENAI_BASE_URL + OPENAI_API_KEY + LLM_MODEL
- image_gen.py 中去除了参考图分支的重复代码(两个分支逻辑完全相同)
- **notes**:
- localStorage 键名变更意味着旧数据不会自动迁移MVP 阶段可接受)
- .env 文件中含真实密钥,需确认 .gitignore 覆盖
### [CL-20260412-0300] 2026-04-12 03:00 — 完成交互原型全面重构
- **tags**: art-agent, 前端, 交互设计, 三栏布局, 标签系统, Canvas标注, 资源库, 后端
- **affected_files**:
- art-agent/frontend/src/app/page.tsx (重写)
- art-agent/frontend/src/app/layout.tsx (添加 AppProvider)
- art-agent/frontend/src/app/globals.css (增加样式)
- art-agent/frontend/src/app/gallery/page.tsx (新建)
- art-agent/frontend/src/lib/types.ts (新建:全局类型定义)
- art-agent/frontend/src/lib/store.ts (新建localStorage 持久化存储)
- art-agent/frontend/src/lib/app-context.tsx (新建:全局状态 Context)
- art-agent/frontend/src/lib/api.ts (更新类型兼容)
- 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/sidebar/tag-filter.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/detail/image-detail-panel.tsx (新建)
- art-agent/frontend/src/components/detail/annotation-canvas.tsx (新建)
- art-agent/backend/app/agent/loop.py (System Prompt + base64 转换)
- **what**: 从单页对话 MVP 全面重构为完整交互原型,三个 Phase 一次性完成
- **why**: 用户要求完整设计交互原型,通过规划讨论确定了三栏布局 + 标签系统 + 标注 + 资源库的完整方案
- **decisions**:
- 数据持久化使用 localStorageMVP 阶段),后续可迁移到后端数据库
- 全局状态管理使用 React Context + useReducer 模式,不引入第三方状态库
- 标注功能使用原生 Canvas API不引入第三方画板库减少依赖
- 标注坐标使用归一化比例0-1与图片实际分辨率解耦
- 资源库使用 Modal overlay 展示详情,而非独立路由页面
- 标注截图通过 base64 data URI 直接传给 OpenAI Vision后端自动处理本地路径转换
- **notes**:
- 新增 12 个源文件,修改 6 个现有文件,无新依赖引入
- 内置标签 6 个UI、Icon、原画、风格探索、立绘、概念图
- 标注工具 4 种:矩形框选、箭头、自由画笔、文字,含撤销/清除
- 构建验证通过,无 linter 错误
- 未来可优化:标签自动推荐逻辑(当前仅预留接口)、后端会话持久化、图片搜索索引
### [CL-20260411-1500] 2026-04-11 15:00 — 完成美术 Agent 产品决策和技术选型
- **tags**: art-agent, 产品决策, 技术选型, 规划
- **affected_files**:
- docs/art-agent/DECISIONS.md
- docs/art-agent/TECH-STACK.md
- **what**: 通过 5 轮结构化讨论确定了美术 Agent 工具的产品决策产品形态、交互模型、AI 架构、风格管理、资源 Pipeline并完成全部 8 个维度的技术选型
- **why**: 项目从零开始,需要先把产品方向和技术基准定下来再动手写代码
- **decisions**:
- 产品形态选 Chat-first Web App排除纯 Bot 和桌面应用)
- 前端 Next.js / 后端 Python FastAPI / 自建 Agent Loop不用 LangChain
- 数据库 PostgreSQL / 图像生成 Replicate / 部署 Vercel + Railway
- 对象存储 MVP 先用本地文件系统
- **notes**: 技术选型中图像生成 API、部署方案、异步任务三项用户未明确选定采用了推荐方案
### [CL-20260411-1600] 2026-04-11 16:00 — 搭建美术 Agent MVP 全部前后端代码
- **tags**: art-agent, MVP, 前端, 后端, FastAPI, Next.js, Agent Loop
- **affected_files**:
- art-agent/README.md
- art-agent/backend/requirements.txt
- art-agent/backend/.env.example
- art-agent/backend/app/main.py
- art-agent/backend/app/api/chat.py
- art-agent/backend/app/agent/loop.py
- art-agent/backend/app/agent/tools.py
- art-agent/backend/app/services/image_gen.py
- art-agent/frontend/package.json
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/app/layout.tsx
- art-agent/frontend/src/app/globals.css
- art-agent/frontend/src/components/chat/chat-messages.tsx
- art-agent/frontend/src/components/chat/chat-input.tsx
- art-agent/frontend/src/components/chat/image-grid.tsx
- art-agent/frontend/src/lib/api.ts
- docs/art-agent/MVP-PLAN.md
- **what**: 从零创建了完整的 MVP 前后端代码,包括 FastAPI 后端Agent Loop + Replicate 图像生成 + SSE 流式推送)和 Next.js 前端Chat UI + 参考图上传 + 图片下载),后端已验证可正常启动
- **why**: 以最小流程跑通端到端闭环(对话 → 生图 → 迭代 → 保存),快速暴露集成问题
- **decisions**:
- MVP 范围刻意砍掉:数据库/持久化、Skill/Rules 机制、风格库、用户认证、云端部署
- Agent Loop 硬编码 system prompt不走 Skill 扩展(最简化)
- 图像模型选 flux-schnell快速版优先验证流程通畅
- OpenAI 客户端延迟初始化,避免无 Key 时模块加载失败
- **notes**:
- 前端使用 Tailwind CSS v4 + PostCSS 配置方式(非 tailwind.config.ts
- 参考图在 MVP 阶段仅通过 GPT vision 理解风格后融入 prompt未直接传给图像模型做 img2img
### [CL-20260411-1630] 2026-04-11 16:30 — 完成开发环境搭建和依赖安装
- **tags**: art-agent, 环境搭建, Node.js, Python
- **affected_files**:
- art-agent/backend/venv/
- art-agent/frontend/node_modules/
- **what**: 通过 winget 安装 Node.js v24.14.1,创建 Python 虚拟环境并安装后端依赖9 个包),安装前端 npm 依赖46 个包),后端启动验证通过
- **why**: 代码写好后需要实际运行环境来验证
- **decisions**:
- Node.js 用 winget 安装 LTS 版本
- Python 虚拟环境放在 backend/venv/ 下
- **notes**:
- 遇到 3 个环境问题已解决OpenAI 延迟初始化、PowerShell 不支持 &&、脚本执行策略限制
- 端到端完整测试待用户配置 API Key 后进行

View File

@@ -0,0 +1,63 @@
# Dev Changelog — Headlines
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
- [CL-20260416-0830] 生图按模型预处理 prompt新增 image_prompt_strategySDXL 默认负向词+---NEGATIVE---拆分Flux 剥粘贴的 Negative 段GPT/Gemini/IP-Adapter 规范化GenerateResult 带 effective_prompt/negative_promptReplicate SDXL 传 negative_prompt工具说明 SDXL 分隔符
- [CL-20260416-0810] 对话输入栏支持剪贴板粘贴图片为参考图ChatInput根容器onPasteCapture提取image/*文件并走startUploadplaceholder/title提示
- [CL-20260416-0745] Gemini原生生图延长读写超时+对RemoteProtocolError等可重试错误自动重试(默认3次指数退避)环境变量可微调VECTORENGINE_GEMINI_*_TIMEOUT
- [CL-20260416-0720] 修复拖拽参考图到ChatInput区域后覆盖层卡住不消失ChatInput的stopPropagation阻止了main.onDrop新增onFileDrop回调通知父组件清除拖拽状态
- [CL-20260416-0700] 新增GeminiNativeImageProvider对接Gemini原生generateContent接口支持多图参考最多14张Gemini恢复supports_ref_image=True
- [CL-20260416-0620] 修复拖拽上传重复(stopPropagation)+Gemini改回不支持参考图(向量引擎不支持Gemini走images/edits)+images.edit移除不支持的quality参数
- [CL-20260416-0600] 多张参考图全链路支持:前端多图上传/拖拽/沿用+后端API/AgentLoop/Provider全部改为list[str]OpenAI走images.edit端点(最多16张)Replicate取首张兼容
- [CL-20260416-0510] 生图模型注册表新增Gemini 3.1 Flash Image(gemini-3.1-flash-image-preview)走向量引擎中转复用OpenAIImageProvider
- [CL-20260416-0500] 全区域拖拽添加参考图拖拽区域从底部输入栏扩展到整个对话区域拖入时全屏覆盖层提示ChatInput改forwardRef暴露uploadFile
- [CL-20260416-0430] 接入GPT Image 1.5生图模型新增OpenAIImageProvider走向量引擎中转IMAGE_MODELS注册表新增gpt-image-1.5
- [CL-20260416-0400] Session级参考图自动沿用发送后记住参考图供后续消息复用+UI提示条+可清除,后端对缺参考图的风格迁移模型返回友好错误
- [CL-20260416-0300] System Prompt新增禁止LLM在回复中嵌入Markdown图片链接(sandbox:等)图片展示由image_result SSE事件处理
- [CL-20260416-0245] 修复三点菜单被右侧对话区遮挡从fixed右侧弹出改为inline下方展开+模型列表折叠式内联,全部在侧栏内完成
- [CL-20260416-0230] LLM注册表扩充至7个模型(+gpt-5.4/claude-sonnet-4-6/claude-opus-4-6/gemini-3.1-pro-preview/glm-4.7)默认改为gpt-5.4
- [CL-20260416-0200] 接入向量引擎中转API+对话级LLM模型切换LLM_MODELS注册表(gpt-4o-mini/gpt-4o/deepseek-chat)provider分发(vectorengine/deepseek)前端三点菜单模型选择Session级llmModel
- [CL-20260416-0030] 青绿山水风格改造色彩体系替换为千里江山图石青石绿色调背景改双层漂移云雾glow-border 柔化为云烟缭绕,新增 fog-scroll 雾气边缘11 个组件硬编码颜色统一替换
- [CL-20260415-2230] 光影流动边框改为 hover 触发常态微弱静态发光hover 时旋转动画通过 opacity 500ms 平滑淡入/淡出,无跳变
- [CL-20260415-2330] 用户个人信息+记忆查看面板:右上角菜单弹窗,展示 Mem0 记忆列表(按时间分组,只读),后端新增 /api/memory/list 端点
- [CL-20260415-2200] 全站 UI 风格改造为 Cyberpunk Dark Neon主色改为青绿霓虹新增环境光晕/毛玻璃/霓虹发光边框13 个组件重构 + 视觉风格文档
- [CL-20260414-2350] 修复登录后 useApp must be used within AppProviderAppProvider 未初始化时区分已认证/未认证,避免子组件拿到 null context
- [CL-20260414-2340] 用 pwdlib (Argon2id) 替换 passlib (bcrypt):彻底解决兼容性问题,新密码 Argon2id旧 bcrypt 哈希兼容验证
- [CL-20260414-2330] 修复 passlib + bcrypt 5.x 兼容性:锁定 bcrypt>=4.0.1,<4.1,解决后端启动 AttributeError + ValueError
- [CL-20260414-2300] 用户系统:管理员邀请制 + JWT 认证 + SQLite 用户表 + Mem0/localStorage 按用户隔离,所有 API 鉴权,前端登录页 + AuthGuard
- [CL-20260414-2230] 对话区域新增"回到底部"浮动按钮:不在底部时显示向下箭头,点击平滑滚到最新消息
- [CL-20260414-2220] 修复长对话切换回来滚动位置上移scroll 事件实时保存替代 effect 延迟保存,避免 DOM 变更后 scrollTop 被浏览器 clamp
- [CL-20260414-2210] 修复会话切换时滚动位置跳动:拆 effect 解决时序竞争 + isSwitching 抑制 smooth scroll 干扰 + instant 同步赋值
- [CL-20260414-2200] 修复多会话滚动位置共享 bugper-session 独立记录滚动位置,切换会话时保存/恢复,无记录时滚到底部
- [CL-20260413-2320] 集成 Mem0 记忆系统Mem0 OSS + DeepSeek 事实提取 + Ollama 本地 embedding + 滑动窗口,一次性解决上下文管理和跨会话长期记忆
- [CL-20260413-1130] 修复 SDXL 模型 404 错误:与 Kolors 同因Replicate 非 Flux 官方模型需补全版本 hash
- [CL-20260413-1100] 修复切换模型后 LLM 仍声称使用旧模型名System Prompt 动态注入当前模型名,阻止 LLM 从对话历史幻觉旧模型
- [CL-20260413-1030] Agent Loop 假生成检测 + 自动重试DeepSeek 用文字模拟生图时自动注入纠正消息强制调用工具
- [CL-20260413-1000] 助手消息气泡添加复制文本按钮hover 显示、点击复制 + 对勾反馈
- [CL-20260413-0930] System Prompt 加入"禁止模拟工具调用"约束,防止 DeepSeek 用文字模拟生图而不调用工具
- [CL-20260413-0900] 非 vision + 参考图场景:不再绝对禁止风格词,改为"不自行猜测但保留用户明确指定的风格"
- [CL-20260413-0800] 修复 InstantStyle ReadTimeout + 多次生成丢失参考图wait 改 False 走纯轮询 + 不再清除 ref_image_url
- [CL-20260413-0730] 修复生图失败后 LLM 重试丢失参考图ref_image_url 改为仅在成功产出图片后才清除
- [CL-20260413-0700] 修复 InstantStyle 生图 422 + ReadTimeout`wait=300` 超限改为 `wait=60`SDK 超时自动 fallback 轮询
- [CL-20260413-0630] 修复 Replicate SDK 不走代理 + 超时不够:注入 HTTPS_PROXY 到 transport + read 300s / connect 30s解决 ConnectTimeout 连环错误
- [CL-20260413-0600] 新增 InstantStyle 模型(强风格迁移),与 Kolors 并列为参考图模型选项block_mode=style-only
- [CL-20260413-0540] 非 vision LLM + 参考图时禁止猜测风格prompt 只描述内容不写画风,风格交由 IP-Adapter 处理
- [CL-20260413-0520] 多图生成改为单次 API 调用(利用模型原生批量参数),避免速率限制 + 修复空错误信息显示
- [CL-20260413-0500] 图片生成后在对话中显示实际使用的模型名称(如"由 Kolors IP-Adapter 生成"),便于模型溯源
- [CL-20260413-0440] 修复 Kolors IP-Adapter 404 错误:社区模型需 `owner/model:version_hash` 格式,补全版本 hash 后生图成功
- [CL-20260413-0430] 图片生成失败时将具体错误信息Replicate API 报错等)透传到前端展示,新增 tool_error SSE 事件
- [CL-20260413-0330] README 补充访问地址汇总(本机/局域网/外网)+ 穿透管理文档(安装/启停/排错)
- [CL-20260412-2345] 参考图上传独立化:选图即上传 + 环形进度条 + 成功/失败状态反馈 + 失败重试,后端新增独立上传端点
- [CL-20260412-2315] 修复 Cloudflare Tunnel 跨域开发警告next.config.ts 新增 allowedDevOrigins 通配符
- [CL-20260412-2300] 集成 Kolors IP-Adapter 模型:参考图可直接用于风格生成,模型注册表新增通用参考图支持机制
- [CL-20260412-2230] 用户消息气泡中显示参考图缩略图ChatMessage 新增 refImageUrl发送后在气泡中渲染参考图
- [CL-20260412-2200] 修复 DeepSeek 不支持 image_url 参考图上传报错:新增 vision 能力检测,非 vision 模型走文字提示
- [CL-20260412-2130] 修复图片生成失败时前端显示破碎图标:后端分离错误信息与有效 URL前端过滤无效 URL 不渲染空图片
- [CL-20260412-2100] 生图模型动态切换Provider 抽象架构 + 前端模型选择器 + Replicate 多模型flux-schnell/flux-dev/SDXL按消息粒度切换
- [CL-20260412-1800] 移动端适配 + Cloudflare Tunnel 内网穿透响应式布局改造9 个组件)+ 一键穿透脚本 + cloudflared 安装
- [CL-20260412-1530] 修复 .env 配置加载时序问题load_dotenv 前置 + override=True + config.py 改函数式懒读取
- [CL-20260412-1500] 品牌重命名 Art Agent → EPEEKit + 后端 API/模型配置集中化到 .env + config.py
- [CL-20260412-0300] 完成交互原型全面重构:三栏布局 + 多会话标签系统 + Canvas 标注 + 资源库页面
- [CL-20260411-1630] 安装 Node.js v24.14.1 + Python venv + 前后端依赖,后端启动验证通过
- [CL-20260411-1600] 从零创建美术 Agent MVP 全部前后端代码FastAPI + Next.js + Agent Loop + SSE
- [CL-20260411-1500] 完成美术 Agent 产品决策5 个核心问题和技术选型8 个维度)两份文档

View File

@@ -0,0 +1,280 @@
# Dev Changelog — Recent
最近 ~10 次改动的摘要记录,按时间倒序排列。
当 Agent 检测到当前任务与近期改动相关时自动读取。
### [CL-20260416-0830] 2026-04-16 — 生图按模型预处理 prompt
- **tags**: 后端, 生图, prompt, SDXL, Flux, Replicate, image_gen, Agent工具
- **affected_files**: image_prompt_strategy.py, image_gen.py, tools.py
- **summary**: 新增 `image_prompt_strategy`:按注册表 id 为 7 个生图模型应用策略OpenAI/Gemini 自然语言规范化BFL Flux 指南对齐、剥 SD 式 Negative 段SDXL 默认负向词 + `---NEGATIVE---`/`|||NEG|||` 拆分IP-Adapter 侧重内容描述)。`generate_images` 统一调用后下发Replicate 非 Flux 路径写入 `negative_prompt``GenerateResult` 增加 `effective_prompt`/`negative_prompt`;工具返回与描述补充 SDXL 分隔符约定。
### [CL-20260416-0810] 2026-04-16 — 对话栏粘贴剪贴板图片为参考图
- **tags**: 前端, ChatInput, 剪贴板, 参考图, UX
- **affected_files**: chat-input.tsx
- **summary**: 在 ChatInput 根容器上使用 onPasteCapture从 clipboardData.items 收集 kind===file 且 image/* 的项,调用 getAsFile 后复用现有 startUpload。无图片文件时不拦截纯文本粘贴照常。placeholder 与 textarea title 提示可粘贴图片。
### [CL-20260416-0745] 2026-04-16 — Gemini 原生生图:超时与断连重试
- **tags**: 后端, Gemini, httpx, 超时, 重试, 向量引擎, image_gen
- **affected_files**: image_gen.py, .env.example
- **summary**: 用户遇到 `Server disconnected without sending a response`httpx RemoteProtocolError 等)。将 Gemini generateContent 默认 read 超时提高到 600s、write 180s、connect/pool 60s对 RemoteProtocolError/ConnectError/ReadTimeout 等可恢复错误默认最多重试 3 次(指数退避 1s/2s失败时错误信息附带调优提示。可通过 VECTORENGINE_GEMINI_* 环境变量覆盖。
### [CL-20260416-0720] 2026-04-16 — 修复拖拽参考图到 ChatInput 区域后覆盖层卡住不消失
- **tags**: bug修复, 拖拽, 覆盖层, ChatInput, stopPropagation, UX
- **affected_files**: chat-input.tsx, page.tsx
- **summary**: 文件拖拽到 ChatInput 区域时ChatInput.handleDrop 的 stopPropagation 阻止事件冒泡到 main.onDrop导致 mainDragging 状态无法重置为 false拖拽覆盖层"松开以添加参考图"卡住不消失。修复ChatInput 新增 onFileDrop 回调 prop在 handleDrop 中调用page.tsx 传入回调重置 dragCounter 和 mainDragging。
### [CL-20260416-0700] 2026-04-16 — 新增 GeminiNativeImageProviderGemini 原生 generateContent 接口对接
- **tags**: 后端, Gemini, Provider, 原生API, generateContent, 多图参考, 架构
- **affected_files**: image_gen.py, config.py
- **summary**: 新增 GeminiNativeImageProvider通过向量引擎中转调用 Gemini 原生 `/v1beta/models/{model}:generateContent` 接口(非 OpenAI 兼容的 images/edit。支持文字+图片混合输入(最多 14 张参考图),图片以 inline_data base64 内联传入。新增 `_resolve_image_base64` 工具函数统一处理 data URI 和本地路径到 (mime, b64) 的解析。config.py 中 Gemini 模型改用 `gemini_native` provider 并恢复 `supports_ref_image: True`。Provider 注册表新增 `gemini_native` 条目。
### [CL-20260416-0620] 2026-04-16 — 修复拖拽上传重复 + Gemini 改回不支持参考图 + images.edit 参数修正
- **tags**: bug修复, 拖拽, Gemini, 向量引擎, images.edit, config
- **affected_files**: chat-input.tsx, config.py, image_gen.py
- **summary**: 三个问题修复:(1) 拖拽到 ChatInput 区域时事件冒泡导致 page.tsx 和 ChatInput 各触发一次上传 → ChatInput handleDrop/handleDragOver 加 stopPropagation(2) 向量引擎 Gemini 图片编辑走原生 generateContent 接口而非 OpenAI 兼容 images/edits对 Gemini 调用 images.edit 返回 500 → Gemini 改回 supports_ref_image=False(3) images.edit 端点不支持 quality 参数 → 从 _edit_with_refs 中移除。
### [CL-20260416-0600] 2026-04-16 — 多张参考图全链路支持
- **tags**: 前端, 后端, 参考图, 多图, OpenAI, images.edit, Provider, API, Agent Loop
- **affected_files**: chat-input.tsx, page.tsx, chat-messages.tsx, api.ts, types.ts, chat.py, loop.py, tools.py, image_gen.py, config.py
- **summary**: 从单张参考图扩展为多张参考图全链路支持。前端ChatInput 改为多图状态数组+file input multiple+多图预览/删除/拖拽API层 sendChat 改为 ref_image_urls: string[]消息气泡和沿用逻辑适配多图。后端chat.py 新增 ref_image_urls JSON 数组参数(兼容旧 ref_image_urlAgent Loop 多图注入 vision LLM 上下文OpenAIImageProvider 有参考图时改用 images.edit 端点支持最多16张Replicate 取首张兼容GPT Image 1.5 和 Gemini 注册表标记 supports_ref_image=True。
### [CL-20260416-0510] 2026-04-16 — 生图模型新增 Gemini 3.1 Flash Image
- **tags**: 后端, 生图模型, Gemini, 向量引擎, config
- **affected_files**: config.py, .env
- **summary**: IMAGE_MODELS 注册表新增 gemini-3.1-flash-imagemodel_id: gemini-3.1-flash-image-previewprovider 为 openai复用 OpenAIImageProvider 走向量引擎中转。无需新增 Provider 代码。
### [CL-20260416-0500] 2026-04-16 — 全区域拖拽添加参考图
- **tags**: 前端, UX, 拖拽, 参考图, 上传
- **affected_files**: page.tsx, chat-input.tsx
- **summary**: 拖拽上传参考图区域从底部输入栏扩展到整个对话区域。ChatInput 改为 forwardRef 暴露 uploadFile 方法main 区域处理拖放事件并调用。拖入时显示全屏覆盖层提示,用 dragCounter 防止子元素冒泡导致闪烁。
### [CL-20260416-0430] 2026-04-16 — 接入 GPT Image 1.5 生图模型(向量引擎中转)
- **tags**: 后端, 生图模型, GPT-Image, OpenAI, 向量引擎, provider
- **affected_files**: image_gen.py, config.py, .env
- **summary**: 新增 OpenAIImageProvider通过向量引擎 API 中转调用 `/v1/images/generations` 端点。IMAGE_MODELS 注册表新增 gpt-image-1.5provider: openai支持 size/quality 参数和 URL/base64 双格式返回。
### [CL-20260416-0400] 2026-04-16 — Session 级参考图自动沿用 + 缺参考图前置校验
- **tags**: 前端, 后端, 参考图, UX, InstantStyle, 风格迁移
- **affected_files**: page.tsx, chat-input.tsx, image_gen.py
- **summary**: 用户发送带参考图的消息后,前端在 session 级别记住该参考图 URL后续消息自动沿用输入框显示"沿用上次参考图"提示条,可清除)。解决切换 InstantStyle 后无参考图导致 "No input, Save money" 的问题。后端对需要参考图但未收到的模型统一返回友好错误。
### [CL-20260416-0300] 2026-04-16 — System Prompt 禁止 LLM 嵌入图片链接
- **tags**: 后端, agent-loop, system-prompt, LLM行为约束
- **affected_files**: loop.py
- **summary**: LLMGPT 系列)在收到 generate_image 工具返回的本地路径后,自行拼凑 `sandbox:/generated/xxx.png` Markdown 图片链接。前端图片展示由 image_result SSE 事件独立处理,文字中的链接无效且多余。在 System Prompt 注意事项中新增禁止嵌入图片 Markdown 的约束。
### [CL-20260416-0245] 2026-04-16 — 修复三点菜单 z-index 层级问题
- **tags**: 前端, UI, sidebar, 菜单, z-index
- **affected_files**: session-list.tsx
- **summary**: 三点菜单原用 fixed 定位在侧栏右侧弹出,被主内容区遮挡。改为 inline 在会话项正下方展开,模型子菜单改为折叠式内联列表(带 max-h 滚动),全部在侧栏内部完成,不再溢出。移除了 menuPos 状态和 fixed 定位逻辑。
### [CL-20260416-0230] 2026-04-16 — LLM 注册表扩充 + 默认模型改 GPT-5.4
- **tags**: 后端, config, LLM, 模型注册表
- **affected_files**: config.py, .env
- **summary**: LLM_MODELS 从 3→7 个:新增 gpt-5.4、claude-sonnet-4-6、claude-opus-4-6、gemini-3.1-pro-preview、glm-4.7,全部走 vectorengine provider。默认模型从 gpt-4o-mini 改为 gpt-5.4。gpt-4o-mini/deepseek-chat 保留。
### [CL-20260416-0200] 2026-04-16 — 接入向量引擎中转 API + 对话级 LLM 模型切换
- **tags**: 后端, 前端, LLM, 向量引擎, 中转API, 模型切换, config, UI, session
- **affected_files**: config.py, loop.py, chat.py, .env, types.ts, api.ts, app-context.tsx, session-list.tsx, page.tsx
- **summary**: 新增 LLM_MODELS 注册表gpt-4o-mini/gpt-4o/deepseek-chatprovider 分发为 vectorengine中转和 deepseek直连。loop.py 改为按 provider 创建 AsyncOpenAI 客户端vision 检测从注册表读取。chat.py 新增 llm_model 参数和 GET /api/llm-models 端点。前端 Session 新增 llmModel 字段侧栏三点菜单支持模型选择子菜单sendChat 传递 llm_model。
### [CL-20260416-0030] 2026-04-16 — 青绿山水风格改造:千里江山图色调 + 云烟雾气动效
- **tags**: 前端, UI改造, 视觉风格, 青绿山水, 千里江山图, CSS动画, 色彩体系, 云烟
- **affected_files**: globals.css, top-nav.tsx, sidebar.tsx, session-list.tsx, chat-messages.tsx, chat-input.tsx, image-grid.tsx, image-detail-panel.tsx, profile-modal.tsx, page.tsx, gallery/page.tsx, login/page.tsx, VISUAL-STYLE-GUIDE.md
- **summary**: 全站从 Cyberpunk 霓虹改为青绿山水风格。色彩替换为石青绿(#4DB8A4)/石绿蓝(#3A8FB7)/墨绿底色(#0C1210)。背景改为双层漂移云雾(60s+45s)。.glow-border hover 效果柔化为云烟缭绕(6s/宽弧段/blur 12px)。.glass-panel 加绿底渐变+增强模糊。新增 .fog-scroll 雾气滚动边缘。11 个组件硬编码颜色全部替换。VISUAL-STYLE-GUIDE.md 完全重写。
### [CL-20260415-2330] 2026-04-15 — 用户个人信息 + 记忆查看面板:右上角菜单弹窗
- **tags**: 前端, 后端, 用户信息, 记忆系统, Mem0, Modal, ProfileModal
- **affected_files**: api/memory.py, main.py, profile-modal.tsx, top-nav.tsx
- **summary**: 后端新建 GET /api/memory/list 端点Mem0 get_all + run_in_executor前端新建 ProfileModal 组件glass-panel 风格居中弹窗),展示用户信息 + 按时间分组的记忆列表只读。TopNav 的 username 行改为可点击按钮,点击打开弹窗。
### [CL-20260415-2230] 2026-04-15 — 光影流动边框效果:导航活动项 + 活动会话项
- **tags**: 前端, CSS动画, 视觉效果, conic-gradient, 霓虹边框
- **affected_files**: globals.css, top-nav.tsx, session-list.tsx, sidebar.tsx
- **summary**: 用 @property + conic-gradient 实现旋转光影边框效果。双伪元素方案:::before 做渐变边框mask-composite 裁内部),::after 做模糊扩散光晕。提供 .glow-border动画和 .glow-border-statichover 静态发光)两个类,分别应用到导航活动 Tab 和活动会话项。
### [CL-20260415-2200] 2026-04-15 — 全站 UI 风格改造Cyberpunk Dark Neon 主题
- **tags**: 前端, UI改造, 视觉风格, Cyberpunk, Neon, 毛玻璃, 全局样式
- **affected_files**: globals.css, page.tsx, gallery/page.tsx, login/page.tsx, top-nav.tsx, sidebar.tsx, session-list.tsx, tag-filter.tsx, chat-messages.tsx, chat-input.tsx, image-grid.tsx, model-selector.tsx, image-detail-panel.tsx, VISUAL-STYLE-GUIDE.md
- **summary**: 参考 SolCasino Dribbble 设计,全站从朴素暗色改为 Cyberpunk Dark Neon 风格。主色从紫色改为青绿霓虹(#00E5A0),新增环境光晕背景、毛玻璃面板(backdrop-blur)、霓虹发光边框。所有组件统一升级圆角和过渡效果,图片卡片加 hover 缩放+发光。同时编写了完整的视觉风格文档。
### [CL-20260414-2350] 2026-04-14 — 修复登录后 useApp must be used within AppProvider 报错
- **tags**: bug修复, 前端, 认证, AppProvider, 初始化时序
- **affected_files**: app-context.tsx
- **summary**: AppProvider 在 `!initialized` 时不提供 context 但渲染了需要 useApp() 的子组件。修复:已认证但未初始化时显示加载状态,未认证时才渲染裸 children/login
### [CL-20260414-2340] 2026-04-14 — 用 pwdlib (Argon2id) 替换 passlib (bcrypt),彻底解决兼容性问题
- **tags**: 重构, 安全, 密码哈希, Argon2, pwdlib, passlib, 依赖升级
- **affected_files**: auth.py, requirements.txt, docs/art-agent/USER-SYSTEM.md
- **summary**: passlib 已停止维护且与 bcrypt 5.x 不兼容。替换为 pwdlib[argon2,bcrypt],新密码用 Argon2id旧 bcrypt 哈希仍可验证。不再需要锁定 bcrypt 版本。
### [CL-20260414-2330] 2026-04-14 — 修复 passlib + bcrypt 5.x 兼容性问题,锁定 bcrypt<4.1
- **tags**: bug修复, 依赖, bcrypt, passlib, 用户系统
- **affected_files**: requirements.txt, docs/art-agent/USER-SYSTEM.md
- **summary**: passlib 1.7.4 与 bcrypt 5.0.0 不兼容(`__about__` 移除 + 密码长度 ValueError后端启动报错。降级 bcrypt 到 4.0.1 并在 requirements.txt 中锁定 `>=4.0.1,<4.1`。同时在用户系统文档故障排除中补充此问题。
### [CL-20260414-2300] 2026-04-14 — 用户系统:管理员邀请制 + JWT 认证 + 数据按用户隔离
- **tags**: 用户系统, 认证, JWT, SQLite, 数据隔离, 多用户, 安全
- **affected_files**: db.py, auth.py, api/auth.py, api/admin.py, chat.py, main.py, loop.py, auth-context.tsx, auth-guard.tsx, api.ts, store.ts, app-context.tsx, layout.tsx, login/page.tsx, top-nav.tsx, model-selector.tsx, requirements.txt, .env.example
- **summary**: 后端新增 SQLite 用户表 + JWT 认证access+refresh+ 管理员/普通用户路由,所有 API 加鉴权Mem0 user_id 改为真实用户 ID。前端新增登录页 + AuthProvider/AuthGuard + 401 自动跳转 + localStorage 按用户 ID 隔离。TopNav 新增用户菜单。首次启动自动创建 admin。
### [CL-20260414-2230] 2026-04-14 — 对话区域新增"回到底部"浮动按钮
- **tags**: 前端, UX, 滚动, 浮动按钮
- **affected_files**: art-agent/frontend/src/app/page.tsx
- **summary**: 长对话中不在底部时,右下角显示向下箭头按钮,点击平滑滚到最新消息。复用 scroll 监听器检测距底部距离(>200px 显示),切换会话后也同步按钮状态。
### [CL-20260414-2220] 2026-04-14 — 修复长对话切换回来滚动位置上移scroll 事件实时保存取代 effect 延迟保存
- **tags**: 前端, bug修复, 滚动, 多会话, UX, scroll 事件
- **affected_files**: art-agent/frontend/src/app/page.tsx
- **summary**: effect 中保存 scrollTop 时 DOM 已变为短对话内容,浏览器 clamp scrollTop 导致保存值偏小。改用 scroll 事件实时记录位置,确保在任何 DOM 变更之前数据已正确保存。
### [CL-20260414-2210] 2026-04-14 — 修复会话切换时滚动位置跳动:时序竞争 + 抑制切换期间 smooth scroll
- **tags**: 前端, bug修复, 滚动, 多会话, UX, 时序
- **affected_files**: art-agent/frontend/src/app/page.tsx
- **summary**: CL-20260414-2200 的 rAF 恢复时序不可靠——React 可能还没渲染新会话内容scrollHeight 是旧值。拆为两个 effect保存+标记 / 等 messages 更新后恢复),新增 `isSwitching` 抑制切换期间的 smooth scroll 干扰instant 模式改为同步赋值 `scrollTop`
### [CL-20260414-2200] 2026-04-14 — 修复多会话滚动位置共享 bugper-session 独立记录 + 切换恢复
- **tags**: 前端, bug修复, 滚动, 多会话, UX
- **affected_files**: art-agent/frontend/src/app/page.tsx
- **summary**: 所有会话共用同一 scrollRef切换时不保存/恢复 scrollTop。修复新增 `scrollPositions` Map 按 sessionId 记录滚动位置,`useEffect` 监听 `activeSessionId` 变化时保存旧位置、恢复新位置(有记录则恢复,无记录则 scrollToBottom`scrollToBottom` 改为 useCallback + 支持 instant 模式。
### [CL-20260413-2320] 2026-04-13 — 集成 Mem0 记忆系统:滑动窗口 + 跨会话长期记忆
- **tags**: Mem0, 记忆系统, 上下文管理, 长期记忆, Ollama, embedding, 滑动窗口
- **affected_files**: app/memory.py, app/agent/loop.py, app/config.py, app/api/chat.py, requirements.txt, .env, frontend/src/lib/api.ts, frontend/src/app/page.tsx
- **summary**: 集成 Mem0 OSS 作为统一记忆方案。DeepSeek 做事实提取Ollama nomic-embed-text 做本地 embeddingQdrant 文件模式做向量存储。agent loop 新增滑动窗口MAX_RECENT_TURNS=20、记忆检索注入 system prompt、异步事实存储。前端传递 session_id 实现会话级记忆作用域。不做静默降级,所有错误显式报告。
### [CL-20260413-1130] 2026-04-13 — 修复 SDXL 模型 404 错误:补全 Replicate 版本 hash
- **tags**: bug修复, Replicate, SDXL, 模型配置, 404
- **affected_files**: art-agent/backend/app/config.py
- **summary**: SDXL 调用 Replicate 404根因同 KolorsCL-20260413-0440非 Flux 官方模型需要 `owner/model:version_hash` 完整格式。将 `stability-ai/sdxl` 补全为 `stability-ai/sdxl:39ed52f2...e08b`
### [CL-20260413-1100] 2026-04-13 — 修复切换模型后 LLM 仍声称使用旧模型名
- **tags**: Agent Loop, system prompt, 模型选择, LLM 幻觉
- **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: System Prompt 不含模型信息LLM 从对话历史幻觉旧模型名。修复:在 system prompt 末尾动态注入当前模型名 + 强调忽略历史中的旧记录假生成重试消息也附带模型名。debug 日志验证传递链路正确,问题仅在 LLM 文字层。
### [CL-20260413-1030] 2026-04-13 — Agent Loop 假生成检测 + 自动重试机制
- **tags**: Agent Loop, DeepSeek, function calling, 防幻觉, 自动修复
- **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: DeepSeek 无视 prompt 约束仍用文字模拟生图。新增代码层面检测LLM 纯文字回复中命中"已生成"等关键词 >= 2 次时,自动注入纠正消息强制重试调用工具。重试消耗一次迭代配额。
### [CL-20260413-1000] 2026-04-13 — 助手消息气泡添加复制文本按钮
- **tags**: 前端, UX, chat-messages, 复制
- **affected_files**: art-agent/frontend/src/components/chat/chat-messages.tsx
- **summary**: 助手回复气泡下方新增复制文本按钮hover 显示),点击通过 clipboard API 复制文本并显示 1.5s 对勾反馈。只对 assistant 消息展示。
### [CL-20260413-0930] 2026-04-13 — System Prompt 加入"禁止模拟工具调用"约束
- **tags**: prompt 工程, system prompt, DeepSeek, function calling, 防幻觉
- **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: DeepSeek 在多步生图任务中有时不调用工具而是用文字模拟"已生成",导致前端收不到图片。在 System Prompt 注意事项中加入硬性约束:"生成图片必须调用 generate_image 工具,禁止用文字模拟"。
### [CL-20260413-0900] 2026-04-13 — 非 vision + 参考图场景:尊重用户主动指定的风格意图
- **tags**: prompt 工程, IP-Adapter, 风格, system prompt, UX
- **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: SYSTEM_PROMPT 和非 vision hint 中对风格关键词的禁止从"绝对禁止"改为"不自行猜测,但用户明确指定则保留到 prompt 中"。之前的绝对性措辞导致 LLM 即使用户主动写了"赛博朋克风"也会丢弃。修正后 IP-Adapter 风格迁移与用户文字风格可叠加。
### [CL-20260413-0800] 2026-04-13 — 修复 InstantStyle ReadTimeout + 多次生成丢失参考图
- **tags**: bug修复, Replicate, InstantStyle, timeout, 参考图, Agent Loop, wait
- **affected_files**: art-agent/backend/app/services/image_gen.py, art-agent/backend/app/agent/loop.py
- **summary**: 运行时日志证实两个问题:(1) `wait=60` 时 SDK 内部 `read=60.5s` timeout 覆盖客户端 300s timeout上传 1.6MB base64 + 等待初始响应超过 60s → ReadTimeout。改为 `wait=False`create 请求立即返回 prediction IDSDK 自动走 `prediction.async_wait()` 轮询,轮询用客户端级 read=300s timeout。(2) 成功生成后清除 ref_image_url 导致后续工具调用丢失参考图InstantStyle 返回 "No input, Save money"。完全移除清除逻辑ref_image_url 在整个对话期间保持有效。
### [CL-20260413-0730] 2026-04-13 — 修复生图失败后 LLM 重试丢失参考图
- **tags**: bug修复, Agent Loop, 参考图, InstantStyle, ref_image_url
- **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: LLM 第一次生图工具调用失败后重试时参考图丢失。InstantStyle 返回 "No input, Save money"。根因:`loop.py` 在每轮工具调用结束后无条件清除 `ref_image_url`。修复:改为只在本轮至少有一次成功产出图片时才清除,失败时保留参考图供 LLM 重试。
### [CL-20260413-0700] 2026-04-13 — 修复 InstantStyle 生图 422 + ReadTimeoutwait 参数超限
- **tags**: bug修复, Replicate, InstantStyle, API参数, async_run, timeout
- **affected_files**: art-agent/backend/app/services/image_gen.py
- **summary**: InstantStyle 生图 422`Prefer: wait=x must be 1-60`+ ReadTimeout61.4s)。两个问题同根因:`async_run(wait=300)` 超出 API 限制,改为 `wait=True` 后 SDK 内部 60.5s timeout 覆盖了客户端 300s timeout。最终改为 `wait=60`API 最大合法值),初始请求 60s 内未完成则 SDK 自动 fallback 到异步轮询,轮询用客户端级 read=300s timeout。
### [CL-20260413-0630] 2026-04-13 — 修复 Replicate SDK 不走代理 + 超时不够
- **tags**: bug修复, Replicate, InstantStyle, 代理, httpx, 超时
- **affected_files**: art-agent/backend/app/services/image_gen.py
- **summary**: Replicate SDK 内部显式传 `transport` 给 httpx绕过了环境变量代理配置导致 ConnectTimeout且默认 read 30s 不够 InstantStyle 的 ~128s。修复`_make_replicate_client()` 工厂函数从 `HTTPS_PROXY` 读取代理注入 `AsyncHTTPTransport(proxy=...)`read 300s / connect 30s。`_download_image` 同步加了代理。
### [CL-20260413-0600] 2026-04-13 — 新增 InstantStyle 模型:强风格迁移选项
- **tags**: 模型注册, InstantStyle, 风格迁移, IP-Adapter
- **affected_files**: art-agent/backend/app/config.py
- **summary**: 注册 InstantStyle`jyoung105/instant-style`)到模型注册表,`block_mode: "style-only"` + `style_strength: 1.0`,通过 `ref_image_param: "style_image"` 适配现有抽象。与 Kolors 并列为参考图模型,提供强风格迁移能力。
### [CL-20260413-0540] 2026-04-13 — 非 vision LLM + 参考图时禁止猜测风格
- **tags**: prompt 工程, IP-Adapter, 风格一致性, system prompt
- **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: 修改 system prompt 和非 vision 模型的参考图提示明确禁止猜测画风。LLM 只描述画面内容,风格交给 IP-Adapter 从参考图提取。解决了 DeepSeek 看不到参考图时脑补 "pixel art" 导致生成结果与参考图风格不一致的问题。
### [CL-20260413-0520] 2026-04-13 — 多图生成改为单次 API 调用 + 修复空错误信息
- **tags**: 性能优化, Replicate, 速率限制, 错误处理, 模型配置
- **affected_files**: art-agent/backend/app/services/image_gen.py, art-agent/backend/app/config.py
- **summary**: 多图生成从 for 循环改为单次 API 调用(新增 `num_images_param` 配置),避免低余额账户的速率限制。同时修复空异常时 `[生成失败: ]` 信息丢失的问题,改为显示异常类型名和 repr。
### [CL-20260413-0500] 2026-04-13 — 生成图片后显示使用的模型名称
- **tags**: 模型溯源, SSE, 图片生成, 前端展示
- **affected_files**: art-agent/backend/app/services/image_gen.py, art-agent/backend/app/agent/tools.py, art-agent/backend/app/agent/loop.py, art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/components/chat/chat-messages.tsx
- **summary**: `generate_images` 返回 `GenerateResult`(含 model_nameSSE 事件携带模型名称前端在图片下方和错误提示中展示。ChatMessage 新增 `modelName` 字段,归档后仍可溯源。
### [CL-20260413-0440] 2026-04-13 — 修复 Kolors IP-Adapter 模型 404 错误:补全 Replicate 版本 hash
- **tags**: bug修复, Replicate, Kolors, IP-Adapter, 模型配置
- **affected_files**: art-agent/backend/app/config.py
- **summary**: Kolors IP-Adapter 生图 404 的根因是 Replicate SDK 对社区模型需要 `owner/model:version_hash` 格式。在 config.py 中补全 kolors-ipadapter 的版本 hash 后,实测生图成功。
### [CL-20260413-0430] 2026-04-13 — 图片生成失败时将具体错误信息透传到前端
- **tags**: 错误处理, Agent Loop, SSE, 调试体验
- **affected_files**: art-agent/backend/app/agent/loop.py, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/lib/api.ts
- **summary**: 新增 `tool_error` SSE 事件类型。Agent Loop 在工具执行产生错误时,额外 yield 一个 tool_error 事件携带具体报错信息;前端处理该事件并以 ⚠️ 前缀展示在对话流中。解决了之前工具失败时用户只能看到 LLM 模糊翻译、无法获取真实错误详情的问题。
### [CL-20260413-0330] 2026-04-13 — README 补充访问地址汇总 + 穿透管理文档
- **tags**: 文档, README, 穿透, Cloudflare, cloudflared
- **affected_files**: art-agent/README.md
- **summary**: README 新增"访问地址"章节(本机/局域网/外网三种场景表格),重写"远程访问(外网穿透)"章节,补充 cloudflared 安装指引winget + 手动下载)、穿透管理操作表(启动/停止/查看状态/重启)、代理配置排错说明。
### [CL-20260412-2345] 2026-04-12 — 参考图上传独立化:进度条 + 状态反馈 + 失败重试
- **tags**: 前端, 后端, 参考图, 上传, UX, 进度反馈
- **affected_files**: art-agent/backend/app/api/chat.py, art-agent/frontend/src/lib/api.ts, art-agent/frontend/src/components/chat/chat-input.tsx, art-agent/frontend/src/app/page.tsx
- **summary**: 参考图从"随消息提交"改为"选中即独立上传"。后端新增 POST /api/upload-ref-image 端点;前端用 XMLHttpRequest 获取上传进度事件ChatInput 显示环形进度条、成功/失败角标、失败可重选。sendChat 参数从 File 改为服务端路径。同时修复 file input 多次上传不重置的问题。
### [CL-20260412-2315] 2026-04-12 — 修复 Cloudflare Tunnel 跨域开发警告
- **tags**: 前端, 配置, Next.js, Cloudflare, 跨域
- **affected_files**: art-agent/frontend/next.config.ts
- **summary**: Next.js 检测到来自 trycloudflare.com 的跨域请求发出警告。在 next.config.ts 中添加 `allowedDevOrigins: ["*.trycloudflare.com"]` 消除警告,通配符覆盖所有 Quick Tunnel 随机域名。
### [CL-20260412-2300] 2026-04-12 — 集成 Kolors IP-Adapter参考图可直接用于风格生成
- **tags**: 后端, 前端, IP-Adapter, 风格迁移, 参考图, 模型注册, Replicate, 架构
- **affected_files**: art-agent/backend/app/config.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/app/agent/loop.py, art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/components/chat/model-selector.tsx
- **summary**: 集成 Kolors IP-Adapter 模型fofr/kolors-with-ipadapter~$0.004/次),参考图通过 base64 data URI 直接传给 Replicate 的 IP-Adapter 参与风格生成。模型注册表新增 supports_ref_image / ref_image_param 通用字段,新增 IP-Adapter 模型只需加配置。to_data_uri 抽为公共函数供 loop.py 和 image_gen.py 共用。前端模型列表显示绿色"参考图"徽章。
### [CL-20260412-2230] 2026-04-12 — 用户消息气泡中显示参考图缩略图
- **tags**: 前端, 参考图, UX, chat-messages
- **affected_files**: art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/components/chat/chat-messages.tsx
- **summary**: 用户上传参考图后发送消息之前聊天气泡中不显示参考图无法确认是否成功附带。修复ChatMessage 类型新增 refImageUrl 字段page.tsx 构造用户消息时用 URL.createObjectURL 保存预览 URLchat-messages.tsx 在用户消息气泡中渲染缩略图(最大 160×120px。Blob URL 仅当前会话有效,不持久化。
### [CL-20260412-2200] 2026-04-12 — 修复 DeepSeek 不支持 image_url 导致参考图上传报错
- **tags**: bug修复, 后端, Agent Loop, vision, DeepSeek, 多模态
- **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: 上传参考图时使用 OpenAI Vision 格式(`image_url` content type发送给 DeepSeek API但 DeepSeek 只支持 `text` 类型,导致 400 错误。修复:新增 vision 能力检测(基于模型名称关键词),不支持 vision 的模型改为文字提示方式告知 LLM 有参考图,参考图仍传给图像生成工具处理。
### [CL-20260412-2130] 2026-04-12 — 修复图片生成失败时前端显示破碎图标
- **tags**: bug修复, 前端, 后端, 图像生成, 错误处理
- **affected_files**: art-agent/backend/app/agent/tools.py, art-agent/frontend/src/app/page.tsx
- **summary**: Replicate API 调用失败(如余额不足 402错误字符串 `[生成失败: ...]` 被混入 images 列表传给前端,导致 `<img>` 标签渲染破碎图标。修复:后端 tools.py 分离有效 URL 和错误信息为 images/errors 两个字段;前端 page.tsx 过滤掉以 `[` 开头的无效 URL无有效图片时不渲染图片网格。
### [CL-20260412-2100] 2026-04-12 — 生图模型动态切换Provider 抽象 + 前端模型选择器
- **tags**: 后端, 前端, 图像生成, 模型切换, Provider, 重构, Replicate
- **affected_files**: art-agent/backend/app/config.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/app/api/chat.py, art-agent/backend/app/agent/loop.py, art-agent/backend/app/agent/tools.py, art-agent/backend/.env, art-agent/backend/.env.example, art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/lib/api.ts, art-agent/frontend/src/components/chat/model-selector.tsx, art-agent/frontend/src/components/chat/chat-input.tsx, art-agent/frontend/src/app/page.tsx
- **summary**: 将图像生成从 Replicate 硬绑定重构为 Provider 抽象层。后端config.py 新增 IMAGE_MODELS 注册表flux-schnell / flux-dev / SDXLimage_gen.py 引入 ImageProvider 基类 + ReplicateProvider 实现chat.py 新增 GET /api/models + POST /api/chat 增加 image_model 参数agent loop / tools 全链路透传。前端:新建 ModelSelector 下拉组件集成到 ChatInput每条消息可选不同模型选择持久化到 localStorage。
### [CL-20260412-1800] 2026-04-12 — 移动端适配 + Cloudflare Tunnel 内网穿透
- **tags**: 前端, 响应式, 移动端, 部署, Cloudflare, 穿透, 配置
- **affected_files**: art-agent/frontend/src/app/layout.tsx, art-agent/frontend/src/app/globals.css, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/app/gallery/page.tsx, art-agent/frontend/src/components/sidebar/sidebar.tsx, art-agent/frontend/src/components/layout/top-nav.tsx, art-agent/frontend/src/components/detail/image-detail-panel.tsx, art-agent/frontend/src/components/chat/chat-messages.tsx, art-agent/frontend/src/components/chat/chat-input.tsx, art-agent/frontend/src/components/chat/image-grid.tsx, art-agent/frontend/src/lib/app-context.tsx, art-agent/frontend/next.config.ts, art-agent/start-tunnel.ps1, art-agent/README.md
- **summary**: 为实现"手机浏览器远程访问"做了两大块工作。(1) 移动端响应式适配侧边栏改为固定定位抽屉式md 以下),顶栏新增汉堡菜单,图片详情面板移动端全屏 overlay气泡/输入框/Gallery 网格间距微调hover 操作在移动端始终可见,默认小屏折叠侧边栏。(2) 穿透方案:安装 cloudflared编写 start-tunnel.ps1 一键脚本自动创建双隧道 + 更新 .env.local + 退出时恢复。Next.js 图片白名单加 trycloudflare.com。uvicorn 改绑 0.0.0.0。路线 B云服务器部署记为延期方案。
### [CL-20260412-1530] 2026-04-12 — 修复 .env 配置加载时序问题
- **tags**: 后端, bug修复, 配置, dotenv, Python
- **affected_files**: art-agent/backend/app/main.py, art-agent/backend/app/config.py, art-agent/backend/app/agent/loop.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/.env
- **summary**: 切换 DeepSeek 后报 "Model Not Exist"。根因:(1) load_dotenv() 在业务模块 import 之后执行config.py 的模块级变量在 import 时就固化为默认值 gpt-4o-mini(2) load_dotenv 默认 override=False 不覆盖已有环境变量。修复load_dotenv(override=True) 提到所有 import 之前config.py 常量改为函数式懒读取;.env base_url 改为不带 /v1。
### [CL-20260412-1500] 2026-04-12 — 品牌重命名 EPEEKit + API 配置集中化
- **tags**: EPEEKit, 品牌, 配置, 后端, 前端, 重构
- **affected_files**: art-agent/frontend/src/app/layout.tsx, art-agent/frontend/src/components/layout/top-nav.tsx, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/lib/store.ts, art-agent/frontend/src/app/gallery/page.tsx, art-agent/frontend/src/components/detail/image-detail-panel.tsx, art-agent/frontend/src/components/chat/image-grid.tsx, art-agent/frontend/package.json, art-agent/backend/app/main.py, art-agent/backend/app/config.py, art-agent/backend/app/agent/loop.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/.env, art-agent/backend/.env.example, art-agent/README.md
- **summary**: 应用正式命名为 "EPEEKit",全部用户可见文案 + 文件名前缀 + localStorage 键名统一替换。后端新增 config.py 集中管理 LLM_MODEL、IMAGE_MODEL 等原硬编码配置,.env 重组为带注释的分组结构,切换 LLM 只需改 3 个环境变量。同时新增 .env.example 模板和 Logo SVG。

View File

@@ -2,10 +2,163 @@
## Active Items
(暂无延期方案)
### [cloud-deploy-route-b] 路线 B云服务器正式部署
- **status**: deferred
- **tags**: deployment, docker, nginx, cloud, production
- **recorded**: 2026-04-12
- **source_chat**: [发布到手机浏览器的方案讨论](discussion-publish-routes)
- **prerequisite**: 路线 A内网穿透 + 移动端适配)完成后
- **related_files**:
- art-agent/backend/app/main.py
- art-agent/frontend/next.config.ts
- art-agent/frontend/.env.local
- art-agent/backend/.env
- **context**: |
买轻量云服务器2C2G ~50元/月),编写前后端 Dockerfile + docker-compose
配置 Nginx/Caddy 反向代理统一入口HTTPS 自动签证书,
环境变量搬迁(去掉本地代理配置),可选绑定域名。
预估工作量 1-2 天。
- **chosen_alternative**: 路线 A — 本地运行 + 内网穿透Cloudflare Tunnel / ngrok
- **deferred_reason**: 当前阶段只需自己和朋友试用,内网穿透足够
### [style-pipeline-layered] 分层组合风格一致性产线(含便利选取 + LoRA 训练)
- **status**: deferred
- **tags**: 风格一致性, 风格选取, IP-Adapter, LoRA, ControlNet, 量产, 美术产线, InstantStyle, 风格库, 训练
- **recorded**: 2026-04-12
- **updated**: 2026-04-16
- **source_chat**: [风格一致性技术方案讨论](style-consistency-discussion)
- **prerequisite**: EPEEKit 进入美术资源量产阶段(风格方向已确定、需要批量产出同风格素材)
- **related_files**:
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/config.py
- art-agent/frontend/src/app/page.tsx
- **context**: |
分层组合的风格一致性产线:
1. 探索期当前Prompt + 参考图,快速试错确定风格方向
2. 锁定期IP-Adapter / ControlNet选 3-5 张精选图作为风格锚点,推理时注入风格
3. 量产期LoRA + ControlNet用精选图训练 LoRA20-50 张),批量产出同风格资源
4. 贯穿全程:标准化 Prompt 模板 + Negative Prompt 模板,确保品质下限
各阶段可叠加使用不互斥。LoRA 训练需要同风格样本积累到足够数量。
2026-04-13 调研补充 — 风格迁移模型层级:
- Kolors IP-Adapter当前在用通用 IP-Adapter风格迁移弱内容+风格+构图混合提取
- InstantStyleReplicate 可用):专门分离内容/风格,风格迁移强,但贵且慢
- Style IPAdapter for NoobAI-XLCivitAI最强画风迁移线条+着色技法),需 ComfyUI
- ICAS 框架(学术前沿 2025.04IP-Adapter + ControlNet 组合,多主体风格一致性最优
锁定期的最佳方案:迁移到 HF Inference Endpoints 后,用 NoobAI-XL + Style IPAdapter + ControlNet
2026-04-16 补充 — 便利的风格选取和 LoRA 训练 UI
- 风格库 UI预设风格卡片缩略图+标签+描述),一键选取即注入对应 Prompt 模板/参考图/LoRA
- LoRA 训练流程集成:在 Agent 内上传训练素材 → 触发云端 LoRA 训练 → 训练完成后自动入库为新风格
- 风格组合:支持多风格叠加(如"水墨 + 赛博朋克"),前端可视化混合权重
- 风格管理:用户自建/收藏风格,按项目/标签分类,团队共享
- **chosen_alternative**: 当前使用 Prompt Engineering + LLM 参考图理解(探索期方案)
- **deferred_reason**: 风格方向尚未确定,过早引入 LoRA/ControlNet 是过度优化
### [doc-gen-management] 文档生成和管理系统
- **status**: deferred
- **tags**: 文档生成, 项目大纲, 游戏风格文档, 美术规范, GDD, 模板, 导入
- **recorded**: 2026-04-16
- **source_chat**: [接下来要做的三件事](next-features-planning-0416)
- **prerequisite**: 基础对话生图能力稳定后
- **related_files**:
- art-agent/frontend/src/app/page.tsx
- art-agent/backend/app/agent/loop.py
- art-agent/backend/app/agent/tools.py
- **context**: |
文档驱动的美术资源生产流程:
1. 项目大纲导入:支持上传/粘贴项目大纲GDD、策划案等Agent 解析结构
2. 游戏风格文档生成:基于大纲 + 对话交互,自动生成美术风格指南
(色彩体系、质感定义、元素规范、参考图收集等)
3. 文档模板预设多种文档模板美术风格指南、UI 规范、角色设定集、场景规范等)
4. 文档管理:按项目组织,版本历史,团队共享,与生图任务关联
5. 文档 → 生图联动:风格文档中的规范自动注入生图 Prompt确保产出一致性
- **chosen_alternative**: 当前无文档管理能力,美术规范靠口头约定或外部文档
- **deferred_reason**: 当前阶段聚焦核心生图能力,文档系统是生图能力稳定后的自然延伸
### [asset-spec-presets] 资产规格快速选取系统
- **status**: deferred
- **tags**: 资产规格, icon, UI资产, 3D资产, 尺寸, 比例, 安全区, 底色, 预设, 美术标准
- **recorded**: 2026-04-16
- **source_chat**: [接下来要做的三件事](next-features-planning-0416)
- **prerequisite**: 基础生图能力稳定后
- **related_files**:
- art-agent/backend/app/agent/tools.py
- art-agent/backend/app/services/image_gen.py
- art-agent/frontend/src/app/page.tsx
- **context**: |
资产规格预设系统——选取资产类型即自动套用全部技术标准:
1. 资产类型库Icon / UI按钮 / 头像框 / 背景图 / 角色立绘 / 场景原画 / 3D贴图 等
2. 每种类型包含完整规格定义:
- 尺寸输出分辨率、DPI
- 比例1:1 / 16:9 / 自定义)
- 安全区(内容区 vs 出血区)
- 底色/透明度要求
- 文件格式PNG / SVG / PSD 等)
- 风格约束(该类资产的通用风格要求,如 icon 需要简洁辨识度高)
- 导出规格(多尺寸自动导出,如 @1x @2x @3x
3. 项目级自定义:允许在预设基础上调整,保存为项目专属规格
4. 3D 资产扩展:未来覆盖 3D 模型的面数/贴图分辨率/UV规范等
5. 一键应用:选取资产类型后,尺寸/比例/后处理参数自动注入生图调用
6. 游戏全品类覆盖:目标是游戏中所有美术资产类型都有对应的规格预设
- **chosen_alternative**: 当前生图参数完全由用户在对话中手动指定或由 LLM 推断
- **deferred_reason**: 当前阶段聚焦核心生图和风格探索,规格系统是进入量产阶段的必备基础设施
### [hf-inference-endpoints] 迁移到 Hugging Face + Inference Endpoints
- **status**: deferred
- **tags**: 基础设施, Hugging Face, Inference Endpoints, ComfyUI, 自定义模型, 风格迁移
- **recorded**: 2026-04-13
- **source_chat**: [Kolors 风格迁移排查与模型调研](kolors-style-investigation)
- **prerequisite**: 探索期结束、确定要用的模型组合后
- **related_files**:
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/config.py
- **context**: |
将生图基础设施从 Replicate API 迁移到 Hugging Face Inference Endpoints
1. 可部署任意 ComfyUI 工作流NoobAI-XL + Style IPAdapter + ControlNet 等任意组合)
2. 不受 Replicate 模型作者暴露的参数限制,完全控制推理流程
3. CivitAI 上的任何 LoRA、IP-Adapter 权重都能直接加载
4. 按 GPU 时长计费,批量生成时远比 Replicate 按次付费便宜
5. 是 style-pipeline-layered 延期方案中"锁定期 → 量产期"的基础设施
背景:当前在 Replicate 上可用的风格迁移模型有限——
- Kolors IP-Adapter风格迁移弱混合提取内容+风格+构图)
- InstantStylejyoung105/instant-style风格迁移强但贵$0.12/次)且慢(~128s
- Style IPAdapter for NoobAI-XL最强画风迁移但需 ComfyUI 环境Replicate 上无法使用
迁移到 HF Inference Endpoints 后可自由组合以上所有方案。
- **chosen_alternative**: 当前使用 Replicate APIKolors IP-Adapter + Flux + SDXL
- **deferred_reason**: 探索期用 Replicate 足够快速迭代,迁移工作量较大(需搭建自定义推理服务)
### [vision-model-asset-library] 专用 Vision 模型用于资源库功能
- **status**: deferred
- **tags**: 资源库, vision, CLIP, BLIP, 图片搜索, 自动标签, embedding
- **recorded**: 2026-04-12
- **source_chat**: [参考图风格解析方案讨论](vision-model-discussion)
- **prerequisite**: 资源库功能进入增强阶段(基础 Gallery 已完成)
- **related_files**:
- art-agent/frontend/src/app/gallery/page.tsx
- art-agent/backend/app/services/image_gen.py
- **context**: |
引入专用 Vision 模型CLIP / BLIP 等)为资源库提供智能化能力:
1. 自动标签生成的图片自动分类UI/角色/场景/图标等)
2. 以图搜图:上传一张图,从资源库中找风格最相似的
3. 语义搜索:用自然语言搜索图片(如"蓝色水晶质感的按钮"
4. 聚类分组:自动将图片按视觉相似性分组
可通过 Replicate 调用或本地部署,成本极低。
- **chosen_alternative**: 当前资源库使用手动标签 + Prompt 文本搜索
- **deferred_reason**: 当前阶段资源量少,手动管理足够;等资源积累到一定量级后再引入自动化
---
## Completed / Cancelled Items
(暂无已完成或已废弃的方案
### [context-window-management] 对话上下文管理(滑动窗口
- **status**: completed
- **completed_date**: 2026-04-13
- **completed_by**: [CL-20260413-2320] Mem0 记忆系统集成
- **notes**: 原计划自建 tiktoken + 滑动窗口 + 摘要。实际方案Mem0 的事实提取替代了"对话摘要",滑动窗口在 loop.py 中实现MAX_RECENT_TURNS=20
### [mem0-long-term-memory] 引入 Mem0 作为长期记忆层
- **status**: completed
- **completed_date**: 2026-04-13
- **completed_by**: [CL-20260413-2320] Mem0 记忆系统集成
- **notes**: Mem0 OSS 自部署DeepSeek 事实提取 + Ollama nomic-embed-text embedding + Qdrant 本地向量库),与上下文管理一并实施

18
.cursor/hooks.json Normal file
View File

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

View File

@@ -0,0 +1,105 @@
# Changelog sync guard — 统一 stop hook
#
# 用确定性逻辑检查:
# 1. 源文件是否比 changelog 更新mtime 比较)
# 2. stdin 中的 agent 上下文是否显示有源文件编辑操作
#
# 静默条件(不触发提醒):
# 1. 环境变量 CURSOR_SKIP_CHANGELOG 被设置sessionStart 在非 agent 模式设置)
# 2. stdin JSON 中 composer_mode 不是 "agent"(如 debug/ask/edit 模式)
# 3. changelog 文件不存在
# 4. .changelog-ack 标记文件存在且足够新(本会话已确认过 changelog 状态)
# 5. 没有源文件比 changelog 更新
# 6. stdin 上下文中没有文件编辑操作的证据(防止跨会话残留 mtime 误触发)
$input = [Console]::In.ReadToEnd()
# === 豁免检查 1环境变量跳过标志 ===
if ($env:CURSOR_SKIP_CHANGELOG) {
Write-Output '{}'
exit 0
}
# === 豁免检查 2从 stdin 解析 composer_mode ===
try {
$data = $input | ConvertFrom-Json
$mode = $data.composer_mode
if ($mode -and $mode -ne "agent") {
Write-Output '{}'
exit 0
}
} catch {
# JSON 解析失败,继续后续检查
}
# === 豁免检查 3从 stdin 文本匹配 debug 上下文关键词 ===
# stop hook 的 $ARGUMENTS 可能包含对话/工具上下文,检测 debug 相关信号
if ($input -match '"mode"\s*:\s*"debug"' -or
$input -match 'debug[\s_-]?mode' -or
$input -match 'Debug Mode') {
Write-Output '{}'
exit 0
}
$changelog = ".cursor\changelog\changelog-headlines.md"
$srcDir = "art-agent"
# === 豁免检查 4changelog 文件不存在 ===
if (-not (Test-Path $changelog)) {
Write-Output '{}'
exit 0
}
$clMtime = (Get-Item $changelog).LastWriteTime
# === 豁免检查 5ack 标记文件足够新 ===
$ackFile = ".cursor\changelog\.changelog-ack"
if ((Test-Path $ackFile) -and (Get-Item $ackFile).LastWriteTime -ge $clMtime) {
Write-Output '{}'
exit 0
}
# === 豁免检查 6stdin 中无文件编辑证据 ===
# 防止跨会话残留 mtime 差异导致误触发:如果 ack 文件不存在(或过旧),
# 但 stdin 上下文中也没有任何文件写入/编辑操作的痕迹,说明本次会话
# 没有进行代码改动,不应触发提醒。
$hasEditEvidence = (
$input -match 'StrReplace|Write\s*tool|edit_file|file_write|write_to_file' -or
$input -match 'Created file|Modified file|Wrote contents' -or
$input -match '"tool"\s*:\s*"(str_replace|write|edit)"'
)
if (-not $hasEditEvidence) {
Write-Output '{}'
exit 0
}
# === 核心检查:是否有源文件比 changelog 更新 ===
$extensions = @("*.py", "*.tsx", "*.ts", "*.css")
$excludeDirs = @("node_modules", ".next", "__pycache__", "venv")
$newerFile = $null
foreach ($ext in $extensions) {
$files = Get-ChildItem -Path $srcDir -Filter $ext -Recurse -ErrorAction SilentlyContinue |
Where-Object {
$skip = $false
foreach ($ex in $excludeDirs) {
if ($_.FullName -like "*\$ex\*") { $skip = $true; break }
}
-not $skip -and $_.LastWriteTime -gt $clMtime
} |
Select-Object -First 1
if ($files) {
$newerFile = $files.Name
break
}
}
if ($newerFile) {
$msg = "[Hook] Source file updated (e.g. $newerFile) but changelog not synced. Run dev-changelog Skill operation A NOW to write all three changelog layers."
$json = '{"followup_message":"' + $msg.Replace('"','\"') + '"}'
Write-Output $json
} else {
Write-Output '{}'
}
exit 0

View File

@@ -0,0 +1,23 @@
# session-init.ps1 — 会话启动时检测 composer_mode非 agent 模式设置跳过标志
#
# sessionStart input 包含 composer_mode 字段("agent" / "ask" / "edit" / "debug" 等)
# 通过 env 输出的环境变量会传递给同会话内所有后续 hook
$input = [Console]::In.ReadToEnd()
try {
$data = $input | ConvertFrom-Json
$mode = $data.composer_mode
} catch {
$mode = $null
}
Remove-Item ".cursor\changelog\.changelog-ack" -ErrorAction SilentlyContinue
if ($mode -and $mode -ne "agent") {
$json = '{"env":{"CURSOR_SKIP_CHANGELOG":"1","CURSOR_COMPOSER_MODE":"' + $mode + '"}}'
Write-Output $json
} else {
Write-Output '{}'
}
exit 0

View File

@@ -0,0 +1,13 @@
# Pitfall Journal
开发过程中踩过的坑,按时间倒序排列。
Agent 进入 Debug mode 或遇到运行时错误时自动检索匹配。
---
### [PF-20260412-1530] Python load_dotenv 默认不覆盖已有环境变量 + 模块级变量在 import 时固化
- **症状**: 修改 .env 切换 API 供应商后,后端仍报旧供应商的错误(如 DeepSeek 报 "Model Not Exist",实际发送的模型名是 OpenAI 的 gpt-4o-mini
- **根因**: 两层叠加:(1) `load_dotenv()` 默认 `override=False`,如果进程/系统中已有同名环境变量,.env 中的新值不会生效;(2) `config.py` 使用模块级常量 `LLM_MODEL = os.getenv(...)` 在 import 时就求值固化,而 `main.py` 的 import 链在 `load_dotenv()` 之前就触发了 config.py 的加载
- **解法**: (1) `load_dotenv(override=True)` 并提到所有业务 import 之前;(2) config.py 改为函数式 `get_llm_model()`,运行时才读取
- **防御**: 任何 Python 项目使用 dotenv 时,始终 `override=True` + 放在文件最顶部(仅在 `import os``from dotenv import load_dotenv` 之后);配置值用函数或属性包装,不要用模块级常量
- **关联**: Python, dotenv, FastAPI, uvicorn, 环境变量, 配置管理, OpenAI SDK

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,97 @@
---
description: 每次会话开始时注入开发日志概要L3并在检测到任务与近期改动相关时自动读取中期记录L2
alwaysApply: true
---
## 开发日志上下文注入
每次会话处理用户第一个任务前,执行以下操作:
1. 读取 `.cursor/changelog/changelog-headlines.md`(不存在则跳过)
2. 如文件存在且有实质内容(不仅是模板头部),将全部条目作为背景知识注入上下文
3. 这些信息帮助 Agent 快速建立位置感:项目进展到什么阶段、最近的工作重心在哪个模块
4. 在后续回复中自然参考,不显式提及"根据开发日志"
## 主动定位辅助
L3 概要的核心价值之一是帮助 Agent 在**冷启动**(新会话、无上下文)时理解用户意图。
当用户的请求缺少具体文件名或模块名时Agent 应主动利用 L3 进行推断:
### 典型场景
1. **隐式延续**:用户说"继续做昨天那个"、"把那个功能完善一下"
→ 从 L3 中找到最近的相关条目,上溯到 L2 获取具体文件列表
2. **模糊指代**:用户说"那个组件有 bug"、"之前改的那个接口"
→ 用 L3 中的关键词匹配用户描述,定位到具体改动
3. **上下文补全**:用户直接提出一个任务,没有背景说明
→ 用 L3 判断该任务是否与近期某个改动有关联(如同一模块、同一功能线)
### 流程
```
1. 解析用户请求,识别是否存在隐式引用或模糊指代
2. 在 L3 概要中查找语义最匹配的 1-3 条记录
3. 提取匹配条目的锚点 ID上溯到 L2 获取 affected_files 和 tags
4. 如有必要,继续上溯到 L1 获取完整的决策背景
5. 将定位到的文件/模块作为任务的起点,开始执行
```
如果 L3 中没有匹配到任何相关记录,正常处理即可——不是所有任务都与近期改动有关。
## L2 自动触发
Agent 开始处理一个新任务时,判断是否需要读取近期详细记录:
1. 从当前任务中提取涉及的文件路径和语义关键词
2. 与 L3 概要中的内容做快速比对——如果近期有相关模块/文件的改动记录
3. 命中时,读取 `.cursor/changelog/changelog-recent.md`,将相关条目纳入上下文
4. 匹配策略:
- 硬匹配:当前任务涉及的文件出现在 L2 条目的 `affected_files` 中
- 软匹配:当前任务的语义关键词与条目的 `tags` 有交集
- 任一命中即触发读取
### 注意
- L3 注入是低成本操作(~50 句话),每次会话都执行
- L2 读取按需触发,只在检测到关联时才读取
- 开发日志是事实性记录,直接使用即可,不像画像那样需要"自然融入"的措辞考量
- 记录的写入和管理由 `dev-changelog` Skill 负责,本 Rule 只负责读取和注入
## 逐级上溯
当 L3 中某条记录的一句话描述**语义模糊**(无法判断具体范围或与当前任务的关系),
按以下步骤精准上溯,**禁止全文读取 L1**
1. 提取该条目的锚点 ID`CL-xxx`
2. 用 Grep 在 `changelog-recent.md`L2中搜索该 ID → 找到则读取该条目
3. 如 L2 中未找到或仍有歧义 → 用 Grep 在 `changelog-full.md`L1中搜索该 ID
获取行号后用 Read 工具读取该行号 ±20 行范围
4. 一次上溯通常只涉及 1-3 条记录,不批量上溯
## 任务完成 Checklist强制
Agent 在即将输出最终回复前,**必须**逐项检查以下清单。
这是硬性要求,不是建议——**跳过任何一项都视为执行错误**。
1. **本次是否有代码/配置文件改动?**(不含 `.cursor/` 下的基础设施文件)
- 否 → 跳过后续项
- 是 → 继续
2. **是否已写入三层开发日志?**
- 否 → **立即**读取 `dev-changelog` Skill 并执行操作 A写入 L1 + L2 + L3 后再输出回复
- 是 → 继续
3. **是否修改了任何 Skill**(包括 SKILL.md 内容变更)
- 否 → 跳过
- 是 → 检查是否已同步 `epee-orchestrator` 的 `registry.md`,未同步则立即执行
4. **本次是否有 Skill 使用异常?**(遗漏、误触、产物与用户预期偏差)
- 否 → 跳过
- 是 → 执行 `epee-orchestrator.mdc` 中的自迭代确认流程
5. **静默 Hook 重复触发**(每次 checklist 完成后必须执行)
- 用 Write 工具创建空文件 `.cursor/changelog/.changelog-ack`
- 这会更新 ack 文件的 mtime阻止 `check-changelog.ps1` hook 在后续回复中重复提醒
**执行原则**:先完成 checklist 中的所有写入动作,再输出面向用户的回复文本。
回复末尾如果执行了第 2 项,附 `[已记录到开发日志]`。

View File

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

View File

@@ -0,0 +1,31 @@
## 踩坑经验自动检索
### 被动检测触发
Agent 在以下场景中,应自动读取 `.cursor/pitfalls/pitfalls.md` 并进行匹配检索:
1. **进入 Debug mode**:读取全部条目,将当前错误症状与已有记录比对
2. **遇到运行时错误**:提取错误信息关键词,在"症状"字段中检索匹配
3. **同一问题第二次出现**:如果当前会话中某个错误已出现过一次且未解决,强制检索
### 匹配策略
```
1. 提取当前问题的信号:错误信息关键词、涉及文件/模块、技术栈
2. 在 pitfalls.md 中匹配:
- 硬匹配:错误关键词出现在条目的"症状"中
- 软匹配:模块/技术栈出现在条目的"关联"中
3. 命中时在分析开头提示:
> 注意:之前遇到过类似问题 [PF-xxx][标题]。根因是 [xxx],先排查这个方向。
```
### 写入提醒
Agent 在完成涉及 debug/修复的任务后,应读取 `pitfall-journal` Skill 并执行其"操作 A写入记录"流程。
判断标准:问题的根因是否"非显而易见"——如果只看代码逻辑觉得应该没问题,但实际运行时才暴露,就值得记录。
### 注意
- 检索结果是**辅助参考**,不是确定性答案——匹配到不代表根因一定相同
- pitfalls.md 不存在时跳过,不报错
- 每次会话中对同一条 pitfall 最多提醒一次

View File

@@ -0,0 +1,41 @@
---
description: 每次会话开始时读取用户画像和项目画像,将精简 Profile 注入上下文以指导 Agent 行为
alwaysApply: true
---
## 画像上下文注入
每次会话处理用户第一个任务前,执行以下操作:
1. 读取 `~/.cursor/profile/user-profile.md`(不存在记为 user_missing
2. 读取 `.cursor/profile/project-profile.md`(不存在记为 project_missing
3. **冷启动检测**:如果 user_missing 或 project_missing 为真,在回复开头简要提醒:
> 画像系统尚未初始化缺少user-profile / project-profile
> 如需启用画像功能,请说"初始化画像",我会引导你完成。
- 每次会话最多提醒一次,不重复打扰
- 如用户回应"初始化画像",读取 `profile-memory` Skill 并按其模板创建文件,
然后引导用户填写基本信息
4. 如文件存在且有实质内容(非空模板),将其内容作为背景知识纳入考量
5. 在后续回复中Agent 应自然地参考画像信息,无需显式引用
### 注意
- 画像信息是背景参考,不是硬性约束——当用户当前指令与画像冲突时,以当前指令为准
- 不要在回复中提及"根据你的画像"之类的措辞,自然融入即可
- 画像的记录和管理由 `profile-memory` Skill 负责,本 Rule 只负责读取和注入
### 逐级上溯
当精简版 Profile 中某条记录**语义模糊**(无法判断偏好的具体适用场景),
按以下步骤精准查找详细 Log**禁止全文读取 Log 文件**
1. 提取该条目的锚点 IDHTML 注释中的 `PF-xxx`
2. 用 Grep 在对应的 Log 文件中搜索该 ID获取行号
3. 用 Read 工具读取该行号 ±15 行范围,获取原始上下文和来源信息
4. 一次上溯通常只涉及 1-2 条记录,不批量上溯
## 被动检测提醒
Agent 在整个对话过程中应保持对画像信号的被动感知。
当对话结束、用户的主线任务完成后,如果检测到了新的画像信息,
应读取 `profile-memory` Skill 并执行其"操作 A"的确认流程。

View File

@@ -0,0 +1,18 @@
---
description: 当用户要求启动项目、运行项目、打开前端/后端服务时触发
globs:
alwaysApply: false
---
## 项目启动快捷指令
当用户表达以下意图时,读取并执行 `project-launcher` Skill
**触发关键词**启动项目、运行项目、跑起来、打开前端和后端、启动服务、start、launch、run dev、启动前端、启动后端
**执行方式**
1. 读取 `.cursor/skills/project-launcher/SKILL.md`
2. 按 Skill 中的流程,在**独立可见的终端窗口**中启动前端和/或后端
3. 必须使用 `Start-Process`PowerShell或 `start cmd`CMD打开新窗口
4. **不要**在 Cursor 内置终端中后台运行——用户需要看到窗口并能手动关闭

View File

@@ -0,0 +1,151 @@
---
name: deferred-decisions
description: >-
记录开发中被延期的技术方案/决策,并在关联任务出现时主动提醒用户。
当对话中出现"以后再做"、"先不做"、"defer"、"延期方案"、"备选方案"、
"递进方案"等语义时触发。也用于浏览、管理已有的 deferred items。
---
# Deferred Decisions Skill
追踪开发中被延期的技术方案,在合适时机主动提醒用户。
## 存储
**数据文件**: `.cursor/deferred/registry.md`
该文件是 Agent 的结构化记忆,不是面向人类的文档。每个 deferred item 是一个 H3 级标题块。
## 操作 A记录延期方案
### 触发识别
当对话中出现以下模式时Agent 应主动提议记录:
- 讨论了多种方案,选择了一种,明确说其余"以后再做"
- 实现了基础版本,提到了可递进升级的高级版本
- 发现了可以改进的点,但当前优先级不够
### 流程
```
1. 从对话中提炼:
- 选择了什么方案chosen_alternative
- 延期了什么(标题 + context
- 为什么延期deferred_reason
- 什么时候可以做prerequisite
- 涉及哪些领域tags和文件related_files
2. 生成 slug 形式的唯一 ID如 ecosystem-lod-switching
3. 读取 .cursor/deferred/registry.md不存在则用模板创建
4. 在 "## Active Items" 区域末尾、"---" 分隔线之前追加新条目
5. 向用户确认已记录,展示条目摘要
```
### 条目模板
```markdown
### [item-slug] 简短标题
- **status**: deferred
- **tags**: tag1, tag2, tag3
- **recorded**: YYYY-MM-DD
- **source_chat**: [简短描述](chat-uuid)
- **prerequisite**: 实施前提条件(可选,无则写 "无"
- **related_files**:
- path/to/file1
- path/to/file2
- **context**: |
延期方案的具体内容2-5 行描述。
包含方案的要点和实施思路。
- **chosen_alternative**: 当时选择的方案简述
- **deferred_reason**: 延期的原因
```
### 字段说明
| 字段 | 必填 | 说明 |
|---|---|---|
| ID方括号内 | 是 | slug 形式,全局唯一 |
| status | 是 | `deferred` / `reminded` / `in_progress` / `done` / `cancelled` |
| tags | 是 | 逗号分隔的领域标签,用于关联匹配 |
| recorded | 是 | 记录日期 |
| source_chat | 否 | 来源对话标识 |
| prerequisite | 否 | 实施前提条件 |
| related_files | 否 | 关联代码文件路径 |
| context | 是 | 延期方案的具体内容 |
| chosen_alternative | 是 | 当时选择了什么 |
| deferred_reason | 是 | 延期原因 |
## 操作 B关联提醒
### 触发条件
`.cursor/rules/common/deferred-recall.mdc` 触发,或当用户任务涉及已有 deferred item 的领域时自动触发。
### 流程
```
1. 读取 .cursor/deferred/registry.md
2. 提取当前任务的关键词和涉及文件
3. 匹配 status=deferred 的 items
- tags 与当前任务关键词有交集
- related_files 与当前任务涉及文件有重叠
- prerequisite 描述的条件可能已满足
4. 如匹配到,在回复开头简要提醒:
"提醒:你之前有一个延期方案 [item-title] 与当前任务相关。要一并处理吗?"
5. 如用户同意,将该 item 的 status 改为 in_progress
```
### 提醒原则
- 每个 item 在同一会话中最多提醒一次
- 只提醒 status=deferred 的 itemsreminded/in_progress 不重复提醒)
- 提醒应简洁,不打断用户的主线任务
## 操作 C状态管理
| 用户动作 | 状态变更 | 额外操作 |
|---|---|---|
| "开始做 X" | deferred → in_progress | 无 |
| "X 完成了" | in_progress → done | 移到 "Completed / Cancelled Items" 区域 |
| "X 不需要了" | any → cancelled | 移到 "Completed / Cancelled Items" 区域 |
| Agent 提醒后用户确认 | deferred → in_progress | 无 |
### 移动条目
将条目从 "Active Items" 剪切到 "Completed / Cancelled Items" 区域时,保留完整内容,仅修改 status。
## 操作 D浏览/回顾
当用户问"有哪些延期方案"、"deferred list"、"待办方案"等时:
```
1. 读取 registry.md
2. 列出所有 status=deferred 的 items 摘要表:
| ID | 标题 | Tags | 记录日期 | 前提条件 |
3. 如用户要求按 tag 过滤,只展示匹配项
```
## Registry 文件模板
`.cursor/deferred/registry.md` 不存在时,用此模板创建:
```markdown
# Deferred Decisions Registry
## Active Items
(暂无延期方案)
---
## Completed / Cancelled Items
(暂无已完成或已废弃的方案)
```
## 自迭代日志
本节记录使用本 Skill 过程中发现的必要检查项。
### 已知必要检查
(暂无)

View File

@@ -0,0 +1,249 @@
---
name: dev-changelog
description: >-
三层开发进程记录系统。在 Agent 完成代码改动后自动记录,提供从一句话概要到完整日志的
多级上下文,帮助 Agent 在跨会话场景下保持对项目开发进展的感知。
当用户提到"开发日志"、"changelog"、"最近改了什么"、"回顾改动"等时触发。
---
# Dev Changelog
三层开发进程记录系统,解决 Agent 跨会话的上下文断裂问题。
## 三层架构
| 层级 | 文件 | 信息密度 | 条目数量 | 注入方式 |
|------|------|---------|---------|---------|
| L1 完整版 | `changelog-full.md` | 高5-15 行/条) | 无上限,只追加 | 用户手动唤醒 |
| L2 中期版 | `changelog-recent.md` | 中3-5 行/条) | 滚动窗口 ~20 条 | 检测到关联时自动读取 |
| L3 概要版 | `changelog-headlines.md` | 低1 行/条) | 滚动窗口 ~50 条 | 每次会话自动注入 |
所有数据文件存放在 `.cursor/changelog/` 目录下。
## 锚点 ID 机制
每条记录在写入时生成一个**锚点 ID**,格式为 `CL-YYYYMMDD-HHMM`(如 `CL-20260412-1430`)。
同一分钟内有多条时追加字母后缀(`CL-20260412-1430a``CL-20260412-1430b`)。
锚点 ID 在三层文件中保持一致,用于跨层精准定位:
- L3 一句话条目以 `[CL-xxx]` 开头
- L2 摘要条目的 H3 标题包含 `[CL-xxx]`
- L1 完整条目的 H3 标题包含 `[CL-xxx]`
这使得从 L3 → L2 → L1 的逐级查找可以通过 Grep 精准定位,无需全文读取。
## 逐级上溯机制
当 Agent 在使用 L3 概要作为上下文时,如果某条记录的一句话描述**语义模糊**
(如无法判断改动的具体范围、与当前任务的关系不明确),执行以下逐级查找:
```
1. 从 L3 条目中提取锚点 ID如 CL-20260412-1430
2. 在 changelog-recent.mdL2中 Grep 该 ID
- 找到 → 读取该条目的 3-5 行摘要,通常足以消除歧义
- 未找到(已滚出 L2 窗口)→ 进入步骤 3
3. 在 changelog-full.mdL1中 Grep 该 ID
- 找到 → 用 Read 工具读取该 ID 所在行号 ±20 行范围(精准读取,不读全文)
- 未找到 → 放弃上溯,该条目上下文不可用
```
### 上溯原则
- **按需触发**:只有在 L3 信息不足以支撑当前任务判断时才上溯,不预防性地批量读取
- **精准读取**:对 L1 的访问必须通过 Grep 定位行号 + Read 局部读取,禁止全文读取
- **最小化**:一次上溯通常只涉及 1-3 条记录,不批量上溯
## 操作 A记录写入核心流程
### 触发条件
Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后,自动触发。
以下情况**不触发**
- 纯对话讨论、方案设计、问题解答(无文件改动)
- 只读操作(查看文件、搜索代码)
- 只改动了 `.cursor/` 目录下的基础设施文件如画像、延期方案、changelog 自身)
### 写入流程
```
1. 生成锚点 IDCL-YYYYMMDD-HHMM检查是否与已有 ID 冲突,冲突则追加字母后缀)
2. 从刚完成的任务中提取以下信息:
- 做了什么what一句话概括
- 为什么这样做why动机和背景
- 改了哪里where受影响的文件/模块列表
- 关键决策decisions如果有方案选择记录选了什么、放弃了什么
- 注意事项notes后续可能受影响的地方、已知限制等
3. 生成三层内容(共享同一个锚点 ID
- L1 完整条目(包含以上全部信息)
- L2 摘要条目what + why + where3-5 行)
- L3 一句话what不超过 80 字)
4. 写入三个文件(按以下顺序):
a. 读取 changelog-full.md在 "## 记录" 下方追加 L1 条目
b. 读取 changelog-recent.md在顶部插入 L2 条目,如超过 10 条则移除最旧的
c. 读取 changelog-headlines.md在顶部插入 L3 条目,如超过 50 条则移除最旧的
5. 在回复末尾附一行提示:"[已记录到开发日志]"
```
### 静默写入原则
- **不需要用户确认**——Agent 自己做的改动,对"做了什么"的认知是一手的
- 用户如果觉得记录不准确,可通过操作 D 修改或删除
- 回滚操作也要记录("回退了 XX 改动"),真实反映开发过程
## 操作 BL2 自动触发读取
### 触发条件
`changelog-recall.mdc` Rule 调度。当 Agent 开始处理一个新任务时,判断该任务是否
与近期改动相关。
### 匹配策略(文件 + 标签双匹配)
```
1. 从当前任务中提取:
- 涉及的文件路径
- 语义关键词(模块名、功能领域等)
2. 读取 changelog-recent.md逐条检查
- 硬匹配:当前任务涉及的文件出现在条目的 affected_files 中
- 软匹配:当前任务的语义关键词与条目的 tags 有交集
3. 任一匹配命中 → 将匹配到的 L2 条目作为上下文纳入考量
4. 在回复中自然融入,不显式提及"根据开发日志"
```
## 操作 CL1 手动检索
### 触发条件
用户主动要求回顾完整改动记录时触发。典型话语:
- "回顾一下最近的改动"
- "XX 模块之前改过什么"
- "查看开发日志"
- "changelog"
### 流程
```
1. 读取 changelog-full.md
2. 根据用户需求过滤:
- 按时间范围
- 按模块/文件
- 按 tags
3. 展示匹配的条目摘要表,用户可以进一步查看某条的完整内容
```
## 操作 D记录管理
用户可以对已有记录进行管理:
| 操作 | 说明 |
|------|------|
| 删除 | 从三层文件中同步移除对应条目 |
| 修改 | 修改某条记录的描述(三层同步更新) |
| 清理 | 手动触发 L1 的归档(如按月分文件,暂不实现,作为演进方向) |
## 条目格式
### L1 完整条目
```markdown
### [CL-20260412-1430] YYYY-MM-DD HH:MM — 一句话标题
- **tags**: tag1, tag2, tag3
- **affected_files**:
- path/to/file1
- path/to/file2
- **what**: 做了什么的简要描述
- **why**: 动机和背景
- **decisions**: 选择了 A 方案(放弃了 B 因为 xxx
- **notes**: 后续注意事项
- **source_chat**: [对话简述](chat-uuid)
```
### L2 摘要条目
```markdown
### [CL-20260412-1430] YYYY-MM-DD — 一句话标题
- **tags**: tag1, tag2, tag3
- **affected_files**: file1, file2
- **summary**: 做了什么 + 为什么3-5 行)
```
### L3 一句话条目
```markdown
- [CL-20260412-1430] 一句话描述改动内容(不超过 80 字)
```
## 滚动窗口维护
### L2 窗口(~20 条)
```
写入新条目后,检查总条目数:
- <= 20 条:不做处理
- > 20 条:移除文件底部(最旧的)条目,直到恰好 20 条
```
### L3 窗口(~50 条)
```
写入新条目后,检查总行数(排除文件头部的标题和说明):
- <= 50 条:不做处理
- > 50 条:移除文件底部(最旧的)条目,直到恰好 50 条
```
被移除的条目不需要额外归档——L1 完整版保留了所有历史。
## 数据文件模板
首次写入时,如对应文件不存在,按以下模板创建。
### changelog-full.md
```markdown
# Dev Changelog — Full
完整的开发改动记录,按时间倒序排列。作为主动 RAG 的数据源,用户手动唤醒时读取。
## 记录
```
### changelog-recent.md
```markdown
# Dev Changelog — Recent
最近 ~10 次改动的摘要记录,按时间倒序排列。
当 Agent 检测到当前任务与近期改动相关时自动读取。
```
### changelog-headlines.md
```markdown
# Dev Changelog — Headlines
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
```
## 与其他系统的协作
| 系统 | 关系 | 说明 |
|------|------|------|
| `changelog-recall` Rule | 下游消费者 | 每次会话注入 L3自动触发 L2 读取 |
| `deferred-decisions` Skill | 互补 | deferred 记"没做什么"changelog 记"做了什么" |
| `profile-memory` Skill | 结构对称 | profile 是"是什么"changelog 是"做了什么" |
| `epee-orchestrator` | 注册 | 在 registry.md 中注册本 Skill |
## 自迭代日志
本节记录使用本 Skill 过程中发现的必要检查项。
### 已知必要检查
1. **大任务收尾遗漏风险** — 当单次任务涉及 5+ 个文件改动时Agent 容易在输出总结回复后遗漏被动写入流程。应在生成最终回复前,先执行 `changelog-recall.mdc` 中的"任务完成 Checklist",确认三层日志已写入后再输出回复。绝不能"先回复再补写"。

View File

@@ -0,0 +1,125 @@
---
name: epee-orchestrator
description: >-
EPEE Skill Orchestrator元层调度系统。检测任务是否可由已有 Skill 处理并分流,
或发现 Skill 缺口并引导创建新 Skill。当 Agent 遇到手动配置密集、重复模式明确、
手工指引过长的任务时触发。也用于 Skill Registry 的维护和同步。
---
# EPEE Skill Orchestrator
元层调度系统,负责 Skill 分流、缺口发现和 Registry 维护。
## 操作 ASkill 分流(匹配已有 Skill
### 流程
```
1. 读取 registry.md 获取所有已注册 Skill 的摘要
2. 将当前任务特征与每个 Skill 的"触发场景"关键词匹配
3. 如果匹配到:
a. 告知用户"此任务可以通过 [Skill名] 高效完成"
b. 简述该 Skill 的能力及与当前任务的契合点
c. 用户确认后,读取并激活对应 Skill 的 SKILL.md
```
### 匹配规则
- 优先匹配触发场景关键词与当前任务描述的交集
- 如有多个 Skill 匹配,按相关度排序推荐,由用户选择
- `deferred-decisions` 等标记为"基础设施"类型的 Skill 不参与任务分流匹配,
仅作为 Orchestrator 的下游工具使用
## 操作 BSkill 缺口发现 + 创建引导
### 触发条件
操作 A 未找到匹配的 Skill且当前任务满足"Skill 创建价值判断标准"中至少 2 条。
### 流程
```
1. 向用户提出建议:
"这类任务可以通过创建一个 [建议 Skill 名] 来自动化"
2. 给出 1-3 句方案概要:
- 这个 Skill 会做什么
- 核心工作机制(如 DSL 生成、YAML 直写、MCP 调用等)
- 预估能节省的重复劳动
3. 询问用户选择:
a) "继续讨论并创建" → 进入创建流程
b) "以后再说" → 进入延期记录流程
c) "不需要" → 结束,正常执行当前任务
```
### a) 创建流程
```
1. 委托给 create-skill Skill路径: ~/.cursor/skills-cursor/create-skill/SKILL.md
2. 将以下上下文传递给 create-skill 流程:
- 触发创建的原始任务描述
- Orchestrator 的方案概要
- 建议的 Skill 名称
3. 按 create-skill 的标准流程完成 Discovery → Design → Implementation → Verification
4. 创建完成后,要求用户对新 Skill 进行实际测试
5. 测试通过后,执行操作 C 同步 Registry
```
### b) 延期记录流程
```
1. 读取 deferred-decisions Skill
2. 按其"操作 A记录延期方案"流程,将 Skill 创建建议记录为 deferred item
3. tags 中包含 "skill-creation" 和相关领域标签
4. context 中记录方案概要,便于未来回忆
```
## 操作 CRegistry 同步
### 触发条件
- 新 Skill 被创建后
- 已有 Skill 的 SKILL.md 被实质性修改后(如能力范围变化、触发场景变化)
### 流程
```
1. 读取目标 Skill 的 SKILL.md
2. 从 frontmatter 提取 name 和 description
3. 从正文提取核心能力和触发场景关键词
4. 读取 registry.md
5. 新增或更新对应条目,遵循 registry.md 中定义的条目格式
6. 写回 registry.md
```
### 条目格式
参见 [registry.md](registry.md) 中的条目结构。
## Skill 创建价值判断标准
当操作 A 无匹配时Agent 使用以下标准评估是否建议创建新 Skill。
满足 **2 条及以上** 即认为值得建议:
1. **频次**:该类任务预计会出现 3 次以上
2. **模式明确**:任务有明确的输入/输出模式(输入 X → 产出 Y
3. **步骤繁多**:手动操作步骤 >= 5 步,或涉及 >= 3 个文件的协调修改
4. **易错**:存在容易出错的重复性操作(如 GUID 填写、格式对齐等)
5. **可自动化**:可以通过生成代码、配置文件、脚本或 MCP 调用来替代手动操作
不满足标准时Agent 正常执行任务,不提出创建建议。
## 与其他系统的协作
| 系统 | 关系 | 说明 |
|------|------|------|
| `create-skill` Skill | 下游委托 | 操作 B 创建流程的执行者 |
| `deferred-decisions` Skill | 下游工具 | 操作 B 延期记录的执行者 |
| `deferred-recall` Rule | 协同 | Orchestrator 延期的建议通过 deferred-recall 在未来自动提醒 |
## 自迭代日志
本节记录使用本 Skill 过程中发现的必要检查项。
### 已知必要检查
1. **收尾动作级联遗漏** — 当 Agent 遗漏了一个收尾动作(如 changelog 写入后续的收尾动作自迭代、Registry 同步)也会被一并遗漏,因为它们都在同一个"收尾阶段"。`changelog-recall.mdc` 中的 Checklist 化设计可以打断这种级联——每项独立检查,不依赖前一项的执行记忆。

View File

@@ -0,0 +1,58 @@
# EPEE Skill Registry
> 本文件由 EPEE Skill Orchestrator 维护,记录所有已实现 Skill 的摘要信息。
> 每次创建/修改 Skill 后必须同步更新(参见 `epee-orchestrator.mdc` Rule
## 条目格式说明
每个条目包含以下字段:
- **类型**:项目级 / 个人级 / 基础设施
- **能力**1 句话核心能力描述
- **触发场景**:逗号分隔的关键词/短语,用于与任务特征匹配
- **输出**:该 Skill 的产出物
- **路径**SKILL.md 的路径(统一使用 `.cursor/skills/...`
- **备注**(可选):特殊说明
---
## 已注册 Skill
### deferred-decisions
- **类型**: 基础设施
- **能力**: 记录和追踪延期的技术方案/决策,在关联任务出现时主动提醒
- **触发场景**: "以后再做"、"先不做"、"defer"、延期方案管理、延期回顾
- **输出**: .cursor/deferred/registry.md 条目
- **路径**: .cursor/skills/deferred-decisions/SKILL.md
- **备注**: 不参与任务分流匹配,仅作为 Orchestrator 的下游工具
### profile-memory
- **类型**: 个人级
- **能力**: 在对话中被动检测用户个人特质和项目信息,经确认后持久化为精简画像和详细日志
- **触发场景**: "画像"、"profile"、"我的偏好"、"查看画像"、"项目信息"、"查看项目画像"、用户主动管理画像
- **输出**: ~/.cursor/profile/user-profile.md、.cursor/profile/project-profile.md 及对应 log 文件
- **路径**: .cursor/skills/profile-memory/SKILL.md
- **备注**: 被动检测由 profile-recall Rule 触发;精简 Profile 每次会话自动注入上下文
### dev-changelog
- **类型**: 基础设施
- **能力**: 三层开发进程记录系统Agent 完成代码改动后自动记录,提供一句话概要到完整日志的多级上下文
- **触发场景**: "开发日志"、"changelog"、"最近改了什么"、"回顾改动"、"查看开发记录"、代码改动后自动写入
- **输出**: .cursor/changelog/ 下的 changelog-full.md、changelog-recent.md、changelog-headlines.md
- **路径**: .cursor/skills/dev-changelog/SKILL.md
- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklistL3 概要每次会话自动注入上下文stop Hook 双保险兜底
### pitfall-journal
- **类型**: 基础设施
- **能力**: 记录开发中踩过的坑(非显而易见的问题),在遇到同类问题时自动检索匹配已有经验
- **触发场景**: debug 完成后、"踩坑"、"之前遇到过"、"坑"、进入 Debug mode、同类错误反复出现
- **输出**: .cursor/pitfalls/pitfalls.md 条目
- **路径**: .cursor/skills/pitfall-journal/SKILL.md
- **备注**: 与 dev-changelog 互补——changelog 记事实pitfall 记经验;由 pitfall-recall Rule 触发自动检索
### project-launcher
- **类型**: 项目级
- **能力**: 一键启动 Art Agent 全部服务Ollama + 后端 FastAPI + 前端 Next.js自动检测已运行的服务并跳过
- **触发场景**: "启动项目"、"运行项目"、"跑起来"、"打开前端和后端"、"启动服务"、"start"、"launch"、"run dev"、"启动前端"、"启动后端"
- **输出**: Ollama 后台服务 + 两个独立的终端窗口(前端 Next.js + 后端 FastAPI
- **路径**: .cursor/skills/project-launcher/SKILL.md
- **备注**: 项目专属 SkillOllama 必须先于后端启动Mem0 embedding 依赖);前后端窗口必须可见

View File

@@ -0,0 +1,116 @@
---
name: pitfall-journal
description: >-
踩坑经验记录系统。在调试完成或发现非显而易见的坑后记录根因和解决方式,
后续遇到同类问题时自动检索匹配,避免重复踩坑。
当 debug 完成、问题反复出现、或用户提到"踩坑"、"之前遇到过"、"坑"时触发。
---
# Pitfall Journal
记录开发过程中遇到的"坑"——那些不看代码逻辑觉得应该没问题、但实际运行时才暴露的问题。
与 dev-changelog 互补changelog 记"做了什么"pitfall-journal 记"踩了什么坑、怎么爬出来的"。
## 数据文件
所有记录存放在 `.cursor/pitfalls/pitfalls.md`
## 条目格式
```markdown
### [PF-YYYYMMDD-HHMM] 一句话标题
- **症状**: 用户/系统看到的错误表现
- **根因**: 技术层面的真正原因
- **解法**: 具体怎么修的
- **防御**: 以后如何避免(可选,如果有通用性的话)
- **关联**: 相关文件、模块、技术栈标签
```
## 操作 A写入记录
### 触发条件
以下任一场景触发:
1. **调试完成后** — 经历了 debug 过程并找到了非显而易见的根因
2. **用户主动提及** — "记录一下这个坑"、"以后别再犯"
3. **Agent 识别到经验价值** — 问题涉及框架/库的隐式行为、配置陷阱、环境差异等
以下情况**不触发**
- 纯拼写错误、简单语法错误
- 问题原因一目了然(如变量名打错)
- 纯业务逻辑调整(不涉及"坑"的语义)
### 流程
```
1. 生成条目 IDPF-YYYYMMDD-HHMM
2. 从调试过程中提取:症状、根因、解法
3. 归纳防御措施(如果有通用性)
4. 读取 pitfalls.md在顶部追加新条目
5. 在回复末尾附:[已记录到踩坑日志]
```
### 静默写入原则
与 dev-changelog 一致——Agent 自己调试出来的问题,不需要用户确认就可以记录。
## 操作 B自动匹配检索
### 触发条件
当 Agent 在当前任务中遇到以下情况时,应主动检索 pitfalls.md
1. **进入 Debug mode** — 读取 pitfalls.md扫描是否有与当前错误症状匹配的记录
2. **同类错误再现** — 错误信息关键词与已有条目的"症状"匹配
3. **涉及已知高危区域** — 当前操作涉及的模块/技术栈在已有条目的"关联"中出现
### 匹配策略
```
1. 提取当前问题的关键信号:
- 错误信息关键词
- 涉及的文件/模块
- 涉及的技术栈/框架
2. 在 pitfalls.md 中匹配:
- 硬匹配:错误信息关键词出现在条目的"症状"中
- 软匹配:涉及的模块/技术栈出现在条目的"关联"中
3. 命中时,在分析中优先考虑已有经验:
> 注意:之前遇到过类似问题 [PF-xxx][一句话描述]。
> 上次的根因是 [xxx],先排查这个方向。
```
## 操作 C手动检索
### 触发条件
用户主动要求回顾踩坑记录。典型话语:
- "之前那个坑是什么来着"
- "看看踩坑日志"
- "有遇到过类似的问题吗"
### 流程
```
1. 读取 pitfalls.md
2. 根据用户描述匹配相关条目
3. 展示匹配结果
```
## 与其他系统的协作
| 系统 | 关系 | 说明 |
|------|------|------|
| `dev-changelog` | 互补 | changelog 记改动事实pitfall 记经验教训 |
| `pitfall-recall` Rule | 下游消费者 | 进入 Debug mode 或遇到错误时自动触发检索 |
| `epee-orchestrator` | 注册 | 在 registry.md 中注册本 Skill |
## 自迭代日志
本节记录使用本 Skill 过程中发现的必要检查项。
### 已知必要检查
(暂无)

View File

@@ -0,0 +1,269 @@
---
name: profile-memory
description: >-
渐进式用户/项目画像系统。在对话中被动检测用户个人特质(审美、技术偏好、做事风格等)
和项目信息(定位、技术栈、产品方向等),对话结束前统一总结并经用户确认后记录。
当用户提到"画像"、"profile"、"我的偏好"、"查看画像"、"项目信息"等时触发。
---
# Profile Memory
渐进式画像系统,被动收集并持久化用户个人特质与项目信息。
## 存储结构
| 文件 | 位置 | 用途 | 注入上下文 |
|------|------|------|-----------|
| user-profile.md | `~/.cursor/profile/` | 个人画像精简版 | 是(每次会话) |
| user-profile-log.md | `~/.cursor/profile/` | 个人画像详细日志 | 否 |
| project-profile.md | `.cursor/profile/` | 项目画像精简版 | 是(每次会话) |
| project-profile-log.md | `.cursor/profile/` | 项目画像详细日志 | 否 |
精简版 Profile 是 Agent 每次会话的上下文输入,必须极度精简。
详细 Log 保留完整上下文,供用户主动查阅和溯源。
## 锚点 ID 机制
每条画像记录在写入时生成一个**锚点 ID**,格式为 `PF-YYYYMMDD-NN`(如 `PF-20260412-01`
其中 NN 为当天的序号。
锚点 ID 在精简版和 Log 中保持一致:
- 精简版条目格式:`- 条目内容 <!-- PF-20260412-01 -->`HTML 注释,不影响可读性)
- Log 条目的 H3 标题:`### [PF-20260412-01] YYYY-MM-DD — 简短标题`
这使得从精简版到 Log 的查找可以通过 Grep 精准定位,无需全文读取 Log。
## 逐级上溯机制
当 Agent 在使用精简版 Profile 作为上下文时,如果某条记录**语义模糊**
(如无法判断偏好的具体适用场景、与当前任务的关系不明确),执行以下查找:
```
1. 从精简版条目中提取锚点 IDHTML 注释中的 PF-xxx
2. 在对应的 Log 文件中 Grep 该 ID
- 找到 → 用 Read 工具读取该 ID 所在行号 ±15 行范围(精准读取,不读全文)
- Log 中包含原始上下文、来源对话等完整信息,通常足以消除歧义
```
### 上溯原则
- **按需触发**:只有在精简版信息不足以支撑当前判断时才上溯
- **精准读取**:通过 Grep 定位行号 + Read 局部读取,禁止全文读取 Log
- **最小化**:一次上溯通常只涉及 1-2 条记录
## 硬上限
- user-profile.md不超过 **50 行**
- project-profile.md不超过 **30 行**
- 每条信息一行,`- ` 开头,措辞客观中立
## 操作 A被动检测 + 对话结束前确认
### 检测范围
在正常对话中被动检测以下信号(**不主动询问**
**个人特质**
- 审美/设计偏好("太花哨了"、"我喜欢 minimal"
- 技术偏好("以后都用 X"、"我不喜欢 class 写法"
- 做事风格("先讨论再动手"、"不要自作主张"
- 沟通偏好("给我简短的回答"、"多解释一下原理"
- 产品理解/思维方式
**项目信息**
- 项目定位和目标("这个项目是做 X 的"
- 技术栈与架构决策
- 设计约定和规范
- 产品方向和目标用户
### 检测原则
- **保守而非激进**:宁可漏记也不误记
- 只记录**持久性**的偏好/特质,忽略一次性的临时需求
- 区分"个人"和"项目"两个维度
### 变更分类
检测到的新信息与已有条目的关系分为三类,处理方式不同:
| 类型 | 定义 | 示例 | 提示强度 |
|------|------|------|---------|
| **新增** | 全新维度,无已有条目 | 首次提到审美偏好 | 常规 |
| **演进** | 已有条目的深化、细化或自然发展 | "偏好 React" → "偏好 React + Next.js 全栈" | 常规,展示前后对比 |
| **转向** | 与已有条目方向性矛盾或根本性变化 | "偏好 React" → "想转 Vue";项目方向从 B2C 转 B2B | **加强提醒**,展示前后对比 |
### 确认流程
```
1. 在对话过程中将检测到的信息在内部缓存,分类为"个人"或"项目"
2. 对每条缓存信息,与已有 Profile 比对,标记变更类型(新增/演进/转向)
3. 当用户的主线任务完成后,统一提出,格式如下:
"本次对话中我注意到以下可记录的画像信息:"
**个人画像:**
- [新增][分类] 条目内容
- [演进][分类] 旧xxx → 新yyy
- [转向][分类] 旧xxx → 新yyy ⚠️
**项目画像:**
- (同上格式)
转向类条目额外标注 ⚠️ 并附一句说明:
"⚠️ 以下条目与已有记录存在方向性变化,请特别关注:"
"是否记录?你可以全部确认、逐条修改或跳过。"
4. 用户确认后执行写入流程
5. 如本次对话未检测到任何画像信息,则不触发此流程
```
### 写入流程
```
1. 读取对应的精简 Profile 和详细 Log不存在则用模板创建
2. 为每条新记录生成锚点 IDPF-YYYYMMDD-NN检查 Log 中已有 ID 避免冲突)
3. 按变更类型执行:
- 新增:在对应分类下追加条目,带锚点 ID 注释
- 演进:替换对应旧条目为新措辞,沿用旧条目的锚点 ID或生成新 ID视变化程度而定
- 转向:替换对应旧条目为新措辞,生成新锚点 ID用户已在确认流程中审核
4. 写入精简 Profile条目格式`- 内容 <!-- PF-xxx -->`
5. 追加详细 Log标题格式`### [PF-xxx] YYYY-MM-DD — 简短标题`),其中:
- 新增条目:操作记为"新增"
- 演进条目:操作记为"更新(旧值 → 新值)"
- 转向条目:操作记为"转向(旧值 → 新值)",便于溯源重大变化
6. 检查精简 Profile 是否超过硬上限,超过则提醒用户精简
```
## 操作 B用户主动管理
当用户说"查看我的画像"、"profile"、"查看项目画像"、"我的偏好"等:
```
1. 读取对应的精简 Profile展示给用户
2. 如用户要看详细版或溯源,读取对应 Log 展示
3. 用户可要求:
- 删除某条记录同步删除精简版条目Log 中标记为已删除)
- 修改某条记录的措辞
- 合并/重组分类
- 新增分类
```
## 操作 C变更比对与冲突处理
### 比对逻辑
```
对每条新检测到的信息,在同分类下的已有条目中查找语义相近项:
1. 无相近条目 → 标记为"新增"
2. 有相近条目且方向一致(深化/细化/补充) → 标记为"演进"
3. 有相近条目且方向矛盾(替代/转向/否定) → 标记为"转向"
```
### 前后对比格式
演进和转向类条目在确认流程中必须展示前后对比:
```
- [演进][技术偏好] 旧:偏好 React → 新:偏好 React + Next.js 全栈开发
- [转向][产品方向] 旧:面向 C 端个人用户 → 新:转向 B 端企业客户 ⚠️
```
### 用户选择
对每条演进/转向条目,用户可以:
- **确认更新**:用新条目替换旧条目
- **保留两者**:旧条目不动,新条目作为补充追加(适用于不同细分维度)
- **放弃**:不记录本条
## 数据文件模板
首次使用时,如对应文件不存在,按以下模板创建。
### user-profile.md
```markdown
# User Profile
## 审美与设计
## 技术偏好
## 做事风格
## 沟通偏好
## 产品理解
```
### user-profile-log.md
```markdown
# User Profile Log
详细记录每次画像更新的完整上下文,按时间正序追加。
## 记录
```
### project-profile.md
```markdown
# Project Profile
## 项目定位
## 技术栈与架构
## 设计约定
## 产品方向
```
### project-profile-log.md
```markdown
# Project Profile Log
详细记录每次项目画像更新的完整上下文,按时间正序追加。
## 记录
```
### Log 条目格式
每次写入 Log 时,追加以下格式的条目:
```markdown
### [PF-20260412-01] YYYY-MM-DD — 简短标题
- **分类**: 对应的精简 Profile 分类名
- **精简版**: 写入精简 Profile 的那一行内容
- **原始上下文**: 用户原话或对话中的关键语句
- **来源对话**: [对话简述](chat-uuid)
- **操作**: 新增 / 演进(旧值 → 新值)/ 转向(旧值 → 新值)/ 删除
```
## 分类扩展
预设的分类列表可以扩展。当检测到的信息不属于任何现有分类时:
```
1. 在确认流程中标注"建议新增分类: [分类名]"
2. 用户确认后在精简 Profile 中新增该分类的 H2 标题
3. 注意硬上限,新增分类会占用行数
```
## 与其他系统的协作
| 系统 | 关系 | 说明 |
|------|------|------|
| `profile-recall` Rule | 下游消费者 | 每次会话读取精简 Profile 并注入上下文 |
| `epee-orchestrator` | 注册 | 在 registry.md 中注册本 Skill |
## 自迭代日志
本节记录使用本 Skill 过程中发现的必要检查项。
### 已知必要检查
(暂无)

View File

@@ -0,0 +1,160 @@
---
name: project-launcher
description: >-
Art Agent 项目一键启动。当用户说"启动项目"、"运行项目"、"打开前端和后端"等时触发,
自动在独立的可见终端窗口中启动前端Next.js和后端FastAPI方便用户随时查看和关闭。
---
# Project Launcher
一键启动 Art Agent 的前端和后端服务,在**独立可见的终端窗口**中运行,
用户可以随时查看日志或手动关闭。
## 触发条件
当用户表达以下意图时触发:
- "启动项目"、"运行项目"、"跑起来"
- "打开前端和后端"、"启动服务"
- "start"、"launch"、"run dev"
- "启动后端"、"启动前端"(可单独启动其中一个)
## 项目路径
| 组件 | 路径 | 启动命令 |
|------|------|---------|
| Ollama | 系统级服务 | `ollama serve` |
| 后端 | `art-agent/backend` | `uvicorn app.main:app --reload --host 0.0.0.0 --port 8000` |
| 前端 | `art-agent/frontend` | `npm run dev` |
## 前置条件
- 后端需要激活 Python 虚拟环境(`art-agent/backend/venv`
- 前端需要 Node.js >= 18
- **Ollama 必须在后端之前启动**Mem0 记忆系统依赖 Ollama 提供本地 embedding 服务(`nomic-embed-text` 模型),端口 `11434`
## 环境 PATH 须知
本机 Node.js 安装在 `C:\Program Files\nodejs\` 但**未加入系统 PATH**。
新开的终端窗口默认找不到 `node` / `npm` 命令。
**解决方式**:在启动前端的命令中,先将 Node.js 路径注入到当前会话的 `$env:PATH` 中。
> 如果后续 Node.js 路径发生变化(如用户重新安装或使用 nvm需要更新此处。
## 核心操作:启动服务
### 流程
```
1. 确定项目根目录workspace 根目录下的 art-agent/
2. 确定操作系统和 Shell 类型Windows / macOS / Linux
3. 检查并启动 Ollama
- 检测 Ollama 是否已在运行(请求 http://localhost:11434/api/tags
- 未运行 → 启动 Ollama 服务,等待就绪
- 已运行 → 跳过
4. 启动后端(在独立可见终端窗口中):
- WindowsPowerShell 或 CMD 均适用):
使用 `Start-Process` 或 `start cmd` 打开新的终端窗口
- macOS/Linux
使用对应的终端打开方式
5. 启动前端(在另一个独立可见终端窗口中):
- 同样在新的终端窗口中启动
6. 确认三个服务正在运行,告知用户访问地址
```
### Ollama 启动(跨平台通用)
先检测 Ollama 是否已在运行,未运行则启动:
```powershell
# 检测PowerShell
try {
Invoke-WebRequest -Uri "http://localhost:11434/api/tags" -UseBasicParsing -TimeoutSec 3 | Out-Null
# 已运行,跳过
} catch {
# 未运行,启动
Start-Process "ollama" -ArgumentList "serve" -WindowStyle Normal
# 等待就绪(最多 10 秒)
Start-Sleep -Seconds 3
}
```
> Ollama 启动后会常驻后台,不需要独立终端窗口。如果用户系统已将 Ollama 设为开机自启,
> 则检测会直接通过,不会重复启动。
### Windows 启动命令
**关键要求**:必须在**新的、可见的终端窗口**中启动,不能在 Cursor 内置终端后台运行。
#### 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
```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
```
#### CMD 环境
启动后端:
```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"'
```
### 执行注意事项
1. **路径拼接**`BACKEND_PATH``FRONTEND_PATH` 必须替换为实际的绝对路径
2. **venv 存在性检查**:启动后端前先确认 `art-agent/backend/venv` 目录存在,
不存在时提醒用户先创建虚拟环境
3. **依赖检查**:如果 `node_modules` 不存在,先提醒用户执行 `npm install`
4. **窗口标题**:尽量为窗口设置有意义的标题(如 "Art Agent Backend"、"Art Agent Frontend"
方便用户在任务栏中识别
5. **不使用 `block_until_ms: 0`**:不要用 Cursor 的后台命令方式,
那样窗口不可见,用户无法直接查看和关闭
## 单独启动
如果用户只说"启动前端"或"启动后端",只启动对应的服务即可,不需要全部启动。
## 访问信息
启动完成后告知用户:
- Ollamahttp://localhost:11434Mem0 embedding 服务)
- 后端 APIhttp://localhost:8000
- 后端文档http://localhost:8000/docs
- 前端页面http://localhost:3000
## 自迭代日志
本节记录使用本 Skill 过程中发现的必要检查项。
### 已知必要检查
1. **Node.js PATH 注入** — 本机 Node.js (`C:\Program Files\nodejs\`) 未加入系统 PATH新开的终端窗口默认找不到 `npm`。启动前端时必须先将此路径注入到会话 PATH 中。
2. **Ollama 必须先于后端启动** — Mem0 记忆系统依赖 Ollama 的 `nomic-embed-text` 模型做本地 embedding端口 11434。Ollama 未运行时后端能启动但对话会报 502 错误。启动流程必须在后端之前检测并启动 Ollama。

55
.gitignore vendored Normal file
View File

@@ -0,0 +1,55 @@
# ═══════════════════════════════════════════════════════════
# EPEEAIKit .gitignore
# ═══════════════════════════════════════════════════════════
# ─── Python ──────────────────────────────────────────────
__pycache__/
*.py[cod]
*$py.class
*.pyo
# 虚拟环境
venv/
.venv/
env/
# 分发 / 构建
*.egg-info/
*.egg
dist/
build/
# ─── Node.js / Next.js ──────────────────────────────────
node_modules/
.next/
out/
# ─── 环境变量 / 密钥(保留 .env.example 作为模板)───────
.env
.env.local
.env.*.local
!.env.example
# ─── 运行时生成物 ────────────────────────────────────────
art-agent/backend/uploads/
art-agent/backend/generated/
art-agent/backend/data/
# ─── 工具二进制 ──────────────────────────────────────────
*.exe
# ─── IDE / 编辑器 ────────────────────────────────────────
.vscode/
.idea/
*.swp
*.swo
*~
# ─── OS 文件 ─────────────────────────────────────────────
.DS_Store
Thumbs.db
desktop.ini
# ─── 日志 / 调试 ─────────────────────────────────────────
*.log
npm-debug.log*

View File

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

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

@@ -0,0 +1,177 @@
# EPEEKit
AI 美术资源生成工具集 — 通过对话生成游戏美术资源。
## 快速开始
### 1. 准备 API Key
你需要两个 API Key
- **OpenAI API Key**(或兼容 APIhttps://platform.openai.com/api-keys
- **Replicate API Token**https://replicate.com/account/api-tokens
### 2. 启动后端
```bash
cd art-agent/backend
# 创建虚拟环境(推荐)
python -m venv venv
venv\Scripts\activate # Windows
# source venv/bin/activate # macOS/Linux
# 安装依赖
pip install -r requirements.txt
# 配置环境变量
copy .env.example .env
# 编辑 .env填入你的 API Key 和模型配置
# 启动(--host 0.0.0.0 允许局域网/穿透访问)
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
### 3. 启动前端
```bash
cd art-agent/frontend
# 安装依赖(需要 Node.js >= 18
npm install
# 启动
npm run dev
```
### 4. 使用
打开浏览器访问 http://localhost:3000
- 输入文字描述你想要的美术资源
- 可以上传参考图引导风格
- 生成的图片可以点击"保存"按钮下载
- 持续对话进行迭代修改
## 访问地址
| 场景 | 前端页面 | 后端 API | 后端文档 |
|------|---------|---------|---------|
| **本机** | http://localhost:3000 | http://localhost:8000 | http://localhost:8000/docs |
| **局域网**(同一 WiFi | `http://<你的IP>:3000` | `http://<你的IP>:8000` | `http://<你的IP>:8000/docs` |
| **外网**Cloudflare Tunnel | `https://xxx.trycloudflare.com`(每次启动不同) | `https://yyy.trycloudflare.com` | — |
查看本机局域网 IP
```powershell
# Windows
Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceAlias -eq 'WLAN' }
# macOS / Linux
ifconfig | grep "inet " | grep -v 127.0.0.1
```
## 配置说明
所有可配置项都集中在 `backend/.env` 文件中,分为以下几组:
| 配置组 | 变量 | 说明 |
|--------|------|------|
| **LLM 对话** | `OPENAI_API_KEY` | API 密钥 |
| | `OPENAI_BASE_URL` | API 基地址,支持任何 OpenAI 兼容 API |
| | `LLM_MODEL` | 模型名称,如 `gpt-4o-mini``deepseek-chat` 等 |
| | `LLM_MAX_ITERATIONS` | Agent 单轮最大工具调用次数 |
| **图像生成** | `REPLICATE_API_TOKEN` | Replicate API Token |
| | `IMAGE_MODEL` | 图像生成模型,如 `black-forest-labs/flux-schnell` |
| | `IMAGE_ASPECT_RATIO` | 默认宽高比,如 `1:1` |
| | `IMAGE_OUTPUT_FORMAT` | 输出格式,如 `png` |
| **网络** | `HTTP_PROXY` / `HTTPS_PROXY` | 代理配置 |
| **服务** | `PORT` | 后端服务端口 |
切换 LLM 供应商只需修改 `OPENAI_BASE_URL` + `OPENAI_API_KEY` + `LLM_MODEL` 三个值。
## 项目结构
```
art-agent/
backend/ # Python FastAPI 后端
app/
main.py # 入口
config.py # 集中配置(读取 .env
api/chat.py # 对话 API
agent/ # Agent Loop 核心
services/ # AI 服务封装
frontend/ # Next.js 前端
src/
app/ # 页面
components/ # UI 组件
lib/ # 工具函数
docs/ # 文档
```
## 远程访问(外网穿透)
通过 Cloudflare Quick Tunnel 免费穿透内网,手机或朋友在任何网络下都能访问,无需买服务器。
### 安装 cloudflared
```powershell
# Windows任选一种
winget install Cloudflare.cloudflared
# 或手动下载
# https://github.com/cloudflare/cloudflared/releases/latest
# 下载 cloudflared-windows-amd64.exe放到项目目录下即可
```
```bash
# macOS
brew install cloudflared
# Linux
# https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
```
### 一键启动穿透
确保前后端服务已经在运行,然后在另一个终端执行:
```powershell
cd art-agent
.\start-tunnel.ps1
```
脚本会自动:
1. 为后端和前端各创建一条穿透隧道
2. 更新 `frontend/.env.local` 中的后端 API 地址为穿透地址
3. 打印手机可访问的公网 HTTPS 链接
4. 关闭脚本时Ctrl+C自动恢复 `.env.local` 为 localhost
> **重要**:穿透启动后需要**重启前端** (`npm run dev`) 才能读取新的 `.env.local`。
### 穿透管理
| 操作 | 方式 |
|------|------|
| **启动** | 运行 `.\start-tunnel.ps1`,等待打印出两条 `trycloudflare.com` 地址 |
| **停止** | 在脚本窗口按 `Ctrl+C`,或在任务管理器中结束 `cloudflared.exe` 进程 |
| **查看状态** | 检查日志:`Get-Content $env:TEMP\epeekit-tunnel-backend.log` |
| **重启** | 先停止再启动地址会变Quick Tunnel 每次分配随机域名) |
### 注意事项
- Quick Tunnel **免费无需账号**,但地址每次启动都会变化
- 如果 cloudflared 连接超时可能需要配置代理Clash 等),在运行脚本前设置:
```powershell
$env:HTTP_PROXY = "http://127.0.0.1:7890"
$env:HTTPS_PROXY = "http://127.0.0.1:7890"
.\start-tunnel.ps1
```
- 电脑关机、休眠或代理断开后隧道会自动失效
- 停止穿透后,`start-tunnel.ps1` 会自动将 `.env.local` 恢复为 `localhost`;如果手动杀进程,需要自行恢复
## 技术栈
- 前端Next.js + TypeScript + Tailwind CSS
- 后端Python FastAPI + SSE
- AI可配置 LLM默认 GPT-4o-mini+ 可配置图像模型(默认 Replicate Flux

View File

@@ -0,0 +1,59 @@
# ╔═══════════════════════════════════════════════════════════╗
# ║ EPEEKit — 集中配置 ║
# ║ 切换 LLM 只需改 OPENAI_BASE_URL + OPENAI_API_KEY ║
# ║ + LLM_MODEL 三个值 ║
# ╚═══════════════════════════════════════════════════════════╝
# ─── LLM 对话配置 ─────────────────────────────────────────
# API 密钥OpenAI 或兼容 API 的 key
OPENAI_API_KEY=sk-your-key-here
# API 基地址(留空 = 直连 api.openai.com
# 常见替代值:
# OpenRouter: https://openrouter.ai/api/v1
# DeepSeek: https://api.deepseek.com/v1
# 本地 Ollama: http://localhost:11434/v1
# OPENAI_BASE_URL=
# 对话模型名称
# 常见选项: gpt-4o-mini | gpt-4o | deepseek-chat | claude-3-haiku-20240307
LLM_MODEL=gpt-4o-mini
# Agent 单轮最大工具调用次数(防止无限循环)
LLM_MAX_ITERATIONS=5
# ─── 图像生成配置 ─────────────────────────────────────────
# Replicate API Token
REPLICATE_API_TOKEN=your-replicate-token-here
# 默认图像生成模型(注册表短 ID前端未指定时的 fallback
# 可选值: flux-schnell | flux-dev | sdxl
# 也兼容完整 Replicate model ID 如 black-forest-labs/flux-schnell
IMAGE_MODEL=flux-schnell
# 默认宽高比
IMAGE_ASPECT_RATIO=1:1
# 输出格式
IMAGE_OUTPUT_FORMAT=png
# Gemini 原生生图(向量引擎 /v1beta/.../generateContent可选调优多图参考易超时或断连
# VECTORENGINE_GEMINI_READ_TIMEOUT=600
# VECTORENGINE_GEMINI_WRITE_TIMEOUT=180
# VECTORENGINE_GEMINI_CONNECT_TIMEOUT=60
# VECTORENGINE_GEMINI_POOL_TIMEOUT=60
# VECTORENGINE_GEMINI_MAX_RETRIES=3
# ─── 网络代理(按需配置)─────────────────────────────────
# HTTP_PROXY=http://127.0.0.1:7890
# HTTPS_PROXY=http://127.0.0.1:7890
# ─── 用户认证 ─────────────────────────────────────────────
# JWT 签名密钥(必填,建议随机生成 32+ 位字符串)
JWT_SECRET=your-random-secret-key-here
# 首次启动时自动创建的管理员密码(留空则随机生成并打印到控制台)
# ADMIN_DEFAULT_PASSWORD=changeme123
# ─── 服务配置 ─────────────────────────────────────────────
PORT=8000

View File

View File

View File

@@ -0,0 +1,359 @@
"""Agent Loop 主循环LLM 对话 -> 工具调用 -> 结果回传 -> 继续。"""
import asyncio
import json
import logging
import re
import os
from typing import AsyncGenerator, Optional
from openai import AsyncOpenAI
from app.agent.tools import TOOL_DEFINITIONS, execute_tool
from app.config import (
get_llm_model_config,
get_llm_max_iterations,
get_image_model_config,
get_max_recent_turns,
)
from app.memory import get_memory
from app.services.image_gen import to_data_uri
logger = logging.getLogger(__name__)
def _get_client(provider: str) -> AsyncOpenAI:
"""按 provider 创建对应的 OpenAI 兼容客户端。"""
if provider == "vectorengine":
return AsyncOpenAI(
api_key=os.getenv("VECTORENGINE_API_KEY"),
base_url=os.getenv("VECTORENGINE_BASE_URL", "https://api.vectorengine.ai/v1"),
)
if provider == "deepseek":
return AsyncOpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
)
return AsyncOpenAI()
SYSTEM_PROMPT = """\
你是一个专业的游戏美术 AI 助手。你的工作是帮助美术人员通过对话生成游戏美术资源。
## 你的能力
- 根据用户的文字描述生成图片UI图标、按钮、插画、立绘、概念图等
- 理解用户的审美意图,将中文描述转化为高质量的英文生成 prompt
- 根据用户反馈迭代修改(调整颜色、风格、构图等)
- 如果用户提供了参考图(支持多张),将参考图的风格元素融入生成 prompt
- 理解多张参考图各自的角色(如"图1的主体 + 图2的风格/视角"),并在 prompt 中准确传达
- 理解用户在图片上的标注(框选区域 + 文字批注),精准定位需要修改的部分
## 工作流程
1. 理解用户需求,必要时追问细节(尺寸、风格、用途等)
2. 将需求转化为详细的英文 prompt调用 generate_image 工具生成图片
3. 向用户展示结果并询问反馈
4. 根据反馈调整 prompt 并重新生成
## 标注理解
当用户发送带有「图片标注」的消息时,表示用户在之前生成的图片上做了标注。
标注格式为:`[区域 (x%, y%) 大小 w%×h%]: 修改意见`
- 区域坐标表示标注框在图片上的相对位置
- 你需要理解标注区域所指的图片内容,并将修改意见融入新的 prompt
- 如果附带了标注截图(参考图),仔细观察红色标注框和文字来理解用户意图
- 在调整 prompt 时,保持原图整体风格不变,只针对标注区域做修改
## 生成 prompt 要求
- 必须使用英文
- 尽量详细描述:主体内容、颜色方案、光照、构图、材质等
- 如果用户要求游戏 UI 元素,添加相关关键词如 "game UI", "icon", "button"
- 如果你能直接看到参考图(图片内容),可以在 prompt 中描述参考图的风格特征。多张参考图时,理解用户对各图的定位(如"图1做主体参考、图2做风格参考"),将相应特征分别融入 prompt
- 如果你无法看到参考图(只收到了文字提示说有参考图),参考图会由生图工具自动处理风格融合。此时你不要自行猜测画风/艺术风格关键词(如 pixel art、watercolor、oil painting 等),把风格交给参考图来决定。但如果用户在消息中明确指定了风格(如"赛博朋克风""水彩风"等),应保留并翻译到 prompt 中——尊重用户的主动意图
## 注意事项
- 用中文和用户交流
- 生成图片后简要说明你使用的 prompt 思路
- 主动建议迭代方向
- **禁止模拟工具调用**:生成图片时必须实际调用 generate_image 工具,绝不能用文字描述"已生成"或假装工具已执行。如果需要生成多张图片,每张都必须单独调用工具
- **禁止在回复中嵌入图片链接**:不要在回复文字中使用 Markdown 图片语法(如 `![](...)` 或 `sandbox:` 链接)。图片展示由系统自动处理,你只需用文字描述结果即可
"""
async def run_agent_loop(
messages: list[dict],
ref_image_urls: Optional[list[str]] = None,
image_model: Optional[str] = None,
session_id: Optional[str] = None,
user_id: str = "default_user",
llm_model: Optional[str] = None,
) -> AsyncGenerator[dict, None]:
"""
运行 Agent Loop以 SSE 事件流形式 yield 结果。
事件类型:
- text_delta: LLM 文字增量
- tool_start: 开始执行工具
- image_result: 图片生成结果
- memory_warning: 记忆存储异常(不中断对话)
- done: 完成
- error: 错误
"""
# ── 滑动窗口:截断过早的消息 ──
max_turns = get_max_recent_turns()
if len(messages) > max_turns:
messages = messages[-max_turns:]
# ── 记忆检索:用最新 user 消息语义检索相关记忆 ──
memory_block = ""
try:
memory = get_memory()
last_user_content = ""
for m in reversed(messages):
if m["role"] == "user":
last_user_content = m["content"]
break
if last_user_content:
search_kwargs = {"query": last_user_content, "user_id": user_id, "limit": 10}
if session_id:
search_kwargs["run_id"] = session_id
relevant = memory.search(**search_kwargs)
results = relevant.get("results", []) if isinstance(relevant, dict) else relevant
if results:
items = "\n".join(f"- {m['memory']}" for m in results if m.get("memory"))
if items:
memory_block = f"\n\n## 用户记忆(来自历史对话)\n{items}"
except Exception as e:
logger.error("Mem0 记忆检索失败: %s", e, exc_info=True)
yield {"type": "error", "data": {"message": f"记忆系统检索失败: {e}"}}
return
# ── 解析 LLM 模型配置 ──
llm_config = get_llm_model_config(llm_model)
llm_provider = llm_config["provider"]
llm_model_id = llm_config["model_id"]
vision_capable = llm_config.get("vision", False)
# ── 构建 system prompt ──
img_model_config = get_image_model_config(image_model)
current_model_name = img_model_config.get('name', '未知')
current_model_id = img_model_config.get('id', '未知')
model_hint = (
f"\n\n## 当前生图模型(重要)\n"
f"本次对话用户选择的生图模型是 **{current_model_name}**"
f"model_id: {current_model_id})。\n"
f"**注意**:对话历史中可能包含之前使用其他模型的记录,忽略那些旧模型名。"
f"本次生成使用的是 {current_model_name},在回复中只能使用这个名称。"
)
api_messages = [{"role": "system", "content": SYSTEM_PROMPT + model_hint + memory_block}]
effective_refs = ref_image_urls or []
for msg in messages:
if msg["role"] == "user" and effective_refs and msg is messages[-1]:
if vision_capable:
content_parts: list[dict] = [{"type": "text", "text": msg["content"]}]
for ref_url in effective_refs:
content_parts.append({
"type": "image_url",
"image_url": {"url": to_data_uri(ref_url)},
})
api_messages.append({"role": "user", "content": content_parts})
else:
n_refs = len(effective_refs)
hint = (
f"{msg['content']}\n\n"
f"【系统提示:用户上传了 {n_refs} 张参考图,已自动传递给图片生成工具。"
"生图工具会根据模型能力自动处理参考图的风格融合。"
"你无法看到这些参考图,因此在生成 prompt 时:\n"
"1. 描述画面内容(主体、构图、光照、材质等)\n"
"2. 不要自行猜测画风/艺术风格——但如果用户明确指定了风格,保留到 prompt 中\n"
"3. 用户未指定风格时,风格完全由参考图决定】"
)
api_messages.append({"role": "user", "content": hint})
continue
api_messages.append({"role": msg["role"], "content": msg["content"]})
client = _get_client(llm_provider)
for _ in range(get_llm_max_iterations()):
try:
response = await client.chat.completions.create(
model=llm_model_id,
messages=api_messages,
tools=TOOL_DEFINITIONS,
stream=True,
)
except Exception as e:
yield {"type": "error", "data": {"message": str(e)}}
return
collected_text = ""
tool_calls_data: dict[int, dict] = {}
async for chunk in response:
delta = chunk.choices[0].delta if chunk.choices else None
if not delta:
continue
# 文字内容
if delta.content:
collected_text += delta.content
yield {"type": "text_delta", "data": {"text": delta.content}}
# 工具调用(流式累积)
if delta.tool_calls:
for tc in delta.tool_calls:
idx = tc.index
if idx not in tool_calls_data:
tool_calls_data[idx] = {
"id": "",
"name": "",
"arguments": "",
}
if tc.id:
tool_calls_data[idx]["id"] = tc.id
if tc.function and tc.function.name:
tool_calls_data[idx]["name"] = tc.function.name
if tc.function and tc.function.arguments:
tool_calls_data[idx]["arguments"] += tc.function.arguments
finish_reason = chunk.choices[0].finish_reason if chunk.choices else None
# 如果没有工具调用:检测 LLM 是否在用文字模拟生图
if not tool_calls_data:
if _looks_like_fake_generation(collected_text):
# LLM 用文字假装调用了工具,丢弃这段文字,注入纠正消息强制重试
yield {
"type": "text_delta",
"data": {"text": "\n\n[系统:检测到未调用生图工具,正在重试...]\n"},
}
api_messages.append({"role": "assistant", "content": collected_text})
api_messages.append({
"role": "user",
"content": (
"你刚才没有调用 generate_image 工具,只是用文字描述了生成过程。"
"请立即调用 generate_image 工具来实际生成图片。"
"不要解释,直接调用工具。"
f"当前使用的生图模型是 {img_model_config.get('name', '未知')}"
),
})
continue
# 对话正常结束,异步存储记忆
async for evt in _store_and_done(messages, session_id, user_id):
yield evt
return
# 将 assistant 消息(含 tool_calls加入历史
assistant_msg: dict = {"role": "assistant"}
if collected_text:
assistant_msg["content"] = collected_text
else:
assistant_msg["content"] = None
assistant_msg["tool_calls"] = []
for idx in sorted(tool_calls_data.keys()):
tc_data = tool_calls_data[idx]
assistant_msg["tool_calls"].append({
"id": tc_data["id"],
"type": "function",
"function": {
"name": tc_data["name"],
"arguments": tc_data["arguments"],
},
})
api_messages.append(assistant_msg)
# 依次执行每个工具调用
for idx in sorted(tool_calls_data.keys()):
tc_data = tool_calls_data[idx]
tool_name = tc_data["name"]
yield {
"type": "tool_start",
"data": {"tool": tool_name, "message": "正在生成图片..."},
}
try:
arguments = json.loads(tc_data["arguments"])
except json.JSONDecodeError:
arguments = {}
result = await execute_tool(tool_name, arguments, effective_refs or None, image_model)
used_model = result.get("model_name", "")
if result.get("errors"):
yield {
"type": "tool_error",
"data": {
"tool": tool_name,
"errors": result["errors"],
"model_name": used_model,
},
}
if result.get("images"):
yield {
"type": "image_result",
"data": {
"images": result["images"],
"prompt_used": result.get("prompt_used", ""),
"model_name": used_model,
},
}
# 工具结果回传给 LLM
api_messages.append({
"role": "tool",
"tool_call_id": tc_data["id"],
"content": json.dumps(result, ensure_ascii=False),
})
# ref_image_url 不清除:消息构建只在循环外执行一次,
# 后续工具调用仍需参考图InstantStyle 等模型必须有 style_image
# 迭代次数用尽,存储记忆后结束
async for evt in _store_and_done(messages, session_id, user_id):
yield evt
# ─── 异步记忆存储 ─────────────────────────────────────────
async def _store_and_done(
messages: list[dict], session_id: Optional[str], user_id: str = "default_user"
) -> AsyncGenerator[dict, None]:
"""触发 Mem0 异步存储后 yield done。存储失败时 yield warning 但不中断。"""
try:
memory = get_memory()
recent = messages[-4:] if len(messages) >= 4 else messages
add_kwargs: dict = {"user_id": user_id}
if session_id:
add_kwargs["run_id"] = session_id
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: memory.add(recent, **add_kwargs))
except Exception as e:
logger.error("Mem0 记忆存储失败: %s", e, exc_info=True)
yield {
"type": "memory_warning",
"data": {"message": f"记忆存储失败: {e}"},
}
yield {"type": "done", "data": {}}
# ─── 假生成检测 ──────────────────────────────────────────
_FAKE_GEN_PATTERNS = re.compile(
r"已生成|生成完成|开始生成|正在生成|图片已|prompt.*?设计思路",
re.IGNORECASE,
)
def _looks_like_fake_generation(text: str) -> bool:
"""判断 LLM 的纯文字回复是否在假装已经调用了生图工具。"""
if not text or len(text) < 50:
return False
matches = _FAKE_GEN_PATTERNS.findall(text)
return len(matches) >= 2

View File

@@ -0,0 +1,68 @@
"""Agent 可调用的工具定义和实现。"""
from app.services.image_gen import generate_images
# OpenAI Function Calling 格式的工具定义
TOOL_DEFINITIONS = [
{
"type": "function",
"function": {
"name": "generate_image",
"description": (
"根据文字描述生成图片。prompt 必须是英文。"
"如果用户提供了参考图,会自动传入 ref_image_url 参数。"
"若当前生图模型为 Stable Diffusion XL可在正提示后单独一行写 ---NEGATIVE--- 再写负向提示;"
"不传则服务端会使用该模型的默认负向词。"
),
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "英文图片描述 prompt详细描述要生成的图片内容、风格、颜色等",
},
"num_images": {
"type": "integer",
"description": "生成图片数量1-4 张",
"default": 1,
"minimum": 1,
"maximum": 4,
},
},
"required": ["prompt"],
},
},
}
]
async def execute_tool(
tool_name: str,
arguments: dict,
ref_image_urls: list[str] | None = None,
image_model: str | None = None,
) -> dict:
"""执行工具调用,返回结果。"""
if tool_name == "generate_image":
prompt = arguments["prompt"]
num_images = arguments.get("num_images", 1)
result = await generate_images(
prompt=prompt,
num_images=num_images,
ref_image_urls=ref_image_urls,
model_id=image_model,
)
valid_urls = [u for u in result.urls if not u.startswith("[")]
errors = [u for u in result.urls if u.startswith("[")]
return {
"success": len(valid_urls) > 0,
"images": valid_urls,
"errors": errors,
"prompt_used": prompt,
"effective_prompt": result.effective_prompt,
"negative_prompt": result.negative_prompt,
"model_name": result.model_name,
"model_id": result.model_id,
}
return {"success": False, "error": f"未知工具: {tool_name}"}

View File

View File

@@ -0,0 +1,95 @@
"""
管理员路由:创建用户 / 用户列表 / 禁用或删除用户。
"""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import hash_password, require_admin
from app.db import User, get_session
router = APIRouter(prefix="/admin", tags=["admin"])
class CreateUserRequest(BaseModel):
username: str
password: str
display_name: str = ""
is_admin: bool = False
class UserOut(BaseModel):
id: str
username: str
display_name: str
is_admin: bool
is_active: bool
created_at: str
@router.post("/users", response_model=UserOut)
def create_user(
body: CreateUserRequest,
_admin: User = Depends(require_admin),
session: Session = Depends(get_session),
):
existing = session.exec(select(User).where(User.username == body.username)).first()
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="用户名已存在")
if len(body.password) < 6:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="密码至少 6 位")
user = User(
username=body.username,
hashed_password=hash_password(body.password),
display_name=body.display_name or body.username,
is_admin=body.is_admin,
)
session.add(user)
session.commit()
session.refresh(user)
return UserOut(
id=user.id,
username=user.username,
display_name=user.display_name,
is_admin=user.is_admin,
is_active=user.is_active,
created_at=user.created_at.isoformat(),
)
@router.get("/users", response_model=list[UserOut])
def list_users(
_admin: User = Depends(require_admin),
session: Session = Depends(get_session),
):
users = session.exec(select(User)).all()
return [
UserOut(
id=u.id,
username=u.username,
display_name=u.display_name,
is_admin=u.is_admin,
is_active=u.is_active,
created_at=u.created_at.isoformat(),
)
for u in users
]
@router.delete("/users/{user_id}")
def disable_user(
user_id: str,
_admin: User = Depends(require_admin),
session: Session = Depends(get_session),
):
user = session.exec(select(User).where(User.id == user_id)).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if user.id == _admin.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能禁用自己")
user.is_active = False
session.add(user)
session.commit()
return {"message": f"用户 {user.username} 已禁用"}

View File

@@ -0,0 +1,99 @@
"""
认证路由:登录 / 刷新令牌 / 修改密码 / 当前用户信息。
"""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import (
create_access_token,
create_refresh_token,
decode_token,
get_current_user,
hash_password,
verify_password,
)
from app.db import User, get_session
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: dict
class RefreshRequest(BaseModel):
refresh_token: str
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
def _user_dict(u: User) -> dict:
return {
"id": u.id,
"username": u.username,
"display_name": u.display_name,
"is_admin": u.is_admin,
}
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, session: Session = Depends(get_session)):
user = session.exec(select(User).where(User.username == body.username)).first()
if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
return TokenResponse(
access_token=create_access_token(user.id),
refresh_token=create_refresh_token(user.id),
user=_user_dict(user),
)
@router.post("/refresh")
def refresh(body: RefreshRequest, session: Session = Depends(get_session)):
user_id = decode_token(body.refresh_token, expected_type="refresh")
user = session.exec(select(User).where(User.id == user_id)).first()
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已禁用")
return {
"access_token": create_access_token(user.id),
"token_type": "bearer",
}
@router.post("/change-password")
def change_password(
body: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session),
):
if not verify_password(body.old_password, current_user.hashed_password):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误")
if len(body.new_password) < 6:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="新密码至少 6 位")
# 重新获取以确保在同一 session 中
user = session.exec(select(User).where(User.id == current_user.id)).first()
if user:
user.hashed_password = hash_password(body.new_password)
session.add(user)
session.commit()
return {"message": "密码已修改"}
@router.get("/me")
def me(current_user: User = Depends(get_current_user)):
return _user_dict(current_user)

View File

@@ -0,0 +1,113 @@
import json
import uuid
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, UploadFile
from sse_starlette.sse import EventSourceResponse
from app.agent.loop import run_agent_loop
from app.auth import get_current_user
from app.config import (
get_image_models_list,
get_default_image_model_id,
get_llm_models_list,
get_default_llm_model_id,
)
from app.db import User
router = APIRouter()
UPLOADS_DIR = Path(__file__).parent.parent.parent / "uploads"
async def _save_upload(file: UploadFile) -> str:
"""保存上传的参考图,返回可访问的 URL 路径。"""
ext = Path(file.filename).suffix or ".png"
filename = f"{uuid.uuid4().hex}{ext}"
filepath = UPLOADS_DIR / filename
content = await file.read()
filepath.write_bytes(content)
return f"/uploads/{filename}"
@router.post("/upload-ref-image")
async def upload_ref_image(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
):
"""
独立的参考图上传端点。
前端选图后立即调用,返回服务端路径,供后续发消息时引用。
"""
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
url = await _save_upload(file)
return {"url": url, "filename": file.filename}
@router.get("/models")
async def list_models(current_user: User = Depends(get_current_user)):
"""返回可用的图像生成模型列表。"""
return {
"models": get_image_models_list(),
"default": get_default_image_model_id(),
}
@router.get("/llm-models")
async def list_llm_models(current_user: User = Depends(get_current_user)):
"""返回可用的 LLM 对话模型列表。"""
return {
"models": get_llm_models_list(),
"default": get_default_llm_model_id(),
}
@router.post("/chat")
async def chat(
messages: str = Form(...),
ref_image: Optional[UploadFile] = File(None),
ref_image_url: Optional[str] = Form(None),
ref_image_urls: Optional[str] = Form(None),
image_model: Optional[str] = Form(None),
session_id: Optional[str] = Form(None),
llm_model: Optional[str] = Form(None),
current_user: User = Depends(get_current_user),
):
"""
主对话端点。
参数:
- messages: JSON 字符串,对话历史 [{role, content}]
- ref_image: 可选的参考图文件(兼容旧方式)
- ref_image_url: 兼容旧方式,单张参考图服务端路径
- ref_image_urls: JSON 数组字符串,多张参考图服务端路径列表
- image_model: 可选,指定本次使用的生图模型短 ID
- session_id: 可选,前端会话 ID用于 Mem0 记忆作用域
- llm_model: 可选,指定本次使用的 LLM 模型短 ID
"""
parsed_messages = json.loads(messages)
resolved_ref_urls: list[str] = []
if ref_image_urls:
resolved_ref_urls = json.loads(ref_image_urls)
elif ref_image_url:
resolved_ref_urls = [ref_image_url]
elif ref_image and ref_image.filename:
resolved_ref_urls = [await _save_upload(ref_image)]
async def event_generator():
async for event in run_agent_loop(
parsed_messages,
resolved_ref_urls or None,
image_model=image_model,
session_id=session_id,
user_id=current_user.id,
llm_model=llm_model,
):
yield {
"event": event["type"],
"data": json.dumps(event["data"], ensure_ascii=False),
}
return EventSourceResponse(event_generator())

View File

@@ -0,0 +1,37 @@
"""
记忆查询 API供前端查看当前用户的 Mem0 记忆条目。
"""
import asyncio
import logging
from fastapi import APIRouter, Depends
from app.auth import get_current_user
from app.db import User
from app.memory import get_memory
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/memory", tags=["memory"])
@router.get("/list")
async def list_memories(current_user: User = Depends(get_current_user)):
"""返回当前用户的所有记忆条目(只读)。"""
memory = get_memory()
loop = asyncio.get_running_loop()
try:
result = await loop.run_in_executor(
None, lambda: memory.get_all(user_id=current_user.id, limit=200)
)
except Exception as e:
logger.error("获取记忆列表失败: %s", e)
return {"memories": [], "error": str(e)}
results = result.get("results", []) if isinstance(result, dict) else result
logger.info("Mem0 get_all 返回 %d 条记忆, result_type=%s", len(results), type(result).__name__)
if results:
sample = results[0]
logger.info("记忆样本 keys=%s, memory=%s", list(sample.keys()) if isinstance(sample, dict) else "not-dict", str(sample)[:200])
return {"memories": results}

View File

@@ -0,0 +1,89 @@
"""
认证工具:密码哈希 + JWT 令牌 + FastAPI 依赖注入。
"""
import os
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
from pwdlib.hashers.bcrypt import BcryptHasher
from sqlmodel import Session, select
from app.db import User, get_session
# Argon2 优先用于新密码BcryptHasher 兼容旧版已有哈希
pwd_hash = PasswordHash((Argon2Hasher(), BcryptHasher()))
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 7
def _get_secret() -> str:
secret = os.getenv("JWT_SECRET", "")
if not secret:
raise RuntimeError("JWT_SECRET 环境变量未设置")
return secret
def hash_password(plain: str) -> str:
return pwd_hash.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_hash.verify(plain, hashed)
def create_access_token(user_id: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return jwt.encode(
{"sub": user_id, "exp": expire, "type": "access"},
_get_secret(),
algorithm=ALGORITHM,
)
def create_refresh_token(user_id: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
return jwt.encode(
{"sub": user_id, "exp": expire, "type": "refresh"},
_get_secret(),
algorithm=ALGORITHM,
)
def decode_token(token: str, expected_type: str = "access") -> str:
"""解码 JWT返回 user_id。无效时抛 HTTPException 401。"""
try:
payload = jwt.decode(token, _get_secret(), algorithms=[ALGORITHM])
user_id: str = payload.get("sub", "")
token_type: str = payload.get("type", "")
if not user_id or token_type != expected_type:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效令牌")
return user_id
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="令牌已过期或无效")
def get_current_user(
token: str = Depends(oauth2_scheme),
session: Session = Depends(get_session),
) -> User:
"""FastAPI 依赖:从 Bearer token 解析当前用户。"""
user_id = decode_token(token, "access")
user = session.exec(select(User).where(User.id == user_id)).first()
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已禁用")
return user
def require_admin(user: User = Depends(get_current_user)) -> User:
"""FastAPI 依赖:要求当前用户是管理员。"""
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
return user

View File

@@ -0,0 +1,293 @@
"""
EPEEKit 集中配置。
所有可调参数从环境变量读取,每次调用实时读取(不缓存),确保 load_dotenv() 后生效。
"""
import os
from typing import Any
def get_llm_max_iterations() -> int:
return int(os.getenv("LLM_MAX_ITERATIONS", "5"))
# ─── LLM 模型注册表 ─────────────────────────────────────
#
# 每个模型的配置说明:
# id — 前端/API 使用的短 ID
# name — 显示名称
# provider — API 提供者(对应 _get_client 的分发键)
# model_id — 传给 OpenAI SDK 的 model 参数
# description — 前端下拉列表说明文字
# vision — 是否支持多模态图片输入
LLM_MODELS: dict[str, dict[str, Any]] = {
"gpt-5.4": {
"id": "gpt-5.4",
"name": "GPT-5.4",
"provider": "vectorengine",
"model_id": "gpt-5.4",
"description": "知识工作与计算机操控最强1M 上下文",
"vision": True,
},
"claude-sonnet-4-6": {
"id": "claude-sonnet-4-6",
"name": "Claude Sonnet 4.6",
"provider": "vectorengine",
"model_id": "claude-sonnet-4-6",
"description": "高性价比编码与日常任务",
"vision": True,
},
"claude-opus-4-6": {
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6",
"provider": "vectorengine",
"model_id": "claude-opus-4-6",
"description": "编码与专家级推理最强128K 输出",
"vision": True,
},
"gemini-3.1-pro-preview": {
"id": "gemini-3.1-pro-preview",
"name": "Gemini 3.1 Pro",
"provider": "vectorengine",
"model_id": "gemini-3.1-pro-preview",
"description": "推理最强、价格最低2M 上下文",
"vision": True,
},
"glm-4.7": {
"id": "glm-4.7",
"name": "GLM-4.7",
"provider": "vectorengine",
"model_id": "glm-4.7",
"description": "智谱 AI中文能力突出免费额度",
"vision": False,
},
"gpt-4o-mini": {
"id": "gpt-4o-mini",
"name": "GPT-4o Mini",
"provider": "vectorengine",
"model_id": "gpt-4o-mini",
"description": "轻量快速、高性价比",
"vision": True,
},
"deepseek-chat": {
"id": "deepseek-chat",
"name": "DeepSeek Chat",
"provider": "deepseek",
"model_id": "deepseek-chat",
"description": "中文对话优化(直连)",
"vision": False,
},
}
def get_default_llm_model_id() -> str:
"""返回 .env 中配置的默认 LLM 短 ID不在注册表中则回退到 gpt-5.4。"""
env_model = os.getenv("LLM_MODEL", "gpt-5.4")
if env_model in LLM_MODELS:
return env_model
return "gpt-5.4"
def get_llm_model_config(model_id: str | None = None) -> dict[str, Any]:
"""根据短 ID 获取 LLM 模型配置,未指定或不存在则使用默认模型。"""
if model_id and model_id in LLM_MODELS:
return LLM_MODELS[model_id]
return LLM_MODELS[get_default_llm_model_id()]
def get_llm_models_list() -> list[dict]:
"""返回前端下拉列表所需的 LLM 模型摘要信息。"""
return [
{
"id": cfg["id"],
"name": cfg["name"],
"description": cfg["description"],
"vision": cfg.get("vision", False),
}
for cfg in LLM_MODELS.values()
]
# ─── 图像模型注册表 ─────────────────────────────────────
#
# 每个模型的配置说明:
# id — 前端/API 使用的短 ID
# name — 显示名称
# provider — 生成服务提供者(对应 image_gen.py 中的 Provider
# model_id — Replicate 上的完整模型 ID
# description — 前端下拉列表中的说明文字
# supports_ref_image — 是否原生支持参考图输入IP-Adapter 等)
# ref_image_param — 传给 Replicate 的参考图参数名(模型间可能不同)
# num_images_param — 批量生成参数名Flux 用 num_outputsKolors 用 number_of_images
# default_params — 默认推理参数
IMAGE_MODELS: dict[str, dict[str, Any]] = {
"gpt-image-1.5": {
"id": "gpt-image-1.5",
"name": "GPT Image 1.5",
"provider": "openai",
"model_id": "gpt-image-1.5",
"description": "OpenAI 最强生图,文字渲染与 prompt 理解最佳,支持多图参考",
"supports_ref_image": True,
"max_ref_images": 16,
"default_params": {
"size": "1024x1024",
"quality": "high",
},
},
"gemini-3.1-flash-image": {
"id": "gemini-3.1-flash-image",
"name": "Gemini 3.1 Flash Image",
"provider": "gemini_native",
"model_id": "gemini-3.1-flash-image-preview",
"description": "Google 原生生图,速度快、价格低,支持多图参考(最多 14 张)",
"supports_ref_image": True,
"max_ref_images": 14,
"default_params": {},
},
"flux-schnell": {
"id": "flux-schnell",
"name": "Flux Schnell",
"provider": "replicate",
"model_id": "black-forest-labs/flux-schnell",
"description": "快速生成,适合快速迭代",
"supports_ref_image": False,
"num_images_param": "num_outputs",
"default_params": {
"aspect_ratio": "1:1",
"output_format": "png",
},
},
"flux-dev": {
"id": "flux-dev",
"name": "Flux Dev",
"provider": "replicate",
"model_id": "black-forest-labs/flux-dev",
"description": "高质量生成,细节更好",
"supports_ref_image": False,
"num_images_param": "num_outputs",
"default_params": {
"aspect_ratio": "1:1",
"output_format": "png",
},
},
"sdxl": {
"id": "sdxl",
"name": "Stable Diffusion XL",
"provider": "replicate",
"model_id": "stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b",
"description": "经典 SDXL支持 negative prompt",
"supports_ref_image": False,
"num_images_param": "num_outputs",
"default_params": {
"width": 1024,
"height": 1024,
"num_inference_steps": 50,
"guidance_scale": 7.5,
},
},
"instant-style": {
"id": "instant-style",
"name": "InstantStyle",
"provider": "replicate",
"model_id": "jyoung105/instant-style:c6f01e12f31cb99f9ee774a78992a71294f630a6f433d9aecfdc33b816fc4baa",
"description": "强风格迁移,画风还原度高(较慢)",
"supports_ref_image": True,
"ref_image_param": "style_image",
"num_images_param": "num_outputs",
"default_params": {
"width": 1024,
"height": 1024,
"num_inference_steps": 30,
"guidance_scale": 5,
"style_strength": 1.0,
"block_mode": "style-only",
"adapter_mode": "original",
},
},
"kolors-ipadapter": {
"id": "kolors-ipadapter",
"name": "Kolors IP-Adapter",
"provider": "replicate",
"model_id": "fofr/kolors-with-ipadapter:5a1a92b2c0f81813225d48ed8e411813da41aa84e7582fb705d1af46eea36eed",
"description": "风格参考生成,上传参考图效果最佳",
"supports_ref_image": True,
"ref_image_param": "image",
"num_images_param": "number_of_images",
"default_params": {
"width": 1024,
"height": 1024,
"steps": 25,
"cfg": 4,
"ip_adapter_weight": 0.8,
"ip_adapter_weight_type": "style transfer precise",
"output_format": "png",
},
},
}
def get_default_image_model_id() -> str:
"""返回 .env 中配置的默认模型短 ID若不在注册表中则回退到 flux-schnell。"""
env_model = os.getenv("IMAGE_MODEL", "flux-schnell")
for mid, cfg in IMAGE_MODELS.items():
if cfg["model_id"] == env_model or mid == env_model:
return mid
return "flux-schnell"
def get_image_model_config(model_id: str | None = None) -> dict[str, Any]:
"""根据短 ID 获取模型配置,未指定或不存在则使用默认模型。"""
if model_id and model_id in IMAGE_MODELS:
return IMAGE_MODELS[model_id]
return IMAGE_MODELS[get_default_image_model_id()]
def get_ref_image_model_id() -> str | None:
"""返回有参考图时推荐使用的模型 ID第一个 supports_ref_image=True 的模型)。"""
for mid, cfg in IMAGE_MODELS.items():
if cfg.get("supports_ref_image"):
return mid
return None
def get_image_models_list() -> list[dict]:
"""返回前端下拉列表所需的模型摘要信息。"""
return [
{
"id": cfg["id"],
"name": cfg["name"],
"description": cfg["description"],
"supports_ref_image": cfg.get("supports_ref_image", False),
}
for cfg in IMAGE_MODELS.values()
]
# ─── 记忆系统配置 ─────────────────────────────────────
def get_deepseek_api_key() -> str:
return os.getenv("DEEPSEEK_API_KEY", "")
def get_ollama_base_url() -> str:
return os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
def get_mem0_embedding_model() -> str:
return os.getenv("MEM0_EMBEDDING_MODEL", "nomic-embed-text")
def get_max_recent_turns() -> int:
return int(os.getenv("MAX_RECENT_TURNS", "20"))
# ─── 图像输出配置 ─────────────────────────────────────
def get_image_aspect_ratio() -> str:
return os.getenv("IMAGE_ASPECT_RATIO", "1:1")
def get_image_output_format() -> str:
return os.getenv("IMAGE_OUTPUT_FORMAT", "png")

View File

@@ -0,0 +1,67 @@
"""
数据库初始化 + User 模型。
使用 SQLite单文件存放在 data/epeekit.db。
"""
import secrets
import uuid
from datetime import datetime
from pathlib import Path
from sqlmodel import Field, Session, SQLModel, create_engine, select
DATA_DIR = Path(__file__).parent.parent / "data"
DATA_DIR.mkdir(exist_ok=True)
DATABASE_URL = f"sqlite:///{DATA_DIR / 'epeekit.db'}"
engine = create_engine(DATABASE_URL, echo=False)
class User(SQLModel, table=True):
id: str = Field(default_factory=lambda: uuid.uuid4().hex, primary_key=True)
username: str = Field(index=True, unique=True)
hashed_password: str
display_name: str = ""
is_admin: bool = False
is_active: bool = True
created_at: datetime = Field(default_factory=datetime.utcnow)
def create_db_and_tables():
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session
def ensure_default_admin():
"""如果 users 表为空,创建默认管理员账号。"""
import os
from app.auth import hash_password
with Session(engine) as session:
user = session.exec(select(User).limit(1)).first()
if user is not None:
return
password = os.getenv("ADMIN_DEFAULT_PASSWORD", "")
if not password:
password = secrets.token_urlsafe(12)
print(f"\n{'='*50}")
print(f" 默认管理员账号已创建")
print(f" 用户名: admin")
print(f" 密码: {password}")
print(f" 请登录后尽快修改密码!")
print(f"{'='*50}\n")
admin = User(
username="admin",
hashed_password=hash_password(password),
display_name="管理员",
is_admin=True,
)
session.add(admin)
session.commit()

View File

@@ -0,0 +1,54 @@
import os
from contextlib import asynccontextmanager
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(override=True)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.chat import router as chat_router
from app.api.auth import router as auth_router
from app.api.admin import router as admin_router
from app.api.memory import router as memory_router
from app.db import create_db_and_tables, ensure_default_admin
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
ensure_default_admin()
yield
app = FastAPI(title="EPEEKit API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 确保存储目录存在
UPLOADS_DIR = Path(__file__).parent.parent / "uploads"
GENERATED_DIR = Path(__file__).parent.parent / "generated"
UPLOADS_DIR.mkdir(exist_ok=True)
GENERATED_DIR.mkdir(exist_ok=True)
# 静态文件服务:提供上传的参考图和生成的图片
app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads")
app.mount("/generated", StaticFiles(directory=str(GENERATED_DIR)), name="generated")
app.include_router(auth_router, prefix="/api")
app.include_router(admin_router, prefix="/api")
app.include_router(chat_router, prefix="/api")
app.include_router(memory_router, prefix="/api")
@app.get("/health")
async def health():
return {"status": "ok"}

View File

@@ -0,0 +1,99 @@
"""
Mem0 记忆层初始化与健康检查。
启动时验证 Ollama 可达和模型可用,失败则抛出 RuntimeError 阻止后端启动。
"""
import logging
import httpx
from mem0 import Memory
from app.config import (
get_deepseek_api_key,
get_ollama_base_url,
get_mem0_embedding_model,
)
logger = logging.getLogger(__name__)
_memory: Memory | None = None
def _check_ollama_health() -> None:
"""检查 Ollama 服务是否可达,以及 embedding 模型是否已安装。"""
base_url = get_ollama_base_url()
model_name = get_mem0_embedding_model()
try:
resp = httpx.get(f"{base_url}/api/tags", timeout=5)
resp.raise_for_status()
except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPStatusError) as e:
raise RuntimeError(
f"无法连接 Ollama 服务({base_url})。"
f"请确认 Ollama 已启动:启动方式参见 https://ollama.com\n"
f"原始错误:{e}"
) from e
models = resp.json().get("models", [])
installed = [m.get("name", "").split(":")[0] for m in models]
if model_name not in installed:
raise RuntimeError(
f"Ollama 已运行,但未找到 embedding 模型 '{model_name}'\n"
f"已安装的模型:{installed}\n"
f"请运行ollama pull {model_name}"
)
logger.info("Ollama 健康检查通过:%s 模型可用", model_name)
def init_memory() -> Memory:
"""初始化 Mem0 Memory 单例。首次调用时执行健康检查。"""
global _memory
if _memory is not None:
return _memory
_check_ollama_health()
deepseek_key = get_deepseek_api_key()
if not deepseek_key:
raise RuntimeError(
"DEEPSEEK_API_KEY 未配置。Mem0 需要该 key 进行事实提取。\n"
"请在 .env 中设置 DEEPSEEK_API_KEY"
)
config = {
"llm": {
"provider": "deepseek",
"config": {
"model": "deepseek-chat",
"temperature": 0.1,
"max_tokens": 1500,
"api_key": deepseek_key,
},
},
"embedder": {
"provider": "ollama",
"config": {
"model": get_mem0_embedding_model(),
"ollama_base_url": get_ollama_base_url(),
},
},
"vector_store": {
"provider": "qdrant",
"config": {
"collection_name": "epeekit_memories",
"embedding_model_dims": 768,
"path": "./data/qdrant",
},
},
}
_memory = Memory.from_config(config)
logger.info("Mem0 记忆层初始化成功")
return _memory
def get_memory() -> Memory:
"""获取已初始化的 Memory 实例。未初始化时自动调用 init_memory()。"""
if _memory is None:
return init_memory()
return _memory

View File

@@ -0,0 +1,573 @@
"""图像生成服务 — Provider 抽象层。"""
import asyncio
import base64
import mimetypes
import os
import uuid
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any
import httpx
from openai import AsyncOpenAI
from replicate import Client as ReplicateClient
from app.config import get_image_aspect_ratio, get_image_model_config, get_image_output_format
from app.services.image_prompt_strategy import prepare_image_prompt_for_model
BACKEND_ROOT = Path(__file__).parent.parent.parent
GENERATED_DIR = BACKEND_ROOT / "generated"
def _make_replicate_client() -> ReplicateClient:
"""
创建 Replicate 客户端。
Replicate SDK 内部创建 httpx transport 时不读取代理环境变量,
需要我们手动把代理配置注入到 transport 中。
"""
timeout = httpx.Timeout(5.0, read=300.0, write=30.0, connect=30.0, pool=10.0)
proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
transport_kwargs: dict[str, Any] = {}
if proxy_url:
transport_kwargs["proxy"] = proxy_url
our_transport = httpx.AsyncHTTPTransport(**transport_kwargs)
client = ReplicateClient(
timeout=timeout,
transport=our_transport,
)
return client
_replicate_client = _make_replicate_client()
# ─── 公共工具 ──────────────────────────────────────────
def to_data_uri(image_path: str) -> str:
"""将本地路径或已有 URL 转为 Replicate 可接受的格式data URI 或原始 URL"""
if image_path.startswith("data:") or image_path.startswith("http"):
return image_path
local = BACKEND_ROOT / image_path.lstrip("/")
if local.exists():
mime = mimetypes.guess_type(str(local))[0] or "image/png"
b64 = base64.b64encode(local.read_bytes()).decode()
return f"data:{mime};base64,{b64}"
return image_path
def _load_image_bytes(image_path: str) -> bytes:
"""将本地路径或 data URI 转为原始字节,供 OpenAI images.edit 使用。"""
if image_path.startswith("data:"):
# data:image/png;base64,xxxx
_, b64_part = image_path.split(",", 1)
return base64.b64decode(b64_part)
if image_path.startswith("http"):
raise ValueError("_load_image_bytes 不支持远程 URL请先下载到本地")
local = BACKEND_ROOT / image_path.lstrip("/")
if local.exists():
return local.read_bytes()
raise FileNotFoundError(f"参考图文件未找到: {local}")
def _resolve_image_base64(image_path: str) -> tuple[str, str]:
"""将图片路径/data URI 解析为 (mime_type, base64_string)。
统一处理三种输入形式:
- data URI (data:image/png;base64,xxxx) → 直接提取 mime 和 base64
- 本地路径 (/uploads/xxx.png) → 读取文件并编码
- 其他 → 尝试作为本地路径处理
"""
if image_path.startswith("data:"):
header, b64_part = image_path.split(",", 1)
# header 格式: data:image/png;base64
mime = header.split(";")[0].replace("data:", "")
return mime, b64_part
local = BACKEND_ROOT / image_path.lstrip("/")
if local.exists():
mime = mimetypes.guess_type(str(local))[0] or "image/png"
b64 = base64.b64encode(local.read_bytes()).decode()
return mime, b64
raise FileNotFoundError(f"参考图文件未找到: {local}")
async def _download_image(url: str) -> str:
"""下载远程图片到本地 generated/ 目录,返回本地 URL 路径。"""
filename = f"{uuid.uuid4().hex}.png"
filepath = GENERATED_DIR / filename
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
async with httpx.AsyncClient(proxy=proxy, timeout=httpx.Timeout(60.0)) as client:
resp = await client.get(url, follow_redirects=True)
resp.raise_for_status()
filepath.write_bytes(resp.content)
return f"/generated/{filename}"
# ─── Provider 抽象基类 ─────────────────────────────────
class ImageProvider(ABC):
"""所有图像生成 provider 的基类。"""
@abstractmethod
async def generate(
self,
model_config: dict[str, Any],
prompt: str,
num_images: int = 1,
ref_image_urls: list[str] | None = None,
negative_prompt: str | None = None,
) -> list[str]:
"""生成图片并返回本地 URL 列表。negative_prompt 仅部分后端使用(如 Replicate SDXL"""
...
class ReplicateProvider(ImageProvider):
"""通过 Replicate API 调用模型。"""
async def generate(
self,
model_config: dict[str, Any],
prompt: str,
num_images: int = 1,
ref_image_urls: list[str] | None = None,
negative_prompt: str | None = None,
) -> list[str]:
replicate_model_id = model_config["model_id"]
default_params: dict[str, Any] = model_config.get("default_params", {})
local_urls: list[str] = []
try:
# Replicate 模型InstantStyle/Kolors只支持单张参考图取第一张
single_ref = ref_image_urls[0] if ref_image_urls else None
input_params = self._build_input(
model_config,
default_params,
prompt,
num_images,
single_ref,
negative_prompt=negative_prompt,
)
output = await _replicate_client.async_run(
replicate_model_id, input=input_params, wait=False
)
items = output if isinstance(output, list) else [output]
for item in items:
url = str(item)
if url.startswith("https://") or url.startswith("http://") or url.startswith("data:"):
local_urls.append(await _download_image(url))
else:
local_urls.append(f"[生成失败: 模型返回非图片内容: {url[:200]}]")
except Exception as e:
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
local_urls.append(f"[生成失败: {detail}]")
return local_urls
@staticmethod
def _build_input(
model_config: dict[str, Any],
default_params: dict[str, Any],
prompt: str,
num_images: int = 1,
ref_image_url: str | None = None,
negative_prompt: str | None = None,
) -> dict[str, Any]:
"""
根据模型配置构建 Replicate input 参数。
Replicate 上的 IP-Adapter 模型只支持单张参考图。
"""
model_id = model_config["model_id"]
supports_ref = model_config.get("supports_ref_image", False)
ref_param_name = model_config.get("ref_image_param", "image")
num_images_param = model_config.get("num_images_param", "num_outputs")
if supports_ref:
params: dict[str, Any] = {"prompt": prompt}
for k, v in default_params.items():
params[k] = v
params[num_images_param] = num_images
if ref_image_url:
params[ref_param_name] = to_data_uri(ref_image_url)
return params
is_flux = "flux" in model_id.lower()
params = {"prompt": prompt, num_images_param: num_images}
if is_flux:
params["aspect_ratio"] = default_params.get(
"aspect_ratio", get_image_aspect_ratio()
)
params["output_format"] = default_params.get(
"output_format", get_image_output_format()
)
else:
params["width"] = default_params.get("width", 1024)
params["height"] = default_params.get("height", 1024)
if "num_inference_steps" in default_params:
params["num_inference_steps"] = default_params["num_inference_steps"]
if "guidance_scale" in default_params:
params["guidance_scale"] = default_params["guidance_scale"]
if negative_prompt is not None:
params["negative_prompt"] = negative_prompt
return params
class OpenAIImageProvider(ImageProvider):
"""通过 OpenAI 兼容 API向量引擎中转调用 GPT Image 系列模型。
有参考图时使用 images.edit支持最多 16 张参考图),
无参考图时使用 images.generate纯文生图
"""
def __init__(self) -> None:
self._client: AsyncOpenAI | None = None
def _get_client(self) -> AsyncOpenAI:
if self._client is None:
self._client = AsyncOpenAI(
api_key=os.getenv("VECTORENGINE_API_KEY"),
base_url=os.getenv("VECTORENGINE_BASE_URL", "https://api.vectorengine.ai/v1"),
timeout=httpx.Timeout(5.0, read=180.0, write=60.0, connect=30.0),
)
return self._client
async def generate(
self,
model_config: dict[str, Any],
prompt: str,
num_images: int = 1,
ref_image_urls: list[str] | None = None,
negative_prompt: str | None = None,
) -> list[str]:
_ = negative_prompt
client = self._get_client()
default_params = model_config.get("default_params", {})
model_id = model_config["model_id"]
local_urls: list[str] = []
try:
if ref_image_urls:
resp = await self._edit_with_refs(
client, model_id, prompt, ref_image_urls, num_images, default_params
)
else:
resp = await client.images.generate(
model=model_id,
prompt=prompt,
n=num_images,
size=default_params.get("size", "1024x1024"),
quality=default_params.get("quality", "high"),
)
for img_data in resp.data:
if img_data.url:
local_urls.append(await _download_image(img_data.url))
elif img_data.b64_json:
filename = f"{uuid.uuid4().hex}.png"
filepath = GENERATED_DIR / filename
filepath.write_bytes(base64.b64decode(img_data.b64_json))
local_urls.append(f"/generated/{filename}")
else:
local_urls.append("[生成失败: 模型未返回图片数据]")
except Exception as e:
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
local_urls.append(f"[生成失败: {detail}]")
return local_urls
@staticmethod
async def _edit_with_refs(
client: AsyncOpenAI,
model_id: str,
prompt: str,
ref_image_urls: list[str],
num_images: int,
default_params: dict[str, Any],
):
"""使用 images.edit 端点传入参考图GPT Image 系列最多 16 张)。"""
image_files: list[Any] = []
for url in ref_image_urls:
image_files.append(_load_image_bytes(url))
image_arg: Any = image_files[0] if len(image_files) == 1 else image_files
return await client.images.edit(
model=model_id,
image=image_arg,
prompt=prompt,
n=num_images,
size=default_params.get("size", "1024x1024"),
)
class GeminiNativeImageProvider(ImageProvider):
"""通过向量引擎中转调用 Gemini 原生 generateContent 接口。
Gemini 原生接口支持文字 + 图片混合输入(最多 14 张参考图),
在一次 generateContent 调用中同时理解参考图并生成新图片。
这是 OpenAI 兼容的 images/generate 和 images/edit 都无法覆盖的能力。
API 格式:
POST /v1beta/models/{model}:generateContent?key={API_KEY}
Body: { contents: [{ parts: [...] }], generationConfig: { responseModalities: ["TEXT","IMAGE"] } }
Response: candidates[0].content.parts[] → text 或 inline_data (base64)
多图 + 生图耗时较长上游或代理可能提前断开httpx: Server disconnected without sending a response
使用较长超时 + 对可恢复网络错误自动重试。
"""
_RETRYABLE: tuple[type[BaseException], ...] = (
httpx.RemoteProtocolError,
httpx.ConnectError,
httpx.ReadTimeout,
httpx.WriteTimeout,
httpx.ConnectTimeout,
httpx.PoolTimeout,
)
@classmethod
def _gemini_timeout(cls) -> httpx.Timeout:
"""可通过环境变量调大,多参考图时请求体大、响应慢。"""
read_s = float(os.getenv("VECTORENGINE_GEMINI_READ_TIMEOUT", "600"))
write_s = float(os.getenv("VECTORENGINE_GEMINI_WRITE_TIMEOUT", "180"))
connect_s = float(os.getenv("VECTORENGINE_GEMINI_CONNECT_TIMEOUT", "60"))
pool_s = float(os.getenv("VECTORENGINE_GEMINI_POOL_TIMEOUT", "60"))
return httpx.Timeout(
connect=connect_s,
read=read_s,
write=write_s,
pool=pool_s,
)
async def generate(
self,
model_config: dict[str, Any],
prompt: str,
num_images: int = 1,
ref_image_urls: list[str] | None = None,
negative_prompt: str | None = None,
) -> list[str]:
_ = negative_prompt
api_key = os.getenv("VECTORENGINE_API_KEY", "")
base_url = os.getenv("VECTORENGINE_BASE_URL", "https://api.vectorengine.ai/v1")
# 从 /v1 回退到根 URL拼接 /v1beta/models/... 端点
api_root = base_url.rstrip("/").removesuffix("/v1")
model_id = model_config["model_id"]
url = f"{api_root}/v1beta/models/{model_id}:generateContent?key={api_key}"
parts = self._build_parts(prompt, ref_image_urls)
body = {
"contents": [{"parts": parts}],
"generationConfig": {
"responseModalities": ["TEXT", "IMAGE"],
},
}
local_urls: list[str] = []
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
max_retries = max(1, int(os.getenv("VECTORENGINE_GEMINI_MAX_RETRIES", "3")))
timeout = self._gemini_timeout()
try:
data: dict[str, Any] | None = None
for attempt in range(max_retries):
try:
async with httpx.AsyncClient(
proxy=proxy,
timeout=timeout,
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
) as client:
resp = await client.post(
url,
json=body,
headers={"Content-Type": "application/json"},
)
resp.raise_for_status()
data = resp.json()
break
except self._RETRYABLE as e:
if attempt < max_retries - 1:
await asyncio.sleep(2 ** attempt)
continue
raise
if data is None:
local_urls.append("[生成失败: 未收到上游响应]")
return local_urls
local_urls = self._extract_images(data)
if not local_urls:
# Gemini 可能只返回了文字(拒绝生图或纯文字回复)
text_parts = self._extract_text(data)
hint = text_parts[:200] if text_parts else "模型未返回图片"
local_urls.append(f"[生成失败: {hint}]")
except httpx.HTTPStatusError as e:
detail = e.response.text[:500] if e.response else str(e)
local_urls.append(f"[生成失败: HTTP {e.response.status_code} - {detail}]")
except self._RETRYABLE as e:
hint = (
"连接被上游或代理提前关闭,常见于多图参考或生图较慢。"
"可稍后重试,或在 .env 中增大 VECTORENGINE_GEMINI_READ_TIMEOUT / 检查代理稳定性。"
)
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
local_urls.append(f"[生成失败: {detail}{hint}]")
except Exception as e:
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
local_urls.append(f"[生成失败: {detail}]")
return local_urls
@staticmethod
def _build_parts(
prompt: str, ref_image_urls: list[str] | None
) -> list[dict[str, Any]]:
"""构建 Gemini generateContent 的 parts 数组:文字 + 内联图片。"""
parts: list[dict[str, Any]] = [{"text": prompt}]
if ref_image_urls:
for url in ref_image_urls:
mime, b64 = _resolve_image_base64(url)
parts.append({
"inline_data": {
"mime_type": mime,
"data": b64,
}
})
return parts
@staticmethod
def _extract_images(response_data: dict) -> list[str]:
"""从 Gemini 响应中提取所有图片并保存到本地。"""
local_urls: list[str] = []
candidates = response_data.get("candidates", [])
for candidate in candidates:
parts = candidate.get("content", {}).get("parts", [])
for part in parts:
inline = part.get("inlineData") or part.get("inline_data")
if inline and inline.get("data"):
mime = inline.get("mimeType") or inline.get("mime_type", "image/png")
ext = ".png" if "png" in mime else ".jpg" if "jpeg" in mime or "jpg" in mime else ".webp" if "webp" in mime else ".png"
filename = f"{uuid.uuid4().hex}{ext}"
filepath = GENERATED_DIR / filename
filepath.write_bytes(base64.b64decode(inline["data"]))
local_urls.append(f"/generated/{filename}")
return local_urls
@staticmethod
def _extract_text(response_data: dict) -> str:
"""从 Gemini 响应中提取文字内容(用于调试或错误提示)。"""
texts: list[str] = []
candidates = response_data.get("candidates", [])
for candidate in candidates:
parts = candidate.get("content", {}).get("parts", [])
for part in parts:
if part.get("text"):
texts.append(part["text"])
return "\n".join(texts)
# ─── Provider 注册 ─────────────────────────────────────
_PROVIDERS: dict[str, ImageProvider] = {
"replicate": ReplicateProvider(),
"openai": OpenAIImageProvider(),
"gemini_native": GeminiNativeImageProvider(),
}
# ─── 公开 API ──────────────────────────────────────────
class GenerateResult:
"""图片生成结果,包含生成的 URL 列表和实际使用的模型信息。"""
def __init__(
self,
urls: list[str],
model_name: str,
model_id: str,
*,
effective_prompt: str | None = None,
negative_prompt: str | None = None,
):
self.urls = urls
self.model_name = model_name
self.model_id = model_id
self.effective_prompt = effective_prompt
self.negative_prompt = negative_prompt
async def generate_images(
prompt: str,
num_images: int = 1,
ref_image_urls: list[str] | None = None,
model_id: str | None = None,
) -> GenerateResult:
"""
统一入口:根据 model_id 查注册表,分发到对应 provider。
ref_image_urls: 参考图路径列表(可为 None 或空列表)。
model_id 为空时使用 .env 中配置的默认模型。
"""
config = get_image_model_config(model_id)
provider_name = config.get("provider", "replicate")
provider = _PROVIDERS.get(provider_name)
model_name = config.get("name", "未知模型")
resolved_id = config.get("id", model_id or "unknown")
if not provider:
return GenerateResult([f"[未知 provider: {provider_name}]"], model_name, resolved_id)
effective_refs = ref_image_urls or []
# Replicate IP-Adapter 系列模型必须有参考图才能工作
if config.get("supports_ref_image") and not effective_refs and provider_name == "replicate":
return GenerateResult(
[f"[生成失败: {model_name} 是风格迁移模型,需要上传参考图才能使用]"],
model_name,
resolved_id,
)
prepared = prepare_image_prompt_for_model(
config,
prompt,
has_reference_images=bool(effective_refs),
)
if not prepared.prompt.strip():
return GenerateResult(
[f"[生成失败: 经模型策略处理后的 prompt 为空]"],
model_name,
resolved_id,
effective_prompt=prepared.prompt,
negative_prompt=prepared.negative_prompt,
)
urls = await provider.generate(
config,
prepared.prompt,
num_images,
effective_refs or None,
negative_prompt=prepared.negative_prompt,
)
return GenerateResult(
urls,
model_name,
resolved_id,
effective_prompt=prepared.prompt,
negative_prompt=prepared.negative_prompt,
)

View File

@@ -0,0 +1,139 @@
"""按生图模型预处理 prompt与 provider 解耦)。
各策略在对应函数中注明依据厂商文档、Replicate 模型 API 字段说明或社区通用写法。
统一入口prepare_image_prompt_for_model。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
# ─── SDXLStability / Replicate────────────────────────────
# Replicate stability-ai/sdxl 提供独立字段 negative_prompt见模型 API 页)。
# 下列为 CLIP 系文生图常见「质量/解剖」排除词,作未显式指定时的基线。
DEFAULT_SDXL_NEGATIVE = (
"low quality, worst quality, normal quality, lowres, blurry, jpeg artifacts, "
"watermark, signature, text, logo, deformed, disfigured, bad anatomy, bad hands, "
"extra fingers, mutated, cropped, poorly drawn face"
)
@dataclass(frozen=True)
class PreparedImagePrompt:
"""下游只读prompt 必填negative_prompt 仅部分后端使用(当前为 SDXL"""
prompt: str
negative_prompt: str | None = None
def prepare_image_prompt_for_model(
model_config: dict[str, Any],
raw_prompt: str,
*,
has_reference_images: bool = False,
) -> PreparedImagePrompt:
"""根据注册表 id 选择策略。未知 id 时原样透传。"""
short_id = model_config.get("id", "")
text = (raw_prompt or "").strip()
if not text:
return PreparedImagePrompt(prompt="")
dispatch: dict[str, Any] = {
"gpt-image-1.5": _openai_gpt_image,
"gemini-3.1-flash-image": _gemini_native_image,
"flux-schnell": _flux_bfl,
"flux-dev": _flux_bfl,
"sdxl": _sdxl_replicate,
"instant-style": _replicate_ip_adapter_scene,
"kolors-ipadapter": _replicate_ip_adapter_scene,
}
fn = dispatch.get(short_id, _passthrough)
return fn(text, model_config, has_reference_images)
def _passthrough(text: str, _model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt:
return PreparedImagePrompt(prompt=text)
def _openai_gpt_image(text: str, _model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt:
"""OpenAI GPT Image自然语言指令遵循强宜为完整、具体的场景描述。
参考https://platform.openai.com/docs/guides/image-generation
"""
return PreparedImagePrompt(prompt=_collapse_ws(text))
def _gemini_native_image(text: str, _model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt:
"""Gemini 原生生图:多模态指令 + 文本;英文描述通常效果稳定。
参考Google AI 文档 generateContent 与图像输出 modality 说明。
"""
return PreparedImagePrompt(prompt=_collapse_ws(text))
def _flux_bfl(text: str, _model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt:
"""Black Forest Labs FLUXSubject + Action + Style + Context靠前放置重点无 negative API。
参考https://docs.bfl.ai/guides/prompting_guide_t2i_fundamentals
若用户从 SDXL 复制了 Negative 段,尽量剥掉以免干扰文意。
"""
cleaned = _strip_pasted_negative_block(text)
return PreparedImagePrompt(prompt=_collapse_ws(cleaned))
def _sdxl_replicate(text: str, model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt:
"""Replicate SDXLprompt + negative_prompt 双字段;支持显式拆分。
约定(可选):正提示与负提示用单独一行分隔符,便于 Agent/用户手写。
- ---NEGATIVE--- 或 |||NEG|||
未拆分时使用 default_params.negative_prompt 或模块默认 DEFAULT_SDXL_NEGATIVE。
"""
neg_fallback = model_config.get("default_params", {}).get(
"negative_prompt", DEFAULT_SDXL_NEGATIVE
)
if "---NEGATIVE---" in text:
pos, _, neg = text.partition("---NEGATIVE---")
pos = pos.strip()
neg = neg.strip()
return PreparedImagePrompt(
prompt=_collapse_ws(pos),
negative_prompt=neg or neg_fallback,
)
if "|||NEG|||" in text:
pos, _, neg = text.partition("|||NEG|||")
pos = pos.strip()
neg = neg.strip()
return PreparedImagePrompt(
prompt=_collapse_ws(pos),
negative_prompt=neg or neg_fallback,
)
return PreparedImagePrompt(
prompt=_collapse_ws(text),
negative_prompt=neg_fallback,
)
def _replicate_ip_adapter_scene(
text: str, _model_config: dict[str, Any], has_reference_images: bool
) -> PreparedImagePrompt:
"""IP-Adapter / InstantStyle / Kolors参考图承担风格与纹理prompt 侧重场景与内容语义。
Replicate 各模型 README 均强调 prompt + 参考图配合;无参考图时由上层拦截。
有参考图时不额外堆叠长前缀,避免稀释主体描述。
"""
_ = has_reference_images
return PreparedImagePrompt(prompt=_collapse_ws(text))
def _collapse_ws(s: str) -> str:
return " ".join(s.split())
def _strip_pasted_negative_block(text: str) -> str:
lower = text.lower()
for sep in ("\n---negative---\n", "\nnegative prompt:", "\nnegative:"):
idx = lower.find(sep)
if idx != -1:
return text[:idx].strip()
return text

View File

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

6
art-agent/frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,16 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
allowedDevOrigins: ["*.trycloudflare.com"],
images: {
remotePatterns: [
{ protocol: "http", hostname: "localhost" },
{ protocol: "http", hostname: "127.0.0.1" },
{ protocol: "https", hostname: "*.trycloudflare.com" },
{ protocol: "https", hostname: "replicate.delivery" },
{ protocol: "https", hostname: "*.replicate.delivery" },
],
},
};
export default nextConfig;

1736
art-agent/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
{
"name": "epeekit-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0",
"tailwindcss": "^4.0.0",
"@tailwindcss/postcss": "^4.0.0",
"postcss": "^8.5.0"
}
}

View File

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

View File

@@ -0,0 +1,654 @@
"use client";
import { useState, useMemo } from "react";
import { TopNav } from "@/components/layout/top-nav";
import { useApp } from "@/lib/app-context";
import { getImageUrl } from "@/lib/api";
import type { ImageAsset } from "@/lib/types";
type ViewMode = "grid" | "list";
type SortBy = "time" | "name";
export default function GalleryPage() {
const { assets, tags, toggleFavorite, deleteAssetById, updateAsset } = useApp();
const [viewMode, setViewMode] = useState<ViewMode>("grid");
const [sortBy, setSortBy] = useState<SortBy>("time");
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [showFavOnly, setShowFavOnly] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [detailAsset, setDetailAsset] = useState<ImageAsset | null>(null);
const tagMap = new Map(tags.map((t) => [t.id, t]));
const filtered = useMemo(() => {
let result = [...assets];
if (showFavOnly) {
result = result.filter((a) => a.favorited);
}
if (selectedTagIds.length > 0) {
result = result.filter((a) => selectedTagIds.some((tid) => a.tags.includes(tid)));
}
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase();
result = result.filter((a) => a.prompt.toLowerCase().includes(q));
}
result.sort((a, b) => {
if (sortBy === "time") return b.createdAt - a.createdAt;
return a.prompt.localeCompare(b.prompt);
});
return result;
}, [assets, showFavOnly, selectedTagIds, searchQuery, sortBy]);
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectAll = () => {
if (selectedIds.size === filtered.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filtered.map((a) => a.id)));
}
};
const handleBatchDelete = () => {
selectedIds.forEach((id) => deleteAssetById(id));
setSelectedIds(new Set());
};
const handleBatchDownload = async () => {
for (const id of selectedIds) {
const asset = assets.find((a) => a.id === id);
if (!asset) continue;
try {
const fullUrl = getImageUrl(asset.url);
const resp = await fetch(fullUrl);
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `epeekit-${asset.id}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch {
// ignore
}
}
};
const handleDownload = async (asset: ImageAsset) => {
try {
const fullUrl = getImageUrl(asset.url);
const resp = await fetch(fullUrl);
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `epeekit-${asset.id}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch {
alert("下载失败");
}
};
return (
<div className="h-screen flex flex-col relative z-[1]">
<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
backdrop-blur-xl px-4 md:px-5 py-3
flex items-center gap-2 md:gap-3 flex-wrap">
{/* 区块标题 */}
<div className="flex items-center gap-2 mr-3">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" 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>
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
</div>
{/* 搜索 */}
<div className="flex items-center gap-1.5 flex-1 min-w-[140px] md: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={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索 Prompt..."
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-1.5 flex-wrap">
{tags.map((tag) => {
const active = selectedTagIds.includes(tag.id);
return (
<button
key={tag.id}
onClick={() =>
setSelectedTagIds((prev) =>
active ? prev.filter((id) => id !== tag.id) : [...prev, tag.id]
)
}
className="px-2.5 py-1 text-[11px] rounded-lg cursor-pointer border transition-all font-medium"
style={{
backgroundColor: active ? tag.color + "18" : "transparent",
borderColor: active ? tag.color + "60" : "var(--border)",
color: active ? tag.color : "var(--text-secondary)",
}}
>
{tag.name}
</button>
);
})}
</div>
<div className="flex-1" />
{/* 收藏筛选 */}
<button
onClick={() => setShowFavOnly(!showFavOnly)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-lg cursor-pointer
transition-all border ${
showFavOnly
? "border-[var(--accent)]/30 bg-[var(--accent)]/8 text-[var(--accent)]"
: "border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill={showFavOnly ? "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>
{/* 排序 */}
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortBy)}
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="time"></option>
<option value="name"></option>
</select>
{/* 视图切换 */}
<div className="flex items-center gap-0.5 bg-[var(--bg-tertiary)] rounded-lg p-0.5 border border-[var(--border)]">
<button
onClick={() => setViewMode("grid")}
className={`p-1.5 rounded-md cursor-pointer transition-all ${
viewMode === "grid"
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
title="网格视图"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" />
</svg>
</button>
<button
onClick={() => setViewMode("list")}
className={`p-1.5 rounded-md cursor-pointer transition-all ${
viewMode === "list"
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
title="列表视图"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />
</svg>
</button>
</div>
</div>
{/* 批量操作栏 */}
{selectedIds.size > 0 && (
<div className="flex-shrink-0 px-5 py-2.5 bg-[var(--accent)]/5 border-b border-[var(--accent)]/20
flex items-center gap-3 backdrop-blur-sm">
<button
onClick={selectAll}
className="text-xs text-[var(--accent)] cursor-pointer hover:underline"
>
{selectedIds.size === filtered.length ? "取消全选" : "全选"}
</button>
<span className="text-xs text-[var(--text-secondary)]">
{selectedIds.size}
</span>
<div className="flex-1" />
<button
onClick={handleBatchDownload}
className="px-3 py-1.5 text-xs 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"
>
</button>
<button
onClick={handleBatchDelete}
className="px-3 py-1.5 text-xs rounded-lg bg-[var(--bg-tertiary)]
text-[var(--hot)]/70 hover:text-[var(--hot)]
border border-[var(--border)] hover:border-[var(--hot)]/40
cursor-pointer transition-all"
>
</button>
</div>
)}
{/* 图片内容区 */}
<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">
<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)]">
{assets.length === 0 ? "还没有生成过图片" : "没有匹配的资源"}
</p>
</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">
{filtered.map((asset) => (
<div
key={asset.id}
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)]"
: ""
}`}
onClick={() => setDetailAsset(asset)}
>
{/* 选择框 */}
<div
className="absolute top-2.5 left-2.5 z-10"
onClick={(e) => {
e.stopPropagation();
toggleSelect(asset.id);
}}
>
<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)]"
: "border-white/40 bg-black/30 backdrop-blur-sm opacity-100 md:opacity-0 md:group-hover:opacity-100"
}`}
>
{selectedIds.has(asset.id) && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="var(--bg-primary)" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
</div>
<img
src={getImageUrl(asset.url)}
alt={asset.prompt.slice(0, 50)}
className="w-full aspect-square object-cover
transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
/>
{/* 底部信息 */}
<div className="p-2.5 bg-[var(--bg-secondary)]/80 backdrop-blur-sm">
<div className="text-[11px] text-[var(--text-secondary)] truncate">
{asset.prompt || "无 prompt"}
</div>
<div className="flex items-center gap-1 mt-1.5 flex-wrap">
{asset.tags.slice(0, 2).map((tid) => {
const tag = tagMap.get(tid);
if (!tag) return null;
return (
<span
key={tid}
className="text-[9px] px-1.5 py-0.5 rounded-md font-medium"
style={{ backgroundColor: tag.color + "18", color: tag.color }}
>
{tag.name}
</span>
);
})}
<span className="text-[9px] text-[var(--text-secondary)] ml-auto">
{new Date(asset.createdAt).toLocaleDateString("zh-CN")}
</span>
</div>
</div>
{/* 操作按钮 */}
<div className="absolute top-2.5 right-2.5 flex gap-1.5
opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
handleDownload(asset);
}}
className="w-7 h-7 rounded-lg bg-black/50 backdrop-blur-sm text-white/70
hover:text-[var(--accent)] hover:bg-black/70
flex items-center justify-center cursor-pointer transition-colors"
title="下载"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
toggleFavorite(asset.id);
}}
className="w-7 h-7 rounded-lg bg-black/50 backdrop-blur-sm
flex items-center justify-center cursor-pointer transition-colors"
style={{ color: asset.favorited ? "var(--accent)" : "rgba(255,255,255,0.5)" }}
title={asset.favorited ? "取消收藏" : "收藏"}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</button>
</div>
</div>
))}
</div>
) : (
<div className="space-y-1.5">
{filtered.map((asset) => (
<div
key={asset.id}
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl cursor-pointer transition-all ${
selectedIds.has(asset.id)
? "bg-[var(--accent)]/8 border border-[var(--accent)]/25"
: "hover:bg-[var(--bg-tertiary)] border border-transparent"
}`}
onClick={() => setDetailAsset(asset)}
>
<div
onClick={(e) => {
e.stopPropagation();
toggleSelect(asset.id);
}}
>
<div
className={`w-4 h-4 rounded-md border flex items-center justify-center transition-all ${
selectedIds.has(asset.id)
? "bg-[var(--accent)] border-[var(--accent)]"
: "border-[var(--border)]"
}`}
>
{selectedIds.has(asset.id) && (
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="var(--bg-primary)" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
</div>
<img
src={getImageUrl(asset.url)}
alt=""
className="w-10 h-10 rounded-lg object-cover border border-[var(--border)]"
loading="lazy"
/>
<div className="flex-1 min-w-0">
<div className="text-sm text-[var(--text-primary)] truncate">
{asset.prompt || "无 prompt"}
</div>
<div className="flex items-center gap-1 mt-0.5">
{asset.tags.slice(0, 3).map((tid) => {
const tag = tagMap.get(tid);
if (!tag) return null;
return (
<span
key={tid}
className="text-[9px] px-1.5 py-0.5 rounded-md font-medium"
style={{ backgroundColor: tag.color + "18", color: tag.color }}
>
{tag.name}
</span>
);
})}
</div>
</div>
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">
{new Date(asset.createdAt).toLocaleDateString("zh-CN")}
</span>
<button
onClick={(e) => {
e.stopPropagation();
toggleFavorite(asset.id);
}}
className="cursor-pointer flex-shrink-0 transition-colors"
style={{ color: asset.favorited ? "var(--accent)" : "var(--text-secondary)" }}
>
<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>
<button
onClick={(e) => {
e.stopPropagation();
handleDownload(asset);
}}
className="cursor-pointer text-[var(--text-secondary)] hover:text-[var(--accent)] flex-shrink-0 transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
</main>
{/* 详情 Modal */}
{detailAsset && (
<GalleryDetailModal
asset={detailAsset}
tags={tags}
onClose={() => setDetailAsset(null)}
onToggleFavorite={() => toggleFavorite(detailAsset.id)}
onDownload={() => handleDownload(detailAsset)}
onDelete={() => {
deleteAssetById(detailAsset.id);
setDetailAsset(null);
}}
/>
)}
</div>
</div>
);
}
function GalleryDetailModal({
asset,
tags,
onClose,
onToggleFavorite,
onDownload,
onDelete,
}: {
asset: ImageAsset;
tags: { id: string; name: string; color: string }[];
onClose: () => void;
onToggleFavorite: () => void;
onDownload: () => void;
onDelete: () => void;
}) {
const tagMap = new Map(tags.map((t) => [t.id, t]));
return (
<div
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"
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>
<button
onClick={onClose}
className="text-[var(--text-secondary)] hover:text-[var(--accent)] cursor-pointer
transition-colors p-1 rounded-lg hover:bg-[var(--accent)]/5"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/* 内容 */}
<div className="flex-1 overflow-y-auto p-5">
<div className="flex gap-5 flex-col md:flex-row">
<div className="flex-1 min-w-0">
<img
src={getImageUrl(asset.url)}
alt={asset.prompt}
className="w-full rounded-xl border border-[var(--border)]"
/>
</div>
<div className="w-full md:w-[260px] flex-shrink-0 space-y-4">
{/* 操作按钮 */}
<div className="flex flex-wrap gap-2">
<button
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)]
cursor-pointer transition-all"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</button>
<button
onClick={onToggleFavorite}
className={`flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
border cursor-pointer transition-all ${
asset.favorited
? "border-[var(--accent)]/40 bg-[var(--accent)]/8 text-[var(--accent)]"
: "border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill={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>
{asset.favorited ? "已收藏" : "收藏"}
</button>
<button
onClick={onDelete}
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
cursor-pointer transition-all"
>
</button>
</div>
{/* Prompt */}
{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>
<button
onClick={() => navigator.clipboard.writeText(asset.prompt)}
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
cursor-pointer transition-colors"
>
</button>
</div>
<div className="p-3 rounded-xl bg-[var(--bg-primary)] border border-[var(--border)]
text-xs text-[var(--text-secondary)] leading-relaxed break-all">
{asset.prompt}
</div>
</div>
)}
{/* 标签 */}
{asset.tags.length > 0 && (
<div>
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5"></span>
<div className="flex flex-wrap gap-1.5">
{asset.tags.map((tid) => {
const tag = tagMap.get(tid);
if (!tag) return null;
return (
<span
key={tid}
className="text-[10px] px-2 py-0.5 rounded-lg font-medium"
style={{ backgroundColor: tag.color + "18", color: tag.color }}
>
{tag.name}
</span>
);
})}
</div>
</div>
)}
{/* 元信息 */}
<div className="text-xs text-[var(--text-secondary)] space-y-1.5">
<div>{new Date(asset.createdAt).toLocaleString("zh-CN")}</div>
<div className="truncate"> ID{asset.sessionId}</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,234 @@
@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;
--hot: #C4654A;
--border: rgba(77, 184, 164, 0.12);
--border-glow: rgba(77, 184, 164, 0.25);
--mist: rgba(77, 184, 164, 0.06);
--gold: #B8935A;
}
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
overscroll-behavior: none;
-webkit-tap-highlight-color: transparent;
}
/* ===== 山雾背景 — 远景层 ===== */
@keyframes fogDriftFar {
0% { transform: translate(0, 0) scale(1); }
33% { transform: translate(2%, -1.5%) scale(1.02); }
66% { transform: translate(-1.5%, 1%) scale(0.98); }
100% { transform: translate(0, 0) scale(1); }
}
@keyframes fogDriftNear {
0% { transform: translate(0, 0) scale(1); }
25% { transform: translate(-3%, 2%) scale(1.03); }
50% { transform: translate(1%, -1%) scale(0.97); }
75% { transform: translate(2.5%, 1.5%) scale(1.01); }
100% { transform: translate(0, 0) scale(1); }
}
body::before {
content: '';
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%);
animation: fogDriftFar 60s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
body::after {
content: '';
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%);
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);
border: 1px solid var(--border);
}
/* ===== 静态霓虹边框(卡片等普通元素) ===== */
.neon-border {
border: 1px solid var(--border);
transition: border-color 200ms ease, box-shadow 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);
}
/* ===== 云烟缭绕边框 ===== */
/*
* .glow-border — 选中态的云烟边框
* 常态:石青色微弱均匀发光
* hover云烟缓慢缭绕opacity 平滑过渡
* 移开:云烟淡散回静态
*/
@property --glow-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
@keyframes mistSpin {
to { --glow-angle: 360deg; }
}
.glow-border {
position: relative;
background: var(--bg-secondary);
border: 1px solid rgba(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);
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);
}
/* 云烟边框层 — 宽弧段柔化渐变6s 缓慢旋转 */
.glow-border::before {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1.5px;
background: conic-gradient(
from var(--glow-angle),
transparent 0%,
transparent 15%,
rgba(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%,
transparent 85%,
transparent 100%
);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
animation: mistSpin 6s linear infinite;
opacity: 0;
transition: opacity 600ms ease;
pointer-events: none;
z-index: -1;
}
/* 雾气扩散层 — 更大范围、更模糊 */
.glow-border::after {
content: '';
position: absolute;
inset: -6px;
border-radius: inherit;
background: conic-gradient(
from var(--glow-angle),
transparent 0%,
transparent 20%,
rgba(77, 184, 164, 0.06) 35%,
rgba(58, 143, 183, 0.08) 50%,
rgba(77, 184, 164, 0.06) 65%,
transparent 80%,
transparent 100%
);
filter: blur(12px);
animation: mistSpin 6s linear infinite;
opacity: 0;
transition: opacity 600ms ease;
pointer-events: none;
z-index: -2;
}
.glow-border:hover::before { opacity: 1; }
.glow-border:hover::after { opacity: 1; }
/* ===== 雾气滚动边缘 ===== */
.fog-scroll {
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0px,
black 36px,
black calc(100% - 36px),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
transparent 0px,
black 36px,
black calc(100% - 36px),
transparent 100%
);
}
/* ===== 自定义滚动条 ===== */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(77, 184, 164, 0.15);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(77, 184, 164, 0.28);
}
select option {
background: var(--bg-secondary);
color: var(--text-primary);
}
.sidebar-overlay {
animation: fadeIn 200ms ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@supports (height: 100dvh) {
.h-screen {
height: 100dvh;
}
}

View File

@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { AuthProvider } from "@/lib/auth-context";
import { AuthGuard } from "@/lib/auth-guard";
import { AppProvider } from "@/lib/app-context";
import "./globals.css";
export const metadata: Metadata = {
title: "EPEEKit",
description: "AI 美术资源生成工具集",
};
export const viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body className="antialiased">
<AuthProvider>
<AuthGuard>
<AppProvider>{children}</AppProvider>
</AuthGuard>
</AuthProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { useState, useEffect, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-context";
export default function LoginPage() {
const { login, isAuthenticated } = useAuth();
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isAuthenticated) {
router.replace("/");
}
}, [isAuthenticated, router]);
if (isAuthenticated) {
return null;
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!username.trim() || !password) return;
setError("");
setLoading(true);
try {
await login(username.trim(), password);
router.replace("/");
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败");
} finally {
setLoading(false);
}
}
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>
<div className="w-full max-w-sm mx-4 relative z-10">
<div 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)]">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="var(--accent)" />
<circle cx="23" cy="12" r="3.5" stroke="var(--accent)" strokeWidth="1.8" fill="none" />
<path d="M23 15.5v5" stroke="var(--accent)" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
</svg>
</div>
<h1 className="text-2xl font-bold text-[var(--text-primary)]">EPEEKit</h1>
<p className="text-sm text-[var(--text-secondary)] mt-1.5">AI </p>
</div>
<form onSubmit={handleSubmit}
className="glass-panel rounded-2xl p-6 space-y-4 shadow-xl shadow-black/20">
<div>
<input
type="text"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
autoComplete="username"
className="w-full px-4 py-3 rounded-xl
bg-[var(--bg-primary)] border border-[var(--border)]
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none focus:border-[var(--accent)]/50
focus:shadow-[0_0_10px_rgba(77,184,164,0.06)]
transition-all"
/>
</div>
<div>
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="w-full px-4 py-3 rounded-xl
bg-[var(--bg-primary)] border border-[var(--border)]
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none focus:border-[var(--accent)]/50
focus:shadow-[0_0_10px_rgba(77,184,164,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>
)}
<button
type="submit"
disabled={loading || !username.trim() || !password}
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)]
disabled:opacity-50 disabled:cursor-not-allowed
transition-all cursor-pointer"
>
{loading ? "登录中..." : "登录"}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,455 @@
"use client";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import { ChatMessages } from "@/components/chat/chat-messages";
import { ChatInput, type ChatInputHandle } from "@/components/chat/chat-input";
import { Sidebar } from "@/components/sidebar/sidebar";
import { TopNav } from "@/components/layout/top-nav";
import { ImageDetailPanel } from "@/components/detail/image-detail-panel";
import { useApp } from "@/lib/app-context";
import { sendChat, getImageUrl, uploadRefImage } from "@/lib/api";
import { generateId } from "@/lib/store";
import type { ChatMessage, ImageAsset, ApiMessage, AnnotationData } from "@/lib/types";
export default function Home() {
const {
activeSession,
activeSessionId,
appendMessage,
addAsset,
updateSessionThumbnail,
detailImage,
sidebarCollapsed,
setSidebarCollapsed,
} = useApp();
const messages = activeSession?.messages ?? [];
const [isLoading, setIsLoading] = useState(false);
const [streamingText, setStreamingText] = useState("");
const [streamingImages, setStreamingImages] = useState<ImageAsset[]>([]);
const [statusText, setStatusText] = useState("");
const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
const [lastRefImageUrls, setLastRefImageUrls] = useState<string[]>([]);
const lastRefPerSession = useRef<Map<string, string[]>>(new Map());
const [mainDragging, setMainDragging] = useState(false);
const dragCounter = useRef(0);
const chatInputRef = useRef<ChatInputHandle>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const scrollPositions = useRef<Map<string, number>>(new Map());
const prevSessionId = useRef<string | null>(null);
const isSwitching = useRef(false);
const [showScrollBtn, setShowScrollBtn] = useState(false);
const scrollToBottom = useCallback((instant?: boolean) => {
if (isSwitching.current && !instant) return;
const el = scrollRef.current;
if (!el) return;
if (instant) {
el.scrollTop = el.scrollHeight;
} else {
setTimeout(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}, 50);
}
}, []);
// 实时记录当前会话的滚动位置 + 判断是否显示"回到底部"按钮
useEffect(() => {
const el = scrollRef.current;
if (!el || !activeSessionId) return;
const handler = () => {
if (!isSwitching.current) {
scrollPositions.current.set(activeSessionId, el.scrollTop);
}
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
setShowScrollBtn(distFromBottom > 200);
};
el.addEventListener("scroll", handler, { passive: true });
return () => el.removeEventListener("scroll", handler);
}, [activeSessionId]);
// 会话切换:标记 switching等 DOM 更新后恢复位置 + 恢复参考图状态
useEffect(() => {
if (!activeSessionId) return;
if (prevSessionId.current && prevSessionId.current !== activeSessionId) {
isSwitching.current = true;
}
prevSessionId.current = activeSessionId;
setLastRefImageUrls(lastRefPerSession.current.get(activeSessionId) ?? []);
}, [activeSessionId]);
useEffect(() => {
if (!activeSessionId || !isSwitching.current) return;
requestAnimationFrame(() => {
const el = scrollRef.current;
if (!el) return;
const saved = scrollPositions.current.get(activeSessionId);
if (saved !== undefined) {
el.scrollTop = saved;
} else {
el.scrollTop = el.scrollHeight;
}
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
setShowScrollBtn(distFromBottom > 200);
isSwitching.current = false;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSessionId, messages.length]);
const handleSend = useCallback(
async (text: string, refImageServerUrls: string[], imageModel: string | null = null) => {
if (!activeSessionId || !activeSession) return;
let finalText = text;
let finalRefServerUrls = [...refImageServerUrls];
if (pendingAnnotation) {
const annotationDescs = pendingAnnotation.annotations
.filter((a) => a.text)
.map((a) => {
if (a.type === "rect") {
return `[区域 (${Math.round(a.x * 100)}%, ${Math.round(a.y * 100)}%) 大小 ${Math.round((a.w ?? 0) * 100)}%×${Math.round((a.h ?? 0) * 100)}%]: ${a.text}`;
}
return `[标注]: ${a.text}`;
})
.join("\n");
if (annotationDescs) {
finalText = `${text}\n\n--- 图片标注 ---\n${annotationDescs}`;
}
if (pendingAnnotation.snapshot && finalRefServerUrls.length === 0) {
try {
const res = await fetch(pendingAnnotation.snapshot);
const blob = await res.blob();
const file = new File([blob], "annotation.png", { type: "image/png" });
const uploadResult = await uploadRefImage(file);
finalRefServerUrls = [uploadResult.url];
} catch {
// 忽略转换/上传失败
}
}
setPendingAnnotation(null);
}
if (finalRefServerUrls.length > 0) {
lastRefPerSession.current.set(activeSessionId, finalRefServerUrls);
setLastRefImageUrls(finalRefServerUrls);
}
const previewUrls = finalRefServerUrls.map((u) => getImageUrl(u));
const userMessage: ChatMessage = {
id: generateId("msg-"),
role: "user",
content: finalText,
refImageUrls: previewUrls.length > 0 ? previewUrls : undefined,
};
appendMessage(activeSessionId, userMessage);
setIsLoading(true);
setStreamingText("");
setStreamingImages([]);
setStatusText("");
scrollToBottom();
const apiMessages: ApiMessage[] = [
...activeSession.messages.map((m) => ({ role: m.role, content: m.content })),
{ role: userMessage.role, content: userMessage.content },
];
let assistantText = "";
let collectedImages: ImageAsset[] = [];
let usedModelName = "";
const llmModel = activeSession?.llmModel ?? null;
try {
for await (const event of sendChat(apiMessages, finalRefServerUrls.length > 0 ? finalRefServerUrls : null, imageModel, activeSessionId, llmModel)) {
switch (event.type) {
case "text_delta":
assistantText += event.data.text as string;
setStreamingText(assistantText);
scrollToBottom();
break;
case "tool_start":
setStatusText(event.data.message as string);
scrollToBottom();
break;
case "image_result": {
const rawUrls = (event.data.images as string[]) || [];
const newUrls = rawUrls.filter((u) => u && !u.startsWith("["));
const prompt = (event.data.prompt_used as string) || "";
const modelName = (event.data.model_name as string) || "";
if (newUrls.length === 0) break;
const newAssets: ImageAsset[] = newUrls.map((url) => ({
id: generateId("img-"),
url,
prompt,
sessionId: activeSessionId,
tags: activeSession.tags ? [...activeSession.tags] : [],
favorited: false,
createdAt: Date.now(),
}));
newAssets.forEach((a) => addAsset(a));
if (newAssets.length > 0) {
updateSessionThumbnail(activeSessionId, newAssets[0].url);
}
collectedImages = [...collectedImages, ...newAssets];
setStreamingImages([...collectedImages]);
if (modelName) {
usedModelName = modelName;
setStatusText(`${modelName} 生成`);
} else {
setStatusText("");
}
scrollToBottom();
break;
}
case "tool_error": {
const errors = (event.data.errors as string[]) || [];
const modelName = (event.data.model_name as string) || "";
const errorDetail = errors.join("\n");
const modelHint = modelName ? ` (${modelName})` : "";
assistantText += `\n\n⚠ 图片生成失败${modelHint}:\n${errorDetail}`;
setStreamingText(assistantText);
setStatusText("");
scrollToBottom();
break;
}
case "memory_warning":
console.warn("[记忆系统]", event.data.message);
setStatusText(`${event.data.message}`);
break;
case "error":
assistantText += `\n\n[错误: ${event.data.message}]`;
setStreamingText(assistantText);
break;
case "done":
break;
}
}
} catch (e) {
assistantText += `\n\n[请求失败: ${e instanceof Error ? e.message : "未知错误"}]`;
}
const assistantMessage: ChatMessage = {
id: generateId("msg-"),
role: "assistant",
content: assistantText || "(生成完成)",
images: collectedImages.length > 0 ? collectedImages : undefined,
modelName: usedModelName || undefined,
};
appendMessage(activeSessionId, assistantMessage);
setIsLoading(false);
setStreamingText("");
setStreamingImages([]);
setStatusText("");
scrollToBottom();
},
[activeSession, activeSessionId, appendMessage, addAsset, updateSessionThumbnail, pendingAnnotation, scrollToBottom]
);
const handleAnnotationComplete = useCallback((data: AnnotationData) => {
setPendingAnnotation(data);
}, []);
return (
<div className="h-screen flex flex-col relative z-[1]">
<TopNav />
<div className="flex-1 flex overflow-hidden">
<Sidebar />
<main
className="flex-1 flex flex-col min-w-0 relative"
onDragEnter={(e: DragEvent) => {
e.preventDefault();
dragCounter.current++;
if (e.dataTransfer.types.includes("Files")) setMainDragging(true);
}}
onDragOver={(e: DragEvent) => e.preventDefault()}
onDragLeave={(e: DragEvent) => {
e.preventDefault();
dragCounter.current--;
if (dragCounter.current <= 0) { dragCounter.current = 0; setMainDragging(false); }
}}
onDrop={(e: DragEvent) => {
e.preventDefault();
dragCounter.current = 0;
setMainDragging(false);
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
files.forEach((f) => chatInputRef.current?.uploadFile(f));
}}
>
{/* 拖拽覆盖层 */}
{mainDragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center
bg-[var(--bg-primary)]/80 backdrop-blur-sm
border-2 border-dashed border-[var(--accent)]/50 rounded-xl m-2
pointer-events-none">
<div className="text-center space-y-2">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none"
stroke="var(--accent)" strokeWidth="1.5" className="mx-auto opacity-70">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<p className="text-sm text-[var(--accent)] font-medium"></p>
</div>
</div>
)}
{/* 桌面端侧边栏展开按钮 */}
{sidebarCollapsed && (
<button
onClick={() => setSidebarCollapsed(false)}
className="absolute left-2 top-2 z-10 p-1.5 rounded-lg
bg-[var(--bg-secondary)]/80 backdrop-blur-sm
border border-[var(--border)]
text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:border-[var(--accent)]/40
transition-all cursor-pointer
hidden md:flex"
title="展开侧边栏"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M13 17l5-5-5-5M6 17l5-5-5-5" />
</svg>
</button>
)}
{/* 标注提示条 */}
{pendingAnnotation && (
<div className="flex-shrink-0 px-5 py-2.5 bg-[var(--accent)]/5 border-b border-[var(--accent)]/20
flex items-center gap-3 backdrop-blur-sm">
<img
src={pendingAnnotation.snapshot}
alt="标注预览"
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/40
shadow-[0_0_8px_rgba(77,184,164,0.12)]"
/>
<span className="text-xs text-[var(--accent)]">
{pendingAnnotation.annotations.length}
</span>
<button
onClick={() => setPendingAnnotation(null)}
className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]
cursor-pointer transition-colors"
>
</button>
</div>
)}
<div ref={scrollRef} className="flex-1 overflow-y-auto fog-scroll">
{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>
<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>
</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>
)}
<ChatInput
ref={chatInputRef}
onSend={handleSend}
disabled={isLoading}
lastRefServerUrls={lastRefImageUrls}
lastRefPreviewUrls={lastRefImageUrls.map((u) => getImageUrl(u))}
onClearLastRefImage={() => {
if (activeSessionId) lastRefPerSession.current.delete(activeSessionId);
setLastRefImageUrls([]);
}}
onFileDrop={() => {
dragCounter.current = 0;
setMainDragging(false);
}}
/>
</main>
{detailImage && (
<ImageDetailPanel onAnnotationComplete={handleAnnotationComplete} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,383 @@
"use client";
import { useRef, useState, useCallback, useImperativeHandle, forwardRef, type KeyboardEvent, type DragEvent, type ClipboardEvent } from "react";
import { ModelSelector } from "./model-selector";
import { uploadRefImage, type UploadProgress } from "@/lib/api";
type UploadStatus = "idle" | "uploading" | "done" | "error";
interface UploadItem {
id: string;
status: UploadStatus;
previewUrl: string | null;
serverUrl: string | null;
progress: number;
errorMsg: string | null;
}
interface ChatInputProps {
onSend: (text: string, refImageServerUrls: string[], imageModel: string | null) => void;
disabled: boolean;
/** 上次使用的参考图服务端路径列表,用于自动沿用 */
lastRefServerUrls?: string[];
/** 上次参考图的完整可预览 URL 列表 */
lastRefPreviewUrls?: string[];
/** 用户主动清除沿用参考图时的回调 */
onClearLastRefImage?: () => void;
/** 文件在 ChatInput 区域内被 drop 时触发,通知父组件清除拖拽覆盖层 */
onFileDrop?: () => void;
}
export interface ChatInputHandle {
uploadFile: (file: File) => void;
}
let _uploadCounter = 0;
/** 从剪贴板提取可上传的图片文件Ctrl+V / 右键粘贴共用) */
function imageFilesFromClipboard(data: DataTransfer | null): File[] {
if (!data?.items?.length) return [];
const out: File[] = [];
for (let i = 0; i < data.items.length; i++) {
const it = data.items[i];
if (it.kind === "file" && it.type.startsWith("image/")) {
const f = it.getAsFile();
if (f) out.push(f);
}
}
return out;
}
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
{ onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop },
ref
) {
const [text, setText] = useState("");
const [uploads, setUploads] = useState<UploadItem[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [selectedModel, setSelectedModel] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement>(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 handleSubmit = () => {
const trimmed = text.trim();
if (!trimmed || disabled) return;
if (hasActiveUpload) return;
const refUrls = doneUrls.length > 0 ? doneUrls : (lastRefServerUrls ?? []);
onSend(trimmed, refUrls, selectedModel || null);
setText("");
clearAllUploads();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const clearAllUploads = useCallback(() => {
abortRefs.current.forEach((c) => c.abort());
abortRefs.current.clear();
setUploads((prev) => {
prev.forEach((u) => { if (u.previewUrl) URL.revokeObjectURL(u.previewUrl); });
return [];
});
if (fileInputRef.current) fileInputRef.current.value = "";
}, []);
const removeUpload = useCallback((id: string) => {
abortRefs.current.get(id)?.abort();
abortRefs.current.delete(id);
setUploads((prev) => {
const item = prev.find((u) => u.id === id);
if (item?.previewUrl) URL.revokeObjectURL(item.previewUrl);
return prev.filter((u) => u.id !== id);
});
}, []);
const startUpload = useCallback(async (file: File) => {
const id = `upload-${++_uploadCounter}`;
const controller = new AbortController();
abortRefs.current.set(id, controller);
const previewUrl = URL.createObjectURL(file);
const newItem: UploadItem = {
id,
status: "uploading",
previewUrl,
serverUrl: null,
progress: 0,
errorMsg: null,
};
setUploads((prev) => [...prev, newItem]);
try {
const result = await uploadRefImage(
file,
(p: UploadProgress) => {
setUploads((prev) =>
prev.map((u) => (u.id === id ? { ...u, progress: p.percent } : u))
);
},
controller.signal
);
setUploads((prev) =>
prev.map((u) =>
u.id === id ? { ...u, status: "done", serverUrl: result.url, progress: 100 } : u
)
);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
const msg = err instanceof Error ? err.message : "上传失败";
setUploads((prev) =>
prev.map((u) =>
u.id === id ? { ...u, status: "error", progress: 0, errorMsg: msg } : u
)
);
}
}, []);
useImperativeHandle(ref, () => ({ uploadFile: startUpload }), [startUpload]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) {
Array.from(files).forEach((f) => startUpload(f));
}
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = () => setIsDragging(false);
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
onFileDrop?.();
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
files.forEach((f) => startUpload(f));
};
const handlePasteCapture = useCallback(
(e: ClipboardEvent) => {
if (disabled) return;
const files = imageFilesFromClipboard(e.clipboardData);
if (files.length === 0) return;
e.preventDefault();
e.stopPropagation();
files.forEach((f) => startUpload(f));
},
[disabled, startUpload]
);
return (
<div
className={`border-t border-[var(--border)] bg-[var(--bg-secondary)]/80 backdrop-blur-xl
px-3 md:px-5 py-2.5 md:py-3 transition-colors ${
isDragging ? "bg-[var(--accent)]/5 border-[var(--accent)]/40" : ""
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onPasteCapture={handlePasteCapture}
>
{isDragging && (
<div className="mb-2 text-center text-xs text-[var(--accent)]">
</div>
)}
{/* 沿用上次参考图提示 */}
{uploads.length === 0 && lastRefPreviewUrls && lastRefPreviewUrls.length > 0 && (
<div className="mb-3 flex items-center gap-2.5">
<div className="flex gap-1.5">
{lastRefPreviewUrls.map((url, i) => (
<img
key={i}
src={url}
alt={`沿用参考图 ${i + 1}`}
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/30
shadow-[0_0_8px_rgba(77,184,164,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(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>
)}
{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>
)}
</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>
)}
<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>
</div>
</div>
);
});

View File

@@ -0,0 +1,153 @@
"use client";
import { useState, useCallback } from "react";
import type { ChatMessage, ImageAsset } from "@/lib/types";
import { ImageGrid } from "./image-grid";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// fallback
}
}, [text]);
return (
<button
onClick={handleCopy}
className="p-1 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:bg-[var(--accent)]/5 transition-colors cursor-pointer"
title="复制文本"
>
{copied ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
)}
</button>
);
}
interface ChatMessagesProps {
messages: ChatMessage[];
isLoading: boolean;
streamingText: string;
streamingImages: ImageAsset[];
statusText: string;
}
export function ChatMessages({
messages,
isLoading,
streamingText,
streamingImages,
statusText,
}: ChatMessagesProps) {
return (
<div className="flex-1 overflow-y-auto px-3 md:px-5 py-4 md:py-6 space-y-4 md:space-y-5">
{messages.map((msg) => (
<div
key={msg.id}
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 ${
msg.role === "user"
? "bg-[var(--accent)]/15 text-[var(--text-primary)] border border-[var(--accent)]/25"
: "glass-panel text-[var(--text-primary)]"
}`}
>
{/* 多图参考(新格式) */}
{msg.refImageUrls && msg.refImageUrls.length > 0 && (
<div className="mb-2">
<div className="flex flex-wrap gap-1.5">
{msg.refImageUrls.map((url, i) => (
<img
key={i}
src={url}
alt={`参考图 ${i + 1}`}
className="max-w-[120px] max-h-[90px] rounded-lg object-cover
border border-[var(--accent)]/20
shadow-[0_0_10px_rgba(77,184,164,0.08)]"
/>
))}
</div>
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block">
{msg.refImageUrls.length}
</span>
</div>
)}
{/* 兼容旧数据:单图 */}
{!msg.refImageUrls && msg.refImageUrl && (
<div className="mb-2">
<img
src={msg.refImageUrl}
alt="参考图"
className="max-w-[160px] max-h-[120px] rounded-lg object-cover
border border-[var(--accent)]/20
shadow-[0_0_10px_rgba(77,184,164,0.08)]"
/>
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block"></span>
</div>
)}
<div className="whitespace-pre-wrap text-sm leading-relaxed">{msg.content}</div>
{msg.images && msg.images.length > 0 && (
<>
<ImageGrid images={msg.images} />
{msg.modelName && (
<div className="mt-1.5 text-[10px] text-[var(--accent)]/50">
{msg.modelName}
</div>
)}
</>
)}
</div>
{msg.content && (
<div className={`opacity-0 group-hover/msg:opacity-100 transition-opacity
${msg.role === "user" ? "mr-1 self-end" : "ml-1"}`}>
<CopyButton text={msg.content} />
</div>
)}
</div>
</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>
)}
</div>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import { getImageUrl } from "@/lib/api";
import { useApp } from "@/lib/app-context";
import type { ImageAsset } from "@/lib/types";
interface ImageGridProps {
images: ImageAsset[];
}
export function ImageGrid({ images }: ImageGridProps) {
const { setDetailImage, toggleFavorite } = useApp();
const handleDownload = async (imageUrl: string, index: number) => {
try {
const fullUrl = getImageUrl(imageUrl);
const response = await fetch(fullUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `epeekit-${Date.now()}-${index + 1}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch {
alert("下载失败,请重试");
}
};
const gridCols =
images.length === 1
? "grid-cols-1 max-w-[280px] md:max-w-sm"
: "grid-cols-2 max-w-[320px] md:max-w-xl";
return (
<div className={`grid ${gridCols} gap-3 my-3`}>
{images.map((asset, i) => (
<div
key={asset.id}
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)}
/>
{/* 底部渐变 + 操作栏 */}
<div
className="absolute bottom-0 left-0 right-0 px-3 py-2
bg-gradient-to-t from-black/80 via-black/40 to-transparent
opacity-100 md:opacity-0 md:group-hover:opacity-100
transition-opacity duration-200
flex items-center gap-1.5"
>
<button
onClick={() => setDetailImage(asset)}
className="p-1.5 rounded-lg text-white/70 hover:text-[var(--accent)]
hover:bg-[var(--accent)]/10 cursor-pointer transition-colors"
title="放大查看"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
</svg>
</button>
<button
onClick={() => handleDownload(asset.url, i)}
className="p-1.5 rounded-lg text-white/70 hover:text-[var(--accent)]
hover:bg-[var(--accent)]/10 cursor-pointer transition-colors"
title="保存"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</button>
<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>
</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
bg-[var(--accent)]/20 text-[var(--accent)]
border border-[var(--accent)]/30 backdrop-blur-sm">
New
</span>
</div>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { ImageModelInfo } from "@/lib/types";
import { fetchModels } from "@/lib/api";
import { getStoreUserId } from "@/lib/store";
function getModelStorageKey() {
const uid = getStoreUserId() || "_anonymous";
return `epeekit-${uid}-selected-image-model`;
}
interface ModelSelectorProps {
value: string;
onChange: (modelId: string) => void;
}
export function ModelSelector({ value, onChange }: ModelSelectorProps) {
const [models, setModels] = useState<ImageModelInfo[]>([]);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetchModels()
.then(({ models: list, default: defaultId }) => {
setModels(list);
const saved = localStorage.getItem(getModelStorageKey());
const validIds = new Set(list.map((m) => m.id));
if (saved && validIds.has(saved)) {
onChange(saved);
} else if (!value || !validIds.has(value)) {
onChange(defaultId);
}
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const selected = models.find((m) => m.id === value);
if (models.length === 0) return null;
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs
border border-[var(--border)] bg-[var(--bg-tertiary)]
text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:border-[var(--accent)]/40 hover:bg-[var(--accent)]/5
transition-all cursor-pointer"
title="切换生图模型"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
<span className="max-w-[100px] truncate">{selected?.name ?? "模型"}</span>
<svg
width="10" height="10" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5"
className={`transition-transform ${open ? "rotate-180" : ""}`}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{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>
)}
</div>
);
}

View File

@@ -0,0 +1,487 @@
"use client";
import { useRef, useState, useEffect, useCallback, type MouseEvent } from "react";
import type { Annotation } from "@/lib/types";
import { generateId } from "@/lib/store";
type Tool = "rect" | "arrow" | "freehand" | "text";
interface AnnotationCanvasProps {
imageUrl: string;
onComplete: (annotations: Annotation[], snapshot: string) => void;
onCancel: () => void;
}
export function AnnotationCanvas({ imageUrl, onComplete, onCancel }: AnnotationCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const [tool, setTool] = useState<Tool>("rect");
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [drawing, setDrawing] = useState(false);
const [startPos, setStartPos] = useState<{ x: number; y: number } | null>(null);
const [currentPos, setCurrentPos] = useState<{ x: number; y: number } | null>(null);
const [freehandPoints, setFreehandPoints] = useState<{ x: number; y: number }[]>([]);
const [editingAnnotation, setEditingAnnotation] = useState<string | null>(null);
const [editText, setEditText] = useState("");
const [undoStack, setUndoStack] = useState<Annotation[][]>([]);
const [imgLoaded, setImgLoaded] = useState(false);
// 获取鼠标相对于 canvas 的坐标(归一化到图片尺寸)
const getRelPos = useCallback((e: MouseEvent): { x: number; y: number } | null => {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left) / rect.width,
y: (e.clientY - rect.top) / rect.height,
};
}, []);
// 加载图片
useEffect(() => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
imageRef.current = img;
setImgLoaded(true);
};
img.src = imageUrl;
}, [imageUrl]);
// 渲染 canvas
const render = useCallback(() => {
const canvas = canvasRef.current;
const img = imageRef.current;
if (!canvas || !img) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
const w = canvas.width;
const h = canvas.height;
// 绘制已有标注
for (const ann of annotations) {
drawAnnotation(ctx, ann, w, h, false);
}
// 绘制当前正在创建的标注
if (drawing && startPos && currentPos) {
ctx.strokeStyle = "#f43f5e";
ctx.lineWidth = Math.max(2, w * 0.003);
ctx.setLineDash([w * 0.005, w * 0.003]);
if (tool === "rect") {
const rx = startPos.x * w, ry = startPos.y * h;
const rw = (currentPos.x - startPos.x) * w;
const rh = (currentPos.y - startPos.y) * h;
ctx.strokeRect(rx, ry, rw, rh);
} else if (tool === "arrow") {
ctx.beginPath();
ctx.moveTo(startPos.x * w, startPos.y * h);
ctx.lineTo(currentPos.x * w, currentPos.y * h);
ctx.stroke();
drawArrowHead(ctx, startPos.x * w, startPos.y * h, currentPos.x * w, currentPos.y * h, w * 0.015);
} else if (tool === "freehand" && freehandPoints.length > 1) {
ctx.beginPath();
ctx.moveTo(freehandPoints[0].x * w, freehandPoints[0].y * h);
for (let i = 1; i < freehandPoints.length; i++) {
ctx.lineTo(freehandPoints[i].x * w, freehandPoints[i].y * h);
}
ctx.stroke();
}
ctx.setLineDash([]);
}
}, [annotations, drawing, startPos, currentPos, freehandPoints, tool]);
useEffect(() => {
if (imgLoaded) render();
}, [imgLoaded, render]);
// 鼠标事件
const handleMouseDown = (e: MouseEvent) => {
if (tool === "text") {
const pos = getRelPos(e);
if (!pos) return;
const ann: Annotation = {
id: generateId("ann-"),
type: "text",
x: pos.x,
y: pos.y,
text: "",
};
pushUndo();
setAnnotations((prev) => [...prev, ann]);
setEditingAnnotation(ann.id);
setEditText("");
return;
}
const pos = getRelPos(e);
if (!pos) return;
setDrawing(true);
setStartPos(pos);
setCurrentPos(pos);
if (tool === "freehand") {
setFreehandPoints([pos]);
}
};
const handleMouseMove = (e: MouseEvent) => {
if (!drawing) return;
const pos = getRelPos(e);
if (!pos) return;
setCurrentPos(pos);
if (tool === "freehand") {
setFreehandPoints((prev) => [...prev, pos]);
}
};
const handleMouseUp = () => {
if (!drawing || !startPos || !currentPos) {
setDrawing(false);
return;
}
pushUndo();
if (tool === "rect") {
const ann: Annotation = {
id: generateId("ann-"),
type: "rect",
x: Math.min(startPos.x, currentPos.x),
y: Math.min(startPos.y, currentPos.y),
w: Math.abs(currentPos.x - startPos.x),
h: Math.abs(currentPos.y - startPos.y),
text: "",
};
setAnnotations((prev) => [...prev, ann]);
setEditingAnnotation(ann.id);
setEditText("");
} else if (tool === "arrow") {
const ann: Annotation = {
id: generateId("ann-"),
type: "arrow",
x: startPos.x,
y: startPos.y,
w: currentPos.x - startPos.x,
h: currentPos.y - startPos.y,
text: "",
};
setAnnotations((prev) => [...prev, ann]);
setEditingAnnotation(ann.id);
setEditText("");
} else if (tool === "freehand") {
const ann: Annotation = {
id: generateId("ann-"),
type: "freehand",
x: freehandPoints[0]?.x ?? 0,
y: freehandPoints[0]?.y ?? 0,
points: [...freehandPoints],
text: "",
};
setAnnotations((prev) => [...prev, ann]);
setFreehandPoints([]);
}
setDrawing(false);
setStartPos(null);
setCurrentPos(null);
};
const pushUndo = () => {
setUndoStack((prev) => [...prev, [...annotations]]);
};
const handleUndo = () => {
if (undoStack.length === 0) return;
const prev = undoStack[undoStack.length - 1];
setUndoStack((s) => s.slice(0, -1));
setAnnotations(prev);
setEditingAnnotation(null);
};
const handleClear = () => {
pushUndo();
setAnnotations([]);
setEditingAnnotation(null);
};
const commitText = () => {
if (!editingAnnotation) return;
setAnnotations((prev) =>
prev.map((a) => (a.id === editingAnnotation ? { ...a, text: editText } : a))
);
setEditingAnnotation(null);
setEditText("");
};
const handleComplete = () => {
// 先提交正在编辑的文本
let finalAnnotations = annotations;
if (editingAnnotation) {
finalAnnotations = annotations.map((a) =>
a.id === editingAnnotation ? { ...a, text: editText } : a
);
}
const canvas = canvasRef.current;
if (!canvas) return;
// 最终渲染一次(含所有文本)
const img = imageRef.current;
if (img) {
const ctx = canvas.getContext("2d");
if (ctx) {
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
for (const ann of finalAnnotations) {
drawAnnotation(ctx, ann, canvas.width, canvas.height, true);
}
}
}
const snapshot = canvas.toDataURL("image/png");
onComplete(finalAnnotations, snapshot);
};
const TOOLS: { key: Tool; label: string; icon: string }[] = [
{ key: "rect", label: "矩形", icon: "□" },
{ key: "arrow", label: "箭头", icon: "→" },
{ key: "freehand", label: "画笔", icon: "✎" },
{ key: "text", label: "文字", icon: "T" },
];
return (
<div className="flex flex-col h-full">
{/* 工具栏 */}
<div className="flex items-center gap-1 px-3 py-2 border-b border-[var(--border)] bg-[var(--bg-tertiary)] flex-wrap">
{TOOLS.map((t) => (
<button
key={t.key}
onClick={() => setTool(t.key)}
className={`px-2.5 py-1 text-xs rounded cursor-pointer transition-colors ${
tool === t.key
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-primary)]"
}`}
title={t.label}
>
{t.icon} {t.label}
</button>
))}
<div className="w-px h-4 bg-[var(--border)] mx-1" />
<button
onClick={handleUndo}
disabled={undoStack.length === 0}
className="px-2 py-1 text-xs rounded text-[var(--text-secondary)]
hover:text-[var(--text-primary)] bg-[var(--bg-primary)]
disabled:opacity-30 cursor-pointer"
>
</button>
<button
onClick={handleClear}
disabled={annotations.length === 0}
className="px-2 py-1 text-xs rounded text-[var(--text-secondary)]
hover:text-[var(--text-primary)] bg-[var(--bg-primary)]
disabled:opacity-30 cursor-pointer"
>
</button>
<div className="flex-1" />
<button
onClick={onCancel}
className="px-2.5 py-1 text-xs rounded text-[var(--text-secondary)]
hover:text-[var(--text-primary)] cursor-pointer"
>
</button>
<button
onClick={handleComplete}
className="px-3 py-1 text-xs rounded bg-[var(--accent)] text-white
hover:bg-[var(--accent-hover)] cursor-pointer"
>
</button>
</div>
{/* Canvas 区域 */}
<div ref={containerRef} className="flex-1 overflow-auto p-3 relative">
<canvas
ref={canvasRef}
className="max-w-full rounded-lg cursor-crosshair"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
{/* 文字输入弹出框 */}
{editingAnnotation && (() => {
const ann = annotations.find((a) => a.id === editingAnnotation);
if (!ann) return null;
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
const container = containerRef.current;
if (!container) return null;
const containerRect = container.getBoundingClientRect();
let px: number, py: number;
if (ann.type === "rect") {
px = (ann.x + (ann.w ?? 0)) * rect.width + (rect.left - containerRect.left);
py = ann.y * rect.height + (rect.top - containerRect.top);
} else {
px = ann.x * rect.width + (rect.left - containerRect.left) + 10;
py = ann.y * rect.height + (rect.top - containerRect.top);
}
return (
<div
className="absolute z-10 bg-[var(--bg-secondary)] border border-[var(--accent)]
rounded-lg shadow-lg p-2 min-w-[180px]"
style={{ left: px, top: py }}
>
<input
autoFocus
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitText();
if (e.key === "Escape") {
setAnnotations((prev) => prev.filter((a) => a.id !== editingAnnotation));
setEditingAnnotation(null);
}
}}
placeholder="输入批注..."
className="w-full px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border)]
rounded text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none focus:border-[var(--accent)]"
/>
<div className="flex justify-end gap-1 mt-1.5">
<button
onClick={() => {
setAnnotations((prev) => prev.filter((a) => a.id !== editingAnnotation));
setEditingAnnotation(null);
}}
className="px-2 py-0.5 text-[10px] text-[var(--text-secondary)] cursor-pointer"
>
</button>
<button
onClick={commitText}
className="px-2 py-0.5 text-[10px] bg-[var(--accent)] text-white
rounded cursor-pointer"
>
</button>
</div>
</div>
);
})()}
</div>
</div>
);
}
// --- 绘制辅助函数 ---
function drawAnnotation(
ctx: CanvasRenderingContext2D,
ann: Annotation,
w: number,
h: number,
isFinal: boolean,
) {
const lineWidth = Math.max(2, w * 0.003);
ctx.strokeStyle = "#f43f5e";
ctx.fillStyle = "#f43f5e";
ctx.lineWidth = lineWidth;
ctx.setLineDash([]);
switch (ann.type) {
case "rect":
ctx.strokeRect(ann.x * w, ann.y * h, (ann.w ?? 0) * w, (ann.h ?? 0) * h);
break;
case "arrow": {
const x1 = ann.x * w, y1 = ann.y * h;
const x2 = (ann.x + (ann.w ?? 0)) * w, y2 = (ann.y + (ann.h ?? 0)) * h;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
drawArrowHead(ctx, x1, y1, x2, y2, w * 0.015);
break;
}
case "freehand":
if (ann.points && ann.points.length > 1) {
ctx.beginPath();
ctx.moveTo(ann.points[0].x * w, ann.points[0].y * h);
for (let i = 1; i < ann.points.length; i++) {
ctx.lineTo(ann.points[i].x * w, ann.points[i].y * h);
}
ctx.stroke();
}
break;
case "text":
break;
}
// 绘制文字标签
if (ann.text) {
const fontSize = Math.max(12, w * 0.018);
ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
const metrics = ctx.measureText(ann.text);
const padding = fontSize * 0.4;
let tx: number, ty: number;
if (ann.type === "rect") {
tx = ann.x * w;
ty = ann.y * h - padding;
} else {
tx = ann.x * w;
ty = ann.y * h - padding;
}
// 文字背景
ctx.fillStyle = "rgba(244, 63, 94, 0.85)";
ctx.fillRect(
tx - padding * 0.5,
ty - fontSize,
metrics.width + padding,
fontSize + padding
);
// 文字
ctx.fillStyle = "#ffffff";
ctx.fillText(ann.text, tx, ty);
}
}
function drawArrowHead(
ctx: CanvasRenderingContext2D,
fromX: number, fromY: number,
toX: number, toY: number,
size: number,
) {
const angle = Math.atan2(toY - fromY, toX - fromX);
ctx.beginPath();
ctx.moveTo(toX, toY);
ctx.lineTo(toX - size * Math.cos(angle - Math.PI / 6), toY - size * Math.sin(angle - Math.PI / 6));
ctx.lineTo(toX - size * Math.cos(angle + Math.PI / 6), toY - size * Math.sin(angle + Math.PI / 6));
ctx.closePath();
ctx.fill();
}

View File

@@ -0,0 +1,239 @@
"use client";
import { useState } from "react";
import { getImageUrl } from "@/lib/api";
import { useApp } from "@/lib/app-context";
import { AnnotationCanvas } from "./annotation-canvas";
import type { Annotation, AnnotationData } from "@/lib/types";
interface ImageDetailPanelProps {
onAnnotationComplete?: (data: AnnotationData) => void;
}
export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps) {
const { detailImage, setDetailImage, toggleFavorite, tags, deleteAssetById } = useApp();
const [scale, setScale] = useState(1);
const [showAnnotate, setShowAnnotate] = useState(false);
if (!detailImage) return null;
const tagMap = new Map(tags.map((t) => [t.id, t]));
const handleDownload = async () => {
try {
const fullUrl = getImageUrl(detailImage.url);
const response = await fetch(fullUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `epeekit-${Date.now()}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch {
alert("下载失败");
}
};
const handleCopyPrompt = () => {
navigator.clipboard.writeText(detailImage.prompt);
};
const handleAnnotationComplete = (annotations: Annotation[], snapshot: string) => {
setShowAnnotate(false);
const data: AnnotationData = {
imageUrl: detailImage.url,
annotations,
snapshot,
};
onAnnotationComplete?.(data);
};
if (showAnnotate) {
return (
<aside className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
md:w-[480px] flex-shrink-0 border-l border-[var(--border)]
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
flex flex-col overflow-hidden">
<AnnotationCanvas
imageUrl={getImageUrl(detailImage.url)}
onComplete={handleAnnotationComplete}
onCancel={() => setShowAnnotate(false)}
/>
</aside>
);
}
return (
<aside className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
md:w-[360px] flex-shrink-0 border-l border-[var(--border)]
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
flex flex-col overflow-hidden">
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
<button
onClick={() => setDetailImage(null)}
className="text-[var(--text-secondary)] hover:text-[var(--accent)]
cursor-pointer transition-colors p-1 rounded-lg hover:bg-[var(--accent)]/5"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto">
{/* 图片预览 */}
<div className="p-4">
<div className="relative rounded-xl overflow-hidden neon-border bg-[var(--bg-primary)]">
<img
src={getImageUrl(detailImage.url)}
alt="预览"
className="w-full transition-transform"
style={{ transform: `scale(${scale})`, transformOrigin: "center" }}
/>
</div>
{/* 缩放控制 */}
<div className="flex items-center justify-center gap-2 mt-3">
<button
onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
>
</button>
<span className="text-xs text-[var(--text-secondary)] min-w-[40px] text-center font-medium">
{Math.round(scale * 100)}%
</span>
<button
onClick={() => setScale((s) => Math.min(3, s + 0.25))}
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
>
+
</button>
<button
onClick={() => setScale(1)}
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
>
</button>
</div>
</div>
{/* 操作按钮 */}
<div className="px-4 pb-3 flex flex-wrap gap-2">
<button
onClick={handleDownload}
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(77,184,164,0.25)]
transition-all cursor-pointer"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</button>
<button
onClick={() => 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 ${
detailImage.favorited
? "border-[var(--accent)]/40 bg-[var(--accent)]/8 text-[var(--accent)]"
: "border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill={detailImage.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
{detailImage.favorited ? "已收藏" : "收藏"}
</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"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
</button>
<button
onClick={() => deleteAssetById(detailImage.id)}
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--bg-tertiary)] text-[var(--hot)]/60
hover:text-[var(--hot)] border border-[var(--border)]
hover:border-[var(--hot)]/40 transition-all cursor-pointer"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
</svg>
</button>
</div>
{/* Prompt */}
{detailImage.prompt && (
<div className="px-4 pb-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-[var(--text-secondary)]">Prompt</span>
<button
onClick={handleCopyPrompt}
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
cursor-pointer transition-colors"
>
</button>
</div>
<div className="p-3 rounded-xl bg-[var(--bg-primary)] border border-[var(--border)]
text-xs text-[var(--text-secondary)] leading-relaxed break-all">
{detailImage.prompt}
</div>
</div>
)}
{/* 标签 */}
{detailImage.tags.length > 0 && (
<div className="px-4 pb-3">
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5"></span>
<div className="flex flex-wrap gap-1.5">
{detailImage.tags.map((tid) => {
const tag = tagMap.get(tid);
if (!tag) return null;
return (
<span
key={tid}
className="text-[10px] px-2 py-0.5 rounded-lg font-medium"
style={{ backgroundColor: tag.color + "18", color: tag.color }}
>
{tag.name}
</span>
);
})}
</div>
</div>
)}
{/* 元信息 */}
<div className="px-4 pb-4">
<span className="text-xs font-medium text-[var(--text-secondary)] 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>
);
}

View File

@@ -0,0 +1,160 @@
"use client";
import { useState, useRef, useEffect } from "react";
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";
const NAV_ITEMS = [
{
href: "/",
label: "对话",
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" />
</svg>
),
},
{
href: "/gallery",
label: "资源库",
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" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
),
},
] as const;
export function TopNav() {
const pathname = usePathname();
const { sidebarCollapsed, setSidebarCollapsed } = useApp();
const { user, logout } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
function handleClick(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [menuOpen]);
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">
{/* 移动端汉堡菜单 */}
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="md:hidden flex-shrink-0 p-1.5 rounded-md
text-[var(--text-secondary)] hover:text-[var(--accent)]
transition-colors cursor-pointer"
title="菜单"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 12h18M3 6h18M3 18h18" />
</svg>
</button>
{/* 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">
<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" />
</svg>
</div>
<span className="text-base font-bold tracking-tight text-[var(--text-primary)]">
EPEEKit
</span>
</Link>
{/* 导航 Tab */}
<nav className="flex items-center gap-1.5">
{NAV_ITEMS.map(({ href, label, icon }) => {
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 ${
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"
}`}
>
{icon}
{label}
</Link>
);
})}
</nav>
<div className="flex-1" />
{/* 用户菜单 */}
{user && (
<div className="relative" ref={menuRef}>
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex items-center gap-2 px-3 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"
>
<div className="w-7 h-7 rounded-full bg-[var(--accent)]/15 border border-[var(--accent)]/30
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">
{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"
>
<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>
)}
</header>
{profileOpen && <ProfileModal onClose={() => setProfileOpen(false)} />}
</>
);
}

View File

@@ -0,0 +1,233 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useAuth, type AuthUser } from "@/lib/auth-context";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
interface MemoryItem {
id: string;
memory: string;
created_at?: string;
updated_at?: string;
}
interface GroupedMemories {
label: string;
items: MemoryItem[];
}
function groupByTime(memories: MemoryItem[]): GroupedMemories[] {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const weekAgo = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000);
const today: MemoryItem[] = [];
const week: MemoryItem[] = [];
const older: MemoryItem[] = [];
for (const m of memories) {
const d = m.created_at ? new Date(m.created_at) : null;
if (!d || isNaN(d.getTime())) {
older.push(m);
} else if (d >= todayStart) {
today.push(m);
} else if (d >= weekAgo) {
week.push(m);
} else {
older.push(m);
}
}
const groups: GroupedMemories[] = [];
if (today.length > 0) groups.push({ label: "今天", items: today });
if (week.length > 0) groups.push({ label: "最近 7 天", items: week });
if (older.length > 0) groups.push({ label: "更早", items: older });
return groups;
}
function formatDate(dateStr?: string): string {
if (!dateStr) return "";
const d = new Date(dateStr);
if (isNaN(d.getTime())) return "";
const now = new Date();
const isThisYear = d.getFullYear() === now.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const hour = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
if (isThisYear) return `${month}-${day} ${hour}:${min}`;
return `${d.getFullYear()}-${month}-${day} ${hour}:${min}`;
}
export function ProfileModal({ onClose }: { onClose: () => void }) {
const { user, token } = useAuth();
const [memories, setMemories] = useState<MemoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchMemories = useCallback(async () => {
if (!token) return;
setLoading(true);
setError(null);
try {
const resp = await fetch(`${API_URL}/api/memory/list`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
setMemories(data.memories || []);
if (data.error) setError(data.error);
} catch (e) {
setError(e instanceof Error ? e.message : "加载记忆失败");
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
fetchMemories();
}, [fetchMemories]);
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose]);
if (!user) return null;
const groups = groupByTime(memories);
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center"
onClick={onClose}
>
{/* 遮罩层 */}
<div 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)]
flex flex-col overflow-hidden"
style={{ animation: "slideUp 200ms ease-out" }}
onClick={(e) => e.stopPropagation()}
>
{/* 关闭按钮 */}
<button
onClick={onClose}
className="absolute top-3 right-3 p-1.5 rounded-lg text-[var(--text-secondary)]
hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]
transition-colors cursor-pointer z-10"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
{/* 用户信息区 */}
<div 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)]">
{(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">
{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>
{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>
)}
</div>
</div>
</div>
{/* 分割线 */}
<div className="mx-6 h-px bg-[var(--border)]" />
{/* 记忆区标题 */}
<div className="px-6 pt-4 pb-2 flex items-center gap-2 flex-shrink-0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
<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>
{!loading && (
<span className="text-xs text-[var(--text-secondary)]">
{memories.length}
</span>
)}
</div>
{/* 记忆列表 */}
<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" />
<span className="ml-3 text-sm text-[var(--text-secondary)]">...</span>
</div>
)}
{!loading && error && memories.length === 0 && (
<div className="text-center py-12">
<p className="text-sm text-[var(--hot)]">{error}</p>
</div>
)}
{!loading && !error && memories.length === 0 && (
<div className="text-center py-12">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)"
strokeWidth="1.5" className="mx-auto mb-3 opacity-50">
<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>
<p className="text-sm text-[var(--text-secondary)]"></p>
<p className="text-xs text-[var(--text-secondary)] mt-1 opacity-60">
</p>
</div>
)}
{!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">
{group.label}
</div>
<div className="space-y-1.5">
{group.items.map((item) => (
<div
key={item.id}
className="px-3 py-2.5 rounded-lg bg-[var(--bg-tertiary)]/50
border border-[var(--border)] hover:border-[var(--border-glow)]
transition-colors group"
>
<p className="text-sm text-[var(--text-primary)] leading-relaxed">
{item.memory}
</p>
{item.created_at && (
<p className="text-xs text-[var(--text-secondary)] mt-1 opacity-0
group-hover:opacity-60 transition-opacity">
{formatDate(item.created_at)}
</p>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,319 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useApp } from "@/lib/app-context";
import { getImageUrl, fetchLlmModels } from "@/lib/api";
import type { LlmModelInfo } from "@/lib/types";
function timeAgo(ts: number): string {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return "刚刚";
if (mins < 60) return `${mins}分钟前`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}小时前`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}天前`;
return new Date(ts).toLocaleDateString("zh-CN");
}
export function SessionList() {
const {
sessions, activeSessionId, tags, selectedTagIds,
createSession, switchSession, deleteSession, renameSession,
updateSessionLlmModel,
} = useApp();
const [menuSessionId, setMenuSessionId] = useState<string | null>(null);
const [showModelSub, setShowModelSub] = useState(false);
const [llmModels, setLlmModels] = useState<LlmModelInfo[]>([]);
const [defaultLlmModel, setDefaultLlmModel] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState("");
const editRef = useRef<HTMLInputElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const filtered = selectedTagIds.length === 0
? sessions
: sessions.filter((s) => selectedTagIds.some((tid) => s.tags.includes(tid)));
const tagMap = new Map(tags.map((t) => [t.id, t]));
useEffect(() => {
fetchLlmModels()
.then((data) => {
setLlmModels(data.models);
setDefaultLlmModel(data.default);
})
.catch(() => {});
}, []);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
closeMenu();
}
};
if (menuSessionId) {
window.addEventListener("mousedown", handler);
return () => window.removeEventListener("mousedown", handler);
}
}, [menuSessionId]);
useEffect(() => {
if (editingId) editRef.current?.focus();
}, [editingId]);
const closeMenu = useCallback(() => {
setMenuSessionId(null);
setShowModelSub(false);
}, []);
const handleDots = (e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
if (menuSessionId === sessionId) {
closeMenu();
return;
}
setMenuSessionId(sessionId);
setShowModelSub(false);
};
const startRename = (id: string, currentTitle: string) => {
setEditingId(id);
setEditTitle(currentTitle);
closeMenu();
};
const commitRename = () => {
if (editingId && editTitle.trim()) {
renameSession(editingId, editTitle.trim());
}
setEditingId(null);
};
const handleSelectModel = (sessionId: string, modelId: string) => {
updateSessionLlmModel(sessionId, modelId);
closeMenu();
};
const menuSession = menuSessionId
? sessions.find((s) => s.id === menuSessionId)
: null;
return (
<div className="flex-1 overflow-y-auto fog-scroll">
{/* 新建按钮 */}
<div className="px-3 py-3">
<button
onClick={() => createSession()}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5
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"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
{/* 会话列表 */}
<div className="px-2 pb-2 space-y-1">
{filtered.map((session) => (
<div key={session.id} className="relative">
<div
onClick={() => switchSession(session.id)}
className={`group relative flex items-start gap-3 px-3 py-2.5 rounded-xl cursor-pointer
transition-all ${
session.id === activeSessionId
? "glow-border !bg-[var(--bg-secondary)]"
: "hover:bg-[var(--bg-tertiary)]/50 border border-transparent"
}`}
>
{/* 缩略图 */}
{session.thumbnail ? (
<img
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)]"
/>
) : (
<div className="w-10 h-10 rounded-lg bg-[var(--bg-primary)] border border-[var(--border)]
flex items-center justify-center flex-shrink-0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="1.5">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</div>
)}
{/* 会话信息 */}
<div className="flex-1 min-w-0">
{editingId === session.id ? (
<input
ref={editRef}
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename();
if (e.key === "Escape") setEditingId(null);
}}
className="w-full text-sm bg-transparent border-b border-[var(--accent)]
text-[var(--text-primary)] focus:outline-none"
/>
) : (
<div className={`text-sm truncate pr-5 ${
session.id === activeSessionId ? "text-[var(--text-primary)] font-medium" : "text-[var(--text-primary)]"
}`}>
{session.title}
</div>
)}
{/* 标签 + 时间 */}
<div className="flex items-center gap-1 mt-1 flex-wrap">
{session.tags.slice(0, 3).map((tid) => {
const tag = tagMap.get(tid);
if (!tag) return null;
return (
<span
key={tid}
className="text-[10px] px-1.5 py-px rounded-full font-medium"
style={{ backgroundColor: tag.color + "22", color: tag.color }}
>
{tag.name}
</span>
);
})}
{session.llmModel && (
<span className="text-[10px] px-1.5 py-px rounded-full font-medium
bg-[var(--accent)]/10 text-[var(--accent)]">
{llmModels.find((m) => m.id === session.llmModel)?.name || session.llmModel}
</span>
)}
<span className="text-[10px] text-[var(--text-secondary)] ml-auto flex-shrink-0">
{timeAgo(session.updatedAt)}
</span>
</div>
</div>
{/* 三点按钮 */}
<button
onClick={(e) => handleDots(e, session.id)}
className="absolute right-2 top-2.5 p-1 rounded-md
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
hover:bg-[var(--bg-tertiary)]
opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="12" cy="19" r="2" />
</svg>
</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"
>
<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>
)}
<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={() => {
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>
)}
</div>
))}
{filtered.length === 0 && (
<div className="text-center text-xs text-[var(--text-secondary)] py-8">
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useApp } from "@/lib/app-context";
import { TagFilter } from "./tag-filter";
import { SessionList } from "./session-list";
export function Sidebar() {
const { sidebarCollapsed, setSidebarCollapsed } = useApp();
return (
<>
{/* 移动端遮罩 */}
{!sidebarCollapsed && (
<div
className="fixed inset-0 z-30 bg-black/60 sidebar-overlay md:hidden"
onClick={() => setSidebarCollapsed(true)}
/>
)}
<aside
className={`
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
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" : ""}
`}
>
<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))" }}>
<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>
</div>
<button
onClick={() => setSidebarCollapsed(true)}
className="text-[var(--text-secondary)] hover:text-[var(--accent)]
transition-colors cursor-pointer p-1 rounded-md
hover:bg-[var(--accent)]/5"
title="折叠侧边栏"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 17l-5-5 5-5M18 17l-5-5 5-5" />
</svg>
</button>
</div>
<TagFilter />
<SessionList />
</aside>
</>
);
}

View File

@@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import { useApp } from "@/lib/app-context";
const TAG_COLORS = [
"#4DB8A4", "#3A8FB7", "#8b5cf6", "#B8935A",
"#ec4899", "#C4654A", "#84cc16", "#6366f1",
];
export function TagFilter() {
const { tags, selectedTagIds, setSelectedTagIds, addTag, deleteTag } = useApp();
const [showManager, setShowManager] = useState(false);
const [newTagName, setNewTagName] = useState("");
const [newTagColor, setNewTagColor] = useState(TAG_COLORS[0]);
const toggle = (tagId: string) => {
setSelectedTagIds(
selectedTagIds.includes(tagId)
? selectedTagIds.filter((id) => id !== tagId)
: [...selectedTagIds, tagId]
);
};
const clearFilter = () => setSelectedTagIds([]);
const handleAddTag = () => {
const trimmed = newTagName.trim();
if (!trimmed) return;
addTag(trimmed, newTagColor);
setNewTagName("");
setNewTagColor(TAG_COLORS[Math.floor(Math.random() * TAG_COLORS.length)]);
};
return (
<div className="px-3 py-2.5 border-b border-[var(--border)]">
<div className="flex flex-wrap gap-1.5">
<button
onClick={clearFilter}
className={`px-2.5 py-1 text-xs rounded-lg transition-all cursor-pointer ${
selectedTagIds.length === 0
? "bg-[var(--accent)]/15 text-[var(--accent)] border border-[var(--accent)]/30"
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-transparent"
}`}
>
</button>
{tags.map((tag) => {
const active = selectedTagIds.includes(tag.id);
return (
<button
key={tag.id}
onClick={() => toggle(tag.id)}
className="px-2.5 py-1 text-xs rounded-lg transition-all cursor-pointer border"
style={{
backgroundColor: active ? tag.color + "18" : "transparent",
borderColor: active ? tag.color + "60" : "var(--border)",
color: active ? tag.color : "var(--text-secondary)",
}}
>
{tag.name}
</button>
);
})}
<button
onClick={() => setShowManager(!showManager)}
className="px-2 py-1 text-xs rounded-lg
text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:bg-[var(--accent)]/5
transition-all cursor-pointer"
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 1 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 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.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 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.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 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.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>
</button>
</div>
{showManager && (
<div className="mt-2.5 p-3 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]">
<div className="flex items-center gap-1.5 mb-2">
<input
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddTag()}
placeholder="新标签名称"
className="flex-1 min-w-0 px-2.5 py-1.5 text-xs rounded-lg 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"
/>
<button
onClick={handleAddTag}
disabled={!newTagName.trim()}
className="flex-shrink-0 px-2.5 py-1.5 text-xs rounded-lg bg-[var(--accent)] text-[var(--bg-primary)] font-medium
disabled:opacity-40 cursor-pointer hover:bg-[var(--accent-hover)]
transition-colors"
>
</button>
</div>
<div className="flex items-center gap-1 mb-2">
{TAG_COLORS.map((c) => (
<button
key={c}
onClick={() => setNewTagColor(c)}
className="w-5 h-5 rounded-full cursor-pointer transition-transform"
style={{
backgroundColor: c,
transform: newTagColor === c ? "scale(1.25)" : "scale(1)",
boxShadow: newTagColor === c ? `0 0 0 2px var(--bg-tertiary), 0 0 0 3px ${c}` : "none",
}}
/>
))}
</div>
{tags.filter((t) => !t.builtin).length > 0 && (
<div className="space-y-1 mt-1.5">
{tags
.filter((t) => !t.builtin)
.map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between px-2 py-1 rounded-lg text-xs
hover:bg-[var(--bg-primary)]/50 transition-colors"
>
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: tag.color }} />
<span className="text-[var(--text-primary)]">{tag.name}</span>
</span>
<button
onClick={() => deleteTag(tag.id)}
className="text-[var(--text-secondary)] hover:text-[var(--hot)] cursor-pointer
transition-colors"
>
×
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,227 @@
/**
* 后端 API 调用封装 + SSE 流式读取。
*/
import type { ApiMessage, ImageModelInfo, LlmModelInfo } from "./types";
import { getStoredToken } from "./auth-context";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
function authHeaders(): Record<string, string> {
const token = getStoredToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
export interface UploadProgress {
/** 0-100 */
percent: number;
loaded: number;
total: number;
}
export interface UploadResult {
url: string;
filename: string;
}
/**
* 独立上传参考图,支持进度回调。
* 使用 XMLHttpRequest 以获取上传进度事件fetch API 不支持)。
*/
export function uploadRefImage(
file: File,
onProgress?: (progress: UploadProgress) => void,
signal?: AbortSignal
): Promise<UploadResult> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
if (signal) {
signal.addEventListener("abort", () => {
xhr.abort();
reject(new DOMException("Upload aborted", "AbortError"));
});
}
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable && onProgress) {
onProgress({
percent: Math.round((e.loaded / e.total) * 100),
loaded: e.loaded,
total: e.total,
});
}
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText));
} catch {
reject(new Error("解析上传响应失败"));
}
} else if (xhr.status === 401) {
window.location.href = "/login";
reject(new Error("登录已过期"));
} else {
reject(new Error(`上传失败: ${xhr.status}`));
}
});
xhr.addEventListener("error", () => reject(new Error("网络错误,上传失败")));
xhr.addEventListener("timeout", () => reject(new Error("上传超时")));
xhr.open("POST", `${API_URL}/api/upload-ref-image`);
xhr.timeout = 120_000;
const token = getStoredToken();
if (token) {
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
}
const formData = new FormData();
formData.append("file", file);
xhr.send(formData);
});
}
export interface SSEEvent {
type: "text_delta" | "tool_start" | "image_result" | "tool_error" | "memory_warning" | "done" | "error";
data: Record<string, unknown>;
}
/**
* 获取可用的图像生成模型列表。
*/
export async function fetchModels(): Promise<{
models: ImageModelInfo[];
default: string;
}> {
const resp = await fetch(`${API_URL}/api/models`, {
headers: authHeaders(),
});
if (resp.status === 401) {
window.location.href = "/login";
throw new Error("登录已过期");
}
if (!resp.ok) throw new Error(`获取模型列表失败: ${resp.status}`);
return resp.json();
}
/**
* 获取可用的 LLM 对话模型列表。
*/
export async function fetchLlmModels(): Promise<{
models: LlmModelInfo[];
default: string;
}> {
const resp = await fetch(`${API_URL}/api/llm-models`, {
headers: authHeaders(),
});
if (resp.status === 401) {
window.location.href = "/login";
throw new Error("登录已过期");
}
if (!resp.ok) throw new Error(`获取 LLM 模型列表失败: ${resp.status}`);
return resp.json();
}
/**
* 发送对话消息到后端,返回 SSE 事件的异步迭代器。
* refImageUrls: 已通过 uploadRefImage 上传后的服务端路径列表
*/
export async function* sendChat(
messages: ApiMessage[],
refImageUrls?: string[] | null,
imageModel?: string | null,
sessionId?: string | null,
llmModel?: string | null,
): AsyncGenerator<SSEEvent> {
const formData = new FormData();
const apiMessages = messages.map((m) => ({
role: m.role,
content: m.content,
}));
formData.append("messages", JSON.stringify(apiMessages));
if (refImageUrls && refImageUrls.length > 0) {
formData.append("ref_image_urls", JSON.stringify(refImageUrls));
}
if (imageModel) {
formData.append("image_model", imageModel);
}
if (sessionId) {
formData.append("session_id", sessionId);
}
if (llmModel) {
formData.append("llm_model", llmModel);
}
const response = await fetch(`${API_URL}/api/chat`, {
method: "POST",
body: formData,
headers: authHeaders(),
});
if (response.status === 401) {
window.location.href = "/login";
throw new Error("登录已过期");
}
if (!response.ok) {
throw new Error(`API 请求失败: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("无法读取响应流");
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
buffer = buffer.replace(/\r\n/g, "\n");
const parts = buffer.split("\n\n");
buffer = parts.pop() || "";
for (const part of parts) {
if (!part.trim()) continue;
let eventType = "message";
let eventData = "";
for (const line of part.split("\n")) {
if (line.startsWith("event:")) {
eventType = line.slice(6).trim();
} else if (line.startsWith("data:")) {
eventData = line.slice(5).trim();
}
}
if (eventData) {
try {
yield {
type: eventType as SSEEvent["type"],
data: JSON.parse(eventData),
};
} catch {
// 解析失败则跳过
}
}
}
}
}
/** 获取图片完整 URL处理相对路径。 */
export function getImageUrl(path: string): string {
if (path.startsWith("http") || path.startsWith("data:")) return path;
return `${API_URL}${path}`;
}

View File

@@ -0,0 +1,323 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { Session, Tag, ImageAsset, ChatMessage } from "./types";
import {
loadSessions,
saveSessions,
createSession as storeCreateSession,
updateSession as storeUpdateSession,
deleteSession as storeDeleteSession,
loadTags,
saveTags,
addTag as storeAddTag,
deleteTag as storeDeleteTag,
loadAssets,
addAsset as storeAddAsset,
updateAsset as storeUpdateAsset,
deleteAsset as storeDeleteAsset,
toggleFavorite as storeToggleFavorite,
generateId,
setStoreUserId,
} from "./store";
import { useAuth } from "./auth-context";
interface AppContextValue {
// 会话
sessions: Session[];
activeSessionId: string | null;
activeSession: Session | null;
createSession: () => Session;
switchSession: (id: string) => void;
deleteSession: (id: string) => void;
renameSession: (id: string, title: string) => void;
updateSessionTags: (id: string, tags: string[]) => void;
updateSessionLlmModel: (id: string, llmModel: string) => void;
appendMessage: (sessionId: string, message: ChatMessage) => void;
updateSessionThumbnail: (sessionId: string, url: string) => void;
// 标签
tags: Tag[];
addTag: (name: string, color: string) => Tag;
deleteTag: (id: string) => void;
selectedTagIds: string[];
setSelectedTagIds: (ids: string[]) => void;
// 图片资源
assets: ImageAsset[];
addAsset: (asset: ImageAsset) => void;
updateAsset: (id: string, patch: Partial<ImageAsset>) => void;
deleteAssetById: (id: string) => void;
toggleFavorite: (id: string) => boolean;
// 右侧面板
detailImage: ImageAsset | null;
setDetailImage: (asset: ImageAsset | null) => void;
// 左栏折叠
sidebarCollapsed: boolean;
setSidebarCollapsed: (v: boolean) => void;
}
const AppContext = createContext<AppContextValue | null>(null);
export function useApp(): AppContextValue {
const ctx = useContext(AppContext);
if (!ctx) throw new Error("useApp must be used within AppProvider");
return ctx;
}
export function AppProvider({ children }: { children: ReactNode }) {
const { user, isAuthenticated } = useAuth();
const [sessions, setSessions] = useState<Session[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [tags, setTags] = useState<Tag[]>([]);
const [assets, setAssets] = useState<ImageAsset[]>([]);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [detailImage, setDetailImage] = useState<ImageAsset | null>(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
if (typeof window !== "undefined") return window.innerWidth < 768;
return false;
});
const [initialized, setInitialized] = useState(false);
// 当用户变化时,切换 store 的 userId 并重新加载数据
useEffect(() => {
if (!isAuthenticated || !user) {
setInitialized(false);
return;
}
setStoreUserId(user.id);
setSessions(loadSessions());
setTags(loadTags());
setAssets(loadAssets());
setActiveSessionId(null);
setDetailImage(null);
setInitialized(true);
}, [user?.id, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
// 初始化后,如果没有会话则自动创建一个
useEffect(() => {
if (!initialized) return;
if (sessions.length === 0) {
const s = storeCreateSession();
setSessions([s]);
setActiveSessionId(s.id);
} else if (!activeSessionId) {
setActiveSessionId(sessions[0].id);
}
}, [initialized, sessions.length, activeSessionId]);
const activeSession = useMemo(
() => sessions.find((s) => s.id === activeSessionId) ?? null,
[sessions, activeSessionId]
);
// --- 会话操作 ---
const createSession = useCallback(() => {
const s = storeCreateSession();
setSessions((prev) => [s, ...prev]);
setActiveSessionId(s.id);
setDetailImage(null);
return s;
}, []);
const switchSession = useCallback((id: string) => {
setActiveSessionId(id);
setDetailImage(null);
if (typeof window !== "undefined" && window.innerWidth < 768) {
setSidebarCollapsed(true);
}
}, []);
const deleteSessionCb = useCallback(
(id: string) => {
storeDeleteSession(id);
setSessions((prev) => {
const next = prev.filter((s) => s.id !== id);
if (activeSessionId === id) {
if (next.length > 0) {
setActiveSessionId(next[0].id);
} else {
const s = storeCreateSession();
next.push(s);
setActiveSessionId(s.id);
}
}
return next;
});
setAssets((prev) => prev.filter((a) => a.sessionId !== id));
setDetailImage(null);
},
[activeSessionId]
);
const renameSession = useCallback((id: string, title: string) => {
setSessions((prev) =>
prev.map((s) => {
if (s.id !== id) return s;
const updated = { ...s, title, updatedAt: Date.now() };
storeUpdateSession(updated);
return updated;
})
);
}, []);
const updateSessionTags = useCallback((id: string, tagIds: string[]) => {
setSessions((prev) =>
prev.map((s) => {
if (s.id !== id) return s;
const updated = { ...s, tags: tagIds, updatedAt: Date.now() };
storeUpdateSession(updated);
return updated;
})
);
}, []);
const updateSessionLlmModel = useCallback((id: string, llmModel: string) => {
setSessions((prev) =>
prev.map((s) => {
if (s.id !== id) return s;
const updated = { ...s, llmModel, updatedAt: Date.now() };
storeUpdateSession(updated);
return updated;
})
);
}, []);
const appendMessage = useCallback((sessionId: string, message: ChatMessage) => {
setSessions((prev) =>
prev.map((s) => {
if (s.id !== sessionId) return s;
const updated = {
...s,
messages: [...s.messages, message],
updatedAt: Date.now(),
};
if (message.role === "user" && s.messages.length === 0) {
updated.title = message.content.slice(0, 30) + (message.content.length > 30 ? "…" : "");
}
storeUpdateSession(updated);
return updated;
})
);
}, []);
const updateSessionThumbnail = useCallback((sessionId: string, url: string) => {
setSessions((prev) =>
prev.map((s) => {
if (s.id !== sessionId) return s;
const updated = { ...s, thumbnail: url, updatedAt: Date.now() };
storeUpdateSession(updated);
return updated;
})
);
}, []);
// --- 标签操作 ---
const addTag = useCallback((name: string, color: string) => {
const t = storeAddTag(name, color);
setTags(loadTags());
return t;
}, []);
const deleteTagCb = useCallback((id: string) => {
storeDeleteTag(id);
setTags(loadTags());
setSelectedTagIds((prev) => prev.filter((tid) => tid !== id));
}, []);
// --- 资源操作 ---
const addAssetCb = useCallback((asset: ImageAsset) => {
storeAddAsset(asset);
setAssets((prev) => [asset, ...prev]);
}, []);
const updateAssetCb = useCallback((id: string, patch: Partial<ImageAsset>) => {
storeUpdateAsset(id, patch);
setAssets((prev) => prev.map((a) => (a.id === id ? { ...a, ...patch } : a)));
setDetailImage((prev) => (prev && prev.id === id ? { ...prev, ...patch } : prev));
}, []);
const deleteAssetCb = useCallback((id: string) => {
storeDeleteAsset(id);
setAssets((prev) => prev.filter((a) => a.id !== id));
setDetailImage((prev) => (prev && prev.id === id ? null : prev));
}, []);
const toggleFavoriteCb = useCallback((id: string) => {
const result = storeToggleFavorite(id);
setAssets((prev) =>
prev.map((a) => (a.id === id ? { ...a, favorited: result } : a))
);
setDetailImage((prev) =>
prev && prev.id === id ? { ...prev, favorited: result } : prev
);
return result;
}, []);
const value = useMemo<AppContextValue>(
() => ({
sessions,
activeSessionId,
activeSession,
createSession,
switchSession,
deleteSession: deleteSessionCb,
renameSession,
updateSessionTags,
updateSessionLlmModel,
appendMessage,
updateSessionThumbnail,
tags,
addTag,
deleteTag: deleteTagCb,
selectedTagIds,
setSelectedTagIds,
assets,
addAsset: addAssetCb,
updateAsset: updateAssetCb,
deleteAssetById: deleteAssetCb,
toggleFavorite: toggleFavoriteCb,
detailImage,
setDetailImage,
sidebarCollapsed,
setSidebarCollapsed,
}),
[
sessions, activeSessionId, activeSession,
createSession, switchSession, deleteSessionCb,
renameSession, updateSessionTags, updateSessionLlmModel, appendMessage, updateSessionThumbnail,
tags, addTag, deleteTagCb, selectedTagIds,
assets, addAssetCb, updateAssetCb, deleteAssetCb, toggleFavoriteCb,
detailImage, sidebarCollapsed,
]
);
// 未登录时(如 /login 页面)直接渲染 children不注入 AppContext
// 已登录但尚未初始化完成时显示加载状态,避免子组件调用 useApp() 拿到 null
if (!initialized) {
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>
);
}
return <>{children}</>;
}
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

View File

@@ -0,0 +1,161 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
export interface AuthUser {
id: string;
username: string;
display_name: string;
is_admin: boolean;
}
interface AuthContextValue {
user: AuthUser | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
const TOKEN_KEY = "epeekit-auth-token";
const REFRESH_KEY = "epeekit-refresh-token";
const USER_KEY = "epeekit-auth-user";
export function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(TOKEN_KEY);
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const saveAuth = useCallback((accessToken: string, refreshToken: string, userData: AuthUser) => {
localStorage.setItem(TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_KEY, refreshToken);
localStorage.setItem(USER_KEY, JSON.stringify(userData));
setToken(accessToken);
setUser(userData);
}, []);
const clearAuth = useCallback(() => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(USER_KEY);
setToken(null);
setUser(null);
}, []);
// 尝试用 refresh token 获取新 access token
const tryRefresh = useCallback(async (): Promise<boolean> => {
const refreshToken = localStorage.getItem(REFRESH_KEY);
if (!refreshToken) return false;
try {
const resp = await fetch(`${API_URL}/api/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!resp.ok) return false;
const data = await resp.json();
const savedUser = localStorage.getItem(USER_KEY);
if (savedUser && data.access_token) {
const userData = JSON.parse(savedUser) as AuthUser;
localStorage.setItem(TOKEN_KEY, data.access_token);
setToken(data.access_token);
setUser(userData);
return true;
}
return false;
} catch {
return false;
}
}, []);
// 启动时验证 token
useEffect(() => {
async function init() {
const savedToken = localStorage.getItem(TOKEN_KEY);
if (!savedToken) {
setIsLoading(false);
return;
}
try {
const resp = await fetch(`${API_URL}/api/auth/me`, {
headers: { Authorization: `Bearer ${savedToken}` },
});
if (resp.ok) {
const userData = await resp.json();
setToken(savedToken);
setUser(userData);
} else if (resp.status === 401) {
const refreshed = await tryRefresh();
if (!refreshed) clearAuth();
} else {
clearAuth();
}
} catch {
// 网络错误时保留本地缓存的用户信息,允许离线使用
const savedUser = localStorage.getItem(USER_KEY);
if (savedUser) {
setToken(savedToken);
setUser(JSON.parse(savedUser));
}
}
setIsLoading(false);
}
init();
}, [clearAuth, tryRefresh]);
const login = useCallback(async (username: string, password: string) => {
const resp = await fetch(`${API_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: "登录失败" }));
throw new Error(err.detail || "登录失败");
}
const data = await resp.json();
saveAuth(data.access_token, data.refresh_token, data.user);
}, [saveAuth]);
const logout = useCallback(() => {
clearAuth();
}, [clearAuth]);
const value = useMemo<AuthContextValue>(
() => ({
user,
token,
isAuthenticated: !!token && !!user,
isLoading,
login,
logout,
}),
[user, token, isLoading, login, logout]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

View File

@@ -0,0 +1,36 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuth } from "./auth-context";
const PUBLIC_PATHS = ["/login"];
export function AuthGuard({ children }: { children: ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const pathname = usePathname();
const router = useRouter();
const isPublic = PUBLIC_PATHS.includes(pathname);
useEffect(() => {
if (isLoading) return;
if (!isAuthenticated && !isPublic) {
router.replace("/login");
}
}, [isAuthenticated, isLoading, isPublic, router]);
if (isLoading) {
return (
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)]">
<div className="text-[var(--text-secondary)] text-sm">...</div>
</div>
);
}
if (!isAuthenticated && !isPublic) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,170 @@
/**
* 基于 localStorage 的客户端持久化存储。
* 提供会话、标签、图片资源的 CRUD 操作。
* 所有 key 按 user_id 隔离,确保多用户数据不混淆。
*/
import type { Session, Tag, ImageAsset, ChatMessage } from "./types";
// 当前登录用户的 ID由 AppProvider 在初始化时设置
let _userId = "";
export function setStoreUserId(id: string) {
_userId = id;
}
export function getStoreUserId(): string {
return _userId;
}
function storageKey(base: string): string {
const prefix = _userId || "_anonymous";
return `epeekit-${prefix}-${base}`;
}
// --------------- 内置标签 ---------------
const BUILTIN_TAGS: Tag[] = [
{ id: "tag-ui", name: "UI", color: "#6366f1", builtin: true },
{ id: "tag-icon", name: "Icon", color: "#f59e0b", builtin: true },
{ id: "tag-illustration", name: "原画", color: "#10b981", builtin: true },
{ id: "tag-style", name: "风格探索", color: "#ec4899", builtin: true },
{ id: "tag-character", name: "立绘", color: "#8b5cf6", builtin: true },
{ id: "tag-concept", name: "概念图", color: "#06b6d4", builtin: true },
];
// --------------- 通用 helpers ---------------
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));
}
// --------------- ID 生成 ---------------
let counter = 0;
export function generateId(prefix = ""): string {
counter++;
const ts = Date.now().toString(36);
const rand = Math.random().toString(36).slice(2, 6);
return `${prefix}${ts}-${rand}-${counter}`;
}
// --------------- 标签 ---------------
export function loadTags(): Tag[] {
const custom = readJSON<Tag[]>(storageKey("tags"), []);
const builtinIds = new Set(BUILTIN_TAGS.map((t) => t.id));
const merged = [...BUILTIN_TAGS, ...custom.filter((t) => !builtinIds.has(t.id))];
return merged;
}
export function saveTags(tags: Tag[]) {
const custom = tags.filter((t) => !t.builtin);
writeJSON(storageKey("tags"), custom);
}
export function addTag(name: string, color: string): Tag {
const tag: Tag = { id: generateId("tag-"), name, color, builtin: false };
const existing = loadTags();
saveTags([...existing, tag]);
return tag;
}
export function deleteTag(tagId: string) {
const tags = loadTags().filter((t) => t.id !== tagId && !t.builtin);
saveTags(tags);
}
// --------------- 会话 ---------------
export function loadSessions(): Session[] {
return readJSON<Session[]>(storageKey("sessions"), []);
}
export function saveSessions(sessions: Session[]) {
writeJSON(storageKey("sessions"), sessions);
}
export function createSession(): Session {
const now = Date.now();
const session: Session = {
id: generateId("sess-"),
title: "新对话",
tags: [],
messages: [],
createdAt: now,
updatedAt: now,
};
const sessions = loadSessions();
saveSessions([session, ...sessions]);
return session;
}
export function updateSession(session: Session) {
const sessions = loadSessions();
const idx = sessions.findIndex((s) => s.id === session.id);
if (idx >= 0) {
sessions[idx] = { ...session, updatedAt: Date.now() };
} else {
sessions.unshift({ ...session, updatedAt: Date.now() });
}
saveSessions(sessions);
}
export function deleteSession(sessionId: string) {
const sessions = loadSessions().filter((s) => s.id !== sessionId);
saveSessions(sessions);
const assets = loadAssets().filter((a) => a.sessionId !== sessionId);
saveAssets(assets);
}
// --------------- 图片资源 ---------------
export function loadAssets(): ImageAsset[] {
return readJSON<ImageAsset[]>(storageKey("assets"), []);
}
export function saveAssets(assets: ImageAsset[]) {
writeJSON(storageKey("assets"), assets);
}
export function addAsset(asset: ImageAsset) {
const assets = loadAssets();
assets.unshift(asset);
saveAssets(assets);
}
export function updateAsset(assetId: string, patch: Partial<ImageAsset>) {
const assets = loadAssets();
const idx = assets.findIndex((a) => a.id === assetId);
if (idx >= 0) {
assets[idx] = { ...assets[idx], ...patch };
saveAssets(assets);
}
}
export function deleteAsset(assetId: string) {
saveAssets(loadAssets().filter((a) => a.id !== assetId));
}
export function toggleFavorite(assetId: string): boolean {
const assets = loadAssets();
const idx = assets.findIndex((a) => a.id === assetId);
if (idx >= 0) {
assets[idx].favorited = !assets[idx].favorited;
saveAssets(assets);
return assets[idx].favorited;
}
return false;
}

View File

@@ -0,0 +1,89 @@
/**
* 全局类型定义。
*/
export interface Tag {
id: string;
name: string;
color: string;
/** 是否为系统内置标签 */
builtin: boolean;
}
export interface ImageAsset {
id: string;
url: string;
prompt: string;
sessionId: string;
tags: string[];
favorited: boolean;
createdAt: number;
}
export interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
images?: ImageAsset[];
/** 用户消息附带的参考图列表(本地预览 URL 或上传后的服务端路径) */
refImageUrls?: string[];
/** @deprecated 兼容旧数据,单张参考图 */
refImageUrl?: string;
/** 生成图片时使用的模型名称 */
modelName?: string;
}
export interface Session {
id: string;
title: string;
tags: string[];
messages: ChatMessage[];
/** 最后生成的图片 URL用于缩略图 */
thumbnail?: string;
/** 该对话使用的 LLM 模型短 IDundefined 时使用后端默认 */
llmModel?: string;
createdAt: number;
updatedAt: number;
}
/** 用于 API 传输的精简消息格式 */
export interface ApiMessage {
role: "user" | "assistant";
content: string;
}
/** 图像生成模型信息(从后端 GET /api/models 返回) */
export interface ImageModelInfo {
id: string;
name: string;
description: string;
/** 是否原生支持参考图输入IP-Adapter 等) */
supports_ref_image?: boolean;
}
/** LLM 对话模型信息(从后端 GET /api/llm-models 返回) */
export interface LlmModelInfo {
id: string;
name: string;
description: string;
vision?: boolean;
}
/** 标注数据 */
export interface Annotation {
id: string;
type: "rect" | "arrow" | "freehand" | "text";
x: number;
y: number;
w?: number;
h?: number;
points?: { x: number; y: number }[];
text: string;
}
export interface AnnotationData {
imageUrl: string;
annotations: Annotation[];
/** canvas 导出的带标注截图base64 */
snapshot?: string;
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

133
art-agent/start-tunnel.ps1 Normal file
View File

@@ -0,0 +1,133 @@
# EPEEKit 内网穿透启动脚本
# 使用 Cloudflare Quick Tunnel无需账号免费
# 用法:在 PowerShell 中执行 .\start-tunnel.ps1
param(
[int]$BackendPort = 8000,
[int]$FrontendPort = 3000
)
$ErrorActionPreference = "Stop"
# 检查 cloudflared 是否可用
try {
$null = Get-Command cloudflared -ErrorAction Stop
} catch {
Write-Host "[错误] 未找到 cloudflared请先安装winget install Cloudflare.cloudflared" -ForegroundColor Red
exit 1
}
Write-Host ""
Write-Host " ╔═══════════════════════════════════════════╗" -ForegroundColor Cyan
Write-Host " ║ EPEEKit 内网穿透 ║" -ForegroundColor Cyan
Write-Host " ╚═══════════════════════════════════════════╝" -ForegroundColor Cyan
Write-Host ""
# 启动后端穿透
Write-Host "[1/2] 启动后端穿透隧道 (localhost:$BackendPort)..." -ForegroundColor Yellow
$backendTunnel = Start-Process cloudflared -ArgumentList "tunnel", "--url", "http://localhost:$BackendPort" `
-PassThru -RedirectStandardError "$env:TEMP\epeekit-tunnel-backend.log" -WindowStyle Hidden
Start-Sleep -Seconds 3
# 从日志中提取后端公网 URL
$backendUrl = ""
for ($i = 0; $i -lt 10; $i++) {
if (Test-Path "$env:TEMP\epeekit-tunnel-backend.log") {
$logContent = Get-Content "$env:TEMP\epeekit-tunnel-backend.log" -Raw -ErrorAction SilentlyContinue
if ($logContent -match '(https://[a-z0-9-]+\.trycloudflare\.com)') {
$backendUrl = $Matches[1]
break
}
}
Start-Sleep -Seconds 2
}
if (-not $backendUrl) {
Write-Host "[错误] 无法获取后端穿透地址,请检查 cloudflared 日志" -ForegroundColor Red
Write-Host " 日志路径: $env:TEMP\epeekit-tunnel-backend.log"
Stop-Process -Id $backendTunnel.Id -Force -ErrorAction SilentlyContinue
exit 1
}
Write-Host " 后端穿透地址: $backendUrl" -ForegroundColor Green
# 更新前端环境变量
$envFile = Join-Path $PSScriptRoot "frontend\.env.local"
@"
# API start-tunnel.ps1
NEXT_PUBLIC_API_URL=$backendUrl
"@ | Set-Content $envFile -Encoding UTF8
Write-Host " 已更新 frontend\.env.local" -ForegroundColor Green
Write-Host ""
# 启动前端穿透
Write-Host "[2/2] 启动前端穿透隧道 (localhost:$FrontendPort)..." -ForegroundColor Yellow
$frontendTunnel = Start-Process cloudflared -ArgumentList "tunnel", "--url", "http://localhost:$FrontendPort" `
-PassThru -RedirectStandardError "$env:TEMP\epeekit-tunnel-frontend.log" -WindowStyle Hidden
Start-Sleep -Seconds 3
$frontendUrl = ""
for ($i = 0; $i -lt 10; $i++) {
if (Test-Path "$env:TEMP\epeekit-tunnel-frontend.log") {
$logContent = Get-Content "$env:TEMP\epeekit-tunnel-frontend.log" -Raw -ErrorAction SilentlyContinue
if ($logContent -match '(https://[a-z0-9-]+\.trycloudflare\.com)') {
$frontendUrl = $Matches[1]
break
}
}
Start-Sleep -Seconds 2
}
if (-not $frontendUrl) {
Write-Host "[错误] 无法获取前端穿透地址" -ForegroundColor Red
Stop-Process -Id $backendTunnel.Id -Force -ErrorAction SilentlyContinue
Stop-Process -Id $frontendTunnel.Id -Force -ErrorAction SilentlyContinue
exit 1
}
Write-Host " 前端穿透地址: $frontendUrl" -ForegroundColor Green
Write-Host ""
Write-Host " ╔═══════════════════════════════════════════╗" -ForegroundColor Green
Write-Host " ║ 穿透隧道已就绪! ║" -ForegroundColor Green
Write-Host " ╚═══════════════════════════════════════════╝" -ForegroundColor Green
Write-Host ""
Write-Host " 手机浏览器访问: $frontendUrl" -ForegroundColor Cyan
Write-Host " 后端 API: $backendUrl" -ForegroundColor Cyan
Write-Host ""
Write-Host " [重要] 前端需要重启才能读取新的 .env.local" -ForegroundColor Yellow
Write-Host " [提示] 按 Ctrl+C 或关闭此窗口停止穿透" -ForegroundColor DarkGray
Write-Host ""
# 等待用户中断
try {
Write-Host "隧道运行中... (按 Ctrl+C 停止)" -ForegroundColor DarkGray
while ($true) {
Start-Sleep -Seconds 5
# 检查进程是否还活着
if ($backendTunnel.HasExited) {
Write-Host "[警告] 后端隧道已断开" -ForegroundColor Red
break
}
if ($frontendTunnel.HasExited) {
Write-Host "[警告] 前端隧道已断开" -ForegroundColor Red
break
}
}
} finally {
Write-Host ""
Write-Host "正在关闭隧道..." -ForegroundColor Yellow
Stop-Process -Id $backendTunnel.Id -Force -ErrorAction SilentlyContinue
Stop-Process -Id $frontendTunnel.Id -Force -ErrorAction SilentlyContinue
# 恢复 .env.local 为 localhost
@"
# API
NEXT_PUBLIC_API_URL=http://localhost:8000
"@ | Set-Content $envFile -Encoding UTF8
Write-Host "已恢复 .env.local 为 localhost" -ForegroundColor Green
Write-Host "隧道已关闭" -ForegroundColor Green
}

168
docs/art-agent/DECISIONS.md Normal file
View File

@@ -0,0 +1,168 @@
# 美术 Agent 工具 — 核心决策记录
> 本文档记录了产品规划阶段讨论确认的所有核心决策,作为后续设计与开发的基准。
---
## 1. 产品形态
**决定Chat-first Web App**
- 以对话为核心交互方式的 Web 应用
- 浏览器访问,无需安装
- 主界面是对话框,但在需要时展开画布、图片网格、参数面板等辅助 UI
- 远期可包装为 Electron 桌面应用(但不影响当前架构选型)
---
## 2. 核心交互模型
### 基本流程
用户描述需求 → LLM 理解意图 → 生成候选图 → 展示并等待反馈 → 迭代或确认
### 候选图
- 用户可自行决定每次生成几张候选图
### 审美表达方式(按阶段)
| 阶段 | 方式 |
|---|---|
| MVP | 语言描述、选择题式(多选一)、参考图上传 |
| Phase 2 | 图上标注(圈出区域 + 局部重绘) |
### 上下文系统
- 需要记忆系统:项目级全局风格 + 会话级当前任务上下文
- 开发者侧:类似 Cursor 的 Skill/Rules 扩展机制,方便动态调整 Agent 行为
- 用户侧:自定义能力暂缓
---
## 3. AI 能力层架构
### LLM 对话层
- 主力OpenAI GPT 系列GPT-4o 等)
- 备选Anthropic Claude
- 做抽象接口层,方便切换和新增模型
### 图像生成后端
- 云端 API不做本地部署
- 候选平台Replicate / fal.ai / Stability API 等
- 同样做抽象接口,按需对接不同服务
### Agent Core 引擎
- 不硬编码业务逻辑
- 通过 Markdown 格式的 Skill 文件定义能力(类似 Cursor SKILL.md
- 通过 Markdown 格式的 Rules 文件定义全局约束/偏好
- 开发者可随时添加/修改 Skill 和 Rules无需改核心代码
### 架构分层
```
前端 (Chat-first Web App)
├── 对话界面
├── 候选图展示/选择
├── 参考图上传
└── 资源管理面板
后端服务
├── API Gateway
├── Agent Core 引擎
│ ├── Skill Registry能力注册表
│ ├── Rules Engine规则引擎
│ └── Memory System记忆系统
└── Task Queue异步任务队列
AI 能力层(云端 API
├── LLM 服务GPT / Claude / ...
├── 图像生成 API
├── LoRA 训练 API
└── 后处理服务(去背景、超分等)
存储层
├── 数据库(用户数据、会话历史、项目配置)
├── 对象存储 OSS生成图、参考图、导出资源
└── 风格库LoRA 权重、Prompt 模板)
```
---
## 4. 风格管理系统
### 风格定义三种方式(按优先级递进)
| 优先级 | 方式 | 说明 |
|---|---|---|
| MVP | Prompt 模板 | 开发者用精调的描述词定义风格,最简单 |
| MVP | 参考图风格迁移 | 用户上传参考图实时引导生成,无需训练 |
| Phase 2 | LoRA 训练 | 上传 5-15 张参考图训练专属风格模型,效果最强 |
### 风格库结构
- **两层共享**:个人风格 + 团队共享风格
- **预置风格**MVP 不做,后续迭代加入常用游戏风格
- 每个风格包含:元数据 / 参考图集 / LoRA 权重(如有)/ Sample 图 / 推荐参数
---
## 5. 资源产出 Pipeline
### MVP 范围(最小可用)
- 对话生成单张图 + 迭代修改
- 导出格式PNG
- 不含批量产出、去背景、超分等后处理
### Phase 2 扩展
- 去背景/透明化
- 超分辨率放大
- 尺寸规范适配(多分辨率导出)
- 批量产出(同风格系列资源)
- 更多导出格式SVG / WebP
---
## MVP 范围总结
**MVP 核心功能清单:**
1. Chat-first Web App 基础界面(对话框 + 图片展示区)
2. 用户通过自然语言描述需求LLM 理解意图并生成图片
3. 多张候选图展示,用户选择/反馈/迭代
4. 参考图上传(引导风格方向)
5. Prompt 模板风格 + 参考图风格迁移
6. 基础记忆系统(会话上下文 + 项目级风格配置)
7. 开发者 Skill/Rules 扩展机制
8. PNG 格式导出
**Phase 2 扩展方向:**
- LoRA 训练
- 图上标注 + 局部重绘
- 批量产出
- 高级后处理 Pipeline
- 预置风格库
- 多格式导出
- 团队协作增强(权限、审核等)
- 3D / 场景资源(远期)
---
## 技术栈选型(已确定)
> 详细选型理由和依赖清单见 [TECH-STACK.md](TECH-STACK.md)
- [x] 前端框架 — **Next.js (React)** + Vercel AI SDK + shadcn/ui
- [x] 后端框架 — **Python FastAPI**
- [x] Agent 框架 — **自建 Agent Loop** + OpenAI Function Calling
- [x] 数据库 — **PostgreSQL**JSONB + pgvector 预留)
- [x] 对象存储 — **MVP 本地文件系统**,后期迁移 Cloudflare R2 / 阿里云 OSS
- [x] 图像生成 API — **Replicate**(首选),做抽象层方便扩展
- [x] 部署方案 — **Vercel** (前端) + **Railway** (后端)
- [x] 异步任务 — **asyncio + SSE**MVP后期 Celery + Redis

229
docs/art-agent/MVP-PLAN.md Normal file
View File

@@ -0,0 +1,229 @@
# MVP 最小流程 — 端到端跑通计划
> 目标:用最小代码量跑通一条完整闭环,快速暴露集成问题。
---
## 范围
**包含:**
- 用户通过对话描述需求LLM 理解意图并调用图像生成
- 用户可上传参考图引导生成风格
- 多轮对话迭代修改
- 生成 1-4 张候选图,用户可下载保存到本地
- SSE 流式推送(实时看到 AI 回复和生成进度)
**刻意砍掉(后续再加):**
- 数据库 / 持久化(用内存存对话历史)
- Skill / Rules 扩展机制
- 风格模板 / 风格库
- 用户认证
- 云端部署(先本地跑通)
---
## 数据流
```
用户输入文字 + 可选参考图
→ 前端 POST /api/chat消息 + 图片)
→ 后端 Agent Loop
→ 调用 OpenAI GPT带 tools 定义)
→ GPT 返回 tool_call: generate_image
→ 调用 Replicate APIFlux 模型)
→ 图片下载到本地 generated/ 目录
→ 工具结果回传 GPT
→ GPT 生成最终文字回复
→ SSE 流式推送给前端
→ 前端展示文字 + 图片网格(带下载按钮)
```
---
## 准备工作API Key
### OpenAI API Key
1. 访问 https://platform.openai.com/signup 注册
2. https://platform.openai.com/api-keys 创建 Key
3. 需预充值(最低 $5模型用 GPT-4o-mini便宜够用
### Replicate API Token
1. 访问 https://replicate.com/signin 用 GitHub 登录
2. https://replicate.com/account/api-tokens 创建 Token
3. 新用户有免费额度
---
## 实施步骤与完成状态
### Step 1项目脚手架 — 已完成
- [x] 后端:`art-agent/backend/`Python + FastAPI
- [x] 前端:`art-agent/frontend/`Next.js + TypeScript + Tailwind CSS v4
- [x] 配置:`.env.example` 放 API Key 模板
- [x] Python 虚拟环境已创建并安装依赖
- [x] Node.js v24.14.1 已安装(通过 winget
- [x] 前端 npm 依赖已安装46 个包)
### Step 2后端核心 API — 已完成
- [x] `POST /api/chat` — 接收消息 + 可选图片,调用 Agent LoopSSE 流式返回
- [x] `/uploads/{filename}` — 通过 StaticFiles 提供上传的参考图
- [x] `/generated/{filename}` — 通过 StaticFiles 提供生成的图片
- [x] `/health` — 健康检查端点
- [x] CORS 中间件已配置(允许所有来源)
- [x] 后端启动验证通过(健康检查返回 `{"status": "ok"}`
### Step 3Agent Loop — 已完成
- [x] 硬编码 system prompt美术助手角色中文交流英文 prompt 生成)
- [x] 定义 `generate_image` 工具prompt、num_images
- [x] Agent 循环LLM 调用 → tool_call → 执行 → 结果回传 → 继续(最多 5 轮)
- [x] 流式输出text_delta / tool_start / image_result / done / error 事件
- [x] 参考图通过 GPT-4o-mini vision 能力理解(附加为 image_url
- [x] OpenAI 客户端延迟初始化(避免无 Key 时导入失败)
### Step 4Replicate 图像生成 — 已完成
- [x] 封装 Replicate API 调用
- [x] 模型:`black-forest-labs/flux-schnell`(快速版)
- [x] 生成图片异步下载到本地 `generated/` 目录
- [x] 返回本地可访问 URL
### Step 5前端 Chat UI — 已完成
- [x] 主页面 `page.tsx`:对话消息区域 + 底部输入框
- [x] `ChatMessages` 组件:渲染历史消息 + 流式生成中的消息
- [x] `ChatInput` 组件:文字输入 + 参考图上传 + 预览 + 发送按钮
- [x] `ImageGrid` 组件:候选图网格展示 + 悬停显示"保存"按钮
- [x] 欢迎页面3 个快捷提示按钮
- [x] 暗色主题 UI
- [x] 加载状态:跳动圆点 + 脉冲状态指示
### Step 6SSE 对接 — 已完成
- [x] `lib/api.ts`SSE 流式读取封装为 AsyncGenerator
- [x] 事件解析text_delta / tool_start / image_result / done / error
- [x] 图片 URL 相对路径转换(`getImageUrl` 工具函数)
- [x] 消息通过 FormData 发送(支持同时上传文本和图片)
### Step 7端到端验证 — 进行中
- [x] 后端启动验证通过
- [ ] 前端启动验证(需配置好 API Key 后完整测试)
- [ ] 完整对话流程验证
- [ ] 参考图上传流程验证
- [ ] 图片下载功能验证
---
## 环境搭建记录
### 已安装的环境
| 工具 | 版本 | 安装方式 |
|---|---|---|
| Python | 3.12.5 | 系统已有 |
| Node.js | 24.14.1 (LTS) | winget 安装 |
| npm | 11.11.0 | 随 Node.js |
### 遇到的问题及解决
1. **OpenAI 客户端模块加载时初始化失败** — 无 API Key 时 `AsyncOpenAI()` 会抛异常。
解决:改为延迟初始化,在 `run_agent_loop` 函数内部创建客户端。
2. **PowerShell 不支持 `&&` 操作符** — 旧版 PowerShell 的语法限制。
解决:使用分号 `;` 或分两条命令执行。
3. **PowerShell 脚本执行策略限制** — 默认禁止运行 `.ps1` 脚本,导致 `npm` 无法执行。
解决:`Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned`
---
## 验证标准
1. 输入"画一个赛博朋克风格的游戏退出按钮" → AI 回复 + 生成图片
2. 点击下载按钮 → 图片保存到本地
3. 输入"颜色再暗一些,加点霓虹光效" → 迭代后新图片
4. 上传参考图 + "参考这张图的风格" → 受参考图引导的图片
---
## 启动命令
### 后端
```powershell
cd d:\GIT_HOME\EPEEAIKit\art-agent\backend
.\venv\Scripts\activate
uvicorn app.main:app --reload --port 8000
```
### 前端
```powershell
cd d:\GIT_HOME\EPEEAIKit\art-agent\frontend
npm run dev
```
访问 http://localhost:3000
---
## 文件清单
```
art-agent/
README.md # 项目说明和启动指南
backend/
.env.example # API Key 模板
.env # 实际 API Key已配置不入 Git
requirements.txt # Python 依赖9 个包)
venv/ # Python 虚拟环境(不入 Git
uploads/ # 用户上传的参考图
generated/ # AI 生成的图片缓存
app/
__init__.py
main.py # FastAPI 入口 + CORS + 静态文件
api/
__init__.py
chat.py # POST /api/chat 端点
agent/
__init__.py
loop.py # Agent Loop 主循环SSE 流式)
tools.py # 工具定义generate_image
services/
__init__.py
image_gen.py # Replicate API 封装
frontend/
.env.local # 后端 API 地址配置
package.json # npm 依赖
tsconfig.json # TypeScript 配置
next.config.ts # Next.js 配置
postcss.config.mjs # PostCSS / Tailwind 配置
node_modules/ # npm 依赖(不入 Git
src/
app/
page.tsx # 主页面(含 SSE 对接逻辑)
layout.tsx # 根布局
globals.css # 全局样式(暗色主题)
components/
chat/
chat-messages.tsx # 对话消息列表
chat-input.tsx # 输入框 + 参考图上传
image-grid.tsx # 图片网格 + 下载按钮
lib/
api.ts # 后端 API 调用 + SSE 解析
docs/
art-agent/
DECISIONS.md # 产品决策文档5 个核心问题)
TECH-STACK.md # 技术选型文档8 个维度)
MVP-PLAN.md # 本文件MVP 实施计划与记录
```
## 技术栈
详见 [TECH-STACK.md](TECH-STACK.md)

View File

@@ -0,0 +1,287 @@
# 美术 Agent 工具 — 技术选型
> 本文档记录所有技术选型决策,与 [DECISIONS.md](DECISIONS.md) 配合使用。
---
## 选型总览
| 维度 | 选定方案 | 备选 |
|---|---|---|
| 前端框架 | Next.js (React) | — |
| 后端框架 | Python FastAPI | — |
| Agent 框架 | 自建 Agent Loop + OpenAI Function Calling | — |
| 数据库 | PostgreSQL | — |
| 对象存储 | MVP 本地文件系统 → 后期迁移云端 OSS | Cloudflare R2 / 阿里云 OSS |
| 图像生成 API | Replicate首选做抽象层方便扩展 | fal.ai / Stability AI / 国内 API |
| 部署方案 | Vercel (前端) + Railway (后端) | Docker + 云服务器 |
| 异步任务 | MVP 用 asyncio + SSE → 后期 Celery + Redis | — |
---
## 1. 前端 — Next.js (React)
**选型理由:**
- Vercel AI SDK 原生支持流式对话和 Tool Use是目前对 AI Chat UI 支持最成熟的方案
- shadcn/ui + Tailwind CSS 提供美观且高度可定制的组件库
- 社区有大量 AI Chat 界面组件可复用,降低开发成本
- App Router 支持服务端组件和流式渲染,适合对话场景
- 后续扩展到 3D 预览时React 生态的 Three.js 集成react-three-fiber也很成熟
**关键依赖:**
| 包名 | 用途 |
|---|---|
| `next` | 框架核心 |
| `ai` (Vercel AI SDK) | 流式对话、Tool Use 前端支持 |
| `shadcn/ui` + `tailwindcss` | UI 组件库 |
| `react-dropzone` | 参考图 / 资源文件上传 |
| `zustand` | 轻量客户端状态管理 |
---
## 2. 后端 — Python FastAPI
**选型理由:**
- AI/ML 生态最强Agent 编排、图像处理库Pillow、OpenCV、所有主流 AI SDK 都有 Python 客户端
- FastAPI 异步性能优秀,原生支持 async/await适合处理 AI API 调用的 I/O 等待
- Pydantic 数据校验与 OpenAI Function Calling 的 JSON Schema 天然契合
- 后处理 Pipeline去背景、超分等的库和工具以 Python 为主
**关键依赖:**
| 包名 | 用途 |
|---|---|
| `fastapi` + `uvicorn` | Web 框架 + ASGI 服务器 |
| `openai` | OpenAI API 客户端GPT 对话 + Function Calling |
| `replicate` | Replicate API 客户端(图像生成) |
| `sqlalchemy` + `asyncpg` | PostgreSQL 异步 ORM |
| `pydantic` | 数据校验FastAPI 内置) |
| `sse-starlette` | SSE 流式推送 |
| `python-frontmatter` | 解析 Skill/Rules Markdown 的 YAML frontmatter |
| `Pillow` | 图像基础处理 |
---
## 3. Agent 框架 — 自建 Agent Loop
**选型理由:**
- 美术类 Agent 逻辑高度专业化多图编排、风格上下文、审美反馈循环通用框架LangChain 等)的厚抽象层反而是负担
- Cursor 和 Claude Code 都是自建 Agent Loop验证了这条路在专业化场景下的可行性
- 直接使用 OpenAI Function Calling / Tool Use 协议,轻量且可控
- 工具注册通过 Skill 文件声明式定义,保持扩展性
**Agent Loop 核心流程:**
```
接收用户消息
→ 加载会话上下文 + 项目风格配置
→ 加载匹配的 Skills / Rules
→ 调用 LLM带 tools 定义)
→ LLM 返回文本 → 流式返回给前端
→ LLM 返回 tool_call → 执行工具 → 结果回传 LLM → 继续循环
→ 保存上下文到 Memory
→ 等待下一条消息
```
**初始工具集:**
| 工具名 | 功能 |
|---|---|
| `generate_image` | 调用 Replicate 生成图片 |
| `get_style` | 查询风格库Prompt 模板 / LoRA |
| `apply_style_ref` | 使用参考图进行风格迁移 |
| `export_asset` | 导出为指定格式MVP 仅 PNG |
---
## 4. 数据库 — PostgreSQL
**选型理由:**
- JSONB 类型兼顾结构化查询和半结构化数据存储Skill 配置、风格参数、对话上下文等)
- pgvector 扩展为未来风格相似度检索留路(根据参考图 embedding 找相似风格)
- 成熟稳定,从开发到生产都可靠
- Railway 和大部分云平台都提供托管 PostgreSQL
**主要表设计方向:**
| 表 | 存储内容 |
|---|---|
| `users` | 用户账户 |
| `projects` | 项目配置(全局风格、默认参数) |
| `conversations` | 会话元数据 |
| `messages` | 对话消息(含工具调用记录) |
| `styles` | 风格库Prompt 模板 / LoRA 元数据) |
| `generated_assets` | 生成资源记录(关联存储路径) |
---
## 5. 对象存储 — MVP 本地文件系统
**选型理由:**
- MVP 阶段减少外部依赖,加快开发速度
- 接口层做好抽象(`StorageBackend` 接口),后续一行配置切换到云端 OSS
**抽象接口设计:**
```python
class StorageBackend(ABC):
async def upload(self, file: bytes, path: str) -> str: ...
async def download(self, path: str) -> bytes: ...
async def get_url(self, path: str) -> str: ...
async def delete(self, path: str) -> None: ...
```
**后续迁移路径:**
- 海外用户 → Cloudflare R2零出站费S3 兼容 API
- 国内用户 → 阿里云 OSS
---
## 6. 图像生成 API — Replicate首选
**选型理由:**
- 模型市场最丰富Flux、Stable Diffusion 各版本、LoRA 训练 API 都有
- API 设计简洁Python SDK 使用方便
- 按秒计费,成本透明可控
- 支持异步预测prediction天然适合长时间图像生成任务
- LoRA 训练 API 成熟,为 Phase 2 的风格训练做好准备
**抽象层设计:**
```python
class ImageGenerator(ABC):
async def generate(self, prompt: str, params: GenerateParams) -> list[str]: ...
async def generate_with_ref(self, prompt: str, ref_image: str, params: GenerateParams) -> list[str]: ...
async def check_status(self, task_id: str) -> TaskStatus: ...
```
后续可实现 `ReplicateGenerator``FalAiGenerator` 等,通过配置切换。
---
## 7. 部署方案 — Vercel + Railway
**选型理由:**
- Vercel 对 Next.js 原生支持最佳(同一团队开发),零配置部署
- Railway 对 Python 服务部署简单,自动构建、自动扩缩,支持托管 PostgreSQL
- MVP 阶段不需要自管 Docker / K8s节省运维精力
- 两者都支持 preview deploymentPR 预览),方便迭代
**部署拓扑:**
```
Vercel (前端)
└── Next.js App
└── 调用后端 API
Railway (后端)
├── FastAPI 服务
└── PostgreSQL 数据库
Replicate (AI 服务)
└── 图像生成 / LoRA 训练
```
---
## 8. 异步任务 — asyncio + SSE
**选型理由:**
- 图像生成等待 10-60 秒Python asyncio 异步等待即可,不需要引入消息队列
- SSEServer-Sent Events实时推送生成进度和中间状态给前端
- 避免 MVP 阶段引入 Redis 依赖,保持架构简单
- 后期规模增大再引入 Celery + Redis 做任务持久化和重试
---
## 前后端通信协议
参考 Cursor / Claude Code 的 Agent 交互模式:
| 消息类型 | 方向 | 传输方式 | 说明 |
|---|---|---|---|
| 用户文本消息 | 前端 → 后端 | HTTP POST | 包含文本 + 可选附件(参考图) |
| LLM 流式文本 | 后端 → 前端 | SSE | 逐 token 推送 |
| 工具调用状态 | 后端 → 前端 | SSE | "正在生成图片..."、进度百分比 |
| 图片结果 | 后端 → 前端 | SSE | 结构化消息,包含图片 URL 列表 |
| 用户选择/反馈 | 前端 → 后端 | HTTP POST | 选择候选图 / 文字反馈 |
---
## Skill / Rules 文件格式
参考 Cursor 的方式,用 Markdown + YAML frontmatter
```markdown
---
name: generate-ui-icon
description: 生成 UI 图标资源
triggers:
- 图标
- icon
- 按钮图标
tools:
- generate_image
- remove_background
---
## 能力描述
当用户需要生成 UI 图标时触发本 Skill。
## 执行规则
1. 默认输出尺寸 512x512PNG 格式,透明背景
2. 使用项目默认风格(如已设置)
3. 生成 4 张候选,展示给用户选择
```
---
## 项目目录结构
```
art-agent/
frontend/ # Next.js 前端
src/
app/ # App Router 页面
components/
chat/ # 对话相关组件
gallery/ # 图片网格 / 候选图选择
style/ # 风格管理面板
upload/ # 文件上传组件
lib/ # 工具函数
stores/ # Zustand 状态管理
backend/ # Python FastAPI 后端
app/
api/ # API 路由层
agent/ # Agent Loop 核心
loop.py # 主循环逻辑
tools/ # 工具注册与实现
memory.py # 记忆系统
skills/ # Skill / Rules 加载器
models/ # SQLAlchemy 数据库模型
services/ # AI 服务抽象层
llm.py # LLM 抽象接口
image_gen.py # 图像生成抽象接口
storage/ # 文件存储抽象层
skills/ # Skill Markdown 文件目录
rules/ # Rules Markdown 文件目录
tests/ # 测试
docs/
art-agent/
DECISIONS.md # 产品决策文档
TECH-STACK.md # 技术选型文档(本文件)
```

View File

@@ -0,0 +1,598 @@
# EPEEKit 用户系统
本文档覆盖用户系统的技术架构、API 参考和日常使用指南。
---
## 目录
- [架构概览](#架构概览)
- [快速开始](#快速开始)
- [环境配置](#环境配置)
- [API 参考](#api-参考)
- [认证接口](#认证接口)
- [管理员接口](#管理员接口)
- [业务接口鉴权](#业务接口鉴权)
- [前端认证流程](#前端认证流程)
- [数据隔离机制](#数据隔离机制)
- [用户管理操作手册](#用户管理操作手册)
- [安全说明](#安全说明)
- [故障排除](#故障排除)
---
## 架构概览
```
┌─────────────────────────────────────────────────────────┐
│ 前端 (Next.js) │
│ │
│ AuthProvider → AuthGuard → AppProvider → 页面组件 │
│ │ │ │ │
│ localStorage /login 白名单 按 user_id 隔离存储 │
│ ├ epeekit-auth-token │
│ ├ epeekit-refresh-token │
│ └ epeekit-{userId}-sessions / tags / assets │
└──────────────────────┬──────────────────────────────────┘
│ Authorization: Bearer <token>
┌─────────────────────────────────────────────────────────┐
│ 后端 (FastAPI) │
│ │
│ /api/auth/* ← 登录、刷新、改密(无需 token
│ /api/admin/* ← 用户管理(需管理员 token
│ /api/chat ← 对话(需 tokenuser_id 注入 Mem0
│ /api/models ← 模型列表(需 token
│ /api/upload-* ← 上传(需 token
│ │
│ 存储: │
│ ├ data/epeekit.db ← SQLiteusers 表 │
│ └ data/qdrant/ ← Mem0 向量库,按 user_id 隔离 │
└─────────────────────────────────────────────────────────┘
```
### 技术选型
| 组件 | 技术 | 说明 |
|---|---|---|
| 数据库 | SQLite (SQLModel) | 零部署,单文件 `data/epeekit.db` |
| 密码哈希 | Argon2id (pwdlib) | 密码哈希竞赛冠军,抗 GPU/ASIC兼容旧 bcrypt 哈希 |
| 令牌 | JWT (python-jose) | 无状态认证HS256 签名 |
| 前端状态 | React Context | AuthProvider + AuthGuard |
### 令牌机制
- **Access Token**:有效期 30 分钟,每个 API 请求携带
- **Refresh Token**:有效期 7 天,用于在 access token 过期后无感刷新
- JWT Payload 结构:`{ sub: user_id, exp: timestamp, type: "access" | "refresh" }`
---
## 快速开始
### 1. 配置环境变量
`art-agent/backend/.env` 中添加:
```env
# 必填JWT 签名密钥(随机字符串,建议 32+ 位)
JWT_SECRET=你的随机密钥
# 可选:首次启动时的管理员密码(不设则随机生成并打印到控制台)
# ADMIN_DEFAULT_PASSWORD=changeme123
```
### 2. 安装依赖
```bash
cd art-agent/backend
pip install -r requirements.txt
```
### 3. 启动后端
```bash
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
首次启动时,控制台会输出:
```
==================================================
默认管理员账号已创建
用户名: admin
密码: <随机生成的密码>
请登录后尽快修改密码!
==================================================
```
**请立即记录这个密码。** 如果设置了 `ADMIN_DEFAULT_PASSWORD`,则使用该值,不会打印。
### 4. 登录
打开前端 `http://localhost:3000`,会自动跳转到登录页,使用 admin 账号登录。
### 5. 创建团队成员账号
登录后,使用管理员 API 创建其他用户(见 [用户管理操作手册](#用户管理操作手册))。
---
## 环境配置
### 后端环境变量
| 变量 | 必填 | 默认值 | 说明 |
|---|---|---|---|
| `JWT_SECRET` | 是 | (无) | JWT 签名密钥,未设置时启动报错 |
| `ADMIN_DEFAULT_PASSWORD` | 否 | (随机) | 首次启动创建管理员时的密码 |
### 数据文件
| 路径 | 说明 |
|---|---|
| `art-agent/backend/data/epeekit.db` | SQLite 数据库,包含 users 表 |
| `art-agent/backend/data/qdrant/` | Mem0 向量数据,按 user_id 隔离 |
> **备份提示**:定期备份 `data/` 目录即可保全用户数据和记忆数据。
---
## API 参考
所有接口基础路径为 `http://localhost:8000`
### 认证接口
#### POST /api/auth/login
用户登录,获取令牌。
**请求体** (JSON)
```json
{
"username": "admin",
"password": "your-password"
}
```
**成功响应** (200)
```json
{
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"token_type": "bearer",
"user": {
"id": "a1b2c3...",
"username": "admin",
"display_name": "管理员",
"is_admin": true
}
}
```
**错误响应**
- `401` — 用户名或密码错误
- `403` — 账号已禁用
---
#### POST /api/auth/refresh
用 refresh token 换取新的 access token。
**请求体** (JSON)
```json
{
"refresh_token": "eyJ..."
}
```
**成功响应** (200)
```json
{
"access_token": "eyJ...",
"token_type": "bearer"
}
```
---
#### POST /api/auth/change-password
修改当前用户密码。**需要 Bearer Token。**
**请求体** (JSON)
```json
{
"old_password": "current-password",
"new_password": "new-password-min-6"
}
```
**成功响应** (200)
```json
{
"message": "密码已修改"
}
```
**错误响应**
- `400` — 旧密码错误 / 新密码少于 6 位
---
#### GET /api/auth/me
获取当前登录用户的信息。**需要 Bearer Token。**
**成功响应** (200)
```json
{
"id": "a1b2c3...",
"username": "admin",
"display_name": "管理员",
"is_admin": true
}
```
---
### 管理员接口
以下接口均需要**管理员**的 Bearer Token普通用户调用返回 `403`
#### POST /api/admin/users
创建新用户。
**请求体** (JSON)
```json
{
"username": "zhangsan",
"password": "123456",
"display_name": "张三",
"is_admin": false
}
```
**成功响应** (200)
```json
{
"id": "d4e5f6...",
"username": "zhangsan",
"display_name": "张三",
"is_admin": false,
"is_active": true,
"created_at": "2026-04-14T23:00:00"
}
```
**错误响应**
- `409` — 用户名已存在
- `400` — 密码少于 6 位
---
#### GET /api/admin/users
获取所有用户列表。
**成功响应** (200)
```json
[
{
"id": "a1b2c3...",
"username": "admin",
"display_name": "管理员",
"is_admin": true,
"is_active": true,
"created_at": "2026-04-14T22:00:00"
},
...
]
```
---
#### DELETE /api/admin/users/{user_id}
禁用指定用户(软删除,不物理删除)。
**成功响应** (200)
```json
{
"message": "用户 zhangsan 已禁用"
}
```
**错误响应**
- `404` — 用户不存在
- `400` — 不能禁用自己
---
### 业务接口鉴权
以下已有接口现在需要 Bearer Token
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/chat` | 对话user_id 自动从 token 注入) |
| POST | `/api/upload-ref-image` | 上传参考图 |
| GET | `/api/models` | 获取模型列表 |
请求示例:
```bash
curl -X POST http://localhost:8000/api/chat \
-H "Authorization: Bearer eyJ..." \
-F "messages=[{\"role\":\"user\",\"content\":\"画一个图标\"}]"
```
未携带或令牌无效时返回 `401`
---
## 前端认证流程
### 组件层级
```
<AuthProvider> ← 管理 token、user 状态
<AuthGuard> ← 路由保护,未登录跳 /login
<AppProvider> ← 业务数据(按用户隔离的 localStorage
{children} ← 页面内容
</AppProvider>
</AuthGuard>
</AuthProvider>
```
### 流程图
```
页面加载
├─ 有 token
│ ├─ 调用 GET /api/auth/me 验证
│ │ ├─ 200 → 认证通过,加载用户数据
│ │ ├─ 401 → 尝试 refresh token
│ │ │ ├─ 成功 → 获得新 access token认证通过
│ │ │ └─ 失败 → 清除 token跳转 /login
│ │ └─ 网络错误 → 保留本地缓存,允许离线查看
│ └─ token 不存在 → 跳转 /login
└─ /login 页面
└─ 提交用户名+密码
├─ 成功 → 保存 token跳转 /
└─ 失败 → 显示错误信息
```
### Token 存储
| localStorage Key | 内容 |
|---|---|
| `epeekit-auth-token` | JWT access token |
| `epeekit-refresh-token` | JWT refresh token |
| `epeekit-auth-user` | 用户信息 JSON (id, username, display_name, is_admin) |
### 401 自动处理
所有 API 请求(`fetch``XMLHttpRequest`)在收到 `401` 响应时,自动跳转到 `/login`
---
## 数据隔离机制
### 后端Mem0 记忆)
Mem0 的 `user_id` 参数从 JWT 中解析的真实用户 ID 传入:
```python
# 之前(所有人共享)
search_kwargs = {"user_id": "default_user", ...}
# 现在(按用户隔离)
search_kwargs = {"user_id": current_user.id, ...}
```
每个用户的对话记忆完全独立,互不可见。
### 前端localStorage
所有 localStorage 键名加上用户 ID 前缀:
```
之前: epeekit-sessions
现在: epeekit-{userId}-sessions
```
完整键名表:
| Key 模式 | 内容 |
|---|---|
| `epeekit-{userId}-sessions` | 对话会话列表 |
| `epeekit-{userId}-tags` | 自定义标签 |
| `epeekit-{userId}-assets` | 图片资源元数据 |
| `epeekit-{userId}-selected-image-model` | 模型选择偏好 |
登出时数据不清除,下次登录同一账号时自动恢复。切换用户时自动加载对应用户的数据。
---
## 用户管理操作手册
### 场景一:为新团队成员创建账号
1. 使用管理员账号登录获取 token
```bash
# 登录
curl -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "your-admin-password"}'
# 记录返回的 access_token
```
2. 创建用户:
```bash
curl -X POST http://localhost:8000/api/admin/users \
-H "Authorization: Bearer <admin-access-token>" \
-H "Content-Type: application/json" \
-d '{
"username": "zhangsan",
"password": "initial-password",
"display_name": "张三"
}'
```
3. 将用户名和初始密码告知成员,建议登录后立即修改密码。
### 场景二:查看所有用户
```bash
curl http://localhost:8000/api/admin/users \
-H "Authorization: Bearer <admin-access-token>"
```
### 场景三:禁用一个用户
```bash
curl -X DELETE http://localhost:8000/api/admin/users/<user-id> \
-H "Authorization: Bearer <admin-access-token>"
```
被禁用的用户:
- 无法登录
- 已有的 token 在下次请求时被拒绝
- 数据保留(不物理删除)
### 场景四:用户修改自己的密码
```bash
curl -X POST http://localhost:8000/api/auth/change-password \
-H "Authorization: Bearer <user-access-token>" \
-H "Content-Type: application/json" \
-d '{
"old_password": "current-password",
"new_password": "new-secure-password"
}'
```
### 场景五:使用 FastAPI 交互式文档
后端启动后,访问 `http://localhost:8000/docs` 可以看到 Swagger UI
- 点击右上角 "Authorize" 按钮
- 输入用户名和密码进行认证
- 之后可以直接在页面上调试所有 API
---
## 安全说明
### 已实施的安全措施
- **Argon2id 密码哈希**:密码哈希竞赛冠军算法,抗 GPU/ASIC 攻击,即使数据库泄露也无法直接获取密码
- **JWT 签名**:令牌使用 HS256 签名,无法伪造
- **令牌过期**access token 30 分钟过期refresh token 7 天过期
- **不开放注册**:只有管理员可以创建账号
- **软删除**:禁用用户而非删除,保留审计轨迹
### 当前限制
- **CORS 全开** (`allow_origins=["*"]`):适合内部使用和开发阶段,生产部署时应改为具体域名
- **无速率限制**:登录接口没有防暴力破解的限流机制,内部网络可接受
- **SQLite 单写**:高并发写入时可能遇到锁,内部团队规模不会触及
- **无审计日志**:未记录登录/操作日志,可后续添加
### JWT_SECRET 安全
- 必须使用足够长的随机字符串(建议 32+ 字符)
- 不要提交到 Git`.env` 文件应在 `.gitignore` 中)
- 更换 JWT_SECRET 会使所有已发放的令牌失效,所有用户需要重新登录
---
## 故障排除
### 启动报错 "JWT_SECRET 环境变量未设置"
`.env` 中添加 `JWT_SECRET=<随机字符串>` 后重启。
### 忘记管理员密码
删除数据库文件后重启,会重新创建默认管理员:
```bash
rm art-agent/backend/data/epeekit.db
# 重启后端,新密码会打印到控制台
```
> 注意:这会丢失所有用户数据。如果只想重置管理员密码,可以用 Python 脚本直接更新:
>
> ```python
> from app.db import engine, User
> from app.auth import hash_password
> from sqlmodel import Session, select
>
> with Session(engine) as s:
> admin = s.exec(select(User).where(User.username == "admin")).first()
> admin.hashed_password = hash_password("new-password")
> s.add(admin)
> s.commit()
> ```
### 前端一直跳转到登录页
- 检查后端是否正常运行(`curl http://localhost:8000/health`
- 检查浏览器控制台是否有 CORS 或网络错误
- 清除浏览器 localStorage 中的 `epeekit-auth-*` 键后重试
### 登录后看不到之前的对话
用户系统上线后localStorage 的键名格式变为 `epeekit-{userId}-sessions`。之前无用户系统时的数据存在旧键名 `epeekit-sessions` 下。如需迁移:
1. 打开浏览器开发者工具 → Application → Local Storage
2. 找到 `epeekit-sessions` 的值并复制
3. 创建新键 `epeekit-<你的userId>-sessions`,粘贴值
4. 刷新页面
### 多人使用同一浏览器
每个用户登录后,对话数据按 user_id 隔离存储在 localStorage 中。切换账号时自动加载对应用户的数据,互不影响。退出登录不会清除数据,下次登录仍可恢复。
---
## 源码结构
```
art-agent/backend/
├── app/
│ ├── db.py ← SQLite 连接、User 模型、建表、默认管理员
│ ├── auth.py ← Argon2id 密码哈希、JWT 编解码、FastAPI 依赖
│ ├── api/
│ │ ├── auth.py ← 登录/刷新/改密/me 路由
│ │ ├── admin.py ← 创建/列表/禁用用户路由
│ │ └── chat.py ← 已有对话路由(已加鉴权)
│ ├── agent/
│ │ └── loop.py ← Agent Loopuser_id 透传 Mem0
│ └── main.py ← 应用入口、路由注册、启动初始化
└── data/
├── epeekit.db ← 用户数据库
└── qdrant/ ← Mem0 向量存储
art-agent/frontend/src/
├── lib/
│ ├── auth-context.tsx ← AuthProvidertoken 管理、login/logout
│ ├── auth-guard.tsx ← AuthGuard路由保护
│ ├── app-context.tsx ← AppProvider按用户加载业务数据
│ ├── api.ts ← API 封装(自动带 Authorization 头)
│ └── store.ts ← localStorage 封装(按 user_id 隔离键名)
├── app/
│ ├── layout.tsx ← AuthProvider → AuthGuard → AppProvider
│ └── login/
│ └── page.tsx ← 登录页面
└── components/layout/
└── top-nav.tsx ← 顶部导航(含用户菜单 + 退出登录)
```

View File

@@ -0,0 +1,293 @@
# EPEEKit 视觉风格指南
> 设计语言:**青绿山水 · 现代演绎**(灵感来源于《千里江山图》)
> 更新日期2026-04-15
## 1. 设计语言总结
### 核心风格关键词
- **墨绿深底** — 深山暗处般的底色,取代冷蓝黑
- **石青石绿** — 千里江山图中的矿物质颜料色,作为主高亮
- **云烟雾气** — 所有发光和动画效果模拟山间云烟缓慢飘散
- **山雾玻璃** — 毛玻璃面板带微弱绿底色,如透过薄雾看山
- **赭金点缀** — 点景人物宫殿的暖色调,用于收藏星标等少量强调
### 视觉感受
> 整体氛围应该是:深夜远眺层峦叠嶂,薄雾在山间缓缓流动,
> 石青石绿的山色在暗底上若隐若现,偶有赭金灯火点缀其间。
---
## 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`)来源于画中点景人物和宫殿使用的暖色颜料。
---
## 3. 排版
### 字体
```css
font-family: 'Inter', system-ui, -apple-system, sans-serif;
```
### 尺寸梯度
| 用途 | 大小 | 字重 |
|------|------|------|
| 页面标题 | 20px | 700 |
| 区块标题 | 16px | 600 |
| 正文/卡片名 | 14px | 500 |
| 辅助文字 | 12px | 400 |
| 标签/徽章 | 11px | 600 |
---
## 4. 布局架构
### 整体结构
```
┌─────────────────────────────────────────────────────┐
│ Top Nav (56px) — 山雾玻璃面板 │
├──────────┬──────────────────────────────────────────┤
│ 侧边栏 │ 主内容区(对话 / 资源库) │
│ (280px) │ (flex-1) │
│ 会话列表 │ ┌────────────────────────────────────┐ │
│ 标签筛选 │ │ 对话消息 / 图片网格 │ │
│ (fog-scroll) │ │ (fog-scroll 雾气边缘) │ │
│ │ └────────────────────────────────────┘ │
│ │ 输入区 │
└──────────┴──────────────────────────────────────────┘
```
### 间距系统
- 面板间间距0紧贴用边框分隔
- 卡片网格间距16px
- 内容区内边距20px
- 面板内边距16px
---
## 5. 组件风格规范
### 5.1 导航栏
- 高度 56px背景 `--bg-secondary`/80 + 毛玻璃
- Logo 方块使用 `--accent` 背景 + 石青发光阴影
- 导航项带图标 + 文字
- 活动项使用 `.glow-border`(云烟边框)
- 非活动项 hover 时只显示静态边框,不触发动画
### 5.2 侧边栏
- 山雾玻璃背景:`bg-[var(--bg-secondary)]/80 backdrop-blur-xl`
- 对话图标带石青色 drop-shadow 微光
- 会话列表使用 `.fog-scroll` 实现顶底雾气渐隐
- 活动会话使用 `.glow-border`
### 5.3 聊天消息区
- 用户消息:`--accent/15` 背景 + 石青边框
- 助手消息:`.glass-panel`(山雾玻璃)
- 加载动画:石青色脉冲圆点
- 滚动区域使用 `.fog-scroll`
### 5.4 资源库卡片
- 圆角 12px
- 使用 `.neon-border` 静态发光边框
- Hover 时边框微微变亮 + 图片 scale(1.03)
- 底部信息区:`--bg-secondary/80` 毛玻璃
### 5.5 图片详情面板
- 山雾玻璃背景:`--bg-secondary/90 backdrop-blur-xl`
- 操作按钮区:石青主色 + 三级背景辅助色
---
## 6. 视觉效果
### 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);
border: 1px solid var(--border);
}
```
相比传统 Glassmorphism增加了微弱绿底色渐变层
模拟透过薄雾看山石的质感。blur 增加到 24px 以加强朦胧感。
### 6.2 静态发光边框
```css
.neon-border {
border: 1px solid var(--border); /* rgba(77, 184, 164, 0.12) */
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);
}
```
### 6.2.1 云烟缭绕边框
用于导航活动项、活动会话等"选中态"组件。
**交互模式**
- **常态(选中)**:石青色微弱均匀静态发光边框 + box-shadow
- **hover**:云烟缓慢缭绕效果通过 `opacity 600ms ease` 平滑淡入
- **鼠标移开**:云烟通过 `opacity 600ms ease` 平滑淡散,无跳变
**视觉感受**hover 时边缘有一团朦胧的云烟缓慢飘过,而非锐利的光线旋转。
**技术实现**
- `@property --glow-angle` 注册可动画角度属性
- `conic-gradient` 使用 40-50% 的宽弧段(比之前的 20% 更宽),更柔和
- 旋转周期 6s比之前的 3s 更慢),模拟云烟的悠缓
- `::before` — 云烟边框层mask-composite 裁出 1.5px 边框
- `::after` — 雾气扩散层,`blur(12px)`(比之前的 6px 更大),范围 `inset: -6px`
- 两层伪元素默认 `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);
}
.glow-border::before,
.glow-border::after {
animation: mistSpin 6s linear infinite;
opacity: 0;
transition: opacity 600ms ease;
}
.glow-border:hover::before,
.glow-border:hover::after {
opacity: 1;
}
```
### 6.3 山雾背景
背景不再使用静态光斑,改为多层缓慢漂移的云雾:
```css
/* 远景雾 — 60s 缓慢漂移 */
body::before {
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) ...);
animation: fogDriftFar 60s ease-in-out infinite;
}
/* 近景雾 — 45s 不同速率漂移 */
body::after {
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) ...);
animation: fogDriftNear 45s ease-in-out infinite;
}
```
两层雾使用不同的漂移速率和方向,模拟远山和近山的视差效果。
`inset` 使用负值以避免雾气漂移时在屏幕边缘露出空白。
### 6.4 雾气滚动边缘
主要滚动区域(聊天消息、会话列表)的顶部和底部使用渐变遮罩,
让内容看起来消失在云雾中:
```css
.fog-scroll {
mask-image: linear-gradient(
to bottom,
transparent 0px,
black 36px,
black calc(100% - 36px),
transparent 100%
);
}
```
纯 CSS 实现,无 JS 开销。36px 的渐变区域提供柔和的过渡。
### 6.5 过渡动画
- 所有交互元素:`transition: all 200ms ease`
- 云烟淡入淡出600ms ease
- 面板展开/折叠250ms ease-in-out
- 新消息出现:`fadeIn 200ms ease-out` + `translateY(8px → 0)`
- 背景雾气漂移45-60s ease-in-out infinite
---
## 7. 与原版 Cyberpunk 风格的对比
| 维度 | Cyberpunk | 青绿山水 |
|------|-----------|---------|
| 底色 | 冷蓝黑 `#0B0E14` | 墨绿黑 `#0C1210` |
| 高亮色 | 霓虹绿 `#00E5A0` | 石青绿 `#4DB8A4` |
| 发光效果 | 锐利霓虹光线旋转 | 柔和云烟缓慢缭绕 |
| 旋转速度 | 3s/圈 | 6s/圈 |
| 光晕模糊 | blur(6px) | blur(12px) |
| 渐变弧段 | 20%(锐利光束) | 40-50%(弥漫雾团) |
| 背景 | 静态双色光斑 | 多层漂移云雾 |
| 面板质感 | 标准毛玻璃 | 山雾玻璃(绿底渐变) |
| 滚动边缘 | 无 | 雾气渐隐 |
| 警告色 | 霓虹红 `#FF3B5C` | 赭石红 `#C4654A` |
---
## 8. 响应式断点
| 断点 | 布局 |
|------|------|
| ≥768px (md) | 双栏:侧边栏(280px) + 主内容区(flex-1) |
| <768px | 单栏:侧边栏为固定抽屉 + 主内容区全宽 |
---
## 9. 设计哲学
本风格将中国传统青绿山水画的色彩语言与现代暗色 UI 设计融合:
- **沉浸感**:深墨底色营造夜间远山的静谧氛围,适合创意工具
- **自然动效**:所有动画模拟自然现象(云烟飘动),而非机械闪烁
- **克制用色**:石青石绿只用于高亮和交互反馈,不滥用;赭金极少量点缀
- **雾气意境**:从边框云烟到背景漂移到滚动渐隐,统一的"雾"意象贯穿全站