diff --git a/.cursor/changelog/.changelog-ack b/.cursor/changelog/.changelog-ack new file mode 100644 index 0000000..e69de29 diff --git a/.cursor/changelog/changelog-full.md b/.cursor/changelog/changelog-full.md new file mode 100644 index 0000000..0b4cd32 --- /dev/null +++ b/.cursor/changelog/changelog-full.md @@ -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 — 新增 GeminiNativeImageProvider:Gemini 原生 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-preview,provider 为 openai +- **why**: 用户希望使用 Google Gemini 3.1 Flash 的原生生图能力,速度快价格低 +- **decisions**: 复用已有的 OpenAIImageProvider(向量引擎中转兼容 OpenAI images/generations 端点),零代码改动 +- **notes**: 如向量引擎对 Gemini 生图的返回格式有差异(如只返回 b64 不返回 URL),OpenAIImageProvider 已兼容两种格式 + +### [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 的 `
` 处理 dragEnter/dragOver/dragLeave/drop,drop 时调用 `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` 存在 `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 provider,model_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**: 接入向量引擎中转 API(OpenAI 兼容格式),实现对话级 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 45s),inset 负值防漂移露白 + - .glow-border hover 效果:conic-gradient 弧段从 20% 扩大到 40-50%(更柔和),旋转从 3s 减慢到 6s,blur 从 6px 增到 12px,transition 从 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 Houdini(Chrome 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-cffi:pwdlib 提供与 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**: + - SQLite(data/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 响应自动跳转 /login,XHR(上传)和 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 — 修复多会话滚动位置共享 bug:per-session 独立记录 + 切换恢复 +- **tags**: 前端, bug修复, 滚动, 多会话, UX +- **affected_files**: + - art-agent/frontend/src/app/page.tsx +- **what**: 修复多会话切换时滚动条位置共享的 bug,实现 per-session 滚动位置独立记录与恢复 +- **why**: 所有会话共用同一个 scrollRef DOM 元素,切换会话时既不保存旧会话的 scrollTop,也不恢复目标会话的位置。导致:从长会话切到短会话可能看到空白底部,从短会话切到长会话不会停在最新消息处 +- **decisions**: 用 `useRef>` 在内存中记录每个会话的 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-Adapter(CL-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 切到 Kolors),LLM 在回复中仍声称使用旧模型。根因是 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 API,hover 显示 + 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` header,create 请求立即返回 prediction ID,SDK 自动进入 `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}s(n 必须 1-60);`False` = 不等待、纯轮询。对慢模型(InstantStyle ~128s)配合大 payload(1.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 + ReadTimeout:wait 参数超限与 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`)和后续 ReadTimeout(61.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}s(n 必须 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-4o)后,LLM 可直接看图描述风格,此约束自动失效(走 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**: 之前工具调用失败时,错误信息只传回给 LLM,LLM 会自行"翻译"错误(如说成"速率限制"),用户无法看到真实报错,排查困难。典型场景:使用 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 而非 fetch(fetch 不支持 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 传给 `` 标签 +- **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 支持短 ID(flux-schnell)和完整 Replicate ID(black-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**: + - 数据持久化使用 localStorage(MVP 阶段),后续可迁移到后端数据库 + - 全局状态管理使用 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 后进行 diff --git a/.cursor/changelog/changelog-headlines.md b/.cursor/changelog/changelog-headlines.md new file mode 100644 index 0000000..f820f76 --- /dev/null +++ b/.cursor/changelog/changelog-headlines.md @@ -0,0 +1,63 @@ +# Dev Changelog — Headlines + +最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。 + +- [CL-20260416-0830] 生图按模型预处理 prompt:新增 image_prompt_strategy(SDXL 默认负向词+---NEGATIVE---拆分,Flux 剥粘贴的 Negative 段,GPT/Gemini/IP-Adapter 规范化);GenerateResult 带 effective_prompt/negative_prompt;Replicate SDXL 传 negative_prompt;工具说明 SDXL 分隔符 +- [CL-20260416-0810] 对话输入栏支持剪贴板粘贴图片为参考图:ChatInput根容器onPasteCapture提取image/*文件并走startUpload,placeholder/title提示 +- [CL-20260416-0745] Gemini原生生图:延长读写超时+对RemoteProtocolError等可重试错误自动重试(默认3次指数退避),环境变量可微调VECTORENGINE_GEMINI_*_TIMEOUT +- [CL-20260416-0720] 修复拖拽参考图到ChatInput区域后覆盖层卡住不消失:ChatInput的stopPropagation阻止了main.onDrop,新增onFileDrop回调通知父组件清除拖拽状态 +- [CL-20260416-0700] 新增GeminiNativeImageProvider:对接Gemini原生generateContent接口,支持多图参考(最多14张),Gemini恢复supports_ref_image=True +- [CL-20260416-0620] 修复拖拽上传重复(stopPropagation)+Gemini改回不支持参考图(向量引擎不支持Gemini走images/edits)+images.edit移除不支持的quality参数 +- [CL-20260416-0600] 多张参考图全链路支持:前端多图上传/拖拽/沿用+后端API/AgentLoop/Provider全部改为list[str],OpenAI走images.edit端点(最多16张),Replicate取首张兼容 +- [CL-20260416-0510] 生图模型注册表新增Gemini 3.1 Flash Image(gemini-3.1-flash-image-preview),走向量引擎中转复用OpenAIImageProvider +- [CL-20260416-0500] 全区域拖拽添加参考图:拖拽区域从底部输入栏扩展到整个对话区域,拖入时全屏覆盖层提示,ChatInput改forwardRef暴露uploadFile +- [CL-20260416-0430] 接入GPT Image 1.5生图模型:新增OpenAIImageProvider走向量引擎中转,IMAGE_MODELS注册表新增gpt-image-1.5 +- [CL-20260416-0400] Session级参考图自动沿用:发送后记住参考图供后续消息复用+UI提示条+可清除,后端对缺参考图的风格迁移模型返回友好错误 +- [CL-20260416-0300] System Prompt新增禁止LLM在回复中嵌入Markdown图片链接(sandbox:等),图片展示由image_result SSE事件处理 +- [CL-20260416-0245] 修复三点菜单被右侧对话区遮挡:从fixed右侧弹出改为inline下方展开+模型列表折叠式内联,全部在侧栏内完成 +- [CL-20260416-0230] LLM注册表扩充至7个模型(+gpt-5.4/claude-sonnet-4-6/claude-opus-4-6/gemini-3.1-pro-preview/glm-4.7),默认改为gpt-5.4 +- [CL-20260416-0200] 接入向量引擎中转API+对话级LLM模型切换:LLM_MODELS注册表(gpt-4o-mini/gpt-4o/deepseek-chat),provider分发(vectorengine/deepseek),前端三点菜单模型选择,Session级llmModel +- [CL-20260416-0030] 青绿山水风格改造:色彩体系替换为千里江山图石青石绿色调,背景改双层漂移云雾,glow-border 柔化为云烟缭绕,新增 fog-scroll 雾气边缘,11 个组件硬编码颜色统一替换 +- [CL-20260415-2230] 光影流动边框改为 hover 触发:常态微弱静态发光,hover 时旋转动画通过 opacity 500ms 平滑淡入/淡出,无跳变 +- [CL-20260415-2330] 用户个人信息+记忆查看面板:右上角菜单弹窗,展示 Mem0 记忆列表(按时间分组,只读),后端新增 /api/memory/list 端点 +- [CL-20260415-2200] 全站 UI 风格改造为 Cyberpunk Dark Neon:主色改为青绿霓虹,新增环境光晕/毛玻璃/霓虹发光边框,13 个组件重构 + 视觉风格文档 +- [CL-20260414-2350] 修复登录后 useApp must be used within AppProvider:AppProvider 未初始化时区分已认证/未认证,避免子组件拿到 null context +- [CL-20260414-2340] 用 pwdlib (Argon2id) 替换 passlib (bcrypt):彻底解决兼容性问题,新密码 Argon2id,旧 bcrypt 哈希兼容验证 +- [CL-20260414-2330] 修复 passlib + bcrypt 5.x 兼容性:锁定 bcrypt>=4.0.1,<4.1,解决后端启动 AttributeError + ValueError +- [CL-20260414-2300] 用户系统:管理员邀请制 + JWT 认证 + SQLite 用户表 + Mem0/localStorage 按用户隔离,所有 API 鉴权,前端登录页 + AuthGuard +- [CL-20260414-2230] 对话区域新增"回到底部"浮动按钮:不在底部时显示向下箭头,点击平滑滚到最新消息 +- [CL-20260414-2220] 修复长对话切换回来滚动位置上移:scroll 事件实时保存替代 effect 延迟保存,避免 DOM 变更后 scrollTop 被浏览器 clamp +- [CL-20260414-2210] 修复会话切换时滚动位置跳动:拆 effect 解决时序竞争 + isSwitching 抑制 smooth scroll 干扰 + instant 同步赋值 +- [CL-20260414-2200] 修复多会话滚动位置共享 bug:per-session 独立记录滚动位置,切换会话时保存/恢复,无记录时滚到底部 +- [CL-20260413-2320] 集成 Mem0 记忆系统:Mem0 OSS + DeepSeek 事实提取 + Ollama 本地 embedding + 滑动窗口,一次性解决上下文管理和跨会话长期记忆 +- [CL-20260413-1130] 修复 SDXL 模型 404 错误:与 Kolors 同因,Replicate 非 Flux 官方模型需补全版本 hash +- [CL-20260413-1100] 修复切换模型后 LLM 仍声称使用旧模型名:System Prompt 动态注入当前模型名,阻止 LLM 从对话历史幻觉旧模型 +- [CL-20260413-1030] Agent Loop 假生成检测 + 自动重试:DeepSeek 用文字模拟生图时自动注入纠正消息强制调用工具 +- [CL-20260413-1000] 助手消息气泡添加复制文本按钮:hover 显示、点击复制 + 对勾反馈 +- [CL-20260413-0930] System Prompt 加入"禁止模拟工具调用"约束,防止 DeepSeek 用文字模拟生图而不调用工具 +- [CL-20260413-0900] 非 vision + 参考图场景:不再绝对禁止风格词,改为"不自行猜测但保留用户明确指定的风格" +- [CL-20260413-0800] 修复 InstantStyle ReadTimeout + 多次生成丢失参考图:wait 改 False 走纯轮询 + 不再清除 ref_image_url +- [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 个维度)两份文档 diff --git a/.cursor/changelog/changelog-recent.md b/.cursor/changelog/changelog-recent.md new file mode 100644 index 0000000..d15d384 --- /dev/null +++ b/.cursor/changelog/changelog-recent.md @@ -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 — 新增 GeminiNativeImageProvider:Gemini 原生 generateContent 接口对接 +- **tags**: 后端, Gemini, Provider, 原生API, generateContent, 多图参考, 架构 +- **affected_files**: image_gen.py, config.py +- **summary**: 新增 GeminiNativeImageProvider,通过向量引擎中转调用 Gemini 原生 `/v1beta/models/{model}:generateContent` 接口(非 OpenAI 兼容的 images/edit)。支持文字+图片混合输入(最多 14 张参考图),图片以 inline_data base64 内联传入。新增 `_resolve_image_base64` 工具函数统一处理 data URI 和本地路径到 (mime, b64) 的解析。config.py 中 Gemini 模型改用 `gemini_native` provider 并恢复 `supports_ref_image: True`。Provider 注册表新增 `gemini_native` 条目。 + +### [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_url);Agent 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-image(model_id: gemini-3.1-flash-image-preview),provider 为 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.5(provider: 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**: LLM(GPT 系列)在收到 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-chat),provider 分发为 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-static(hover 静态发光)两个类,分别应用到导航活动 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 — 修复多会话滚动位置共享 bug:per-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 做本地 embedding,Qdrant 文件模式做向量存储。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,根因同 Kolors(CL-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 ID,SDK 自动走 `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 + ReadTimeout:wait 参数超限 +- **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`)+ ReadTimeout(61.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_name),SSE 事件携带模型名称,前端在图片下方和错误提示中展示。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 保存预览 URL,chat-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 列表传给前端,导致 `` 标签渲染破碎图标。修复:后端 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 / SDXL),image_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。 + diff --git a/.cursor/deferred/registry.md b/.cursor/deferred/registry.md index c592c87..e0b31cb 100644 --- a/.cursor/deferred/registry.md +++ b/.cursor/deferred/registry.md @@ -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,用精选图训练 LoRA(20-50 张),批量产出同风格资源 + 4. 贯穿全程:标准化 Prompt 模板 + Negative Prompt 模板,确保品质下限 + 各阶段可叠加使用,不互斥。LoRA 训练需要同风格样本积累到足够数量。 + + 2026-04-13 调研补充 — 风格迁移模型层级: + - Kolors IP-Adapter(当前在用):通用 IP-Adapter,风格迁移弱,内容+风格+构图混合提取 + - InstantStyle(Replicate 可用):专门分离内容/风格,风格迁移强,但贵且慢 + - Style IPAdapter for NoobAI-XL(CivitAI):最强画风迁移(线条+着色技法),需 ComfyUI + - ICAS 框架(学术前沿 2025.04):IP-Adapter + ControlNet 组合,多主体风格一致性最优 + 锁定期的最佳方案:迁移到 HF Inference Endpoints 后,用 NoobAI-XL + Style IPAdapter + ControlNet + + 2026-04-16 补充 — 便利的风格选取和 LoRA 训练 UI: + - 风格库 UI:预设风格卡片(缩略图+标签+描述),一键选取即注入对应 Prompt 模板/参考图/LoRA + - LoRA 训练流程集成:在 Agent 内上传训练素材 → 触发云端 LoRA 训练 → 训练完成后自动入库为新风格 + - 风格组合:支持多风格叠加(如"水墨 + 赛博朋克"),前端可视化混合权重 + - 风格管理:用户自建/收藏风格,按项目/标签分类,团队共享 +- **chosen_alternative**: 当前使用 Prompt Engineering + LLM 参考图理解(探索期方案) +- **deferred_reason**: 风格方向尚未确定,过早引入 LoRA/ControlNet 是过度优化 + +### [doc-gen-management] 文档生成和管理系统 +- **status**: deferred +- **tags**: 文档生成, 项目大纲, 游戏风格文档, 美术规范, GDD, 模板, 导入 +- **recorded**: 2026-04-16 +- **source_chat**: [接下来要做的三件事](next-features-planning-0416) +- **prerequisite**: 基础对话生图能力稳定后 +- **related_files**: + - art-agent/frontend/src/app/page.tsx + - art-agent/backend/app/agent/loop.py + - art-agent/backend/app/agent/tools.py +- **context**: | + 文档驱动的美术资源生产流程: + 1. 项目大纲导入:支持上传/粘贴项目大纲(GDD、策划案等),Agent 解析结构 + 2. 游戏风格文档生成:基于大纲 + 对话交互,自动生成美术风格指南 + (色彩体系、质感定义、元素规范、参考图收集等) + 3. 文档模板:预设多种文档模板(美术风格指南、UI 规范、角色设定集、场景规范等) + 4. 文档管理:按项目组织,版本历史,团队共享,与生图任务关联 + 5. 文档 → 生图联动:风格文档中的规范自动注入生图 Prompt,确保产出一致性 +- **chosen_alternative**: 当前无文档管理能力,美术规范靠口头约定或外部文档 +- **deferred_reason**: 当前阶段聚焦核心生图能力,文档系统是生图能力稳定后的自然延伸 + +### [asset-spec-presets] 资产规格快速选取系统 +- **status**: deferred +- **tags**: 资产规格, icon, UI资产, 3D资产, 尺寸, 比例, 安全区, 底色, 预设, 美术标准 +- **recorded**: 2026-04-16 +- **source_chat**: [接下来要做的三件事](next-features-planning-0416) +- **prerequisite**: 基础生图能力稳定后 +- **related_files**: + - art-agent/backend/app/agent/tools.py + - art-agent/backend/app/services/image_gen.py + - art-agent/frontend/src/app/page.tsx +- **context**: | + 资产规格预设系统——选取资产类型即自动套用全部技术标准: + 1. 资产类型库:Icon / UI按钮 / 头像框 / 背景图 / 角色立绘 / 场景原画 / 3D贴图 等 + 2. 每种类型包含完整规格定义: + - 尺寸(输出分辨率、DPI) + - 比例(1:1 / 16:9 / 自定义) + - 安全区(内容区 vs 出血区) + - 底色/透明度要求 + - 文件格式(PNG / SVG / PSD 等) + - 风格约束(该类资产的通用风格要求,如 icon 需要简洁辨识度高) + - 导出规格(多尺寸自动导出,如 @1x @2x @3x) + 3. 项目级自定义:允许在预设基础上调整,保存为项目专属规格 + 4. 3D 资产扩展:未来覆盖 3D 模型的面数/贴图分辨率/UV规范等 + 5. 一键应用:选取资产类型后,尺寸/比例/后处理参数自动注入生图调用 + 6. 游戏全品类覆盖:目标是游戏中所有美术资产类型都有对应的规格预设 +- **chosen_alternative**: 当前生图参数完全由用户在对话中手动指定或由 LLM 推断 +- **deferred_reason**: 当前阶段聚焦核心生图和风格探索,规格系统是进入量产阶段的必备基础设施 + +### [hf-inference-endpoints] 迁移到 Hugging Face + Inference Endpoints +- **status**: deferred +- **tags**: 基础设施, Hugging Face, Inference Endpoints, ComfyUI, 自定义模型, 风格迁移 +- **recorded**: 2026-04-13 +- **source_chat**: [Kolors 风格迁移排查与模型调研](kolors-style-investigation) +- **prerequisite**: 探索期结束、确定要用的模型组合后 +- **related_files**: + - art-agent/backend/app/services/image_gen.py + - art-agent/backend/app/config.py +- **context**: | + 将生图基础设施从 Replicate API 迁移到 Hugging Face Inference Endpoints: + 1. 可部署任意 ComfyUI 工作流(NoobAI-XL + Style IPAdapter + ControlNet 等任意组合) + 2. 不受 Replicate 模型作者暴露的参数限制,完全控制推理流程 + 3. CivitAI 上的任何 LoRA、IP-Adapter 权重都能直接加载 + 4. 按 GPU 时长计费,批量生成时远比 Replicate 按次付费便宜 + 5. 是 style-pipeline-layered 延期方案中"锁定期 → 量产期"的基础设施 + 背景:当前在 Replicate 上可用的风格迁移模型有限—— + - Kolors IP-Adapter:风格迁移弱(混合提取内容+风格+构图) + - InstantStyle(jyoung105/instant-style):风格迁移强但贵($0.12/次)且慢(~128s) + - Style IPAdapter for NoobAI-XL:最强画风迁移,但需 ComfyUI 环境,Replicate 上无法使用 + 迁移到 HF Inference Endpoints 后可自由组合以上所有方案。 +- **chosen_alternative**: 当前使用 Replicate API(Kolors IP-Adapter + Flux + SDXL) +- **deferred_reason**: 探索期用 Replicate 足够快速迭代,迁移工作量较大(需搭建自定义推理服务) + +### [vision-model-asset-library] 专用 Vision 模型用于资源库功能 +- **status**: deferred +- **tags**: 资源库, vision, CLIP, BLIP, 图片搜索, 自动标签, embedding +- **recorded**: 2026-04-12 +- **source_chat**: [参考图风格解析方案讨论](vision-model-discussion) +- **prerequisite**: 资源库功能进入增强阶段(基础 Gallery 已完成) +- **related_files**: + - art-agent/frontend/src/app/gallery/page.tsx + - art-agent/backend/app/services/image_gen.py +- **context**: | + 引入专用 Vision 模型(CLIP / BLIP 等)为资源库提供智能化能力: + 1. 自动标签:生成的图片自动分类(UI/角色/场景/图标等) + 2. 以图搜图:上传一张图,从资源库中找风格最相似的 + 3. 语义搜索:用自然语言搜索图片(如"蓝色水晶质感的按钮") + 4. 聚类分组:自动将图片按视觉相似性分组 + 可通过 Replicate 调用或本地部署,成本极低。 +- **chosen_alternative**: 当前资源库使用手动标签 + Prompt 文本搜索 +- **deferred_reason**: 当前阶段资源量少,手动管理足够;等资源积累到一定量级后再引入自动化 --- ## 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 本地向量库),与上下文管理一并实施 diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 0000000..f3c7ae3 --- /dev/null +++ b/.cursor/hooks.json @@ -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 + } + ] + } +} diff --git a/.cursor/hooks/check-changelog.ps1 b/.cursor/hooks/check-changelog.ps1 new file mode 100644 index 0000000..9243fc2 --- /dev/null +++ b/.cursor/hooks/check-changelog.ps1 @@ -0,0 +1,105 @@ +# Changelog sync guard — 统一 stop hook +# +# 用确定性逻辑检查: +# 1. 源文件是否比 changelog 更新(mtime 比较) +# 2. stdin 中的 agent 上下文是否显示有源文件编辑操作 +# +# 静默条件(不触发提醒): +# 1. 环境变量 CURSOR_SKIP_CHANGELOG 被设置(sessionStart 在非 agent 模式设置) +# 2. stdin JSON 中 composer_mode 不是 "agent"(如 debug/ask/edit 模式) +# 3. changelog 文件不存在 +# 4. .changelog-ack 标记文件存在且足够新(本会话已确认过 changelog 状态) +# 5. 没有源文件比 changelog 更新 +# 6. stdin 上下文中没有文件编辑操作的证据(防止跨会话残留 mtime 误触发) + +$input = [Console]::In.ReadToEnd() + +# === 豁免检查 1:环境变量跳过标志 === +if ($env:CURSOR_SKIP_CHANGELOG) { + Write-Output '{}' + exit 0 +} + +# === 豁免检查 2:从 stdin 解析 composer_mode === +try { + $data = $input | ConvertFrom-Json + $mode = $data.composer_mode + if ($mode -and $mode -ne "agent") { + Write-Output '{}' + exit 0 + } +} catch { + # JSON 解析失败,继续后续检查 +} + +# === 豁免检查 3:从 stdin 文本匹配 debug 上下文关键词 === +# stop hook 的 $ARGUMENTS 可能包含对话/工具上下文,检测 debug 相关信号 +if ($input -match '"mode"\s*:\s*"debug"' -or + $input -match 'debug[\s_-]?mode' -or + $input -match 'Debug Mode') { + Write-Output '{}' + exit 0 +} + +$changelog = ".cursor\changelog\changelog-headlines.md" +$srcDir = "art-agent" + +# === 豁免检查 4:changelog 文件不存在 === +if (-not (Test-Path $changelog)) { + Write-Output '{}' + exit 0 +} + +$clMtime = (Get-Item $changelog).LastWriteTime + +# === 豁免检查 5:ack 标记文件足够新 === +$ackFile = ".cursor\changelog\.changelog-ack" +if ((Test-Path $ackFile) -and (Get-Item $ackFile).LastWriteTime -ge $clMtime) { + Write-Output '{}' + exit 0 +} + +# === 豁免检查 6:stdin 中无文件编辑证据 === +# 防止跨会话残留 mtime 差异导致误触发:如果 ack 文件不存在(或过旧), +# 但 stdin 上下文中也没有任何文件写入/编辑操作的痕迹,说明本次会话 +# 没有进行代码改动,不应触发提醒。 +$hasEditEvidence = ( + $input -match 'StrReplace|Write\s*tool|edit_file|file_write|write_to_file' -or + $input -match 'Created file|Modified file|Wrote contents' -or + $input -match '"tool"\s*:\s*"(str_replace|write|edit)"' +) +if (-not $hasEditEvidence) { + Write-Output '{}' + exit 0 +} + +# === 核心检查:是否有源文件比 changelog 更新 === +$extensions = @("*.py", "*.tsx", "*.ts", "*.css") +$excludeDirs = @("node_modules", ".next", "__pycache__", "venv") + +$newerFile = $null +foreach ($ext in $extensions) { + $files = Get-ChildItem -Path $srcDir -Filter $ext -Recurse -ErrorAction SilentlyContinue | + Where-Object { + $skip = $false + foreach ($ex in $excludeDirs) { + if ($_.FullName -like "*\$ex\*") { $skip = $true; break } + } + -not $skip -and $_.LastWriteTime -gt $clMtime + } | + Select-Object -First 1 + + if ($files) { + $newerFile = $files.Name + break + } +} + +if ($newerFile) { + $msg = "[Hook] Source file updated (e.g. $newerFile) but changelog not synced. Run dev-changelog Skill operation A NOW to write all three changelog layers." + $json = '{"followup_message":"' + $msg.Replace('"','\"') + '"}' + Write-Output $json +} else { + Write-Output '{}' +} +exit 0 diff --git a/.cursor/hooks/session-init.ps1 b/.cursor/hooks/session-init.ps1 new file mode 100644 index 0000000..de54553 --- /dev/null +++ b/.cursor/hooks/session-init.ps1 @@ -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 diff --git a/.cursor/pitfalls/pitfalls.md b/.cursor/pitfalls/pitfalls.md new file mode 100644 index 0000000..6d2f3f6 --- /dev/null +++ b/.cursor/pitfalls/pitfalls.md @@ -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 diff --git a/.cursor/profile/project-profile-log.md b/.cursor/profile/project-profile-log.md new file mode 100644 index 0000000..d832761 --- /dev/null +++ b/.cursor/profile/project-profile-log.md @@ -0,0 +1,26 @@ +# Project Profile Log + +详细记录每次项目画像更新的完整上下文,按时间正序追加。 + +## 记录 + +### 2026-04-11 — 初始化项目画像 +- **分类**: 项目定位 +- **精简版**: 面向游戏美术团队的 AI Agent 工具,通过对话驱动产出美术资源 +- **原始上下文**: "我希望做一个agent工具方便我们团队的美术更简易便捷地主要通过对话的形式去产出美术资源" +- **来源对话**: 初始化会话 +- **操作**: 新增 + +### 2026-04-11 — 初始化项目画像 +- **分类**: 设计约定 +- **精简版**: 目标用户是美术人员,交互设计需优先考虑非技术用户的友好度 +- **原始上下文**: "方便我们团队的美术更简易便捷地" +- **来源对话**: 初始化会话 +- **操作**: 新增 + +### 2026-04-11 — 初始化项目画像 +- **分类**: 产品方向 +- **精简版**: 当前阶段:平面资源、UI 类资源、交互设计、美术风格;远期:场景、3D 资源 +- **原始上下文**: "目前可能主要考虑产出平面资源和UI类资源、交互、美术风格等,以后也考虑要产出场景甚至3D资源" +- **来源对话**: 初始化会话 +- **操作**: 新增 diff --git a/.cursor/profile/project-profile.md b/.cursor/profile/project-profile.md new file mode 100644 index 0000000..7f77857 --- /dev/null +++ b/.cursor/profile/project-profile.md @@ -0,0 +1,13 @@ +# Project Profile + +## 项目定位 +- 面向游戏美术团队的 AI Agent 工具,通过对话驱动产出美术资源 + +## 技术栈与架构 + +## 设计约定 +- 目标用户是美术人员,交互设计需优先考虑非技术用户的友好度 + +## 产品方向 +- 当前阶段:平面资源、UI 类资源、交互设计、美术风格 +- 远期规划:场景资源、3D 资源生成 diff --git a/.cursor/rules/common/baserule.mdc b/.cursor/rules/common/baserule.mdc index c6fabcd..b017d8f 100644 --- a/.cursor/rules/common/baserule.mdc +++ b/.cursor/rules/common/baserule.mdc @@ -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/`(可选,方便其他项目使用) diff --git a/.cursor/rules/common/changelog-recall.mdc b/.cursor/rules/common/changelog-recall.mdc new file mode 100644 index 0000000..5dcfaf4 --- /dev/null +++ b/.cursor/rules/common/changelog-recall.mdc @@ -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 项,附 `[已记录到开发日志]`。 diff --git a/.cursor/rules/common/epee-orchestrator.mdc b/.cursor/rules/common/epee-orchestrator.mdc index 19fea9f..1162057 100644 --- a/.cursor/rules/common/epee-orchestrator.mdc +++ b/.cursor/rules/common/epee-orchestrator.mdc @@ -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. 识别问题根因,归纳为一条简明的检查项 diff --git a/.cursor/rules/common/pitfall-recall.mdc b/.cursor/rules/common/pitfall-recall.mdc new file mode 100644 index 0000000..da8fb39 --- /dev/null +++ b/.cursor/rules/common/pitfall-recall.mdc @@ -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 最多提醒一次 diff --git a/.cursor/rules/common/profile-recall.mdc b/.cursor/rules/common/profile-recall.mdc new file mode 100644 index 0000000..83f7fcf --- /dev/null +++ b/.cursor/rules/common/profile-recall.mdc @@ -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. 提取该条目的锚点 ID(HTML 注释中的 `PF-xxx`) +2. 用 Grep 在对应的 Log 文件中搜索该 ID,获取行号 +3. 用 Read 工具读取该行号 ±15 行范围,获取原始上下文和来源信息 +4. 一次上溯通常只涉及 1-2 条记录,不批量上溯 + +## 被动检测提醒 + +Agent 在整个对话过程中应保持对画像信号的被动感知。 +当对话结束、用户的主线任务完成后,如果检测到了新的画像信息, +应读取 `profile-memory` Skill 并执行其"操作 A"的确认流程。 diff --git a/.cursor/rules/project/project-launcher.mdc b/.cursor/rules/project/project-launcher.mdc new file mode 100644 index 0000000..ac82461 --- /dev/null +++ b/.cursor/rules/project/project-launcher.mdc @@ -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 内置终端中后台运行——用户需要看到窗口并能手动关闭 diff --git a/.cursor/skills/deferred-decisions/SKILL.md b/.cursor/skills/deferred-decisions/SKILL.md new file mode 100644 index 0000000..f65c20f --- /dev/null +++ b/.cursor/skills/deferred-decisions/SKILL.md @@ -0,0 +1,151 @@ +--- +name: deferred-decisions +description: >- + 记录开发中被延期的技术方案/决策,并在关联任务出现时主动提醒用户。 + 当对话中出现"以后再做"、"先不做"、"defer"、"延期方案"、"备选方案"、 + "递进方案"等语义时触发。也用于浏览、管理已有的 deferred items。 +--- + +# Deferred Decisions Skill + +追踪开发中被延期的技术方案,在合适时机主动提醒用户。 + +## 存储 + +**数据文件**: `.cursor/deferred/registry.md` + +该文件是 Agent 的结构化记忆,不是面向人类的文档。每个 deferred item 是一个 H3 级标题块。 + +## 操作 A:记录延期方案 + +### 触发识别 + +当对话中出现以下模式时,Agent 应主动提议记录: +- 讨论了多种方案,选择了一种,明确说其余"以后再做" +- 实现了基础版本,提到了可递进升级的高级版本 +- 发现了可以改进的点,但当前优先级不够 + +### 流程 + +``` +1. 从对话中提炼: + - 选择了什么方案(chosen_alternative) + - 延期了什么(标题 + context) + - 为什么延期(deferred_reason) + - 什么时候可以做(prerequisite) + - 涉及哪些领域(tags)和文件(related_files) +2. 生成 slug 形式的唯一 ID(如 ecosystem-lod-switching) +3. 读取 .cursor/deferred/registry.md(不存在则用模板创建) +4. 在 "## Active Items" 区域末尾、"---" 分隔线之前追加新条目 +5. 向用户确认已记录,展示条目摘要 +``` + +### 条目模板 + +```markdown +### [item-slug] 简短标题 +- **status**: deferred +- **tags**: tag1, tag2, tag3 +- **recorded**: YYYY-MM-DD +- **source_chat**: [简短描述](chat-uuid) +- **prerequisite**: 实施前提条件(可选,无则写 "无") +- **related_files**: + - path/to/file1 + - path/to/file2 +- **context**: | + 延期方案的具体内容,2-5 行描述。 + 包含方案的要点和实施思路。 +- **chosen_alternative**: 当时选择的方案简述 +- **deferred_reason**: 延期的原因 +``` + +### 字段说明 + +| 字段 | 必填 | 说明 | +|---|---|---| +| ID(方括号内) | 是 | slug 形式,全局唯一 | +| status | 是 | `deferred` / `reminded` / `in_progress` / `done` / `cancelled` | +| tags | 是 | 逗号分隔的领域标签,用于关联匹配 | +| recorded | 是 | 记录日期 | +| source_chat | 否 | 来源对话标识 | +| prerequisite | 否 | 实施前提条件 | +| related_files | 否 | 关联代码文件路径 | +| context | 是 | 延期方案的具体内容 | +| chosen_alternative | 是 | 当时选择了什么 | +| deferred_reason | 是 | 延期原因 | + +## 操作 B:关联提醒 + +### 触发条件 + +由 `.cursor/rules/common/deferred-recall.mdc` 触发,或当用户任务涉及已有 deferred item 的领域时自动触发。 + +### 流程 + +``` +1. 读取 .cursor/deferred/registry.md +2. 提取当前任务的关键词和涉及文件 +3. 匹配 status=deferred 的 items: + - tags 与当前任务关键词有交集 + - related_files 与当前任务涉及文件有重叠 + - prerequisite 描述的条件可能已满足 +4. 如匹配到,在回复开头简要提醒: + "提醒:你之前有一个延期方案 [item-title] 与当前任务相关。要一并处理吗?" +5. 如用户同意,将该 item 的 status 改为 in_progress +``` + +### 提醒原则 + +- 每个 item 在同一会话中最多提醒一次 +- 只提醒 status=deferred 的 items(reminded/in_progress 不重复提醒) +- 提醒应简洁,不打断用户的主线任务 + +## 操作 C:状态管理 + +| 用户动作 | 状态变更 | 额外操作 | +|---|---|---| +| "开始做 X" | deferred → in_progress | 无 | +| "X 完成了" | in_progress → done | 移到 "Completed / Cancelled Items" 区域 | +| "X 不需要了" | any → cancelled | 移到 "Completed / Cancelled Items" 区域 | +| Agent 提醒后用户确认 | deferred → in_progress | 无 | + +### 移动条目 + +将条目从 "Active Items" 剪切到 "Completed / Cancelled Items" 区域时,保留完整内容,仅修改 status。 + +## 操作 D:浏览/回顾 + +当用户问"有哪些延期方案"、"deferred list"、"待办方案"等时: + +``` +1. 读取 registry.md +2. 列出所有 status=deferred 的 items 摘要表: + | ID | 标题 | Tags | 记录日期 | 前提条件 | +3. 如用户要求按 tag 过滤,只展示匹配项 +``` + +## Registry 文件模板 + +当 `.cursor/deferred/registry.md` 不存在时,用此模板创建: + +```markdown +# Deferred Decisions Registry + +## Active Items + +(暂无延期方案) + +--- + +## Completed / Cancelled Items + +(暂无已完成或已废弃的方案) +``` + +## 自迭代日志 + +本节记录使用本 Skill 过程中发现的必要检查项。 + +### 已知必要检查 + +(暂无) diff --git a/.cursor/skills/dev-changelog/SKILL.md b/.cursor/skills/dev-changelog/SKILL.md new file mode 100644 index 0000000..20f538f --- /dev/null +++ b/.cursor/skills/dev-changelog/SKILL.md @@ -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.md(L2)中 Grep 该 ID + - 找到 → 读取该条目的 3-5 行摘要,通常足以消除歧义 + - 未找到(已滚出 L2 窗口)→ 进入步骤 3 +3. 在 changelog-full.md(L1)中 Grep 该 ID + - 找到 → 用 Read 工具读取该 ID 所在行号 ±20 行范围(精准读取,不读全文) + - 未找到 → 放弃上溯,该条目上下文不可用 +``` + +### 上溯原则 + +- **按需触发**:只有在 L3 信息不足以支撑当前任务判断时才上溯,不预防性地批量读取 +- **精准读取**:对 L1 的访问必须通过 Grep 定位行号 + Read 局部读取,禁止全文读取 +- **最小化**:一次上溯通常只涉及 1-3 条记录,不批量上溯 + +## 操作 A:记录写入(核心流程) + +### 触发条件 + +Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后,自动触发。 + +以下情况**不触发**: +- 纯对话讨论、方案设计、问题解答(无文件改动) +- 只读操作(查看文件、搜索代码) +- 只改动了 `.cursor/` 目录下的基础设施文件(如画像、延期方案、changelog 自身) + +### 写入流程 + +``` +1. 生成锚点 ID:CL-YYYYMMDD-HHMM(检查是否与已有 ID 冲突,冲突则追加字母后缀) + +2. 从刚完成的任务中提取以下信息: + - 做了什么(what):一句话概括 + - 为什么这样做(why):动机和背景 + - 改了哪里(where):受影响的文件/模块列表 + - 关键决策(decisions):如果有方案选择,记录选了什么、放弃了什么 + - 注意事项(notes):后续可能受影响的地方、已知限制等 + +3. 生成三层内容(共享同一个锚点 ID): + - L1 完整条目(包含以上全部信息) + - L2 摘要条目(what + why + where,3-5 行) + - L3 一句话(what,不超过 80 字) + +4. 写入三个文件(按以下顺序): + a. 读取 changelog-full.md,在 "## 记录" 下方追加 L1 条目 + b. 读取 changelog-recent.md,在顶部插入 L2 条目,如超过 10 条则移除最旧的 + c. 读取 changelog-headlines.md,在顶部插入 L3 条目,如超过 50 条则移除最旧的 + +5. 在回复末尾附一行提示:"[已记录到开发日志]" +``` + +### 静默写入原则 + +- **不需要用户确认**——Agent 自己做的改动,对"做了什么"的认知是一手的 +- 用户如果觉得记录不准确,可通过操作 D 修改或删除 +- 回滚操作也要记录("回退了 XX 改动"),真实反映开发过程 + +## 操作 B:L2 自动触发读取 + +### 触发条件 + +由 `changelog-recall.mdc` Rule 调度。当 Agent 开始处理一个新任务时,判断该任务是否 +与近期改动相关。 + +### 匹配策略(文件 + 标签双匹配) + +``` +1. 从当前任务中提取: + - 涉及的文件路径 + - 语义关键词(模块名、功能领域等) + +2. 读取 changelog-recent.md,逐条检查: + - 硬匹配:当前任务涉及的文件出现在条目的 affected_files 中 + - 软匹配:当前任务的语义关键词与条目的 tags 有交集 + +3. 任一匹配命中 → 将匹配到的 L2 条目作为上下文纳入考量 +4. 在回复中自然融入,不显式提及"根据开发日志" +``` + +## 操作 C:L1 手动检索 + +### 触发条件 + +用户主动要求回顾完整改动记录时触发。典型话语: +- "回顾一下最近的改动" +- "XX 模块之前改过什么" +- "查看开发日志" +- "changelog" + +### 流程 + +``` +1. 读取 changelog-full.md +2. 根据用户需求过滤: + - 按时间范围 + - 按模块/文件 + - 按 tags +3. 展示匹配的条目摘要表,用户可以进一步查看某条的完整内容 +``` + +## 操作 D:记录管理 + +用户可以对已有记录进行管理: + +| 操作 | 说明 | +|------|------| +| 删除 | 从三层文件中同步移除对应条目 | +| 修改 | 修改某条记录的描述(三层同步更新) | +| 清理 | 手动触发 L1 的归档(如按月分文件,暂不实现,作为演进方向) | + +## 条目格式 + +### L1 完整条目 + +```markdown +### [CL-20260412-1430] YYYY-MM-DD HH:MM — 一句话标题 +- **tags**: tag1, tag2, tag3 +- **affected_files**: + - path/to/file1 + - path/to/file2 +- **what**: 做了什么的简要描述 +- **why**: 动机和背景 +- **decisions**: 选择了 A 方案(放弃了 B 因为 xxx) +- **notes**: 后续注意事项 +- **source_chat**: [对话简述](chat-uuid) +``` + +### L2 摘要条目 + +```markdown +### [CL-20260412-1430] YYYY-MM-DD — 一句话标题 +- **tags**: tag1, tag2, tag3 +- **affected_files**: file1, file2 +- **summary**: 做了什么 + 为什么(3-5 行) +``` + +### L3 一句话条目 + +```markdown +- [CL-20260412-1430] 一句话描述改动内容(不超过 80 字) +``` + +## 滚动窗口维护 + +### L2 窗口(~20 条) + +``` +写入新条目后,检查总条目数: +- <= 20 条:不做处理 +- > 20 条:移除文件底部(最旧的)条目,直到恰好 20 条 +``` + +### L3 窗口(~50 条) + +``` +写入新条目后,检查总行数(排除文件头部的标题和说明): +- <= 50 条:不做处理 +- > 50 条:移除文件底部(最旧的)条目,直到恰好 50 条 +``` + +被移除的条目不需要额外归档——L1 完整版保留了所有历史。 + +## 数据文件模板 + +首次写入时,如对应文件不存在,按以下模板创建。 + +### changelog-full.md + +```markdown +# Dev Changelog — Full + +完整的开发改动记录,按时间倒序排列。作为主动 RAG 的数据源,用户手动唤醒时读取。 + +## 记录 +``` + +### changelog-recent.md + +```markdown +# Dev Changelog — Recent + +最近 ~10 次改动的摘要记录,按时间倒序排列。 +当 Agent 检测到当前任务与近期改动相关时自动读取。 +``` + +### changelog-headlines.md + +```markdown +# Dev Changelog — Headlines + +最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。 +``` + +## 与其他系统的协作 + +| 系统 | 关系 | 说明 | +|------|------|------| +| `changelog-recall` Rule | 下游消费者 | 每次会话注入 L3,自动触发 L2 读取 | +| `deferred-decisions` Skill | 互补 | deferred 记"没做什么",changelog 记"做了什么" | +| `profile-memory` Skill | 结构对称 | profile 是"是什么",changelog 是"做了什么" | +| `epee-orchestrator` | 注册 | 在 registry.md 中注册本 Skill | + +## 自迭代日志 + +本节记录使用本 Skill 过程中发现的必要检查项。 + +### 已知必要检查 + +1. **大任务收尾遗漏风险** — 当单次任务涉及 5+ 个文件改动时,Agent 容易在输出总结回复后遗漏被动写入流程。应在生成最终回复前,先执行 `changelog-recall.mdc` 中的"任务完成 Checklist",确认三层日志已写入后再输出回复。绝不能"先回复再补写"。 diff --git a/.cursor/skills/epee-orchestrator/SKILL.md b/.cursor/skills/epee-orchestrator/SKILL.md new file mode 100644 index 0000000..3bb3fa7 --- /dev/null +++ b/.cursor/skills/epee-orchestrator/SKILL.md @@ -0,0 +1,125 @@ +--- +name: epee-orchestrator +description: >- + EPEE Skill Orchestrator:元层调度系统。检测任务是否可由已有 Skill 处理并分流, + 或发现 Skill 缺口并引导创建新 Skill。当 Agent 遇到手动配置密集、重复模式明确、 + 手工指引过长的任务时触发。也用于 Skill Registry 的维护和同步。 +--- + +# EPEE Skill Orchestrator + +元层调度系统,负责 Skill 分流、缺口发现和 Registry 维护。 + +## 操作 A:Skill 分流(匹配已有 Skill) + +### 流程 + +``` +1. 读取 registry.md 获取所有已注册 Skill 的摘要 +2. 将当前任务特征与每个 Skill 的"触发场景"关键词匹配 +3. 如果匹配到: + a. 告知用户"此任务可以通过 [Skill名] 高效完成" + b. 简述该 Skill 的能力及与当前任务的契合点 + c. 用户确认后,读取并激活对应 Skill 的 SKILL.md +``` + +### 匹配规则 + +- 优先匹配触发场景关键词与当前任务描述的交集 +- 如有多个 Skill 匹配,按相关度排序推荐,由用户选择 +- `deferred-decisions` 等标记为"基础设施"类型的 Skill 不参与任务分流匹配, + 仅作为 Orchestrator 的下游工具使用 + +## 操作 B:Skill 缺口发现 + 创建引导 + +### 触发条件 + +操作 A 未找到匹配的 Skill,且当前任务满足"Skill 创建价值判断标准"中至少 2 条。 + +### 流程 + +``` +1. 向用户提出建议: + "这类任务可以通过创建一个 [建议 Skill 名] 来自动化" +2. 给出 1-3 句方案概要: + - 这个 Skill 会做什么 + - 核心工作机制(如 DSL 生成、YAML 直写、MCP 调用等) + - 预估能节省的重复劳动 +3. 询问用户选择: + a) "继续讨论并创建" → 进入创建流程 + b) "以后再说" → 进入延期记录流程 + c) "不需要" → 结束,正常执行当前任务 +``` + +### a) 创建流程 + +``` +1. 委托给 create-skill Skill(路径: ~/.cursor/skills-cursor/create-skill/SKILL.md) +2. 将以下上下文传递给 create-skill 流程: + - 触发创建的原始任务描述 + - Orchestrator 的方案概要 + - 建议的 Skill 名称 +3. 按 create-skill 的标准流程完成 Discovery → Design → Implementation → Verification +4. 创建完成后,要求用户对新 Skill 进行实际测试 +5. 测试通过后,执行操作 C 同步 Registry +``` + +### b) 延期记录流程 + +``` +1. 读取 deferred-decisions Skill +2. 按其"操作 A:记录延期方案"流程,将 Skill 创建建议记录为 deferred item +3. tags 中包含 "skill-creation" 和相关领域标签 +4. context 中记录方案概要,便于未来回忆 +``` + +## 操作 C:Registry 同步 + +### 触发条件 + +- 新 Skill 被创建后 +- 已有 Skill 的 SKILL.md 被实质性修改后(如能力范围变化、触发场景变化) + +### 流程 + +``` +1. 读取目标 Skill 的 SKILL.md +2. 从 frontmatter 提取 name 和 description +3. 从正文提取核心能力和触发场景关键词 +4. 读取 registry.md +5. 新增或更新对应条目,遵循 registry.md 中定义的条目格式 +6. 写回 registry.md +``` + +### 条目格式 + +参见 [registry.md](registry.md) 中的条目结构。 + +## Skill 创建价值判断标准 + +当操作 A 无匹配时,Agent 使用以下标准评估是否建议创建新 Skill。 +满足 **2 条及以上** 即认为值得建议: + +1. **频次**:该类任务预计会出现 3 次以上 +2. **模式明确**:任务有明确的输入/输出模式(输入 X → 产出 Y) +3. **步骤繁多**:手动操作步骤 >= 5 步,或涉及 >= 3 个文件的协调修改 +4. **易错**:存在容易出错的重复性操作(如 GUID 填写、格式对齐等) +5. **可自动化**:可以通过生成代码、配置文件、脚本或 MCP 调用来替代手动操作 + +不满足标准时,Agent 正常执行任务,不提出创建建议。 + +## 与其他系统的协作 + +| 系统 | 关系 | 说明 | +|------|------|------| +| `create-skill` Skill | 下游委托 | 操作 B 创建流程的执行者 | +| `deferred-decisions` Skill | 下游工具 | 操作 B 延期记录的执行者 | +| `deferred-recall` Rule | 协同 | Orchestrator 延期的建议通过 deferred-recall 在未来自动提醒 | + +## 自迭代日志 + +本节记录使用本 Skill 过程中发现的必要检查项。 + +### 已知必要检查 + +1. **收尾动作级联遗漏** — 当 Agent 遗漏了一个收尾动作(如 changelog 写入)后,后续的收尾动作(自迭代、Registry 同步)也会被一并遗漏,因为它们都在同一个"收尾阶段"。`changelog-recall.mdc` 中的 Checklist 化设计可以打断这种级联——每项独立检查,不依赖前一项的执行记忆。 diff --git a/.cursor/skills/epee-orchestrator/registry.md b/.cursor/skills/epee-orchestrator/registry.md new file mode 100644 index 0000000..4934ab2 --- /dev/null +++ b/.cursor/skills/epee-orchestrator/registry.md @@ -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"触发(原被动写入提醒已升级为强制 checklist);L3 概要每次会话自动注入上下文;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 +- **备注**: 项目专属 Skill;Ollama 必须先于后端启动(Mem0 embedding 依赖);前后端窗口必须可见 diff --git a/.cursor/skills/pitfall-journal/SKILL.md b/.cursor/skills/pitfall-journal/SKILL.md new file mode 100644 index 0000000..977a963 --- /dev/null +++ b/.cursor/skills/pitfall-journal/SKILL.md @@ -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. 生成条目 ID:PF-YYYYMMDD-HHMM +2. 从调试过程中提取:症状、根因、解法 +3. 归纳防御措施(如果有通用性) +4. 读取 pitfalls.md,在顶部追加新条目 +5. 在回复末尾附:[已记录到踩坑日志] +``` + +### 静默写入原则 + +与 dev-changelog 一致——Agent 自己调试出来的问题,不需要用户确认就可以记录。 + +## 操作 B:自动匹配检索 + +### 触发条件 + +当 Agent 在当前任务中遇到以下情况时,应主动检索 pitfalls.md: + +1. **进入 Debug mode** — 读取 pitfalls.md,扫描是否有与当前错误症状匹配的记录 +2. **同类错误再现** — 错误信息关键词与已有条目的"症状"匹配 +3. **涉及已知高危区域** — 当前操作涉及的模块/技术栈在已有条目的"关联"中出现 + +### 匹配策略 + +``` +1. 提取当前问题的关键信号: + - 错误信息关键词 + - 涉及的文件/模块 + - 涉及的技术栈/框架 + +2. 在 pitfalls.md 中匹配: + - 硬匹配:错误信息关键词出现在条目的"症状"中 + - 软匹配:涉及的模块/技术栈出现在条目的"关联"中 + +3. 命中时,在分析中优先考虑已有经验: + > 注意:之前遇到过类似问题 [PF-xxx]:[一句话描述]。 + > 上次的根因是 [xxx],先排查这个方向。 +``` + +## 操作 C:手动检索 + +### 触发条件 + +用户主动要求回顾踩坑记录。典型话语: +- "之前那个坑是什么来着" +- "看看踩坑日志" +- "有遇到过类似的问题吗" + +### 流程 + +``` +1. 读取 pitfalls.md +2. 根据用户描述匹配相关条目 +3. 展示匹配结果 +``` + +## 与其他系统的协作 + +| 系统 | 关系 | 说明 | +|------|------|------| +| `dev-changelog` | 互补 | changelog 记改动事实,pitfall 记经验教训 | +| `pitfall-recall` Rule | 下游消费者 | 进入 Debug mode 或遇到错误时自动触发检索 | +| `epee-orchestrator` | 注册 | 在 registry.md 中注册本 Skill | + +## 自迭代日志 + +本节记录使用本 Skill 过程中发现的必要检查项。 + +### 已知必要检查 + +(暂无) diff --git a/.cursor/skills/profile-memory/SKILL.md b/.cursor/skills/profile-memory/SKILL.md new file mode 100644 index 0000000..9c9047f --- /dev/null +++ b/.cursor/skills/profile-memory/SKILL.md @@ -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 中保持一致: +- 精简版条目格式:`- 条目内容 `(HTML 注释,不影响可读性) +- Log 条目的 H3 标题:`### [PF-20260412-01] YYYY-MM-DD — 简短标题` + +这使得从精简版到 Log 的查找可以通过 Grep 精准定位,无需全文读取 Log。 + +## 逐级上溯机制 + +当 Agent 在使用精简版 Profile 作为上下文时,如果某条记录**语义模糊** +(如无法判断偏好的具体适用场景、与当前任务的关系不明确),执行以下查找: + +``` +1. 从精简版条目中提取锚点 ID(HTML 注释中的 PF-xxx) +2. 在对应的 Log 文件中 Grep 该 ID + - 找到 → 用 Read 工具读取该 ID 所在行号 ±15 行范围(精准读取,不读全文) + - Log 中包含原始上下文、来源对话等完整信息,通常足以消除歧义 +``` + +### 上溯原则 + +- **按需触发**:只有在精简版信息不足以支撑当前判断时才上溯 +- **精准读取**:通过 Grep 定位行号 + Read 局部读取,禁止全文读取 Log +- **最小化**:一次上溯通常只涉及 1-2 条记录 + +## 硬上限 + +- user-profile.md:不超过 **50 行** +- project-profile.md:不超过 **30 行** +- 每条信息一行,`- ` 开头,措辞客观中立 + +## 操作 A:被动检测 + 对话结束前确认 + +### 检测范围 + +在正常对话中被动检测以下信号(**不主动询问**): + +**个人特质**: +- 审美/设计偏好("太花哨了"、"我喜欢 minimal") +- 技术偏好("以后都用 X"、"我不喜欢 class 写法") +- 做事风格("先讨论再动手"、"不要自作主张") +- 沟通偏好("给我简短的回答"、"多解释一下原理") +- 产品理解/思维方式 + +**项目信息**: +- 项目定位和目标("这个项目是做 X 的") +- 技术栈与架构决策 +- 设计约定和规范 +- 产品方向和目标用户 + +### 检测原则 + +- **保守而非激进**:宁可漏记也不误记 +- 只记录**持久性**的偏好/特质,忽略一次性的临时需求 +- 区分"个人"和"项目"两个维度 + +### 变更分类 + +检测到的新信息与已有条目的关系分为三类,处理方式不同: + +| 类型 | 定义 | 示例 | 提示强度 | +|------|------|------|---------| +| **新增** | 全新维度,无已有条目 | 首次提到审美偏好 | 常规 | +| **演进** | 已有条目的深化、细化或自然发展 | "偏好 React" → "偏好 React + Next.js 全栈" | 常规,展示前后对比 | +| **转向** | 与已有条目方向性矛盾或根本性变化 | "偏好 React" → "想转 Vue";项目方向从 B2C 转 B2B | **加强提醒**,展示前后对比 | + +### 确认流程 + +``` +1. 在对话过程中将检测到的信息在内部缓存,分类为"个人"或"项目" +2. 对每条缓存信息,与已有 Profile 比对,标记变更类型(新增/演进/转向) +3. 当用户的主线任务完成后,统一提出,格式如下: + + "本次对话中我注意到以下可记录的画像信息:" + + **个人画像:** + - [新增][分类] 条目内容 + - [演进][分类] 旧:xxx → 新:yyy + - [转向][分类] 旧:xxx → 新:yyy ⚠️ + + **项目画像:** + - (同上格式) + + 转向类条目额外标注 ⚠️ 并附一句说明: + "⚠️ 以下条目与已有记录存在方向性变化,请特别关注:" + + "是否记录?你可以全部确认、逐条修改或跳过。" + +4. 用户确认后执行写入流程 +5. 如本次对话未检测到任何画像信息,则不触发此流程 +``` + +### 写入流程 + +``` +1. 读取对应的精简 Profile 和详细 Log(不存在则用模板创建) +2. 为每条新记录生成锚点 ID:PF-YYYYMMDD-NN(检查 Log 中已有 ID 避免冲突) +3. 按变更类型执行: + - 新增:在对应分类下追加条目,带锚点 ID 注释 + - 演进:替换对应旧条目为新措辞,沿用旧条目的锚点 ID(或生成新 ID,视变化程度而定) + - 转向:替换对应旧条目为新措辞,生成新锚点 ID(用户已在确认流程中审核) +4. 写入精简 Profile(条目格式:`- 内容 `) +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 过程中发现的必要检查项。 + +### 已知必要检查 + +(暂无) diff --git a/.cursor/skills/project-launcher/SKILL.md b/.cursor/skills/project-launcher/SKILL.md new file mode 100644 index 0000000..5867fc2 --- /dev/null +++ b/.cursor/skills/project-launcher/SKILL.md @@ -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. 启动后端(在独立可见终端窗口中): + - Windows(PowerShell 或 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 的后台命令方式, + 那样窗口不可见,用户无法直接查看和关闭 + +## 单独启动 + +如果用户只说"启动前端"或"启动后端",只启动对应的服务即可,不需要全部启动。 + +## 访问信息 + +启动完成后告知用户: +- Ollama:http://localhost:11434(Mem0 embedding 服务) +- 后端 API:http://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。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d85189 --- /dev/null +++ b/.gitignore @@ -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* diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 68d1f85..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "AndreaNovelHelper.workspaceDisabled": true -} \ No newline at end of file diff --git a/art-agent/README.md b/art-agent/README.md new file mode 100644 index 0000000..3d5ec36 --- /dev/null +++ b/art-agent/README.md @@ -0,0 +1,177 @@ +# EPEEKit + +AI 美术资源生成工具集 — 通过对话生成游戏美术资源。 + +## 快速开始 + +### 1. 准备 API Key + +你需要两个 API Key: + +- **OpenAI API Key**(或兼容 API):https://platform.openai.com/api-keys +- **Replicate API Token**:https://replicate.com/account/api-tokens + +### 2. 启动后端 + +```bash +cd art-agent/backend + +# 创建虚拟环境(推荐) +python -m venv venv +venv\Scripts\activate # Windows +# source venv/bin/activate # macOS/Linux + +# 安装依赖 +pip install -r requirements.txt + +# 配置环境变量 +copy .env.example .env +# 编辑 .env,填入你的 API Key 和模型配置 + +# 启动(--host 0.0.0.0 允许局域网/穿透访问) +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +### 3. 启动前端 + +```bash +cd art-agent/frontend + +# 安装依赖(需要 Node.js >= 18) +npm install + +# 启动 +npm run dev +``` + +### 4. 使用 + +打开浏览器访问 http://localhost:3000 + +- 输入文字描述你想要的美术资源 +- 可以上传参考图引导风格 +- 生成的图片可以点击"保存"按钮下载 +- 持续对话进行迭代修改 + +## 访问地址 + +| 场景 | 前端页面 | 后端 API | 后端文档 | +|------|---------|---------|---------| +| **本机** | http://localhost:3000 | http://localhost:8000 | http://localhost:8000/docs | +| **局域网**(同一 WiFi) | `http://<你的IP>:3000` | `http://<你的IP>:8000` | `http://<你的IP>:8000/docs` | +| **外网**(Cloudflare Tunnel) | `https://xxx.trycloudflare.com`(每次启动不同) | `https://yyy.trycloudflare.com` | — | + +查看本机局域网 IP: + +```powershell +# Windows +Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceAlias -eq 'WLAN' } + +# macOS / Linux +ifconfig | grep "inet " | grep -v 127.0.0.1 +``` + +## 配置说明 + +所有可配置项都集中在 `backend/.env` 文件中,分为以下几组: + +| 配置组 | 变量 | 说明 | +|--------|------|------| +| **LLM 对话** | `OPENAI_API_KEY` | API 密钥 | +| | `OPENAI_BASE_URL` | API 基地址,支持任何 OpenAI 兼容 API | +| | `LLM_MODEL` | 模型名称,如 `gpt-4o-mini`、`deepseek-chat` 等 | +| | `LLM_MAX_ITERATIONS` | Agent 单轮最大工具调用次数 | +| **图像生成** | `REPLICATE_API_TOKEN` | Replicate API Token | +| | `IMAGE_MODEL` | 图像生成模型,如 `black-forest-labs/flux-schnell` | +| | `IMAGE_ASPECT_RATIO` | 默认宽高比,如 `1:1` | +| | `IMAGE_OUTPUT_FORMAT` | 输出格式,如 `png` | +| **网络** | `HTTP_PROXY` / `HTTPS_PROXY` | 代理配置 | +| **服务** | `PORT` | 后端服务端口 | + +切换 LLM 供应商只需修改 `OPENAI_BASE_URL` + `OPENAI_API_KEY` + `LLM_MODEL` 三个值。 + +## 项目结构 + +``` +art-agent/ + backend/ # Python FastAPI 后端 + app/ + main.py # 入口 + config.py # 集中配置(读取 .env) + api/chat.py # 对话 API + agent/ # Agent Loop 核心 + services/ # AI 服务封装 + frontend/ # Next.js 前端 + src/ + app/ # 页面 + components/ # UI 组件 + lib/ # 工具函数 + docs/ # 文档 +``` + +## 远程访问(外网穿透) + +通过 Cloudflare Quick Tunnel 免费穿透内网,手机或朋友在任何网络下都能访问,无需买服务器。 + +### 安装 cloudflared + +```powershell +# Windows(任选一种) +winget install Cloudflare.cloudflared + +# 或手动下载 +# https://github.com/cloudflare/cloudflared/releases/latest +# 下载 cloudflared-windows-amd64.exe,放到项目目录下即可 +``` + +```bash +# macOS +brew install cloudflared + +# Linux +# https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ +``` + +### 一键启动穿透 + +确保前后端服务已经在运行,然后在另一个终端执行: + +```powershell +cd art-agent +.\start-tunnel.ps1 +``` + +脚本会自动: +1. 为后端和前端各创建一条穿透隧道 +2. 更新 `frontend/.env.local` 中的后端 API 地址为穿透地址 +3. 打印手机可访问的公网 HTTPS 链接 +4. 关闭脚本时(Ctrl+C)自动恢复 `.env.local` 为 localhost + +> **重要**:穿透启动后需要**重启前端** (`npm run dev`) 才能读取新的 `.env.local`。 + +### 穿透管理 + +| 操作 | 方式 | +|------|------| +| **启动** | 运行 `.\start-tunnel.ps1`,等待打印出两条 `trycloudflare.com` 地址 | +| **停止** | 在脚本窗口按 `Ctrl+C`,或在任务管理器中结束 `cloudflared.exe` 进程 | +| **查看状态** | 检查日志:`Get-Content $env:TEMP\epeekit-tunnel-backend.log` | +| **重启** | 先停止再启动,地址会变(Quick Tunnel 每次分配随机域名) | + +### 注意事项 + +- Quick Tunnel **免费无需账号**,但地址每次启动都会变化 +- 如果 cloudflared 连接超时,可能需要配置代理(Clash 等),在运行脚本前设置: + ```powershell + $env:HTTP_PROXY = "http://127.0.0.1:7890" + $env:HTTPS_PROXY = "http://127.0.0.1:7890" + .\start-tunnel.ps1 + ``` +- 电脑关机、休眠或代理断开后隧道会自动失效 +- 停止穿透后,`start-tunnel.ps1` 会自动将 `.env.local` 恢复为 `localhost`;如果手动杀进程,需要自行恢复 + +## 技术栈 + +- 前端:Next.js + TypeScript + Tailwind CSS +- 后端:Python FastAPI + SSE +- AI:可配置 LLM(默认 GPT-4o-mini)+ 可配置图像模型(默认 Replicate Flux) diff --git a/art-agent/backend/.env.example b/art-agent/backend/.env.example new file mode 100644 index 0000000..02dfbb6 --- /dev/null +++ b/art-agent/backend/.env.example @@ -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 diff --git a/art-agent/backend/app/__init__.py b/art-agent/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/art-agent/backend/app/agent/__init__.py b/art-agent/backend/app/agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/art-agent/backend/app/agent/loop.py b/art-agent/backend/app/agent/loop.py new file mode 100644 index 0000000..e89ebea --- /dev/null +++ b/art-agent/backend/app/agent/loop.py @@ -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 diff --git a/art-agent/backend/app/agent/tools.py b/art-agent/backend/app/agent/tools.py new file mode 100644 index 0000000..829a2e5 --- /dev/null +++ b/art-agent/backend/app/agent/tools.py @@ -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}"} diff --git a/art-agent/backend/app/api/__init__.py b/art-agent/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/art-agent/backend/app/api/admin.py b/art-agent/backend/app/api/admin.py new file mode 100644 index 0000000..d7fd250 --- /dev/null +++ b/art-agent/backend/app/api/admin.py @@ -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} 已禁用"} diff --git a/art-agent/backend/app/api/auth.py b/art-agent/backend/app/api/auth.py new file mode 100644 index 0000000..4ec72da --- /dev/null +++ b/art-agent/backend/app/api/auth.py @@ -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) diff --git a/art-agent/backend/app/api/chat.py b/art-agent/backend/app/api/chat.py new file mode 100644 index 0000000..21c18ae --- /dev/null +++ b/art-agent/backend/app/api/chat.py @@ -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()) diff --git a/art-agent/backend/app/api/memory.py b/art-agent/backend/app/api/memory.py new file mode 100644 index 0000000..efcf855 --- /dev/null +++ b/art-agent/backend/app/api/memory.py @@ -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} diff --git a/art-agent/backend/app/auth.py b/art-agent/backend/app/auth.py new file mode 100644 index 0000000..2538d5c --- /dev/null +++ b/art-agent/backend/app/auth.py @@ -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 diff --git a/art-agent/backend/app/config.py b/art-agent/backend/app/config.py new file mode 100644 index 0000000..8bbd431 --- /dev/null +++ b/art-agent/backend/app/config.py @@ -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_outputs,Kolors 用 number_of_images) +# default_params — 默认推理参数 + +IMAGE_MODELS: dict[str, dict[str, Any]] = { + "gpt-image-1.5": { + "id": "gpt-image-1.5", + "name": "GPT Image 1.5", + "provider": "openai", + "model_id": "gpt-image-1.5", + "description": "OpenAI 最强生图,文字渲染与 prompt 理解最佳,支持多图参考", + "supports_ref_image": True, + "max_ref_images": 16, + "default_params": { + "size": "1024x1024", + "quality": "high", + }, + }, + "gemini-3.1-flash-image": { + "id": "gemini-3.1-flash-image", + "name": "Gemini 3.1 Flash Image", + "provider": "gemini_native", + "model_id": "gemini-3.1-flash-image-preview", + "description": "Google 原生生图,速度快、价格低,支持多图参考(最多 14 张)", + "supports_ref_image": True, + "max_ref_images": 14, + "default_params": {}, + }, + "flux-schnell": { + "id": "flux-schnell", + "name": "Flux Schnell", + "provider": "replicate", + "model_id": "black-forest-labs/flux-schnell", + "description": "快速生成,适合快速迭代", + "supports_ref_image": False, + "num_images_param": "num_outputs", + "default_params": { + "aspect_ratio": "1:1", + "output_format": "png", + }, + }, + "flux-dev": { + "id": "flux-dev", + "name": "Flux Dev", + "provider": "replicate", + "model_id": "black-forest-labs/flux-dev", + "description": "高质量生成,细节更好", + "supports_ref_image": False, + "num_images_param": "num_outputs", + "default_params": { + "aspect_ratio": "1:1", + "output_format": "png", + }, + }, + "sdxl": { + "id": "sdxl", + "name": "Stable Diffusion XL", + "provider": "replicate", + "model_id": "stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b", + "description": "经典 SDXL,支持 negative prompt", + "supports_ref_image": False, + "num_images_param": "num_outputs", + "default_params": { + "width": 1024, + "height": 1024, + "num_inference_steps": 50, + "guidance_scale": 7.5, + }, + }, + "instant-style": { + "id": "instant-style", + "name": "InstantStyle", + "provider": "replicate", + "model_id": "jyoung105/instant-style:c6f01e12f31cb99f9ee774a78992a71294f630a6f433d9aecfdc33b816fc4baa", + "description": "强风格迁移,画风还原度高(较慢)", + "supports_ref_image": True, + "ref_image_param": "style_image", + "num_images_param": "num_outputs", + "default_params": { + "width": 1024, + "height": 1024, + "num_inference_steps": 30, + "guidance_scale": 5, + "style_strength": 1.0, + "block_mode": "style-only", + "adapter_mode": "original", + }, + }, + "kolors-ipadapter": { + "id": "kolors-ipadapter", + "name": "Kolors IP-Adapter", + "provider": "replicate", + "model_id": "fofr/kolors-with-ipadapter:5a1a92b2c0f81813225d48ed8e411813da41aa84e7582fb705d1af46eea36eed", + "description": "风格参考生成,上传参考图效果最佳", + "supports_ref_image": True, + "ref_image_param": "image", + "num_images_param": "number_of_images", + "default_params": { + "width": 1024, + "height": 1024, + "steps": 25, + "cfg": 4, + "ip_adapter_weight": 0.8, + "ip_adapter_weight_type": "style transfer precise", + "output_format": "png", + }, + }, +} + + +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") diff --git a/art-agent/backend/app/db.py b/art-agent/backend/app/db.py new file mode 100644 index 0000000..7ea52ee --- /dev/null +++ b/art-agent/backend/app/db.py @@ -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() diff --git a/art-agent/backend/app/main.py b/art-agent/backend/app/main.py new file mode 100644 index 0000000..37335a9 --- /dev/null +++ b/art-agent/backend/app/main.py @@ -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"} diff --git a/art-agent/backend/app/memory.py b/art-agent/backend/app/memory.py new file mode 100644 index 0000000..2cfcb4e --- /dev/null +++ b/art-agent/backend/app/memory.py @@ -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 diff --git a/art-agent/backend/app/services/__init__.py b/art-agent/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/art-agent/backend/app/services/image_gen.py b/art-agent/backend/app/services/image_gen.py new file mode 100644 index 0000000..740bf4a --- /dev/null +++ b/art-agent/backend/app/services/image_gen.py @@ -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, + ) diff --git a/art-agent/backend/app/services/image_prompt_strategy.py b/art-agent/backend/app/services/image_prompt_strategy.py new file mode 100644 index 0000000..48496e6 --- /dev/null +++ b/art-agent/backend/app/services/image_prompt_strategy.py @@ -0,0 +1,139 @@ +"""按生图模型预处理 prompt(与 provider 解耦)。 + +各策略在对应函数中注明依据:厂商文档、Replicate 模型 API 字段说明或社区通用写法。 +统一入口:prepare_image_prompt_for_model。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +# ─── SDXL(Stability / Replicate)──────────────────────────── +# Replicate stability-ai/sdxl 提供独立字段 negative_prompt(见模型 API 页)。 +# 下列为 CLIP 系文生图常见「质量/解剖」排除词,作未显式指定时的基线。 +DEFAULT_SDXL_NEGATIVE = ( + "low quality, worst quality, normal quality, lowres, blurry, jpeg artifacts, " + "watermark, signature, text, logo, deformed, disfigured, bad anatomy, bad hands, " + "extra fingers, mutated, cropped, poorly drawn face" +) + + +@dataclass(frozen=True) +class PreparedImagePrompt: + """下游只读:prompt 必填;negative_prompt 仅部分后端使用(当前为 SDXL)。""" + + prompt: str + negative_prompt: str | None = None + + +def prepare_image_prompt_for_model( + model_config: dict[str, Any], + raw_prompt: str, + *, + has_reference_images: bool = False, +) -> PreparedImagePrompt: + """根据注册表 id 选择策略。未知 id 时原样透传。""" + short_id = model_config.get("id", "") + text = (raw_prompt or "").strip() + if not text: + return PreparedImagePrompt(prompt="") + + dispatch: dict[str, Any] = { + "gpt-image-1.5": _openai_gpt_image, + "gemini-3.1-flash-image": _gemini_native_image, + "flux-schnell": _flux_bfl, + "flux-dev": _flux_bfl, + "sdxl": _sdxl_replicate, + "instant-style": _replicate_ip_adapter_scene, + "kolors-ipadapter": _replicate_ip_adapter_scene, + } + fn = dispatch.get(short_id, _passthrough) + return fn(text, model_config, has_reference_images) + + +def _passthrough(text: str, _model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt: + return PreparedImagePrompt(prompt=text) + + +def _openai_gpt_image(text: str, _model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt: + """OpenAI GPT Image:自然语言指令遵循强,宜为完整、具体的场景描述。 + + 参考:https://platform.openai.com/docs/guides/image-generation + """ + return PreparedImagePrompt(prompt=_collapse_ws(text)) + + +def _gemini_native_image(text: str, _model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt: + """Gemini 原生生图:多模态指令 + 文本;英文描述通常效果稳定。 + + 参考:Google AI 文档 generateContent 与图像输出 modality 说明。 + """ + return PreparedImagePrompt(prompt=_collapse_ws(text)) + + +def _flux_bfl(text: str, _model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt: + """Black Forest Labs FLUX:Subject + Action + Style + Context;靠前放置重点;无 negative API。 + + 参考:https://docs.bfl.ai/guides/prompting_guide_t2i_fundamentals + 若用户从 SDXL 复制了 Negative 段,尽量剥掉以免干扰文意。 + """ + cleaned = _strip_pasted_negative_block(text) + return PreparedImagePrompt(prompt=_collapse_ws(cleaned)) + + +def _sdxl_replicate(text: str, model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt: + """Replicate SDXL:prompt + negative_prompt 双字段;支持显式拆分。 + + 约定(可选):正提示与负提示用单独一行分隔符,便于 Agent/用户手写。 + - ---NEGATIVE--- 或 |||NEG||| + 未拆分时使用 default_params.negative_prompt 或模块默认 DEFAULT_SDXL_NEGATIVE。 + """ + neg_fallback = model_config.get("default_params", {}).get( + "negative_prompt", DEFAULT_SDXL_NEGATIVE + ) + if "---NEGATIVE---" in text: + pos, _, neg = text.partition("---NEGATIVE---") + pos = pos.strip() + neg = neg.strip() + return PreparedImagePrompt( + prompt=_collapse_ws(pos), + negative_prompt=neg or neg_fallback, + ) + if "|||NEG|||" in text: + pos, _, neg = text.partition("|||NEG|||") + pos = pos.strip() + neg = neg.strip() + return PreparedImagePrompt( + prompt=_collapse_ws(pos), + negative_prompt=neg or neg_fallback, + ) + return PreparedImagePrompt( + prompt=_collapse_ws(text), + negative_prompt=neg_fallback, + ) + + +def _replicate_ip_adapter_scene( + text: str, _model_config: dict[str, Any], has_reference_images: bool +) -> PreparedImagePrompt: + """IP-Adapter / InstantStyle / Kolors:参考图承担风格与纹理,prompt 侧重场景与内容语义。 + + Replicate 各模型 README 均强调 prompt + 参考图配合;无参考图时由上层拦截。 + 有参考图时不额外堆叠长前缀,避免稀释主体描述。 + """ + _ = has_reference_images + return PreparedImagePrompt(prompt=_collapse_ws(text)) + + +def _collapse_ws(s: str) -> str: + return " ".join(s.split()) + + +def _strip_pasted_negative_block(text: str) -> str: + lower = text.lower() + for sep in ("\n---negative---\n", "\nnegative prompt:", "\nnegative:"): + idx = lower.find(sep) + if idx != -1: + return text[:idx].strip() + return text diff --git a/art-agent/backend/requirements.txt b/art-agent/backend/requirements.txt new file mode 100644 index 0000000..34bb58b --- /dev/null +++ b/art-agent/backend/requirements.txt @@ -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 diff --git a/art-agent/frontend/next-env.d.ts b/art-agent/frontend/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/art-agent/frontend/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/art-agent/frontend/next.config.ts b/art-agent/frontend/next.config.ts new file mode 100644 index 0000000..9d924f1 --- /dev/null +++ b/art-agent/frontend/next.config.ts @@ -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; diff --git a/art-agent/frontend/package-lock.json b/art-agent/frontend/package-lock.json new file mode 100644 index 0000000..e7a0310 --- /dev/null +++ b/art-agent/frontend/package-lock.json @@ -0,0 +1,1736 @@ +{ + "name": "epeekit-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "epeekit-frontend", + "version": "0.1.0", + "dependencies": { + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "postcss": "^8.5.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.15.tgz", + "integrity": "sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.15.tgz", + "integrity": "sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.15.tgz", + "integrity": "sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.15.tgz", + "integrity": "sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.15.tgz", + "integrity": "sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.15.tgz", + "integrity": "sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.15.tgz", + "integrity": "sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.15.tgz", + "integrity": "sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.15.tgz", + "integrity": "sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", + "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "postcss": "^8.5.6", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz", + "integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.15", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.15", + "@next/swc-darwin-x64": "15.5.15", + "@next/swc-linux-arm64-gnu": "15.5.15", + "@next/swc-linux-arm64-musl": "15.5.15", + "@next/swc-linux-x64-gnu": "15.5.15", + "@next/swc-linux-x64-musl": "15.5.15", + "@next/swc-win32-arm64-msvc": "15.5.15", + "@next/swc-win32-x64-msvc": "15.5.15", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/art-agent/frontend/package.json b/art-agent/frontend/package.json new file mode 100644 index 0000000..758a5c6 --- /dev/null +++ b/art-agent/frontend/package.json @@ -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" + } +} diff --git a/art-agent/frontend/postcss.config.mjs b/art-agent/frontend/postcss.config.mjs new file mode 100644 index 0000000..79bcf13 --- /dev/null +++ b/art-agent/frontend/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/art-agent/frontend/src/app/gallery/page.tsx b/art-agent/frontend/src/app/gallery/page.tsx new file mode 100644 index 0000000..5ed94fc --- /dev/null +++ b/art-agent/frontend/src/app/gallery/page.tsx @@ -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("grid"); + const [sortBy, setSortBy] = useState("time"); + const [selectedTagIds, setSelectedTagIds] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [showFavOnly, setShowFavOnly] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [detailAsset, setDetailAsset] = useState(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 ( +
+ + +
+
+ {/* 工具栏 */} +
+ {/* 区块标题 */} +
+ + + + + + 资源库 +
+ + {/* 搜索 */} +
+ + + + + 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" + /> +
+ + {/* 标签筛选 */} +
+ {tags.map((tag) => { + const active = selectedTagIds.includes(tag.id); + return ( + + ); + })} +
+ +
+ + {/* 收藏筛选 */} + + + {/* 排序 */} + + + {/* 视图切换 */} +
+ + +
+
+ + {/* 批量操作栏 */} + {selectedIds.size > 0 && ( +
+ + + 已选 {selectedIds.size} 项 + +
+ + +
+ )} + + {/* 图片内容区 */} +
+ {filtered.length === 0 ? ( +
+
+
+ + + + + +
+

+ {assets.length === 0 ? "还没有生成过图片" : "没有匹配的资源"} +

+
+
+ ) : viewMode === "grid" ? ( +
+ {filtered.map((asset) => ( +
setDetailAsset(asset)} + > + {/* 选择框 */} +
{ + e.stopPropagation(); + toggleSelect(asset.id); + }} + > +
+ {selectedIds.has(asset.id) && ( + + + + )} +
+
+ + {asset.prompt.slice(0, + + {/* 底部信息 */} +
+
+ {asset.prompt || "无 prompt"} +
+
+ {asset.tags.slice(0, 2).map((tid) => { + const tag = tagMap.get(tid); + if (!tag) return null; + return ( + + {tag.name} + + ); + })} + + {new Date(asset.createdAt).toLocaleDateString("zh-CN")} + +
+
+ + {/* 操作按钮 */} +
+ + +
+
+ ))} +
+ ) : ( +
+ {filtered.map((asset) => ( +
setDetailAsset(asset)} + > +
{ + e.stopPropagation(); + toggleSelect(asset.id); + }} + > +
+ {selectedIds.has(asset.id) && ( + + + + )} +
+
+ + + +
+
+ {asset.prompt || "无 prompt"} +
+
+ {asset.tags.slice(0, 3).map((tid) => { + const tag = tagMap.get(tid); + if (!tag) return null; + return ( + + {tag.name} + + ); + })} +
+
+ + + {new Date(asset.createdAt).toLocaleDateString("zh-CN")} + + + + + +
+ ))} +
+ )} +
+
+ + {/* 详情 Modal */} + {detailAsset && ( + setDetailAsset(null)} + onToggleFavorite={() => toggleFavorite(detailAsset.id)} + onDownload={() => handleDownload(detailAsset)} + onDelete={() => { + deleteAssetById(detailAsset.id); + setDetailAsset(null); + }} + /> + )} +
+
+ ); +} + +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 ( +
+
e.stopPropagation()} + > + {/* 头部 */} +
+ 资源详情 + +
+ + {/* 内容 */} +
+
+
+ {asset.prompt} +
+ +
+ {/* 操作按钮 */} +
+ + + +
+ + {/* Prompt */} + {asset.prompt && ( +
+
+ Prompt + +
+
+ {asset.prompt} +
+
+ )} + + {/* 标签 */} + {asset.tags.length > 0 && ( +
+ 标签 +
+ {asset.tags.map((tid) => { + const tag = tagMap.get(tid); + if (!tag) return null; + return ( + + {tag.name} + + ); + })} +
+
+ )} + + {/* 元信息 */} +
+
创建时间:{new Date(asset.createdAt).toLocaleString("zh-CN")}
+
会话 ID:{asset.sessionId}
+
+
+
+
+
+
+ ); +} diff --git a/art-agent/frontend/src/app/globals.css b/art-agent/frontend/src/app/globals.css new file mode 100644 index 0000000..ffbe1c1 --- /dev/null +++ b/art-agent/frontend/src/app/globals.css @@ -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: ""; + 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; + } +} diff --git a/art-agent/frontend/src/app/layout.tsx b/art-agent/frontend/src/app/layout.tsx new file mode 100644 index 0000000..c6ec6a8 --- /dev/null +++ b/art-agent/frontend/src/app/layout.tsx @@ -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 ( + + + + + {children} + + + + + ); +} diff --git a/art-agent/frontend/src/app/login/page.tsx b/art-agent/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..52f0c4f --- /dev/null +++ b/art-agent/frontend/src/app/login/page.tsx @@ -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 ( +
+ {/* 背景环境光 */} +
+
+
+ +
+
+ {/* Logo 发光 */} +
+ + + + + + +
+

EPEEKit

+

AI 美术资源生成工具

+
+ +
+
+ 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" + /> +
+
+ 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" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+ ); +} diff --git a/art-agent/frontend/src/app/page.tsx b/art-agent/frontend/src/app/page.tsx new file mode 100644 index 0000000..0600c65 --- /dev/null +++ b/art-agent/frontend/src/app/page.tsx @@ -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([]); + const [statusText, setStatusText] = useState(""); + const [pendingAnnotation, setPendingAnnotation] = useState(null); + const [lastRefImageUrls, setLastRefImageUrls] = useState([]); + const lastRefPerSession = useRef>(new Map()); + const [mainDragging, setMainDragging] = useState(false); + const dragCounter = useRef(0); + const chatInputRef = useRef(null); + const scrollRef = useRef(null); + const scrollPositions = useRef>(new Map()); + const prevSessionId = useRef(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 ( +
+ + +
+ + +
{ + 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 && ( +
+
+ + + + + +

松开以添加参考图(可多张)

+
+
+ )} + + {/* 桌面端侧边栏展开按钮 */} + {sidebarCollapsed && ( + + )} + + {/* 标注提示条 */} + {pendingAnnotation && ( +
+ 标注预览 + + 标注已就绪({pendingAnnotation.annotations.length} 处)— 输入修改意见后发送 + + +
+ )} + +
+ {messages.length === 0 && !isLoading ? ( +
+
+ {/* Logo 发光效果 */} +
+ + + + + + +
+

+ 欢迎使用 EPEEKit +

+

+ 描述你想要的美术资源,我来帮你生成。 +
+ 你可以上传参考图来引导风格方向。 +

+
+ {[ + "画一个赛博朋克风格的退出按钮", + "设计一个卡通风格的金币图标", + "画一个奇幻风格的游戏角色立绘", + ].map((hint) => ( + + ))} +
+
+
+ ) : ( + + )} +
+ + {showScrollBtn && ( + + )} + + getImageUrl(u))} + onClearLastRefImage={() => { + if (activeSessionId) lastRefPerSession.current.delete(activeSessionId); + setLastRefImageUrls([]); + }} + onFileDrop={() => { + dragCounter.current = 0; + setMainDragging(false); + }} + /> +
+ + {detailImage && ( + + )} +
+
+ ); +} diff --git a/art-agent/frontend/src/components/chat/chat-input.tsx b/art-agent/frontend/src/components/chat/chat-input.tsx new file mode 100644 index 0000000..8c9336c --- /dev/null +++ b/art-agent/frontend/src/components/chat/chat-input.tsx @@ -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(function ChatInput( + { onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop }, + ref +) { + const [text, setText] = useState(""); + const [uploads, setUploads] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const [selectedModel, setSelectedModel] = useState(""); + const fileInputRef = useRef(null); + const abortRefs = useRef>(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) => { + 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 ( +
+ {isDragging && ( +
+ 松开以添加参考图(可多张) +
+ )} + + {/* 沿用上次参考图提示 */} + {uploads.length === 0 && lastRefPreviewUrls && lastRefPreviewUrls.length > 0 && ( +
+
+ {lastRefPreviewUrls.map((url, i) => ( + {`沿用参考图 + ))} +
+ + 沿用上次参考图({lastRefPreviewUrls.length} 张) + + +
+ )} + + {/* 参考图上传状态区(多图) */} + {uploads.length > 0 && ( +
+ {uploads.map((item) => ( +
+ {item.previewUrl && ( + 参考图 + )} + {item.status === "uploading" && ( +
+ + + + +
+ )} + {item.status === "done" && ( +
+ ✓ +
+ )} + {item.status === "error" && ( +
+ ! +
+ )} + {item.status !== "uploading" && ( + + )} +
+ ))} + {/* 汇总状态 */} +
+ {hasActiveUpload && ( + + + 上传中... + + )} + {allDone && ( + + {doneUrls.length} 张参考图已就绪 + + )} + {uploads.some((u) => u.status === "error") && ( + + {uploads.filter((u) => u.status === "error").length} 张上传失败 + + )} +
+
+ )} + +
+ + + {/* 上传参考图按钮 */} + + + + {/* 文字输入框 */} +