diff --git a/.cursor/changelog/changelog-full.md b/.cursor/changelog/changelog-full.md index 5368f7d..0b4cd32 100644 --- a/.cursor/changelog/changelog-full.md +++ b/.cursor/changelog/changelog-full.md @@ -4,6 +4,328 @@ ## 记录 +### [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**: diff --git a/.cursor/changelog/changelog-headlines.md b/.cursor/changelog/changelog-headlines.md index 2a017d8..f820f76 100644 --- a/.cursor/changelog/changelog-headlines.md +++ b/.cursor/changelog/changelog-headlines.md @@ -2,6 +2,26 @@ 最近 ~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 diff --git a/.cursor/changelog/changelog-recent.md b/.cursor/changelog/changelog-recent.md index a5df874..d15d384 100644 --- a/.cursor/changelog/changelog-recent.md +++ b/.cursor/changelog/changelog-recent.md @@ -3,6 +3,106 @@ 最近 ~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 diff --git a/.cursor/deferred/registry.md b/.cursor/deferred/registry.md index 6e3f05a..e0b31cb 100644 --- a/.cursor/deferred/registry.md +++ b/.cursor/deferred/registry.md @@ -21,16 +21,17 @@ - **chosen_alternative**: 路线 A — 本地运行 + 内网穿透(Cloudflare Tunnel / ngrok) - **deferred_reason**: 当前阶段只需自己和朋友试用,内网穿透足够 -### [style-pipeline-layered] 分层组合风格一致性产线 +### [style-pipeline-layered] 分层组合风格一致性产线(含便利选取 + LoRA 训练) - **status**: deferred -- **tags**: 风格一致性, IP-Adapter, LoRA, ControlNet, 量产, 美术产线, InstantStyle +- **tags**: 风格一致性, 风格选取, IP-Adapter, LoRA, ControlNet, 量产, 美术产线, InstantStyle, 风格库, 训练 - **recorded**: 2026-04-12 -- **updated**: 2026-04-13 +- **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 + 参考图,快速试错确定风格方向 @@ -45,9 +46,64 @@ - 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, 自定义模型, 风格迁移 diff --git a/.cursor/hooks/check-changelog.ps1 b/.cursor/hooks/check-changelog.ps1 index fbda0f1..9243fc2 100644 --- a/.cursor/hooks/check-changelog.ps1 +++ b/.cursor/hooks/check-changelog.ps1 @@ -1,4 +1,4 @@ -# Changelog sync guard — 统一 stop hook(合并了原 prompt hook 和 command hook) +# Changelog sync guard — 统一 stop hook # # 用确定性逻辑检查: # 1. 源文件是否比 changelog 更新(mtime 比较) @@ -10,6 +10,7 @@ # 3. changelog 文件不存在 # 4. .changelog-ack 标记文件存在且足够新(本会话已确认过 changelog 状态) # 5. 没有源文件比 changelog 更新 +# 6. stdin 上下文中没有文件编辑操作的证据(防止跨会话残留 mtime 误触发) $input = [Console]::In.ReadToEnd() @@ -58,6 +59,20 @@ if ((Test-Path $ackFile) -and (Get-Item $ackFile).LastWriteTime -ge $clMtime) { 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") diff --git a/.cursor/skills/epee-orchestrator/registry.md b/.cursor/skills/epee-orchestrator/registry.md index 285cdc5..4934ab2 100644 --- a/.cursor/skills/epee-orchestrator/registry.md +++ b/.cursor/skills/epee-orchestrator/registry.md @@ -51,8 +51,8 @@ ### project-launcher - **类型**: 项目级 -- **能力**: 一键在独立可见终端窗口中启动 Art Agent 前端和后端服务 +- **能力**: 一键启动 Art Agent 全部服务(Ollama + 后端 FastAPI + 前端 Next.js),自动检测已运行的服务并跳过 - **触发场景**: "启动项目"、"运行项目"、"跑起来"、"打开前端和后端"、"启动服务"、"start"、"launch"、"run dev"、"启动前端"、"启动后端" -- **输出**: 两个独立的终端窗口(前端 Next.js + 后端 FastAPI) +- **输出**: Ollama 后台服务 + 两个独立的终端窗口(前端 Next.js + 后端 FastAPI) - **路径**: .cursor/skills/project-launcher/SKILL.md -- **备注**: 项目专属 Skill;窗口必须可见,用户可直接查看日志和手动关闭 +- **备注**: 项目专属 Skill;Ollama 必须先于后端启动(Mem0 embedding 依赖);前后端窗口必须可见 diff --git a/.cursor/skills/project-launcher/SKILL.md b/.cursor/skills/project-launcher/SKILL.md index 804cbca..5867fc2 100644 --- a/.cursor/skills/project-launcher/SKILL.md +++ b/.cursor/skills/project-launcher/SKILL.md @@ -22,13 +22,15 @@ description: >- | 组件 | 路径 | 启动命令 | |------|------|---------| +| 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。 +- 后端需要激活 Python 虚拟环境(`art-agent/backend/venv`) +- 前端需要 Node.js >= 18 +- **Ollama 必须在后端之前启动**:Mem0 记忆系统依赖 Ollama 提供本地 embedding 服务(`nomic-embed-text` 模型),端口 `11434` ## 环境 PATH 须知 @@ -48,18 +50,43 @@ description: >- 2. 确定操作系统和 Shell 类型(Windows / macOS / Linux) -3. 启动后端(在独立可见终端窗口中): +3. 检查并启动 Ollama: + - 检测 Ollama 是否已在运行(请求 http://localhost:11434/api/tags) + - 未运行 → 启动 Ollama 服务,等待就绪 + - 已运行 → 跳过 + +4. 启动后端(在独立可见终端窗口中): - Windows(PowerShell 或 CMD 均适用): 使用 `Start-Process` 或 `start cmd` 打开新的终端窗口 - macOS/Linux: 使用对应的终端打开方式 -4. 启动前端(在另一个独立可见终端窗口中): +5. 启动前端(在另一个独立可见终端窗口中): - 同样在新的终端窗口中启动 -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 内置终端后台运行。 @@ -118,6 +145,7 @@ osascript -e 'tell application "Terminal" to do script "cd FRONTEND_PATH && npm ## 访问信息 启动完成后告知用户: +- Ollama:http://localhost:11434(Mem0 embedding 服务) - 后端 API:http://localhost:8000 - 后端文档:http://localhost:8000/docs - 前端页面:http://localhost:3000 @@ -129,3 +157,4 @@ osascript -e 'tell application "Terminal" to do script "cd FRONTEND_PATH && npm ### 已知必要检查 1. **Node.js PATH 注入** — 本机 Node.js (`C:\Program Files\nodejs\`) 未加入系统 PATH,新开的终端窗口默认找不到 `npm`。启动前端时必须先将此路径注入到会话 PATH 中。 +2. **Ollama 必须先于后端启动** — Mem0 记忆系统依赖 Ollama 的 `nomic-embed-text` 模型做本地 embedding(端口 11434)。Ollama 未运行时后端能启动但对话会报 502 错误。启动流程必须在后端之前检测并启动 Ollama。 diff --git a/art-agent/backend/.env.example b/art-agent/backend/.env.example index 331e0af..02dfbb6 100644 --- a/art-agent/backend/.env.example +++ b/art-agent/backend/.env.example @@ -37,6 +37,13 @@ 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 diff --git a/art-agent/backend/app/agent/loop.py b/art-agent/backend/app/agent/loop.py index b8b2337..e89ebea 100644 --- a/art-agent/backend/app/agent/loop.py +++ b/art-agent/backend/app/agent/loop.py @@ -11,7 +11,7 @@ from openai import AsyncOpenAI from app.agent.tools import TOOL_DEFINITIONS, execute_tool from app.config import ( - get_llm_model, + get_llm_model_config, get_llm_max_iterations, get_image_model_config, get_max_recent_turns, @@ -22,9 +22,19 @@ from app.services.image_gen import to_data_uri logger = logging.getLogger(__name__) -def _get_client() -> AsyncOpenAI: - base_url = os.getenv("OPENAI_BASE_URL") - return AsyncOpenAI(base_url=base_url) if base_url else AsyncOpenAI() +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 助手。你的工作是帮助美术人员通过对话生成游戏美术资源。 @@ -33,7 +43,8 @@ SYSTEM_PROMPT = """\ - 根据用户的文字描述生成图片(UI图标、按钮、插画、立绘、概念图等) - 理解用户的审美意图,将中文描述转化为高质量的英文生成 prompt - 根据用户反馈迭代修改(调整颜色、风格、构图等) -- 如果用户提供了参考图,将参考图的风格元素融入生成 prompt +- 如果用户提供了参考图(支持多张),将参考图的风格元素融入生成 prompt +- 理解多张参考图各自的角色(如"图1的主体 + 图2的风格/视角"),并在 prompt 中准确传达 - 理解用户在图片上的标注(框选区域 + 文字批注),精准定位需要修改的部分 ## 工作流程 @@ -54,23 +65,25 @@ SYSTEM_PROMPT = """\ - 必须使用英文 - 尽量详细描述:主体内容、颜色方案、光照、构图、材质等 - 如果用户要求游戏 UI 元素,添加相关关键词如 "game UI", "icon", "button" 等 -- 如果你能直接看到参考图(图片内容),可以在 prompt 中描述参考图的风格特征 -- 如果你无法看到参考图(只收到了文字提示说有参考图),参考图会由生图工具的 IP-Adapter 自动处理风格融合。此时你不要自行猜测画风/艺术风格关键词(如 pixel art、watercolor、oil painting 等),把风格交给参考图来决定。但如果用户在消息中明确指定了风格(如"赛博朋克风"、"水彩风"等),应保留并翻译到 prompt 中——尊重用户的主动意图 +- 如果你能直接看到参考图(图片内容),可以在 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_url: Optional[str] = None, + 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 结果。 @@ -114,10 +127,16 @@ async def run_agent_loop( 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 ── - model_config = get_image_model_config(image_model) - current_model_name = model_config.get('name', '未知') - current_model_id = model_config.get('id', '未知') + 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}**" @@ -127,43 +146,39 @@ async def run_agent_loop( ) api_messages = [{"role": "system", "content": SYSTEM_PROMPT + model_hint + memory_block}] - # 检测当前 LLM 是否支持 vision(多模态图片输入) - llm_model = get_llm_model().lower() - vision_capable = any(kw in llm_model for kw in ("gpt-4o", "gpt-4-vision", "claude")) + effective_refs = ref_image_urls or [] for msg in messages: - if msg["role"] == "user" and ref_image_url and msg is messages[-1]: + if msg["role"] == "user" and effective_refs and msg is messages[-1]: if vision_capable: - api_messages.append({ - "role": "user", - "content": [ - {"type": "text", "text": msg["content"]}, - { - "type": "image_url", - "image_url": {"url": to_data_uri(ref_image_url)}, - }, - ], - }) + 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" - "【系统提示:用户上传了一张参考图,已自动传递给图片生成工具的 IP-Adapter。" - "IP-Adapter 会从参考图中提取风格并融合到生成结果中。" - "你无法看到这张参考图,因此在生成 prompt 时:\n" + f"【系统提示:用户上传了 {n_refs} 张参考图,已自动传递给图片生成工具。" + "生图工具会根据模型能力自动处理参考图的风格融合。" + "你无法看到这些参考图,因此在生成 prompt 时:\n" "1. 描述画面内容(主体、构图、光照、材质等)\n" "2. 不要自行猜测画风/艺术风格——但如果用户明确指定了风格,保留到 prompt 中\n" - "3. 用户未指定风格时,风格完全由参考图通过 IP-Adapter 决定】" + "3. 用户未指定风格时,风格完全由参考图决定】" ) api_messages.append({"role": "user", "content": hint}) continue api_messages.append({"role": msg["role"], "content": msg["content"]}) - client = _get_client() + client = _get_client(llm_provider) for _ in range(get_llm_max_iterations()): try: response = await client.chat.completions.create( - model=get_llm_model(), + model=llm_model_id, messages=api_messages, tools=TOOL_DEFINITIONS, stream=True, @@ -219,7 +234,7 @@ async def run_agent_loop( "你刚才没有调用 generate_image 工具,只是用文字描述了生成过程。" "请立即调用 generate_image 工具来实际生成图片。" "不要解释,直接调用工具。" - f"当前使用的生图模型是 {model_config.get('name', '未知')}。" + f"当前使用的生图模型是 {img_model_config.get('name', '未知')}。" ), }) continue @@ -263,7 +278,7 @@ async def run_agent_loop( except json.JSONDecodeError: arguments = {} - result = await execute_tool(tool_name, arguments, ref_image_url, image_model) + result = await execute_tool(tool_name, arguments, effective_refs or None, image_model) used_model = result.get("model_name", "") diff --git a/art-agent/backend/app/agent/tools.py b/art-agent/backend/app/agent/tools.py index 90e2ef8..829a2e5 100644 --- a/art-agent/backend/app/agent/tools.py +++ b/art-agent/backend/app/agent/tools.py @@ -11,6 +11,8 @@ TOOL_DEFINITIONS = [ "description": ( "根据文字描述生成图片。prompt 必须是英文。" "如果用户提供了参考图,会自动传入 ref_image_url 参数。" + "若当前生图模型为 Stable Diffusion XL,可在正提示后单独一行写 ---NEGATIVE--- 再写负向提示;" + "不传则服务端会使用该模型的默认负向词。" ), "parameters": { "type": "object", @@ -37,7 +39,7 @@ TOOL_DEFINITIONS = [ async def execute_tool( tool_name: str, arguments: dict, - ref_image_url: str | None = None, + ref_image_urls: list[str] | None = None, image_model: str | None = None, ) -> dict: """执行工具调用,返回结果。""" @@ -47,7 +49,7 @@ async def execute_tool( result = await generate_images( prompt=prompt, num_images=num_images, - ref_image_url=ref_image_url, + ref_image_urls=ref_image_urls, model_id=image_model, ) valid_urls = [u for u in result.urls if not u.startswith("[")] @@ -57,6 +59,8 @@ async def execute_tool( "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, } diff --git a/art-agent/backend/app/api/chat.py b/art-agent/backend/app/api/chat.py index 202e8a5..21c18ae 100644 --- a/art-agent/backend/app/api/chat.py +++ b/art-agent/backend/app/api/chat.py @@ -8,7 +8,12 @@ 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 +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() @@ -49,13 +54,24 @@ async def list_models(current_user: User = Depends(get_current_user)): } +@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), ): """ @@ -64,25 +80,30 @@ async def chat( 参数: - messages: JSON 字符串,对话历史 [{role, content}] - ref_image: 可选的参考图文件(兼容旧方式) - - ref_image_url: 可选,已通过 /upload-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_url: Optional[str] = None - if ref_image_url: - resolved_ref_url = ref_image_url + 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_url = await _save_upload(ref_image) + resolved_ref_urls = [await _save_upload(ref_image)] async def event_generator(): async for event in run_agent_loop( parsed_messages, - resolved_ref_url, + 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"], 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/config.py b/art-agent/backend/app/config.py index 5184639..8bbd431 100644 --- a/art-agent/backend/app/config.py +++ b/art-agent/backend/app/config.py @@ -7,14 +7,108 @@ import os from typing import Any -def get_llm_model() -> str: - return os.getenv("LLM_MODEL", "gpt-4o-mini") - - 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() + ] + + # ─── 图像模型注册表 ───────────────────────────────────── # # 每个模型的配置说明: @@ -29,6 +123,29 @@ def get_llm_max_iterations() -> int: # 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", diff --git a/art-agent/backend/app/main.py b/art-agent/backend/app/main.py index d67082b..37335a9 100644 --- a/art-agent/backend/app/main.py +++ b/art-agent/backend/app/main.py @@ -12,6 +12,7 @@ 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 @@ -45,6 +46,7 @@ app.mount("/generated", StaticFiles(directory=str(GENERATED_DIR)), name="generat 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") diff --git a/art-agent/backend/app/services/image_gen.py b/art-agent/backend/app/services/image_gen.py index 9e6faa7..740bf4a 100644 --- a/art-agent/backend/app/services/image_gen.py +++ b/art-agent/backend/app/services/image_gen.py @@ -1,5 +1,6 @@ """图像生成服务 — Provider 抽象层。""" +import asyncio import base64 import mimetypes import os @@ -9,9 +10,11 @@ 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_model_config, get_image_aspect_ratio, get_image_output_format +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" @@ -57,6 +60,43 @@ def to_data_uri(image_path: str) -> str: 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" @@ -80,9 +120,10 @@ class ImageProvider(ABC): model_config: dict[str, Any], prompt: str, num_images: int = 1, - ref_image_url: str | None = None, + ref_image_urls: list[str] | None = None, + negative_prompt: str | None = None, ) -> list[str]: - """生成图片并返回本地 URL 列表。""" + """生成图片并返回本地 URL 列表。negative_prompt 仅部分后端使用(如 Replicate SDXL)。""" ... @@ -94,7 +135,8 @@ class ReplicateProvider(ImageProvider): model_config: dict[str, Any], prompt: str, num_images: int = 1, - ref_image_url: str | None = None, + 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", {}) @@ -102,8 +144,15 @@ class ReplicateProvider(ImageProvider): 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, ref_image_url + model_config, + default_params, + prompt, + num_images, + single_ref, + negative_prompt=negative_prompt, ) output = await _replicate_client.async_run( @@ -131,20 +180,18 @@ class ReplicateProvider(ImageProvider): prompt: str, num_images: int = 1, ref_image_url: str | None = None, + negative_prompt: str | None = None, ) -> dict[str, Any]: """ 根据模型配置构建 Replicate input 参数。 - 通过 model_config 中的标志字段自动适配不同模型: - - supports_ref_image / ref_image_param: 参考图注入 - - num_images_param: 各模型的批量生成参数名(如 number_of_images / num_outputs) + 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") - # ── 支持参考图的模型(IP-Adapter 系列)── if supports_ref: params: dict[str, Any] = {"prompt": prompt} for k, v in default_params.items(): @@ -154,7 +201,6 @@ class ReplicateProvider(ImageProvider): params[ref_param_name] = to_data_uri(ref_image_url) return params - # ── Flux 系列(纯文生图)── is_flux = "flux" in model_id.lower() params = {"prompt": prompt, num_images_param: num_images} @@ -166,21 +212,281 @@ class ReplicateProvider(ImageProvider): "output_format", get_image_output_format() ) else: - # SDXL 类模型 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(), } @@ -189,21 +495,32 @@ _PROVIDERS: dict[str, ImageProvider] = { class GenerateResult: """图片生成结果,包含生成的 URL 列表和实际使用的模型信息。""" - def __init__(self, urls: list[str], model_name: str, model_id: str): + 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_url: str | None = None, + 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) @@ -216,5 +533,41 @@ async def generate_images( if not provider: return GenerateResult([f"[未知 provider: {provider_name}]"], model_name, resolved_id) - urls = await provider.generate(config, prompt, num_images, ref_image_url) - return GenerateResult(urls, 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/frontend/src/app/gallery/page.tsx b/art-agent/frontend/src/app/gallery/page.tsx index add8c6c..5ed94fc 100644 --- a/art-agent/frontend/src/app/gallery/page.tsx +++ b/art-agent/frontend/src/app/gallery/page.tsx @@ -85,7 +85,7 @@ export default function GalleryPage() { document.body.removeChild(a); window.URL.revokeObjectURL(url); } catch { - // 忽略单张下载失败 + // ignore } } }; @@ -109,17 +109,29 @@ export default function GalleryPage() { }; return ( -
+
- {/* 主内容 */}
{/* 工具栏 */} -
+ {/* 区块标题 */} +
+ + + + + + 资源库 +
+ {/* 搜索 */} -
+
@@ -128,14 +140,14 @@ export default function GalleryPage() { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索 Prompt..." - className="flex-1 px-2 py-1 text-sm bg-transparent border-none + 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 ( @@ -146,10 +158,10 @@ export default function GalleryPage() { active ? prev.filter((id) => id !== tag.id) : [...prev, tag.id] ) } - className="px-2 py-0.5 text-[10px] rounded-full cursor-pointer border transition-colors" + className="px-2.5 py-1 text-[11px] rounded-lg cursor-pointer border transition-all font-medium" style={{ - backgroundColor: active ? tag.color + "22" : "transparent", - borderColor: active ? tag.color : "var(--border)", + backgroundColor: active ? tag.color + "18" : "transparent", + borderColor: active ? tag.color + "60" : "var(--border)", color: active ? tag.color : "var(--text-secondary)", }} > @@ -164,11 +176,12 @@ export default function GalleryPage() { {/* 收藏筛选 */} @@ -231,15 +249,19 @@ export default function GalleryPage() {
@@ -247,45 +269,53 @@ export default function GalleryPage() { )} {/* 图片内容区 */} -
+
{filtered.length === 0 ? (
-
-
📁
+
+
+ + + + + +

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

) : viewMode === "grid" ? ( -
+
{filtered.map((asset) => (
setDetailAsset(asset)} > {/* 选择框 */}
{ e.stopPropagation(); toggleSelect(asset.id); }} >
{selectedIds.has(asset.id) && ( - + )} @@ -295,24 +325,25 @@ export default function GalleryPage() { {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} @@ -324,16 +355,17 @@ export default function GalleryPage() {
- {/* 操作按钮:移动端始终可见 */} -
) : ( - /* 列表视图 */ -
+
{filtered.map((asset) => (
setDetailAsset(asset)} > @@ -377,14 +409,14 @@ export default function GalleryPage() { }} >
{selectedIds.has(asset.id) && ( - + )} @@ -394,7 +426,7 @@ export default function GalleryPage() { @@ -409,8 +441,8 @@ export default function GalleryPage() { return ( {tag.name} @@ -428,8 +460,8 @@ export default function GalleryPage() { e.stopPropagation(); toggleFavorite(asset.id); }} - className="cursor-pointer flex-shrink-0" - style={{ color: asset.favorited ? "#f59e0b" : "var(--text-secondary)" }} + className="cursor-pointer flex-shrink-0 transition-colors" + style={{ color: asset.favorited ? "var(--accent)" : "var(--text-secondary)" }} > @@ -441,7 +473,7 @@ export default function GalleryPage() { e.stopPropagation(); handleDownload(asset); }} - className="cursor-pointer text-[var(--text-secondary)] hover:text-[var(--text-primary)] flex-shrink-0" + className="cursor-pointer text-[var(--text-secondary)] hover:text-[var(--accent)] flex-shrink-0 transition-colors" > @@ -473,7 +505,6 @@ export default function GalleryPage() { ); } -// 资源详情 Modal function GalleryDetailModal({ asset, tags, @@ -493,20 +524,22 @@ function GalleryDetailModal({ return (
e.stopPropagation()} > {/* 头部 */} -
- 资源详情 +
+ 资源详情 @@ -570,17 +601,17 @@ function GalleryDetailModal({ {/* Prompt */} {asset.prompt && (
-
+
Prompt
-
{asset.prompt}
@@ -590,16 +621,16 @@ function GalleryDetailModal({ {/* 标签 */} {asset.tags.length > 0 && (
- 标签 -
+ 标签 +
{asset.tags.map((tid) => { const tag = tagMap.get(tid); if (!tag) return null; return ( {tag.name} @@ -610,9 +641,9 @@ function GalleryDetailModal({ )} {/* 元信息 */} -
+
创建时间:{new Date(asset.createdAt).toLocaleString("zh-CN")}
-
会话 ID:{asset.sessionId}
+
会话 ID:{asset.sessionId}
diff --git a/art-agent/frontend/src/app/globals.css b/art-agent/frontend/src/app/globals.css index 36b075a..ffbe1c1 100644 --- a/art-agent/frontend/src/app/globals.css +++ b/art-agent/frontend/src/app/globals.css @@ -1,25 +1,200 @@ @import "tailwindcss"; +/* ===== 青绿山水色彩体系 ===== */ :root { - --bg-primary: #0a0a0a; - --bg-secondary: #141414; - --bg-tertiary: #1e1e1e; - --text-primary: #e5e5e5; - --text-secondary: #a3a3a3; - --accent: #6366f1; - --accent-hover: #818cf8; - --border: #2e2e2e; + --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: system-ui, -apple-system, sans-serif; + 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; @@ -28,17 +203,18 @@ body { background: transparent; } ::-webkit-scrollbar-thumb { - background: var(--border); + background: rgba(77, 184, 164, 0.15); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { + background: rgba(77, 184, 164, 0.28); +} -/* 原生 select 下拉框暗色 */ select option { background: var(--bg-secondary); color: var(--text-primary); } -/* 移动端侧边栏遮罩动画 */ .sidebar-overlay { animation: fadeIn 200ms ease-out; } @@ -46,8 +222,11 @@ select option { from { opacity: 0; } to { opacity: 1; } } +@keyframes slideUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} -/* 移动端防止 iOS 地址栏 bounce */ @supports (height: 100dvh) { .h-screen { height: 100dvh; diff --git a/art-agent/frontend/src/app/login/page.tsx b/art-agent/frontend/src/app/login/page.tsx index 81d6e4e..52f0c4f 100644 --- a/art-agent/frontend/src/app/login/page.tsx +++ b/art-agent/frontend/src/app/login/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, type FormEvent } from "react"; +import { useState, useEffect, type FormEvent } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth-context"; @@ -12,8 +12,13 @@ export default function LoginPage() { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + useEffect(() => { + if (isAuthenticated) { + router.replace("/"); + } + }, [isAuthenticated, router]); + if (isAuthenticated) { - router.replace("/"); return null; } @@ -33,15 +38,38 @@ export default function LoginPage() { } return ( -
-
+
+ {/* 背景环境光 */} +
+
+
+ +
-
🎨
-

EPEEKit

-

AI 美术资源生成工具

+ {/* Logo 发光 */} +
+ + + + + + +
+

EPEEKit

+

AI 美术资源生成工具

-
+
setUsername(e.target.value)} autoFocus autoComplete="username" - className="w-full px-4 py-3 rounded-lg - bg-[var(--bg-secondary)] border border-[var(--border)] + 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)] - transition-colors" + focus:outline-none focus:border-[var(--accent)]/50 + focus:shadow-[0_0_10px_rgba(77,184,164,0.06)] + transition-all" />
@@ -64,16 +93,18 @@ export default function LoginPage() { value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" - className="w-full px-4 py-3 rounded-lg - bg-[var(--bg-secondary)] border border-[var(--border)] + 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)] - transition-colors" + focus:outline-none focus:border-[var(--accent)]/50 + focus:shadow-[0_0_10px_rgba(77,184,164,0.06)] + transition-all" />
{error && ( -
+
{error}
)} @@ -81,11 +112,12 @@ export default function LoginPage() { diff --git a/art-agent/frontend/src/app/page.tsx b/art-agent/frontend/src/app/page.tsx index a448367..0600c65 100644 --- a/art-agent/frontend/src/app/page.tsx +++ b/art-agent/frontend/src/app/page.tsx @@ -1,8 +1,8 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type DragEvent } from "react"; import { ChatMessages } from "@/components/chat/chat-messages"; -import { ChatInput } from "@/components/chat/chat-input"; +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"; @@ -30,6 +30,11 @@ export default function Home() { 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); @@ -67,13 +72,14 @@ export default function Home() { return () => el.removeEventListener("scroll", handler); }, [activeSessionId]); - // 会话切换:标记 switching,等 DOM 更新后恢复位置 + // 会话切换:标记 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(() => { @@ -95,11 +101,11 @@ export default function Home() { }, [activeSessionId, messages.length]); const handleSend = useCallback( - async (text: string, refImageServerUrl: string | null, imageModel: string | null = null) => { + async (text: string, refImageServerUrls: string[], imageModel: string | null = null) => { if (!activeSessionId || !activeSession) return; let finalText = text; - let finalRefServerUrl = refImageServerUrl; + let finalRefServerUrls = [...refImageServerUrls]; if (pendingAnnotation) { const annotationDescs = pendingAnnotation.annotations @@ -116,14 +122,13 @@ export default function Home() { finalText = `${text}\n\n--- 图片标注 ---\n${annotationDescs}`; } - // 标注截图作为参考图:需要先上传再获取 URL - if (pendingAnnotation.snapshot && !refImageServerUrl) { + 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); - finalRefServerUrl = uploadResult.url; + finalRefServerUrls = [uploadResult.url]; } catch { // 忽略转换/上传失败 } @@ -132,17 +137,18 @@ export default function Home() { setPendingAnnotation(null); } - // 用户消息中的参考图预览:优先使用服务端路径(通过 getImageUrl 转为完整 URL) - let previewUrl: string | undefined; - if (finalRefServerUrl) { - previewUrl = getImageUrl(finalRefServerUrl); + 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, - refImageUrl: previewUrl, + refImageUrls: previewUrls.length > 0 ? previewUrls : undefined, }; appendMessage(activeSessionId, userMessage); setIsLoading(true); @@ -160,8 +166,10 @@ export default function Home() { let collectedImages: ImageAsset[] = []; let usedModelName = ""; + const llmModel = activeSession?.llmModel ?? null; + try { - for await (const event of sendChat(apiMessages, finalRefServerUrl, imageModel, activeSessionId)) { + 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; @@ -261,21 +269,61 @@ export default function Home() { }, []); 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 && (
)} -
+
{messages.length === 0 && !isLoading ? (
-
-
🎨
-

+
+ {/* Logo 发光效果 */} +
+ + + + + + +
+

欢迎使用 EPEEKit

-

+

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

-
+
{[ "画一个赛博朋克风格的退出按钮", "设计一个卡通风格的金币图标", @@ -328,11 +387,12 @@ export default function Home() { ].map((hint) => ( @@ -355,10 +415,10 @@ export default function Home() { )} - + 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 index fe82df5..8c9336c 100644 --- a/art-agent/frontend/src/components/chat/chat-input.tsx +++ b/art-agent/frontend/src/components/chat/chat-input.tsx @@ -1,52 +1,77 @@ "use client"; -import { useRef, useState, useCallback, type KeyboardEvent, type DragEvent } from "react"; +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 UploadState { +interface UploadItem { + id: string; status: UploadStatus; - /** 本地预览 URL */ previewUrl: string | null; - /** 上传成功后的服务端路径 */ serverUrl: string | null; - /** 上传进度 0-100 */ progress: number; - /** 错误信息 */ errorMsg: string | null; } -const INITIAL_UPLOAD: UploadState = { - status: "idle", - previewUrl: null, - serverUrl: null, - progress: 0, - errorMsg: null, -}; - interface ChatInputProps { - onSend: (text: string, refImageServerUrl: string | null, imageModel: string | null) => void; + onSend: (text: string, refImageServerUrls: string[], imageModel: string | null) => void; disabled: boolean; + /** 上次使用的参考图服务端路径列表,用于自动沿用 */ + lastRefServerUrls?: string[]; + /** 上次参考图的完整可预览 URL 列表 */ + lastRefPreviewUrls?: string[]; + /** 用户主动清除沿用参考图时的回调 */ + onClearLastRefImage?: () => void; + /** 文件在 ChatInput 区域内被 drop 时触发,通知父组件清除拖拽覆盖层 */ + onFileDrop?: () => void; } -export function ChatInput({ onSend, disabled }: ChatInputProps) { +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 [upload, setUpload] = useState(INITIAL_UPLOAD); + const [uploads, setUploads] = useState([]); const [isDragging, setIsDragging] = useState(false); const [selectedModel, setSelectedModel] = useState(""); const fileInputRef = useRef(null); - const abortRef = 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 (upload.status === "uploading") return; + if (hasActiveUpload) return; - onSend(trimmed, upload.serverUrl, selectedModel || null); + const refUrls = doneUrls.length > 0 ? doneUrls : (lastRefServerUrls ?? []); + onSend(trimmed, refUrls, selectedModel || null); setText(""); - clearUpload(); + clearAllUploads(); }; const handleKeyDown = (e: KeyboardEvent) => { @@ -56,247 +81,263 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) { } }; - const clearUpload = useCallback(() => { - abortRef.current?.abort(); - abortRef.current = null; - setUpload((prev) => { - if (prev.previewUrl) URL.revokeObjectURL(prev.previewUrl); - return INITIAL_UPLOAD; + 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) => { - abortRef.current?.abort(); + const id = `upload-${++_uploadCounter}`; const controller = new AbortController(); - abortRef.current = controller; + abortRefs.current.set(id, controller); const previewUrl = URL.createObjectURL(file); - setUpload({ + 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) => { - setUpload((prev) => ({ ...prev, progress: p.percent })); + setUploads((prev) => + prev.map((u) => (u.id === id ? { ...u, progress: p.percent } : u)) + ); }, controller.signal ); - setUpload((prev) => ({ - ...prev, - status: "done", - serverUrl: result.url, - progress: 100, - })); + 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 : "上传失败"; - setUpload((prev) => ({ - ...prev, - status: "error", - progress: 0, - errorMsg: msg, - })); + 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 file = e.target.files?.[0]; - if (file) { - startUpload(file); + const files = e.target.files; + if (files) { + Array.from(files).forEach((f) => startUpload(f)); } - // 重置 input value,确保选同一文件也能触发 if (fileInputRef.current) fileInputRef.current.value = ""; }; - const retryUpload = useCallback(() => { - if (fileInputRef.current) fileInputRef.current.click(); - }, []); - - // 拖拽 const handleDragOver = (e: DragEvent) => { e.preventDefault(); + e.stopPropagation(); setIsDragging(true); }; const handleDragLeave = () => setIsDragging(false); const handleDrop = (e: DragEvent) => { e.preventDefault(); + e.stopPropagation(); setIsDragging(false); - const file = e.dataTransfer.files[0]; - if (file && file.type.startsWith("image/")) { - startUpload(file); - } + onFileDrop?.(); + const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/")); + files.forEach((f) => startUpload(f)); }; - const statusLabel = (() => { - switch (upload.status) { - case "uploading": - return `上传中 ${upload.progress}%`; - case "done": - return "参考图已就绪"; - case "error": - return upload.errorMsg || "上传失败"; - default: - return null; - } - })(); - - const statusColor = (() => { - switch (upload.status) { - case "uploading": - return "text-[var(--accent)]"; - case "done": - return "text-green-400"; - case "error": - return "text-red-400"; - default: - return ""; - } - })(); + 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 && (
- 松开以添加参考图 + 松开以添加参考图(可多张)
)} - {/* 参考图上传状态区 */} - {upload.status !== "idle" && ( -
-
- {upload.previewUrl && ( + {/* 沿用上次参考图提示 */} + {uploads.length === 0 && lastRefPreviewUrls && lastRefPreviewUrls.length > 0 && ( +
+
+ {lastRefPreviewUrls.map((url, i) => ( 参考图 - )} - {/* 上传中的遮罩进度环 */} - {upload.status === "uploading" && ( -
- - - - -
- )} - {/* 成功 ✓ 角标 */} - {upload.status === "done" && ( -
- ✓ -
- )} - {/* 失败 ! 角标 */} - {upload.status === "error" && ( -
- ! -
- )} - {/* 删除按钮(非上传中时显示) */} - {upload.status !== "uploading" && ( - - )} + ))}
+ + 沿用上次参考图({lastRefPreviewUrls.length} 张) + + +
+ )} -
- - {upload.status === "uploading" && ( - - )} - {statusLabel} - - {/* 进度条 */} - {upload.status === "uploading" && ( -
-
0 && ( +
+ {uploads.map((item) => ( +
+ {item.previewUrl && ( + 参考图 -
+ )} + {item.status === "uploading" && ( +
+ + + + +
+ )} + {item.status === "done" && ( +
+ ✓ +
+ )} + {item.status === "error" && ( +
+ ! +
+ )} + {item.status !== "uploading" && ( + + )} +
+ ))} + {/* 汇总状态 */} +
+ {hasActiveUpload && ( + + + 上传中... + )} - {/* 失败时显示重试 */} - {upload.status === "error" && ( - + {allDone && ( + + {doneUrls.length} 张参考图已就绪 + + )} + {uploads.some((u) => u.status === "error") && ( + + {uploads.filter((u) => u.status === "error").length} 张上传失败 + )}
)}
- {/* 模型选择器 */} + {/* 上传参考图按钮 */} @@ -306,15 +347,17 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) { value={text} onChange={(e) => setText(e.target.value)} onKeyDown={handleKeyDown} - placeholder="描述你想要的美术资源..." + placeholder="描述你想要的美术资源...(可粘贴图片)" + title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图" disabled={disabled} rows={1} - className="flex-1 resize-none rounded-lg border border-[var(--border)] + className="flex-1 resize-none rounded-xl border border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] px-4 py-2.5 text-sm leading-relaxed - focus:outline-none focus:border-[var(--accent)] - disabled:opacity-50 transition-colors + focus:outline-none focus:border-[var(--accent)]/50 + focus:shadow-[0_0_10px_rgba(77,184,164,0.06)] + disabled:opacity-50 transition-all min-h-[42px] max-h-[120px]" style={{ fieldSizing: "content" } as React.CSSProperties} /> @@ -322,11 +365,12 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) { {/* 发送按钮 */}
); -} +}); diff --git a/art-agent/frontend/src/components/chat/chat-messages.tsx b/art-agent/frontend/src/components/chat/chat-messages.tsx index 1e00941..c7e4354 100644 --- a/art-agent/frontend/src/components/chat/chat-messages.tsx +++ b/art-agent/frontend/src/components/chat/chat-messages.tsx @@ -13,19 +13,19 @@ function CopyButton({ text }: { text: string }) { setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { - // fallback 不做处理 + // fallback } }, [text]); return (
+ + {/* 最新生成标签 */} + {i === 0 && images.length > 1 && ( +
+ + New + +
+ )}
))}
diff --git a/art-agent/frontend/src/components/chat/model-selector.tsx b/art-agent/frontend/src/components/chat/model-selector.tsx index 0607c10..a53b9d1 100644 --- a/art-agent/frontend/src/components/chat/model-selector.tsx +++ b/art-agent/frontend/src/components/chat/model-selector.tsx @@ -33,14 +33,10 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) { onChange(defaultId); } }) - .catch(() => { - // 后端不可用时静默降级 - }); - // 仅初始化时执行一次 + .catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // 点击外部关闭 useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { @@ -61,10 +57,11 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
- {/* 内容滚动区 */}
{/* 图片预览 */}
-
+
预览 {/* 缩放控制 */} -
+
- + {Math.round(scale * 100)}% @@ -129,10 +133,10 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
- {/* Prompt 信息 */} + {/* Prompt */} {detailImage.prompt && (
@@ -194,7 +197,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps 复制
-
{detailImage.prompt}
@@ -205,15 +208,15 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps {detailImage.tags.length > 0 && (
标签 -
+
{detailImage.tags.map((tid) => { const tag = tagMap.get(tid); if (!tag) return null; return ( {tag.name} diff --git a/art-agent/frontend/src/components/layout/top-nav.tsx b/art-agent/frontend/src/components/layout/top-nav.tsx index af64631..6b2dc5e 100644 --- a/art-agent/frontend/src/components/layout/top-nav.tsx +++ b/art-agent/frontend/src/components/layout/top-nav.tsx @@ -5,10 +5,29 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useApp } from "@/lib/app-context"; import { useAuth } from "@/lib/auth-context"; +import { ProfileModal } from "@/components/profile-modal"; const NAV_ITEMS = [ - { href: "/", label: "对话" }, - { href: "/gallery", label: "资源库" }, + { + href: "/", + label: "对话", + icon: ( + + + + ), + }, + { + href: "/gallery", + label: "资源库", + icon: ( + + + + + + ), + }, ] as const; export function TopNav() { @@ -16,6 +35,7 @@ export function TopNav() { const { sidebarCollapsed, setSidebarCollapsed } = useApp(); const { user, logout } = useAuth(); const [menuOpen, setMenuOpen] = useState(false); + const [profileOpen, setProfileOpen] = useState(false); const menuRef = useRef(null); useEffect(() => { @@ -30,12 +50,13 @@ export function TopNav() { }, [menuOpen]); return ( -
+ <> +
{/* 移动端汉堡菜单 */} {/* Logo */} - - - - - - - - + +
+ + + + + + +
EPEEKit {/* 导航 Tab */} -
+ + {profileOpen && setProfileOpen(false)} />} + ); } diff --git a/art-agent/frontend/src/components/profile-modal.tsx b/art-agent/frontend/src/components/profile-modal.tsx new file mode 100644 index 0000000..a0159d9 --- /dev/null +++ b/art-agent/frontend/src/components/profile-modal.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useAuth, type AuthUser } from "@/lib/auth-context"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; + +interface MemoryItem { + id: string; + memory: string; + created_at?: string; + updated_at?: string; +} + +interface GroupedMemories { + label: string; + items: MemoryItem[]; +} + +function groupByTime(memories: MemoryItem[]): GroupedMemories[] { + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const weekAgo = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000); + + const today: MemoryItem[] = []; + const week: MemoryItem[] = []; + const older: MemoryItem[] = []; + + for (const m of memories) { + const d = m.created_at ? new Date(m.created_at) : null; + if (!d || isNaN(d.getTime())) { + older.push(m); + } else if (d >= todayStart) { + today.push(m); + } else if (d >= weekAgo) { + week.push(m); + } else { + older.push(m); + } + } + + const groups: GroupedMemories[] = []; + if (today.length > 0) groups.push({ label: "今天", items: today }); + if (week.length > 0) groups.push({ label: "最近 7 天", items: week }); + if (older.length > 0) groups.push({ label: "更早", items: older }); + return groups; +} + +function formatDate(dateStr?: string): string { + if (!dateStr) return ""; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return ""; + const now = new Date(); + const isThisYear = d.getFullYear() === now.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const hour = String(d.getHours()).padStart(2, "0"); + const min = String(d.getMinutes()).padStart(2, "0"); + if (isThisYear) return `${month}-${day} ${hour}:${min}`; + return `${d.getFullYear()}-${month}-${day} ${hour}:${min}`; +} + +export function ProfileModal({ onClose }: { onClose: () => void }) { + const { user, token } = useAuth(); + const [memories, setMemories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchMemories = useCallback(async () => { + if (!token) return; + setLoading(true); + setError(null); + try { + const resp = await fetch(`${API_URL}/api/memory/list`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + setMemories(data.memories || []); + if (data.error) setError(data.error); + } catch (e) { + setError(e instanceof Error ? e.message : "加载记忆失败"); + } finally { + setLoading(false); + } + }, [token]); + + useEffect(() => { + fetchMemories(); + }, [fetchMemories]); + + useEffect(() => { + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [onClose]); + + if (!user) return null; + + const groups = groupByTime(memories); + + return ( +
+ {/* 遮罩层 */} +
+ + {/* 弹窗主体 */} +
e.stopPropagation()} + > + {/* 关闭按钮 */} + + + {/* 用户信息区 */} +
+
+ {(user.display_name || user.username).charAt(0).toUpperCase()} +
+
+

+ {user.display_name || user.username} +

+
+ @{user.username} + {user.is_admin && ( + + 管理员 + + )} +
+
+
+ + {/* 分割线 */} +
+ + {/* 记忆区标题 */} +
+ + + + + 记忆系统 + {!loading && ( + + {memories.length} 条记录 + + )} +
+ + {/* 记忆列表 */} +
+ {loading && ( +
+
+ 加载中... +
+ )} + + {!loading && error && memories.length === 0 && ( +
+

{error}

+
+ )} + + {!loading && !error && memories.length === 0 && ( +
+ + + + +

暂无记忆记录

+

+ 与助手对话时,系统会自动记录关键信息 +

+
+ )} + + {!loading && groups.map((group) => ( +
+
+ {group.label} +
+
+ {group.items.map((item) => ( +
+

+ {item.memory} +

+ {item.created_at && ( +

+ {formatDate(item.created_at)} +

+ )} +
+ ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/art-agent/frontend/src/components/sidebar/session-list.tsx b/art-agent/frontend/src/components/sidebar/session-list.tsx index 81c9ecf..039de5c 100644 --- a/art-agent/frontend/src/components/sidebar/session-list.tsx +++ b/art-agent/frontend/src/components/sidebar/session-list.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; import { useApp } from "@/lib/app-context"; -import { getImageUrl } from "@/lib/api"; +import { getImageUrl, fetchLlmModels } from "@/lib/api"; +import type { LlmModelInfo } from "@/lib/types"; function timeAgo(ts: number): string { const diff = Date.now() - ts; @@ -20,41 +21,68 @@ export function SessionList() { const { sessions, activeSessionId, tags, selectedTagIds, createSession, switchSession, deleteSession, renameSession, + updateSessionLlmModel, } = useApp(); - const [contextMenu, setContextMenu] = useState<{ x: number; y: number; sessionId: string } | null>(null); + const [menuSessionId, setMenuSessionId] = useState(null); + const [showModelSub, setShowModelSub] = useState(false); + const [llmModels, setLlmModels] = useState([]); + const [defaultLlmModel, setDefaultLlmModel] = useState(""); const [editingId, setEditingId] = useState(null); const [editTitle, setEditTitle] = useState(""); const editRef = useRef(null); + const menuRef = useRef(null); - // 标签过滤 const filtered = selectedTagIds.length === 0 ? sessions : sessions.filter((s) => selectedTagIds.some((tid) => s.tags.includes(tid))); const tagMap = new Map(tags.map((t) => [t.id, t])); - // 关闭右键菜单 useEffect(() => { - const close = () => setContextMenu(null); - window.addEventListener("click", close); - return () => window.removeEventListener("click", close); + fetchLlmModels() + .then((data) => { + setLlmModels(data.models); + setDefaultLlmModel(data.default); + }) + .catch(() => {}); }, []); - // 编辑聚焦 + useEffect(() => { + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + closeMenu(); + } + }; + if (menuSessionId) { + window.addEventListener("mousedown", handler); + return () => window.removeEventListener("mousedown", handler); + } + }, [menuSessionId]); + useEffect(() => { if (editingId) editRef.current?.focus(); }, [editingId]); - const handleContextMenu = (e: React.MouseEvent, sessionId: string) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY, sessionId }); + const closeMenu = useCallback(() => { + setMenuSessionId(null); + setShowModelSub(false); + }, []); + + const handleDots = (e: React.MouseEvent, sessionId: string) => { + e.stopPropagation(); + if (menuSessionId === sessionId) { + closeMenu(); + return; + } + setMenuSessionId(sessionId); + setShowModelSub(false); }; const startRename = (id: string, currentTitle: string) => { setEditingId(id); setEditTitle(currentTitle); - setContextMenu(null); + closeMenu(); }; const commitRename = () => { @@ -64,16 +92,26 @@ export function SessionList() { setEditingId(null); }; + const handleSelectModel = (sessionId: string, modelId: string) => { + updateSessionLlmModel(sessionId, modelId); + closeMenu(); + }; + + const menuSession = menuSessionId + ? sessions.find((s) => s.id === menuSessionId) + : null; + return ( -
+
{/* 新建按钮 */} -
+
{/* 会话列表 */} -
+
{filtered.map((session) => ( -
switchSession(session.id)} - onContextMenu={(e) => handleContextMenu(e, session.id)} - className={`group relative flex items-start gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer - transition-colors ${ - session.id === activeSessionId - ? "bg-[var(--bg-tertiary)]" - : "hover:bg-[var(--bg-tertiary)]/50" - }`} - > - {/* 缩略图 */} - {session.thumbnail ? ( - - ) : ( -
- 💬 -
- )} - - {/* 会话信息 */} -
- {editingId === session.id ? ( - setEditTitle(e.target.value)} - onBlur={commitRename} - onKeyDown={(e) => { - if (e.key === "Enter") commitRename(); - if (e.key === "Escape") setEditingId(null); - }} - className="w-full text-sm bg-transparent border-b border-[var(--accent)] - text-[var(--text-primary)] focus:outline-none" +
+
switchSession(session.id)} + className={`group relative flex items-start gap-3 px-3 py-2.5 rounded-xl cursor-pointer + transition-all ${ + session.id === activeSessionId + ? "glow-border !bg-[var(--bg-secondary)]" + : "hover:bg-[var(--bg-tertiary)]/50 border border-transparent" + }`} + > + {/* 缩略图 */} + {session.thumbnail ? ( + ) : ( -
- {session.title} +
+ + +
)} - {/* 标签 + 时间 */} -
- {session.tags.slice(0, 3).map((tid) => { - const tag = tagMap.get(tid); - if (!tag) return null; - return ( - - {tag.name} + {/* 会话信息 */} +
+ {editingId === session.id ? ( + setEditTitle(e.target.value)} + onBlur={commitRename} + onKeyDown={(e) => { + if (e.key === "Enter") commitRename(); + if (e.key === "Escape") setEditingId(null); + }} + className="w-full text-sm bg-transparent border-b border-[var(--accent)] + text-[var(--text-primary)] focus:outline-none" + /> + ) : ( +
+ {session.title} +
+ )} + + {/* 标签 + 时间 */} +
+ {session.tags.slice(0, 3).map((tid) => { + const tag = tagMap.get(tid); + if (!tag) return null; + return ( + + {tag.name} + + ); + })} + {session.llmModel && ( + + {llmModels.find((m) => m.id === session.llmModel)?.name || session.llmModel} - ); - })} - - {timeAgo(session.updatedAt)} - + )} + + {timeAgo(session.updatedAt)} + +
+ + {/* 三点按钮 */} +
+ + {/* 下拉菜单:在会话项正下方展开 */} + {menuSessionId === session.id && menuSession && ( +
e.stopPropagation()} + > + {/* 模型选择(折叠/展开式) */} + + + {showModelSub && ( +
+ {llmModels.map((model) => { + const isActive = menuSession.llmModel + ? menuSession.llmModel === model.id + : model.id === defaultLlmModel; + return ( + + ); + })} +
+ )} + +
+ + + + +
+ )}
))} {filtered.length === 0 && ( -
+
没有匹配的对话
)}
- - {/* 右键菜单 */} - {contextMenu && ( -
e.stopPropagation()} - > - - -
- )}
); } diff --git a/art-agent/frontend/src/components/sidebar/sidebar.tsx b/art-agent/frontend/src/components/sidebar/sidebar.tsx index ac26628..2a593f5 100644 --- a/art-agent/frontend/src/components/sidebar/sidebar.tsx +++ b/art-agent/frontend/src/components/sidebar/sidebar.tsx @@ -12,30 +12,37 @@ export function Sidebar() { {/* 移动端遮罩 */} {!sidebarCollapsed && (
setSidebarCollapsed(true)} /> )}