加了一堆模型和一堆功能

This commit is contained in:
2026-04-16 00:36:21 +08:00
parent 47c0863bab
commit 40de992516
34 changed files with 3111 additions and 718 deletions

View File

@@ -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 — 新增 GeminiNativeImageProviderGemini 原生 generateContent 接口对接
- **tags**: 后端, Gemini, Provider, 原生API, generateContent, 多图参考, 架构, 向量引擎
- **affected_files**:
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/config.py
- **what**: 新增 `GeminiNativeImageProvider` 类,对接 Gemini 原生 `generateContent` 接口,使 Gemini 3.1 Flash Image 恢复多图参考能力
- **why**: 向量引擎对 Gemini 不支持 OpenAI 兼容的 `images/edit` 端点CL-20260416-0620 回退了此方案。Gemini 原生 API 通过 `generateContent` + `inline_data` 支持文字+图片混合输入,这是唯一可行的路径
- **decisions**:
- 选择直接 HTTP 调用向量引擎中转的 `/v1beta/models/{model}:generateContent` 端点,而非引入 `google-genai` SDK 新依赖——避免依赖膨胀且向量引擎已提供中转
- 新增 `_resolve_image_base64` 公共工具函数统一 data URI / 本地路径到 (mime, base64) 的解析Gemini inline_data 和未来其他 provider 都可复用
- Gemini 响应中同时处理 `inlineData`camelCase`inline_data`snake_case两种字段名因为向量引擎中转可能改变命名风格
- **notes**:
- Provider 注册表现有三个:`replicate``openai``gemini_native`,分发逻辑无需改动
- `generate_images` 中 Replicate 独有的"无参考图报错"逻辑已有 `provider_name == "replicate"` 限定Gemini 无参考图时正常走纯文生图
- API key 复用 `VECTORENGINE_API_KEY`base_url 从 `VECTORENGINE_BASE_URL` 推导(去掉 `/v1` 后拼 `/v1beta/...`
- **source_chat**: [Gemini原生Provider实现](eb8bd100-5a4b-439f-bdc9-077311a456ad)
### [CL-20260416-0620] 2026-04-16 06:20 — 修复拖拽上传重复 + Gemini 改回不支持参考图 + images.edit 参数修正
- **tags**: bug修复, 拖拽, 事件冒泡, Gemini, 向量引擎, images.edit, config, stopPropagation
- **affected_files**:
- art-agent/frontend/src/components/chat/chat-input.tsx
- art-agent/backend/app/config.py
- art-agent/backend/app/services/image_gen.py
- **what**: 三个问题修复拖拽上传重复、Gemini 500 错误、images.edit quality 参数不支持
- **why**: 用户测试多图上传时发现拖 1 张图显示 2 张Gemini 使用参考图时 500 报错
- **decisions**:
- 拖拽重复ChatInput 的 handleDrop/handleDragOver 加 e.stopPropagation(),阻止事件冒泡到 page.tsx 的 onDrop
- Gemini 500向量引擎的 Gemini 图片编辑走原生 `/v1beta/models/xxx:generateContent` 端点,不支持 OpenAI 兼容的 `/v1/images/edits`。Gemini 改回 supports_ref_image=False有参考图时走纯文生图
- images.edit 的 quality 参数OpenAI SDK images.edit 不接受 quality 参数(那是 images.generate 的),移除避免报错
- **notes**:
- Gemini 图片编辑需要对接原生 generateContent 接口才能支持参考图,作为未来方向
- GPT Image 1.5 通过向量引擎 /v1/images/edits 走通的前提是向量引擎正确转发 multipart/form-data
### [CL-20260416-0600] 2026-04-16 06:00 — 多张参考图全链路支持
- **tags**: 前端, 后端, 参考图, 多图, OpenAI, images.edit, Provider, API, Agent Loop, UX
- **affected_files**:
- art-agent/frontend/src/components/chat/chat-input.tsx
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/components/chat/chat-messages.tsx
- art-agent/frontend/src/lib/api.ts
- art-agent/frontend/src/lib/types.ts
- art-agent/backend/app/api/chat.py
- art-agent/backend/app/agent/loop.py
- art-agent/backend/app/agent/tools.py
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/config.py
- **what**: 从单张参考图扩展为多张参考图全链路支持(前端上传/显示 + API + Agent Loop + 生图 Provider
- **why**: GPT Image 1.5 支持最多 16 张参考图images.edit 端点Gemini 支持 14 张,用户需要"保留图1主体+图2视角"等多图组合控制能力
- **decisions**:
- OpenAI Provider 有参考图时从 images.generate 切换到 images.edit 端点(而非统一用 edit
- Replicate IP-Adapter 模型仍只支持单张,取 ref_image_urls[0] 兼容
- 后端 API 新增 ref_image_urls JSON 数组字段,保留旧 ref_image_url 单值字段向后兼容
- 前端 ChatMessage 新增 refImageUrls 数组字段,保留旧 refImageUrl 兼容已有聊天记录
- System Prompt 更新LLM 需要理解多张参考图各自的角色并在 prompt 中传达
- **notes**:
- 向量引擎中转需支持 /v1/images/edits 端点才能真正生效,待验证
- Gemini 走 OpenAI 兼容 API 中转images.edit 是否被正确转发取决于中转层实现
- _load_image_bytes 新函数用于将本地路径/data URI 转为 bytes 供 images.edit 使用
### [CL-20260416-0510] 2026-04-16 05:10 — 生图模型新增 Gemini 3.1 Flash Image
- **tags**: 后端, 生图模型, Gemini, 向量引擎, config
- **affected_files**:
- art-agent/backend/app/config.py
- art-agent/backend/.env
- **what**: IMAGE_MODELS 注册表新增 gemini-3.1-flash-image 条目model_id 为 gemini-3.1-flash-image-previewprovider 为 openai
- **why**: 用户希望使用 Google Gemini 3.1 Flash 的原生生图能力,速度快价格低
- **decisions**: 复用已有的 OpenAIImageProvider向量引擎中转兼容 OpenAI images/generations 端点),零代码改动
- **notes**: 如向量引擎对 Gemini 生图的返回格式有差异(如只返回 b64 不返回 URLOpenAIImageProvider 已兼容两种格式
### [CL-20260416-0500] 2026-04-16 05:00 — 全区域拖拽添加参考图
- **tags**: 前端, UX, 拖拽, 参考图, 上传
- **affected_files**:
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/components/chat/chat-input.tsx
- **what**: 将拖拽上传参考图的区域从底部输入栏扩展到整个对话区域main拖入时显示全屏半透明覆盖层提示"松开以添加参考图"
- **why**: 原先拖拽事件只绑在 ChatInput 组件(底部一小条),用户拖图片到对话区域无反应,体验不直觉
- **decisions**: ChatInput 改为 forwardRef暴露 `uploadFile` 方法ChatInputHandle 接口。page.tsx 的 `<main>` 处理 dragEnter/dragOver/dragLeave/dropdrop 时调用 `chatInputRef.current.uploadFile(file)` 复用已有上传逻辑。用 dragCounter ref 解决子元素 dragEnter/dragLeave 冒泡导致覆盖层闪烁的经典问题
- **notes**: ChatInput 自身的拖拽处理保留(作为 fallback两套不冲突
### [CL-20260416-0430] 2026-04-16 04:30 — 接入 GPT Image 1.5 生图模型(向量引擎中转)
- **tags**: 后端, 生图模型, GPT-Image, OpenAI, 向量引擎, provider
- **affected_files**:
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/config.py
- art-agent/backend/.env
- **what**: 新增 OpenAIImageProvider通过向量引擎 API 中转调用 GPT Image 1.5 模型生图。IMAGE_MODELS 注册表新增 gpt-image-1.5 条目,放在列表首位
- **why**: 用户希望使用 OpenAI 最新的 GPT Image 1.5 模型,该模型在 prompt 理解和文字渲染方面表现最好。通过向量引擎中转可复用已有的 API Key
- **decisions**: 新建 `OpenAIImageProvider` 类(与 `ReplicateProvider` 并列),使用 OpenAI Python SDK 的 `images.generate` 端点,懒初始化客户端。支持 URL 和 base64 两种返回格式。Provider 注册键为 `"openai"`
- **notes**: GPT Image 系列不支持参考图IP-Adapter`supports_ref_image=False`。如果后续需要接入 gpt-image-1 或 gpt-image-1-mini只需在注册表新增条目Provider 代码无需改动
### [CL-20260416-0400] 2026-04-16 04:00 — Session 级参考图自动沿用 + 缺参考图前置校验
- **tags**: 前端, 后端, 参考图, UX, InstantStyle, 风格迁移
- **affected_files**:
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/components/chat/chat-input.tsx
- art-agent/backend/app/services/image_gen.py
- **what**: 发送消息使用参考图后,在 session 级别记住该参考图 URL后续消息自动沿用。输入框上方显示"沿用上次参考图"提示条(含缩略图+清除按钮。后端对需要参考图但未收到的模型InstantStyle/Kolors 等)返回友好错误而非发给模型得到诡异结果
- **why**: 用户切换到 InstantStyle 模型后输入"再执行一次前面的任务",因前端每次发送后清除参考图状态,导致 InstantStyle 收不到 style_image 参数,模型返回 "No input, Save money" 文本。高频场景(换模型重试同任务)不应要求重新上传参考图
- **decisions**: 参考图以 per-session `Map<sessionId, serverUrl>` 存在 `useRef` 中(不持久化到 localStorage切换 session 时恢复对应参考图。UI 提示条仅在无主动上传且有历史参考图时显示,用户可一键清除。后端在 `generate_images` 统一入口处加前置校验,比在各 provider 内部检查更统一
- **notes**: 参考图信息不写入 Session 类型定义(不持久化到 localStorage因为服务端文件可能被清理如果将来需要持久化需考虑文件存在性校验
### [CL-20260416-0300] 2026-04-16 03:00 — System Prompt 禁止 LLM 在回复中嵌入图片 Markdown 链接
- **tags**: 后端, agent-loop, system-prompt, LLM行为约束
- **affected_files**:
- art-agent/backend/app/agent/loop.py
- **what**: 在 System Prompt 注意事项中新增约束,禁止 LLM 在回复文字中使用 `![](...)``sandbox:` 等 Markdown 图片语法
- **why**: GPT 系列模型在收到 generate_image 工具返回的本地路径后,会自行拼凑 `sandbox:/generated/xxx.png` 格式的 Markdown 图片链接嵌入回复。该链接在前端不可渲染(`sandbox:` 不是有效协议),且图片展示已由 `image_result` SSE 事件独立处理,文字中的链接纯属多余
- **decisions**: 通过 System Prompt 约束解决(而非后端过滤),因为这是 LLM 行为问题,从源头阻止更干净
- **notes**: 同时排查发现 gpt-5.4 在向量引擎中转后实际调用了 gpt-4o-mini该问题属于向量引擎 API 侧的模型映射/降级,非代码 bug
### [CL-20260416-0245] 2026-04-16 02:45 — 修复三点菜单被右侧对话区遮挡
- **tags**: 前端, UI, sidebar, 菜单, z-index
- **affected_files**:
- art-agent/frontend/src/components/sidebar/session-list.tsx
- **what**: 三点菜单从 fixed 右侧弹出改为 inline 下方展开,模型子菜单改为折叠式内联
- **why**: 菜单用 fixed 定位弹到侧栏右侧,被主内容区的 stacking context 遮挡
- **decisions**: 放弃 fixed+绝对坐标的弹出菜单方案,改为在会话项下方 inline 渲染。模型子菜单从右侧 absolute 弹出改为折叠展开式,带 max-h 240px 滚动防止过长
- **notes**: 移除了 menuPos 状态,简化了定位逻辑
### [CL-20260416-0230] 2026-04-16 02:30 — LLM 模型注册表扩充:新增 5 个前沿模型,默认改为 GPT-5.4
- **tags**: 后端, config, LLM, 模型注册表
- **affected_files**:
- art-agent/backend/app/config.py
- art-agent/backend/.env
- **what**: LLM_MODELS 注册表从 3 个模型扩充到 7 个,默认模型从 gpt-4o-mini 改为 gpt-5.4
- **why**: 用户需要使用 2026 年前沿模型GPT-5.4, Claude Sonnet/Opus 4.6, Gemini 3.1 Pro, GLM-4.7
- **decisions**: 所有新增模型走 vectorengine providermodel_id 使用向量引擎确认的名称gpt-4o-mini 和 deepseek-chat 保留作为轻量/直连选项
- **notes**: 新增模型的 model_id 需向量引擎实际支持,如有出入需调整
### [CL-20260416-0200] 2026-04-16 02:00 — 接入向量引擎中转 API + 对话级 LLM 模型切换
- **tags**: 后端, 前端, LLM, 向量引擎, 中转API, 模型切换, config, UI, session
- **affected_files**:
- art-agent/backend/app/config.py
- art-agent/backend/app/agent/loop.py
- art-agent/backend/app/api/chat.py
- art-agent/backend/.env
- art-agent/frontend/src/lib/types.ts
- art-agent/frontend/src/lib/api.ts
- art-agent/frontend/src/lib/app-context.tsx
- art-agent/frontend/src/components/sidebar/session-list.tsx
- art-agent/frontend/src/app/page.tsx
- **what**: 接入向量引擎中转 APIOpenAI 兼容格式),实现对话级 LLM 模型切换
- **why**: 用户需要通过中转 API 使用 OpenAI 系模型GPT-4o/4o-mini同时保留 DeepSeek 直连。每条对话应可独立选择不同模型
- **decisions**:
- 后端采用 LLM_MODELS 注册表 + provider 分发的架构(参考已有 IMAGE_MODELS 模式)
- provider 分为 vectorengine向量引擎中转 OpenAI 系)和 deepseek直连各有独立的 API key 和 base_url
- 移除旧的 OPENAI_API_KEY/OPENAI_BASE_URL 全局配置,改为 provider 级别
- vision 能力检测改为从注册表读取,不再子串匹配模型名
- 前端对话列表的右键菜单改为显式三点按钮菜单,模型选择作为子菜单项
- Session 类型新增 llmModel 字段llmModel 为空时使用后端默认
- **notes**:
- .env 中 VECTORENGINE_API_KEY 需要用户填入自己的 key
- Mem0 记忆系统仍使用 DEEPSEEK_API_KEY不受影响
- 三点菜单预留了分享、归档等功能的扩展位
- 旧 localStorage 中没有 llmModel 字段的 Session 自动走默认模型
### [CL-20260416-0030] 2026-04-16 00:30 — 青绿山水风格改造:从 Cyberpunk 霓虹转为千里江山图色调 + 云烟雾气动效
- **tags**: 前端, UI改造, 视觉风格, 青绿山水, 千里江山图, CSS动画, 色彩体系, 云烟, 雾气
- **affected_files**:
- art-agent/frontend/src/app/globals.css
- art-agent/frontend/src/components/layout/top-nav.tsx
- art-agent/frontend/src/components/sidebar/sidebar.tsx
- art-agent/frontend/src/components/sidebar/session-list.tsx
- art-agent/frontend/src/components/chat/chat-messages.tsx
- art-agent/frontend/src/components/chat/chat-input.tsx
- art-agent/frontend/src/components/chat/image-grid.tsx
- art-agent/frontend/src/components/detail/image-detail-panel.tsx
- art-agent/frontend/src/components/profile-modal.tsx
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/app/gallery/page.tsx
- art-agent/frontend/src/app/login/page.tsx
- docs/art-agent/VISUAL-STYLE-GUIDE.md
- **what**: 全站视觉风格从 Cyberpunk Dark Neon 改为"青绿山水"——以《千里江山图》矿物质颜料色为灵感的现代演绎。色彩体系、动画效果、背景光晕全面重做
- **why**: 用户希望美术风格从赛博朋克调整为中国传统青绿山水的现代演绎,发光效果添加"雾气感"
- **decisions**:
- 色彩体系:深墨绿底色(#0C1210+ 石青绿高亮(#4DB8A4+ 石绿蓝辅助(#3A8FB7+ 赭石红(#C4654A+ 赭金(#B8935A
- 新增 --mist 和 --gold CSS 变量
- 背景改为双层漂移云雾body::before 60s + body::after 45sinset 负值防漂移露白
- .glow-border hover 效果conic-gradient 弧段从 20% 扩大到 40-50%(更柔和),旋转从 3s 减慢到 6sblur 从 6px 增到 12pxtransition 从 500ms 增到 600ms
- .glass-panel 加内层渐变 + blur 从 20px 增到 24px + saturate(1.1)
- 新增 .fog-scroll 类mask-image 渐变遮罩),应用到聊天区和会话列表
- 11 个组件文件中的硬编码 rgba(0,229,160,...) 全部替换为 rgba(77,184,164,...)
- 动画命名从 glowSpin 改为 mistSpin
- **notes**:
- profile-modal.tsx 也有硬编码颜色,在计划外被一并修复
- Logo SVG 中的 #0B0E14 替换为新的 --bg-primary 色值 #0C1210
- VISUAL-STYLE-GUIDE.md 完全重写,包含新旧风格对比表
- **source_chat**: [青绿山水风格改造]
### [CL-20260415-2330] 2026-04-15 23:30 — 用户个人信息 + 记忆查看面板:右上角菜单弹窗
- **tags**: 前端, 后端, 用户信息, 记忆系统, Mem0, Modal, ProfileModal, API
- **affected_files**:
- art-agent/backend/app/api/memory.py
- art-agent/backend/app/main.py
- art-agent/frontend/src/components/profile-modal.tsx
- art-agent/frontend/src/components/layout/top-nav.tsx
- **what**: 新增用户个人信息弹窗,展示用户基本信息和 Mem0 记忆系统中按时间分组的记忆列表
- **why**: 用户希望能查看记忆系统为自己记录了哪些信息,右上角菜单是最自然的入口
- **decisions**:
- 后端新建独立路由文件 api/memory.py职责分离暴露 GET /api/memory/list 端点
- 使用 Mem0 OSS `Memory.get_all(user_id=..., limit=200)` 获取全部记忆
- 同步调用通过 `run_in_executor` 包装避免阻塞事件循环
- 前端用居中 Modal非 Drawer/独立页),复用 glass-panel + Cyberpunk 风格
- 记忆按 created_at 时间分组(今天/最近7天/更早),不依赖 Mem0 不稳定的 categories 字段
- 只读模式,不实现删除功能
- **notes**:
- TopNav 的 username 行从静态文本改为可点击按钮,点击打开 ProfileModal
- TopNav return 改为 Fragment 以容纳 header + Modal 两个顶级元素
- ESC 键可关闭弹窗,点击遮罩层也可关闭
- **source_chat**: [用户个人信息记忆面板]
### [CL-20260415-2230] 2026-04-15 22:30 — 光影流动边框效果:导航活动项 + 活动会话项
- **tags**: 前端, CSS动画, 视觉效果, conic-gradient, 霓虹边框, 导航, 侧边栏
- **affected_files**:
- art-agent/frontend/src/app/globals.css
- art-agent/frontend/src/components/layout/top-nav.tsx
- art-agent/frontend/src/components/sidebar/session-list.tsx
- art-agent/frontend/src/components/sidebar/sidebar.tsx
- **what**: 实现 Dribbble 设计图中的边框光影流动效果,应用到导航活动 Tab 和活动会话项
- **why**: 用户指出按钮/标签缺少设计图中的发光+光影流动感。需要用 CSS 动画实现旋转锥形渐变边框
- **decisions**:
-`@property --glow-angle` + `conic-gradient` 实现旋转光线3s 一圈
- 双伪元素方案:`::before` 做精确渐变边框mask-composite 裁掉内部),`::after` 做外层模糊扩散光晕
- 提供两个 CSS 类:`.glow-border`(动画旋转,用于活动态)、`.glow-border-static`(静态发光,用于 hover
- 导航非活动项 hover 时用静态发光,不抢活动项视觉焦点
- **notes**:
- `@property` 需要浏览器支持 CSS HoudiniChrome 85+, Edge 85+, Safari 15.4+),对目标用户群体覆盖足够
- 光影流动 3s 周期可通过修改 `glowSpin` 动画时长调整
- **source_chat**: [光影流动边框效果实现]
### [CL-20260415-2200] 2026-04-15 22:00 — 全站 UI 风格改造Cyberpunk Dark Neon 主题
- **tags**: 前端, UI改造, 视觉风格, Cyberpunk, Neon, 毛玻璃, 全局样式, 组件重构
- **affected_files**:
- art-agent/frontend/src/app/globals.css
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/app/gallery/page.tsx
- art-agent/frontend/src/app/login/page.tsx
- art-agent/frontend/src/components/layout/top-nav.tsx
- art-agent/frontend/src/components/sidebar/sidebar.tsx
- art-agent/frontend/src/components/sidebar/session-list.tsx
- art-agent/frontend/src/components/sidebar/tag-filter.tsx
- art-agent/frontend/src/components/chat/chat-messages.tsx
- art-agent/frontend/src/components/chat/chat-input.tsx
- art-agent/frontend/src/components/chat/image-grid.tsx
- art-agent/frontend/src/components/chat/model-selector.tsx
- art-agent/frontend/src/components/detail/image-detail-panel.tsx
- docs/art-agent/VISUAL-STYLE-GUIDE.md
- **what**: 参考 SolCasino Dribbble 设计,将全站 UI 从朴素暗色主题改造为 Cyberpunk Dark Neon 风格
- **why**: 用户希望产品页面更具视觉冲击力,参考了 Dribbble 上的加密赌场 Cases 页面设计,提取其核心视觉语言(霓虹发光、毛玻璃、环境光晕、卡片网格)应用到美术 Agent 工具
- **decisions**:
- 主色从紫色(#6366f1)改为青绿霓虹(#00E5A0),更符合 Cyberpunk 调性
- 新增 CSS 变量:`--bg-card`, `--hot`, `--accent-secondary`, `--border-glow`
- 背景加 body::before 环境光晕(两团 radial-gradient 光斑)
- 全部面板改为 backdrop-blur 毛玻璃效果
- 圆角从 rounded-lg/2xl 统一升级为 rounded-xl/2xl
- 新增 `.glass-panel``.neon-border` 全局 CSS 类
- 图片卡片 hover 时有微缩放(1.03) + 霓虹边框发光
- 登录页加独立环境光背景 + 毛玻璃表单容器
- Gallery 卡片改为 neon-border 风格,选中时霓虹发光阴影
- 收藏色从金色改为 accent 青绿色,保持色彩一致性
- **notes**:
- 同时编写了视觉风格文档 `docs/art-agent/VISUAL-STYLE-GUIDE.md`,记录完整的设计规范
- annotation-canvas.tsx 未做改动(工具型组件,暗色已适配)
- 所有组件保留原有功能和交互逻辑,仅变更视觉层
- **source_chat**: [Cyberpunk UI 风格改造]
### [CL-20260414-2350] 2026-04-14 23:50 — 修复登录后 useApp must be used within AppProvider 报错
- **tags**: bug修复, 前端, 认证, AppProvider, 初始化时序
- **affected_files**:
- art-agent/frontend/src/lib/app-context.tsx
- **what**: 修复登录成功跳转到主页时 useApp() 拿到 null context 报错
- **why**: AppProvider 在 `!initialized` 时直接渲染 `<>{children}</>`(不提供 AppContext但已登录用户跳转到 `/``page.tsx` 立即调用 `useApp()`,而 `useEffect` 中的 `setInitialized(true)` 还没执行,导致 context 为 null
- **decisions**: 区分两种 `!initialized` 场景——已认证时显示加载状态(不渲染需要 context 的子组件),未认证时渲染 children供 /login 页面使用)
- **notes**: 这是 React 的 useEffect 异步特性导致的时序问题,登录跳转瞬间路由已变但 state 还没更新
- **source_chat**: [修复 AppProvider 初始化时序](a33b7af6-a8c0-4e88-8372-ec4b5b9dd703)
### [CL-20260414-2340] 2026-04-14 23:40 — 用 pwdlib (Argon2id) 替换 passlib (bcrypt),彻底解决兼容性问题 ### [CL-20260414-2340] 2026-04-14 23:40 — 用 pwdlib (Argon2id) 替换 passlib (bcrypt),彻底解决兼容性问题
- **tags**: 重构, 安全, 密码哈希, Argon2, pwdlib, passlib, 依赖升级 - **tags**: 重构, 安全, 密码哈希, Argon2, pwdlib, passlib, 依赖升级
- **affected_files**: - **affected_files**:

View File

@@ -2,6 +2,26 @@
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。 最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
- [CL-20260416-0830] 生图按模型预处理 prompt新增 image_prompt_strategySDXL 默认负向词+---NEGATIVE---拆分Flux 剥粘贴的 Negative 段GPT/Gemini/IP-Adapter 规范化GenerateResult 带 effective_prompt/negative_promptReplicate SDXL 传 negative_prompt工具说明 SDXL 分隔符
- [CL-20260416-0810] 对话输入栏支持剪贴板粘贴图片为参考图ChatInput根容器onPasteCapture提取image/*文件并走startUploadplaceholder/title提示
- [CL-20260416-0745] Gemini原生生图延长读写超时+对RemoteProtocolError等可重试错误自动重试(默认3次指数退避)环境变量可微调VECTORENGINE_GEMINI_*_TIMEOUT
- [CL-20260416-0720] 修复拖拽参考图到ChatInput区域后覆盖层卡住不消失ChatInput的stopPropagation阻止了main.onDrop新增onFileDrop回调通知父组件清除拖拽状态
- [CL-20260416-0700] 新增GeminiNativeImageProvider对接Gemini原生generateContent接口支持多图参考最多14张Gemini恢复supports_ref_image=True
- [CL-20260416-0620] 修复拖拽上传重复(stopPropagation)+Gemini改回不支持参考图(向量引擎不支持Gemini走images/edits)+images.edit移除不支持的quality参数
- [CL-20260416-0600] 多张参考图全链路支持:前端多图上传/拖拽/沿用+后端API/AgentLoop/Provider全部改为list[str]OpenAI走images.edit端点(最多16张)Replicate取首张兼容
- [CL-20260416-0510] 生图模型注册表新增Gemini 3.1 Flash Image(gemini-3.1-flash-image-preview)走向量引擎中转复用OpenAIImageProvider
- [CL-20260416-0500] 全区域拖拽添加参考图拖拽区域从底部输入栏扩展到整个对话区域拖入时全屏覆盖层提示ChatInput改forwardRef暴露uploadFile
- [CL-20260416-0430] 接入GPT Image 1.5生图模型新增OpenAIImageProvider走向量引擎中转IMAGE_MODELS注册表新增gpt-image-1.5
- [CL-20260416-0400] Session级参考图自动沿用发送后记住参考图供后续消息复用+UI提示条+可清除,后端对缺参考图的风格迁移模型返回友好错误
- [CL-20260416-0300] System Prompt新增禁止LLM在回复中嵌入Markdown图片链接(sandbox:等)图片展示由image_result SSE事件处理
- [CL-20260416-0245] 修复三点菜单被右侧对话区遮挡从fixed右侧弹出改为inline下方展开+模型列表折叠式内联,全部在侧栏内完成
- [CL-20260416-0230] LLM注册表扩充至7个模型(+gpt-5.4/claude-sonnet-4-6/claude-opus-4-6/gemini-3.1-pro-preview/glm-4.7)默认改为gpt-5.4
- [CL-20260416-0200] 接入向量引擎中转API+对话级LLM模型切换LLM_MODELS注册表(gpt-4o-mini/gpt-4o/deepseek-chat)provider分发(vectorengine/deepseek)前端三点菜单模型选择Session级llmModel
- [CL-20260416-0030] 青绿山水风格改造色彩体系替换为千里江山图石青石绿色调背景改双层漂移云雾glow-border 柔化为云烟缭绕,新增 fog-scroll 雾气边缘11 个组件硬编码颜色统一替换
- [CL-20260415-2230] 光影流动边框改为 hover 触发常态微弱静态发光hover 时旋转动画通过 opacity 500ms 平滑淡入/淡出,无跳变
- [CL-20260415-2330] 用户个人信息+记忆查看面板:右上角菜单弹窗,展示 Mem0 记忆列表(按时间分组,只读),后端新增 /api/memory/list 端点
- [CL-20260415-2200] 全站 UI 风格改造为 Cyberpunk Dark Neon主色改为青绿霓虹新增环境光晕/毛玻璃/霓虹发光边框13 个组件重构 + 视觉风格文档
- [CL-20260414-2350] 修复登录后 useApp must be used within AppProviderAppProvider 未初始化时区分已认证/未认证,避免子组件拿到 null context
- [CL-20260414-2340] 用 pwdlib (Argon2id) 替换 passlib (bcrypt):彻底解决兼容性问题,新密码 Argon2id旧 bcrypt 哈希兼容验证 - [CL-20260414-2340] 用 pwdlib (Argon2id) 替换 passlib (bcrypt):彻底解决兼容性问题,新密码 Argon2id旧 bcrypt 哈希兼容验证
- [CL-20260414-2330] 修复 passlib + bcrypt 5.x 兼容性:锁定 bcrypt>=4.0.1,<4.1,解决后端启动 AttributeError + ValueError - [CL-20260414-2330] 修复 passlib + bcrypt 5.x 兼容性:锁定 bcrypt>=4.0.1,<4.1,解决后端启动 AttributeError + ValueError
- [CL-20260414-2300] 用户系统:管理员邀请制 + JWT 认证 + SQLite 用户表 + Mem0/localStorage 按用户隔离,所有 API 鉴权,前端登录页 + AuthGuard - [CL-20260414-2300] 用户系统:管理员邀请制 + JWT 认证 + SQLite 用户表 + Mem0/localStorage 按用户隔离,所有 API 鉴权,前端登录页 + AuthGuard

View File

@@ -3,6 +3,106 @@
最近 ~10 次改动的摘要记录,按时间倒序排列。 最近 ~10 次改动的摘要记录,按时间倒序排列。
当 Agent 检测到当前任务与近期改动相关时自动读取。 当 Agent 检测到当前任务与近期改动相关时自动读取。
### [CL-20260416-0830] 2026-04-16 — 生图按模型预处理 prompt
- **tags**: 后端, 生图, prompt, SDXL, Flux, Replicate, image_gen, Agent工具
- **affected_files**: image_prompt_strategy.py, image_gen.py, tools.py
- **summary**: 新增 `image_prompt_strategy`:按注册表 id 为 7 个生图模型应用策略OpenAI/Gemini 自然语言规范化BFL Flux 指南对齐、剥 SD 式 Negative 段SDXL 默认负向词 + `---NEGATIVE---`/`|||NEG|||` 拆分IP-Adapter 侧重内容描述)。`generate_images` 统一调用后下发Replicate 非 Flux 路径写入 `negative_prompt``GenerateResult` 增加 `effective_prompt`/`negative_prompt`;工具返回与描述补充 SDXL 分隔符约定。
### [CL-20260416-0810] 2026-04-16 — 对话栏粘贴剪贴板图片为参考图
- **tags**: 前端, ChatInput, 剪贴板, 参考图, UX
- **affected_files**: chat-input.tsx
- **summary**: 在 ChatInput 根容器上使用 onPasteCapture从 clipboardData.items 收集 kind===file 且 image/* 的项,调用 getAsFile 后复用现有 startUpload。无图片文件时不拦截纯文本粘贴照常。placeholder 与 textarea title 提示可粘贴图片。
### [CL-20260416-0745] 2026-04-16 — Gemini 原生生图:超时与断连重试
- **tags**: 后端, Gemini, httpx, 超时, 重试, 向量引擎, image_gen
- **affected_files**: image_gen.py, .env.example
- **summary**: 用户遇到 `Server disconnected without sending a response`httpx RemoteProtocolError 等)。将 Gemini generateContent 默认 read 超时提高到 600s、write 180s、connect/pool 60s对 RemoteProtocolError/ConnectError/ReadTimeout 等可恢复错误默认最多重试 3 次(指数退避 1s/2s失败时错误信息附带调优提示。可通过 VECTORENGINE_GEMINI_* 环境变量覆盖。
### [CL-20260416-0720] 2026-04-16 — 修复拖拽参考图到 ChatInput 区域后覆盖层卡住不消失
- **tags**: bug修复, 拖拽, 覆盖层, ChatInput, stopPropagation, UX
- **affected_files**: chat-input.tsx, page.tsx
- **summary**: 文件拖拽到 ChatInput 区域时ChatInput.handleDrop 的 stopPropagation 阻止事件冒泡到 main.onDrop导致 mainDragging 状态无法重置为 false拖拽覆盖层"松开以添加参考图"卡住不消失。修复ChatInput 新增 onFileDrop 回调 prop在 handleDrop 中调用page.tsx 传入回调重置 dragCounter 和 mainDragging。
### [CL-20260416-0700] 2026-04-16 — 新增 GeminiNativeImageProviderGemini 原生 generateContent 接口对接
- **tags**: 后端, Gemini, Provider, 原生API, generateContent, 多图参考, 架构
- **affected_files**: image_gen.py, config.py
- **summary**: 新增 GeminiNativeImageProvider通过向量引擎中转调用 Gemini 原生 `/v1beta/models/{model}:generateContent` 接口(非 OpenAI 兼容的 images/edit。支持文字+图片混合输入(最多 14 张参考图),图片以 inline_data base64 内联传入。新增 `_resolve_image_base64` 工具函数统一处理 data URI 和本地路径到 (mime, b64) 的解析。config.py 中 Gemini 模型改用 `gemini_native` provider 并恢复 `supports_ref_image: True`。Provider 注册表新增 `gemini_native` 条目。
### [CL-20260416-0620] 2026-04-16 — 修复拖拽上传重复 + Gemini 改回不支持参考图 + images.edit 参数修正
- **tags**: bug修复, 拖拽, Gemini, 向量引擎, images.edit, config
- **affected_files**: chat-input.tsx, config.py, image_gen.py
- **summary**: 三个问题修复:(1) 拖拽到 ChatInput 区域时事件冒泡导致 page.tsx 和 ChatInput 各触发一次上传 → ChatInput handleDrop/handleDragOver 加 stopPropagation(2) 向量引擎 Gemini 图片编辑走原生 generateContent 接口而非 OpenAI 兼容 images/edits对 Gemini 调用 images.edit 返回 500 → Gemini 改回 supports_ref_image=False(3) images.edit 端点不支持 quality 参数 → 从 _edit_with_refs 中移除。
### [CL-20260416-0600] 2026-04-16 — 多张参考图全链路支持
- **tags**: 前端, 后端, 参考图, 多图, OpenAI, images.edit, Provider, API, Agent Loop
- **affected_files**: chat-input.tsx, page.tsx, chat-messages.tsx, api.ts, types.ts, chat.py, loop.py, tools.py, image_gen.py, config.py
- **summary**: 从单张参考图扩展为多张参考图全链路支持。前端ChatInput 改为多图状态数组+file input multiple+多图预览/删除/拖拽API层 sendChat 改为 ref_image_urls: string[]消息气泡和沿用逻辑适配多图。后端chat.py 新增 ref_image_urls JSON 数组参数(兼容旧 ref_image_urlAgent Loop 多图注入 vision LLM 上下文OpenAIImageProvider 有参考图时改用 images.edit 端点支持最多16张Replicate 取首张兼容GPT Image 1.5 和 Gemini 注册表标记 supports_ref_image=True。
### [CL-20260416-0510] 2026-04-16 — 生图模型新增 Gemini 3.1 Flash Image
- **tags**: 后端, 生图模型, Gemini, 向量引擎, config
- **affected_files**: config.py, .env
- **summary**: IMAGE_MODELS 注册表新增 gemini-3.1-flash-imagemodel_id: gemini-3.1-flash-image-previewprovider 为 openai复用 OpenAIImageProvider 走向量引擎中转。无需新增 Provider 代码。
### [CL-20260416-0500] 2026-04-16 — 全区域拖拽添加参考图
- **tags**: 前端, UX, 拖拽, 参考图, 上传
- **affected_files**: page.tsx, chat-input.tsx
- **summary**: 拖拽上传参考图区域从底部输入栏扩展到整个对话区域。ChatInput 改为 forwardRef 暴露 uploadFile 方法main 区域处理拖放事件并调用。拖入时显示全屏覆盖层提示,用 dragCounter 防止子元素冒泡导致闪烁。
### [CL-20260416-0430] 2026-04-16 — 接入 GPT Image 1.5 生图模型(向量引擎中转)
- **tags**: 后端, 生图模型, GPT-Image, OpenAI, 向量引擎, provider
- **affected_files**: image_gen.py, config.py, .env
- **summary**: 新增 OpenAIImageProvider通过向量引擎 API 中转调用 `/v1/images/generations` 端点。IMAGE_MODELS 注册表新增 gpt-image-1.5provider: openai支持 size/quality 参数和 URL/base64 双格式返回。
### [CL-20260416-0400] 2026-04-16 — Session 级参考图自动沿用 + 缺参考图前置校验
- **tags**: 前端, 后端, 参考图, UX, InstantStyle, 风格迁移
- **affected_files**: page.tsx, chat-input.tsx, image_gen.py
- **summary**: 用户发送带参考图的消息后,前端在 session 级别记住该参考图 URL后续消息自动沿用输入框显示"沿用上次参考图"提示条,可清除)。解决切换 InstantStyle 后无参考图导致 "No input, Save money" 的问题。后端对需要参考图但未收到的模型统一返回友好错误。
### [CL-20260416-0300] 2026-04-16 — System Prompt 禁止 LLM 嵌入图片链接
- **tags**: 后端, agent-loop, system-prompt, LLM行为约束
- **affected_files**: loop.py
- **summary**: LLMGPT 系列)在收到 generate_image 工具返回的本地路径后,自行拼凑 `sandbox:/generated/xxx.png` Markdown 图片链接。前端图片展示由 image_result SSE 事件独立处理,文字中的链接无效且多余。在 System Prompt 注意事项中新增禁止嵌入图片 Markdown 的约束。
### [CL-20260416-0245] 2026-04-16 — 修复三点菜单 z-index 层级问题
- **tags**: 前端, UI, sidebar, 菜单, z-index
- **affected_files**: session-list.tsx
- **summary**: 三点菜单原用 fixed 定位在侧栏右侧弹出,被主内容区遮挡。改为 inline 在会话项正下方展开,模型子菜单改为折叠式内联列表(带 max-h 滚动),全部在侧栏内部完成,不再溢出。移除了 menuPos 状态和 fixed 定位逻辑。
### [CL-20260416-0230] 2026-04-16 — LLM 注册表扩充 + 默认模型改 GPT-5.4
- **tags**: 后端, config, LLM, 模型注册表
- **affected_files**: config.py, .env
- **summary**: LLM_MODELS 从 3→7 个:新增 gpt-5.4、claude-sonnet-4-6、claude-opus-4-6、gemini-3.1-pro-preview、glm-4.7,全部走 vectorengine provider。默认模型从 gpt-4o-mini 改为 gpt-5.4。gpt-4o-mini/deepseek-chat 保留。
### [CL-20260416-0200] 2026-04-16 — 接入向量引擎中转 API + 对话级 LLM 模型切换
- **tags**: 后端, 前端, LLM, 向量引擎, 中转API, 模型切换, config, UI, session
- **affected_files**: config.py, loop.py, chat.py, .env, types.ts, api.ts, app-context.tsx, session-list.tsx, page.tsx
- **summary**: 新增 LLM_MODELS 注册表gpt-4o-mini/gpt-4o/deepseek-chatprovider 分发为 vectorengine中转和 deepseek直连。loop.py 改为按 provider 创建 AsyncOpenAI 客户端vision 检测从注册表读取。chat.py 新增 llm_model 参数和 GET /api/llm-models 端点。前端 Session 新增 llmModel 字段侧栏三点菜单支持模型选择子菜单sendChat 传递 llm_model。
### [CL-20260416-0030] 2026-04-16 — 青绿山水风格改造:千里江山图色调 + 云烟雾气动效
- **tags**: 前端, UI改造, 视觉风格, 青绿山水, 千里江山图, CSS动画, 色彩体系, 云烟
- **affected_files**: globals.css, top-nav.tsx, sidebar.tsx, session-list.tsx, chat-messages.tsx, chat-input.tsx, image-grid.tsx, image-detail-panel.tsx, profile-modal.tsx, page.tsx, gallery/page.tsx, login/page.tsx, VISUAL-STYLE-GUIDE.md
- **summary**: 全站从 Cyberpunk 霓虹改为青绿山水风格。色彩替换为石青绿(#4DB8A4)/石绿蓝(#3A8FB7)/墨绿底色(#0C1210)。背景改为双层漂移云雾(60s+45s)。.glow-border hover 效果柔化为云烟缭绕(6s/宽弧段/blur 12px)。.glass-panel 加绿底渐变+增强模糊。新增 .fog-scroll 雾气滚动边缘。11 个组件硬编码颜色全部替换。VISUAL-STYLE-GUIDE.md 完全重写。
### [CL-20260415-2330] 2026-04-15 — 用户个人信息 + 记忆查看面板:右上角菜单弹窗
- **tags**: 前端, 后端, 用户信息, 记忆系统, Mem0, Modal, ProfileModal
- **affected_files**: api/memory.py, main.py, profile-modal.tsx, top-nav.tsx
- **summary**: 后端新建 GET /api/memory/list 端点Mem0 get_all + run_in_executor前端新建 ProfileModal 组件glass-panel 风格居中弹窗),展示用户信息 + 按时间分组的记忆列表只读。TopNav 的 username 行改为可点击按钮,点击打开弹窗。
### [CL-20260415-2230] 2026-04-15 — 光影流动边框效果:导航活动项 + 活动会话项
- **tags**: 前端, CSS动画, 视觉效果, conic-gradient, 霓虹边框
- **affected_files**: globals.css, top-nav.tsx, session-list.tsx, sidebar.tsx
- **summary**: 用 @property + conic-gradient 实现旋转光影边框效果。双伪元素方案:::before 做渐变边框mask-composite 裁内部),::after 做模糊扩散光晕。提供 .glow-border动画和 .glow-border-statichover 静态发光)两个类,分别应用到导航活动 Tab 和活动会话项。
### [CL-20260415-2200] 2026-04-15 — 全站 UI 风格改造Cyberpunk Dark Neon 主题
- **tags**: 前端, UI改造, 视觉风格, Cyberpunk, Neon, 毛玻璃, 全局样式
- **affected_files**: globals.css, page.tsx, gallery/page.tsx, login/page.tsx, top-nav.tsx, sidebar.tsx, session-list.tsx, tag-filter.tsx, chat-messages.tsx, chat-input.tsx, image-grid.tsx, model-selector.tsx, image-detail-panel.tsx, VISUAL-STYLE-GUIDE.md
- **summary**: 参考 SolCasino Dribbble 设计,全站从朴素暗色改为 Cyberpunk Dark Neon 风格。主色从紫色改为青绿霓虹(#00E5A0),新增环境光晕背景、毛玻璃面板(backdrop-blur)、霓虹发光边框。所有组件统一升级圆角和过渡效果,图片卡片加 hover 缩放+发光。同时编写了完整的视觉风格文档。
### [CL-20260414-2350] 2026-04-14 — 修复登录后 useApp must be used within AppProvider 报错
- **tags**: bug修复, 前端, 认证, AppProvider, 初始化时序
- **affected_files**: app-context.tsx
- **summary**: AppProvider 在 `!initialized` 时不提供 context 但渲染了需要 useApp() 的子组件。修复:已认证但未初始化时显示加载状态,未认证时才渲染裸 children/login
### [CL-20260414-2340] 2026-04-14 — 用 pwdlib (Argon2id) 替换 passlib (bcrypt),彻底解决兼容性问题 ### [CL-20260414-2340] 2026-04-14 — 用 pwdlib (Argon2id) 替换 passlib (bcrypt),彻底解决兼容性问题
- **tags**: 重构, 安全, 密码哈希, Argon2, pwdlib, passlib, 依赖升级 - **tags**: 重构, 安全, 密码哈希, Argon2, pwdlib, passlib, 依赖升级
- **affected_files**: auth.py, requirements.txt, docs/art-agent/USER-SYSTEM.md - **affected_files**: auth.py, requirements.txt, docs/art-agent/USER-SYSTEM.md

View File

@@ -21,16 +21,17 @@
- **chosen_alternative**: 路线 A — 本地运行 + 内网穿透Cloudflare Tunnel / ngrok - **chosen_alternative**: 路线 A — 本地运行 + 内网穿透Cloudflare Tunnel / ngrok
- **deferred_reason**: 当前阶段只需自己和朋友试用,内网穿透足够 - **deferred_reason**: 当前阶段只需自己和朋友试用,内网穿透足够
### [style-pipeline-layered] 分层组合风格一致性产线 ### [style-pipeline-layered] 分层组合风格一致性产线(含便利选取 + LoRA 训练)
- **status**: deferred - **status**: deferred
- **tags**: 风格一致性, IP-Adapter, LoRA, ControlNet, 量产, 美术产线, InstantStyle - **tags**: 风格一致性, 风格选取, IP-Adapter, LoRA, ControlNet, 量产, 美术产线, InstantStyle, 风格库, 训练
- **recorded**: 2026-04-12 - **recorded**: 2026-04-12
- **updated**: 2026-04-13 - **updated**: 2026-04-16
- **source_chat**: [风格一致性技术方案讨论](style-consistency-discussion) - **source_chat**: [风格一致性技术方案讨论](style-consistency-discussion)
- **prerequisite**: EPEEKit 进入美术资源量产阶段(风格方向已确定、需要批量产出同风格素材) - **prerequisite**: EPEEKit 进入美术资源量产阶段(风格方向已确定、需要批量产出同风格素材)
- **related_files**: - **related_files**:
- art-agent/backend/app/services/image_gen.py - art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/config.py - art-agent/backend/app/config.py
- art-agent/frontend/src/app/page.tsx
- **context**: | - **context**: |
分层组合的风格一致性产线: 分层组合的风格一致性产线:
1. 探索期当前Prompt + 参考图,快速试错确定风格方向 1. 探索期当前Prompt + 参考图,快速试错确定风格方向
@@ -45,9 +46,64 @@
- Style IPAdapter for NoobAI-XLCivitAI最强画风迁移线条+着色技法),需 ComfyUI - Style IPAdapter for NoobAI-XLCivitAI最强画风迁移线条+着色技法),需 ComfyUI
- ICAS 框架(学术前沿 2025.04IP-Adapter + ControlNet 组合,多主体风格一致性最优 - ICAS 框架(学术前沿 2025.04IP-Adapter + ControlNet 组合,多主体风格一致性最优
锁定期的最佳方案:迁移到 HF Inference Endpoints 后,用 NoobAI-XL + Style IPAdapter + 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 参考图理解(探索期方案) - **chosen_alternative**: 当前使用 Prompt Engineering + LLM 参考图理解(探索期方案)
- **deferred_reason**: 风格方向尚未确定,过早引入 LoRA/ControlNet 是过度优化 - **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 ### [hf-inference-endpoints] 迁移到 Hugging Face + Inference Endpoints
- **status**: deferred - **status**: deferred
- **tags**: 基础设施, Hugging Face, Inference Endpoints, ComfyUI, 自定义模型, 风格迁移 - **tags**: 基础设施, Hugging Face, Inference Endpoints, ComfyUI, 自定义模型, 风格迁移

View File

@@ -1,4 +1,4 @@
# Changelog sync guard — 统一 stop hook(合并了原 prompt hook 和 command hook # Changelog sync guard — 统一 stop hook
# #
# 用确定性逻辑检查: # 用确定性逻辑检查:
# 1. 源文件是否比 changelog 更新mtime 比较) # 1. 源文件是否比 changelog 更新mtime 比较)
@@ -10,6 +10,7 @@
# 3. changelog 文件不存在 # 3. changelog 文件不存在
# 4. .changelog-ack 标记文件存在且足够新(本会话已确认过 changelog 状态) # 4. .changelog-ack 标记文件存在且足够新(本会话已确认过 changelog 状态)
# 5. 没有源文件比 changelog 更新 # 5. 没有源文件比 changelog 更新
# 6. stdin 上下文中没有文件编辑操作的证据(防止跨会话残留 mtime 误触发)
$input = [Console]::In.ReadToEnd() $input = [Console]::In.ReadToEnd()
@@ -58,6 +59,20 @@ if ((Test-Path $ackFile) -and (Get-Item $ackFile).LastWriteTime -ge $clMtime) {
exit 0 exit 0
} }
# === 豁免检查 6stdin 中无文件编辑证据 ===
# 防止跨会话残留 mtime 差异导致误触发:如果 ack 文件不存在(或过旧),
# 但 stdin 上下文中也没有任何文件写入/编辑操作的痕迹,说明本次会话
# 没有进行代码改动,不应触发提醒。
$hasEditEvidence = (
$input -match 'StrReplace|Write\s*tool|edit_file|file_write|write_to_file' -or
$input -match 'Created file|Modified file|Wrote contents' -or
$input -match '"tool"\s*:\s*"(str_replace|write|edit)"'
)
if (-not $hasEditEvidence) {
Write-Output '{}'
exit 0
}
# === 核心检查:是否有源文件比 changelog 更新 === # === 核心检查:是否有源文件比 changelog 更新 ===
$extensions = @("*.py", "*.tsx", "*.ts", "*.css") $extensions = @("*.py", "*.tsx", "*.ts", "*.css")
$excludeDirs = @("node_modules", ".next", "__pycache__", "venv") $excludeDirs = @("node_modules", ".next", "__pycache__", "venv")

View File

@@ -51,8 +51,8 @@
### project-launcher ### project-launcher
- **类型**: 项目级 - **类型**: 项目级
- **能力**: 一键在独立可见终端窗口中启动 Art Agent 前端和后端服务 - **能力**: 一键启动 Art Agent 全部服务Ollama + 后端 FastAPI + 前端 Next.js自动检测已运行的服务并跳过
- **触发场景**: "启动项目"、"运行项目"、"跑起来"、"打开前端和后端"、"启动服务"、"start"、"launch"、"run dev"、"启动前端"、"启动后端" - **触发场景**: "启动项目"、"运行项目"、"跑起来"、"打开前端和后端"、"启动服务"、"start"、"launch"、"run dev"、"启动前端"、"启动后端"
- **输出**: 两个独立的终端窗口(前端 Next.js + 后端 FastAPI - **输出**: Ollama 后台服务 + 两个独立的终端窗口(前端 Next.js + 后端 FastAPI
- **路径**: .cursor/skills/project-launcher/SKILL.md - **路径**: .cursor/skills/project-launcher/SKILL.md
- **备注**: 项目专属 Skill窗口必须可见,用户可直接查看日志和手动关闭 - **备注**: 项目专属 SkillOllama 必须先于后端启动Mem0 embedding 依赖);前后端窗口必须可见

View File

@@ -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/backend` | `uvicorn app.main:app --reload --host 0.0.0.0 --port 8000` |
| 前端 | `art-agent/frontend` | `npm run dev` | | 前端 | `art-agent/frontend` | `npm run dev` |
## 前置条件 ## 前置条件
后端需要激活 Python 虚拟环境(`art-agent/backend/venv` - 后端需要激活 Python 虚拟环境(`art-agent/backend/venv`
前端需要 Node.js >= 18 - 前端需要 Node.js >= 18
- **Ollama 必须在后端之前启动**Mem0 记忆系统依赖 Ollama 提供本地 embedding 服务(`nomic-embed-text` 模型),端口 `11434`
## 环境 PATH 须知 ## 环境 PATH 须知
@@ -48,18 +50,43 @@ description: >-
2. 确定操作系统和 Shell 类型Windows / macOS / Linux 2. 确定操作系统和 Shell 类型Windows / macOS / Linux
3. 启动后端(在独立可见终端窗口中) 3. 检查并启动 Ollama
- 检测 Ollama 是否已在运行(请求 http://localhost:11434/api/tags
- 未运行 → 启动 Ollama 服务,等待就绪
- 已运行 → 跳过
4. 启动后端(在独立可见终端窗口中):
- WindowsPowerShell 或 CMD 均适用): - WindowsPowerShell 或 CMD 均适用):
使用 `Start-Process` 或 `start cmd` 打开新的终端窗口 使用 `Start-Process` 或 `start cmd` 打开新的终端窗口
- macOS/Linux - 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 启动命令 ### Windows 启动命令
**关键要求**:必须在**新的、可见的终端窗口**中启动,不能在 Cursor 内置终端后台运行。 **关键要求**:必须在**新的、可见的终端窗口**中启动,不能在 Cursor 内置终端后台运行。
@@ -118,6 +145,7 @@ osascript -e 'tell application "Terminal" to do script "cd FRONTEND_PATH && npm
## 访问信息 ## 访问信息
启动完成后告知用户: 启动完成后告知用户:
- Ollamahttp://localhost:11434Mem0 embedding 服务)
- 后端 APIhttp://localhost:8000 - 后端 APIhttp://localhost:8000
- 后端文档http://localhost:8000/docs - 后端文档http://localhost:8000/docs
- 前端页面http://localhost:3000 - 前端页面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 中。 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。

View File

@@ -37,6 +37,13 @@ IMAGE_ASPECT_RATIO=1:1
# 输出格式 # 输出格式
IMAGE_OUTPUT_FORMAT=png 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 # HTTP_PROXY=http://127.0.0.1:7890
# HTTPS_PROXY=http://127.0.0.1:7890 # HTTPS_PROXY=http://127.0.0.1:7890

View File

@@ -11,7 +11,7 @@ from openai import AsyncOpenAI
from app.agent.tools import TOOL_DEFINITIONS, execute_tool from app.agent.tools import TOOL_DEFINITIONS, execute_tool
from app.config import ( from app.config import (
get_llm_model, get_llm_model_config,
get_llm_max_iterations, get_llm_max_iterations,
get_image_model_config, get_image_model_config,
get_max_recent_turns, get_max_recent_turns,
@@ -22,9 +22,19 @@ from app.services.image_gen import to_data_uri
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_client() -> AsyncOpenAI: def _get_client(provider: str) -> AsyncOpenAI:
base_url = os.getenv("OPENAI_BASE_URL") """按 provider 创建对应的 OpenAI 兼容客户端。"""
return AsyncOpenAI(base_url=base_url) if base_url else AsyncOpenAI() 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 = """\ SYSTEM_PROMPT = """\
你是一个专业的游戏美术 AI 助手。你的工作是帮助美术人员通过对话生成游戏美术资源。 你是一个专业的游戏美术 AI 助手。你的工作是帮助美术人员通过对话生成游戏美术资源。
@@ -33,7 +43,8 @@ SYSTEM_PROMPT = """\
- 根据用户的文字描述生成图片UI图标、按钮、插画、立绘、概念图等 - 根据用户的文字描述生成图片UI图标、按钮、插画、立绘、概念图等
- 理解用户的审美意图,将中文描述转化为高质量的英文生成 prompt - 理解用户的审美意图,将中文描述转化为高质量的英文生成 prompt
- 根据用户反馈迭代修改(调整颜色、风格、构图等) - 根据用户反馈迭代修改(调整颜色、风格、构图等)
- 如果用户提供了参考图,将参考图的风格元素融入生成 prompt - 如果用户提供了参考图(支持多张),将参考图的风格元素融入生成 prompt
- 理解多张参考图各自的角色(如"图1的主体 + 图2的风格/视角"),并在 prompt 中准确传达
- 理解用户在图片上的标注(框选区域 + 文字批注),精准定位需要修改的部分 - 理解用户在图片上的标注(框选区域 + 文字批注),精准定位需要修改的部分
## 工作流程 ## 工作流程
@@ -54,23 +65,25 @@ SYSTEM_PROMPT = """\
- 必须使用英文 - 必须使用英文
- 尽量详细描述:主体内容、颜色方案、光照、构图、材质等 - 尽量详细描述:主体内容、颜色方案、光照、构图、材质等
- 如果用户要求游戏 UI 元素,添加相关关键词如 "game UI", "icon", "button" - 如果用户要求游戏 UI 元素,添加相关关键词如 "game UI", "icon", "button"
- 如果你能直接看到参考图(图片内容),可以在 prompt 中描述参考图的风格特征 - 如果你能直接看到参考图(图片内容),可以在 prompt 中描述参考图的风格特征。多张参考图时,理解用户对各图的定位(如"图1做主体参考、图2做风格参考"),将相应特征分别融入 prompt
- 如果你无法看到参考图(只收到了文字提示说有参考图),参考图会由生图工具的 IP-Adapter 自动处理风格融合。此时你不要自行猜测画风/艺术风格关键词(如 pixel art、watercolor、oil painting 等),把风格交给参考图来决定。但如果用户在消息中明确指定了风格(如"赛博朋克风""水彩风"等),应保留并翻译到 prompt 中——尊重用户的主动意图 - 如果你无法看到参考图(只收到了文字提示说有参考图),参考图会由生图工具自动处理风格融合。此时你不要自行猜测画风/艺术风格关键词(如 pixel art、watercolor、oil painting 等),把风格交给参考图来决定。但如果用户在消息中明确指定了风格(如"赛博朋克风""水彩风"等),应保留并翻译到 prompt 中——尊重用户的主动意图
## 注意事项 ## 注意事项
- 用中文和用户交流 - 用中文和用户交流
- 生成图片后简要说明你使用的 prompt 思路 - 生成图片后简要说明你使用的 prompt 思路
- 主动建议迭代方向 - 主动建议迭代方向
- **禁止模拟工具调用**:生成图片时必须实际调用 generate_image 工具,绝不能用文字描述"已生成"或假装工具已执行。如果需要生成多张图片,每张都必须单独调用工具 - **禁止模拟工具调用**:生成图片时必须实际调用 generate_image 工具,绝不能用文字描述"已生成"或假装工具已执行。如果需要生成多张图片,每张都必须单独调用工具
- **禁止在回复中嵌入图片链接**:不要在回复文字中使用 Markdown 图片语法(如 `![](...)` 或 `sandbox:` 链接)。图片展示由系统自动处理,你只需用文字描述结果即可
""" """
async def run_agent_loop( async def run_agent_loop(
messages: list[dict], messages: list[dict],
ref_image_url: Optional[str] = None, ref_image_urls: Optional[list[str]] = None,
image_model: Optional[str] = None, image_model: Optional[str] = None,
session_id: Optional[str] = None, session_id: Optional[str] = None,
user_id: str = "default_user", user_id: str = "default_user",
llm_model: Optional[str] = None,
) -> AsyncGenerator[dict, None]: ) -> AsyncGenerator[dict, None]:
""" """
运行 Agent Loop以 SSE 事件流形式 yield 结果。 运行 Agent Loop以 SSE 事件流形式 yield 结果。
@@ -114,10 +127,16 @@ async def run_agent_loop(
yield {"type": "error", "data": {"message": f"记忆系统检索失败: {e}"}} yield {"type": "error", "data": {"message": f"记忆系统检索失败: {e}"}}
return 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 ── # ── 构建 system prompt ──
model_config = get_image_model_config(image_model) img_model_config = get_image_model_config(image_model)
current_model_name = model_config.get('name', '未知') current_model_name = img_model_config.get('name', '未知')
current_model_id = model_config.get('id', '未知') current_model_id = img_model_config.get('id', '未知')
model_hint = ( model_hint = (
f"\n\n## 当前生图模型(重要)\n" f"\n\n## 当前生图模型(重要)\n"
f"本次对话用户选择的生图模型是 **{current_model_name}**" f"本次对话用户选择的生图模型是 **{current_model_name}**"
@@ -127,43 +146,39 @@ async def run_agent_loop(
) )
api_messages = [{"role": "system", "content": SYSTEM_PROMPT + model_hint + memory_block}] api_messages = [{"role": "system", "content": SYSTEM_PROMPT + model_hint + memory_block}]
# 检测当前 LLM 是否支持 vision多模态图片输入 effective_refs = ref_image_urls or []
llm_model = get_llm_model().lower()
vision_capable = any(kw in llm_model for kw in ("gpt-4o", "gpt-4-vision", "claude"))
for msg in messages: 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: if vision_capable:
api_messages.append({ content_parts: list[dict] = [{"type": "text", "text": msg["content"]}]
"role": "user", for ref_url in effective_refs:
"content": [ content_parts.append({
{"type": "text", "text": msg["content"]}, "type": "image_url",
{ "image_url": {"url": to_data_uri(ref_url)},
"type": "image_url", })
"image_url": {"url": to_data_uri(ref_image_url)}, api_messages.append({"role": "user", "content": content_parts})
},
],
})
else: else:
n_refs = len(effective_refs)
hint = ( hint = (
f"{msg['content']}\n\n" f"{msg['content']}\n\n"
"【系统提示:用户上传了张参考图,已自动传递给图片生成工具的 IP-Adapter" f"【系统提示:用户上传了 {n_refs} 张参考图,已自动传递给图片生成工具。"
"IP-Adapter 会从参考图中提取风格融合到生成结果中" "生图工具会根据模型能力自动处理参考图的风格融合。"
"你无法看到这参考图,因此在生成 prompt 时:\n" "你无法看到这参考图,因此在生成 prompt 时:\n"
"1. 描述画面内容(主体、构图、光照、材质等)\n" "1. 描述画面内容(主体、构图、光照、材质等)\n"
"2. 不要自行猜测画风/艺术风格——但如果用户明确指定了风格,保留到 prompt 中\n" "2. 不要自行猜测画风/艺术风格——但如果用户明确指定了风格,保留到 prompt 中\n"
"3. 用户未指定风格时,风格完全由参考图通过 IP-Adapter 决定】" "3. 用户未指定风格时,风格完全由参考图决定】"
) )
api_messages.append({"role": "user", "content": hint}) api_messages.append({"role": "user", "content": hint})
continue continue
api_messages.append({"role": msg["role"], "content": msg["content"]}) api_messages.append({"role": msg["role"], "content": msg["content"]})
client = _get_client() client = _get_client(llm_provider)
for _ in range(get_llm_max_iterations()): for _ in range(get_llm_max_iterations()):
try: try:
response = await client.chat.completions.create( response = await client.chat.completions.create(
model=get_llm_model(), model=llm_model_id,
messages=api_messages, messages=api_messages,
tools=TOOL_DEFINITIONS, tools=TOOL_DEFINITIONS,
stream=True, stream=True,
@@ -219,7 +234,7 @@ async def run_agent_loop(
"你刚才没有调用 generate_image 工具,只是用文字描述了生成过程。" "你刚才没有调用 generate_image 工具,只是用文字描述了生成过程。"
"请立即调用 generate_image 工具来实际生成图片。" "请立即调用 generate_image 工具来实际生成图片。"
"不要解释,直接调用工具。" "不要解释,直接调用工具。"
f"当前使用的生图模型是 {model_config.get('name', '未知')}" f"当前使用的生图模型是 {img_model_config.get('name', '未知')}"
), ),
}) })
continue continue
@@ -263,7 +278,7 @@ async def run_agent_loop(
except json.JSONDecodeError: except json.JSONDecodeError:
arguments = {} 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", "") used_model = result.get("model_name", "")

View File

@@ -11,6 +11,8 @@ TOOL_DEFINITIONS = [
"description": ( "description": (
"根据文字描述生成图片。prompt 必须是英文。" "根据文字描述生成图片。prompt 必须是英文。"
"如果用户提供了参考图,会自动传入 ref_image_url 参数。" "如果用户提供了参考图,会自动传入 ref_image_url 参数。"
"若当前生图模型为 Stable Diffusion XL可在正提示后单独一行写 ---NEGATIVE--- 再写负向提示;"
"不传则服务端会使用该模型的默认负向词。"
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
@@ -37,7 +39,7 @@ TOOL_DEFINITIONS = [
async def execute_tool( async def execute_tool(
tool_name: str, tool_name: str,
arguments: dict, arguments: dict,
ref_image_url: str | None = None, ref_image_urls: list[str] | None = None,
image_model: str | None = None, image_model: str | None = None,
) -> dict: ) -> dict:
"""执行工具调用,返回结果。""" """执行工具调用,返回结果。"""
@@ -47,7 +49,7 @@ async def execute_tool(
result = await generate_images( result = await generate_images(
prompt=prompt, prompt=prompt,
num_images=num_images, num_images=num_images,
ref_image_url=ref_image_url, ref_image_urls=ref_image_urls,
model_id=image_model, model_id=image_model,
) )
valid_urls = [u for u in result.urls if not u.startswith("[")] valid_urls = [u for u in result.urls if not u.startswith("[")]
@@ -57,6 +59,8 @@ async def execute_tool(
"images": valid_urls, "images": valid_urls,
"errors": errors, "errors": errors,
"prompt_used": prompt, "prompt_used": prompt,
"effective_prompt": result.effective_prompt,
"negative_prompt": result.negative_prompt,
"model_name": result.model_name, "model_name": result.model_name,
"model_id": result.model_id, "model_id": result.model_id,
} }

View File

@@ -8,7 +8,12 @@ from sse_starlette.sse import EventSourceResponse
from app.agent.loop import run_agent_loop from app.agent.loop import run_agent_loop
from app.auth import get_current_user 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 from app.db import User
router = APIRouter() 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") @router.post("/chat")
async def chat( async def chat(
messages: str = Form(...), messages: str = Form(...),
ref_image: Optional[UploadFile] = File(None), ref_image: Optional[UploadFile] = File(None),
ref_image_url: Optional[str] = Form(None), ref_image_url: Optional[str] = Form(None),
ref_image_urls: Optional[str] = Form(None),
image_model: Optional[str] = Form(None), image_model: Optional[str] = Form(None),
session_id: Optional[str] = Form(None), session_id: Optional[str] = Form(None),
llm_model: Optional[str] = Form(None),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
@@ -64,25 +80,30 @@ async def chat(
参数: 参数:
- messages: JSON 字符串,对话历史 [{role, content}] - messages: JSON 字符串,对话历史 [{role, content}]
- ref_image: 可选的参考图文件(兼容旧方式) - ref_image: 可选的参考图文件(兼容旧方式)
- ref_image_url: 可选,已通过 /upload-ref-image 上传后的服务端路径 - ref_image_url: 兼容旧方式,单张参考图服务端路径
- ref_image_urls: JSON 数组字符串,多张参考图服务端路径列表
- image_model: 可选,指定本次使用的生图模型短 ID - image_model: 可选,指定本次使用的生图模型短 ID
- session_id: 可选,前端会话 ID用于 Mem0 记忆作用域 - session_id: 可选,前端会话 ID用于 Mem0 记忆作用域
- llm_model: 可选,指定本次使用的 LLM 模型短 ID
""" """
parsed_messages = json.loads(messages) parsed_messages = json.loads(messages)
resolved_ref_url: Optional[str] = None resolved_ref_urls: list[str] = []
if ref_image_url: if ref_image_urls:
resolved_ref_url = ref_image_url 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: 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 def event_generator():
async for event in run_agent_loop( async for event in run_agent_loop(
parsed_messages, parsed_messages,
resolved_ref_url, resolved_ref_urls or None,
image_model=image_model, image_model=image_model,
session_id=session_id, session_id=session_id,
user_id=current_user.id, user_id=current_user.id,
llm_model=llm_model,
): ):
yield { yield {
"event": event["type"], "event": event["type"],

View File

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

View File

@@ -7,14 +7,108 @@ import os
from typing import Any from typing import Any
def get_llm_model() -> str:
return os.getenv("LLM_MODEL", "gpt-4o-mini")
def get_llm_max_iterations() -> int: def get_llm_max_iterations() -> int:
return int(os.getenv("LLM_MAX_ITERATIONS", "5")) 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 — 默认推理参数 # default_params — 默认推理参数
IMAGE_MODELS: dict[str, dict[str, Any]] = { 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": { "flux-schnell": {
"id": "flux-schnell", "id": "flux-schnell",
"name": "Flux Schnell", "name": "Flux Schnell",

View File

@@ -12,6 +12,7 @@ from fastapi.staticfiles import StaticFiles
from app.api.chat import router as chat_router from app.api.chat import router as chat_router
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.admin import router as admin_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 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(auth_router, prefix="/api")
app.include_router(admin_router, prefix="/api") app.include_router(admin_router, prefix="/api")
app.include_router(chat_router, prefix="/api") app.include_router(chat_router, prefix="/api")
app.include_router(memory_router, prefix="/api")
@app.get("/health") @app.get("/health")

View File

@@ -1,5 +1,6 @@
"""图像生成服务 — Provider 抽象层。""" """图像生成服务 — Provider 抽象层。"""
import asyncio
import base64 import base64
import mimetypes import mimetypes
import os import os
@@ -9,9 +10,11 @@ from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
from openai import AsyncOpenAI
from replicate import Client as ReplicateClient 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 BACKEND_ROOT = Path(__file__).parent.parent.parent
GENERATED_DIR = BACKEND_ROOT / "generated" GENERATED_DIR = BACKEND_ROOT / "generated"
@@ -57,6 +60,43 @@ def to_data_uri(image_path: str) -> str:
return image_path 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: async def _download_image(url: str) -> str:
"""下载远程图片到本地 generated/ 目录,返回本地 URL 路径。""" """下载远程图片到本地 generated/ 目录,返回本地 URL 路径。"""
filename = f"{uuid.uuid4().hex}.png" filename = f"{uuid.uuid4().hex}.png"
@@ -80,9 +120,10 @@ class ImageProvider(ABC):
model_config: dict[str, Any], model_config: dict[str, Any],
prompt: str, prompt: str,
num_images: int = 1, num_images: int = 1,
ref_image_url: str | None = None, ref_image_urls: list[str] | None = None,
negative_prompt: str | None = None,
) -> list[str]: ) -> list[str]:
"""生成图片并返回本地 URL 列表。""" """生成图片并返回本地 URL 列表。negative_prompt 仅部分后端使用(如 Replicate SDXL"""
... ...
@@ -94,7 +135,8 @@ class ReplicateProvider(ImageProvider):
model_config: dict[str, Any], model_config: dict[str, Any],
prompt: str, prompt: str,
num_images: int = 1, num_images: int = 1,
ref_image_url: str | None = None, ref_image_urls: list[str] | None = None,
negative_prompt: str | None = None,
) -> list[str]: ) -> list[str]:
replicate_model_id = model_config["model_id"] replicate_model_id = model_config["model_id"]
default_params: dict[str, Any] = model_config.get("default_params", {}) default_params: dict[str, Any] = model_config.get("default_params", {})
@@ -102,8 +144,15 @@ class ReplicateProvider(ImageProvider):
local_urls: list[str] = [] local_urls: list[str] = []
try: try:
# Replicate 模型InstantStyle/Kolors只支持单张参考图取第一张
single_ref = ref_image_urls[0] if ref_image_urls else None
input_params = self._build_input( 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( output = await _replicate_client.async_run(
@@ -131,20 +180,18 @@ class ReplicateProvider(ImageProvider):
prompt: str, prompt: str,
num_images: int = 1, num_images: int = 1,
ref_image_url: str | None = None, ref_image_url: str | None = None,
negative_prompt: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
根据模型配置构建 Replicate input 参数。 根据模型配置构建 Replicate input 参数。
通过 model_config 中的标志字段自动适配不同模型: Replicate 上的 IP-Adapter 模型只支持单张参考图。
- supports_ref_image / ref_image_param: 参考图注入
- num_images_param: 各模型的批量生成参数名(如 number_of_images / num_outputs
""" """
model_id = model_config["model_id"] model_id = model_config["model_id"]
supports_ref = model_config.get("supports_ref_image", False) supports_ref = model_config.get("supports_ref_image", False)
ref_param_name = model_config.get("ref_image_param", "image") ref_param_name = model_config.get("ref_image_param", "image")
num_images_param = model_config.get("num_images_param", "num_outputs") num_images_param = model_config.get("num_images_param", "num_outputs")
# ── 支持参考图的模型IP-Adapter 系列)──
if supports_ref: if supports_ref:
params: dict[str, Any] = {"prompt": prompt} params: dict[str, Any] = {"prompt": prompt}
for k, v in default_params.items(): for k, v in default_params.items():
@@ -154,7 +201,6 @@ class ReplicateProvider(ImageProvider):
params[ref_param_name] = to_data_uri(ref_image_url) params[ref_param_name] = to_data_uri(ref_image_url)
return params return params
# ── Flux 系列(纯文生图)──
is_flux = "flux" in model_id.lower() is_flux = "flux" in model_id.lower()
params = {"prompt": prompt, num_images_param: num_images} params = {"prompt": prompt, num_images_param: num_images}
@@ -166,21 +212,281 @@ class ReplicateProvider(ImageProvider):
"output_format", get_image_output_format() "output_format", get_image_output_format()
) )
else: else:
# SDXL 类模型
params["width"] = default_params.get("width", 1024) params["width"] = default_params.get("width", 1024)
params["height"] = default_params.get("height", 1024) params["height"] = default_params.get("height", 1024)
if "num_inference_steps" in default_params: if "num_inference_steps" in default_params:
params["num_inference_steps"] = default_params["num_inference_steps"] params["num_inference_steps"] = default_params["num_inference_steps"]
if "guidance_scale" in default_params: if "guidance_scale" in default_params:
params["guidance_scale"] = default_params["guidance_scale"] params["guidance_scale"] = default_params["guidance_scale"]
if negative_prompt is not None:
params["negative_prompt"] = negative_prompt
return params 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 注册 ───────────────────────────────────── # ─── Provider 注册 ─────────────────────────────────────
_PROVIDERS: dict[str, ImageProvider] = { _PROVIDERS: dict[str, ImageProvider] = {
"replicate": ReplicateProvider(), "replicate": ReplicateProvider(),
"openai": OpenAIImageProvider(),
"gemini_native": GeminiNativeImageProvider(),
} }
@@ -189,21 +495,32 @@ _PROVIDERS: dict[str, ImageProvider] = {
class GenerateResult: class GenerateResult:
"""图片生成结果,包含生成的 URL 列表和实际使用的模型信息。""" """图片生成结果,包含生成的 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.urls = urls
self.model_name = model_name self.model_name = model_name
self.model_id = model_id self.model_id = model_id
self.effective_prompt = effective_prompt
self.negative_prompt = negative_prompt
async def generate_images( async def generate_images(
prompt: str, prompt: str,
num_images: int = 1, num_images: int = 1,
ref_image_url: str | None = None, ref_image_urls: list[str] | None = None,
model_id: str | None = None, model_id: str | None = None,
) -> GenerateResult: ) -> GenerateResult:
""" """
统一入口:根据 model_id 查注册表,分发到对应 provider。 统一入口:根据 model_id 查注册表,分发到对应 provider。
ref_image_urls: 参考图路径列表(可为 None 或空列表)。
model_id 为空时使用 .env 中配置的默认模型。 model_id 为空时使用 .env 中配置的默认模型。
""" """
config = get_image_model_config(model_id) config = get_image_model_config(model_id)
@@ -216,5 +533,41 @@ async def generate_images(
if not provider: if not provider:
return GenerateResult([f"[未知 provider: {provider_name}]"], model_name, resolved_id) return GenerateResult([f"[未知 provider: {provider_name}]"], model_name, resolved_id)
urls = await provider.generate(config, prompt, num_images, ref_image_url) effective_refs = ref_image_urls or []
return GenerateResult(urls, model_name, resolved_id)
# Replicate IP-Adapter 系列模型必须有参考图才能工作
if config.get("supports_ref_image") and not effective_refs and provider_name == "replicate":
return GenerateResult(
[f"[生成失败: {model_name} 是风格迁移模型,需要上传参考图才能使用]"],
model_name,
resolved_id,
)
prepared = prepare_image_prompt_for_model(
config,
prompt,
has_reference_images=bool(effective_refs),
)
if not prepared.prompt.strip():
return GenerateResult(
[f"[生成失败: 经模型策略处理后的 prompt 为空]"],
model_name,
resolved_id,
effective_prompt=prepared.prompt,
negative_prompt=prepared.negative_prompt,
)
urls = await provider.generate(
config,
prepared.prompt,
num_images,
effective_refs or None,
negative_prompt=prepared.negative_prompt,
)
return GenerateResult(
urls,
model_name,
resolved_id,
effective_prompt=prepared.prompt,
negative_prompt=prepared.negative_prompt,
)

View File

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

View File

@@ -85,7 +85,7 @@ export default function GalleryPage() {
document.body.removeChild(a); document.body.removeChild(a);
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
} catch { } catch {
// 忽略单张下载失败 // ignore
} }
} }
}; };
@@ -109,17 +109,29 @@ export default function GalleryPage() {
}; };
return ( return (
<div className="h-screen flex flex-col"> <div className="h-screen flex flex-col relative z-[1]">
<TopNav /> <TopNav />
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
{/* 主内容 */}
<main className="flex-1 flex flex-col min-w-0"> <main className="flex-1 flex flex-col min-w-0">
{/* 工具栏 */} {/* 工具栏 */}
<div className="flex-shrink-0 border-b border-[var(--border)] bg-[var(--bg-secondary)] px-3 md:px-4 py-2 <div className="flex-shrink-0 border-b border-[var(--border)] bg-[var(--bg-secondary)]/80
backdrop-blur-xl px-4 md:px-5 py-3
flex items-center gap-2 md:gap-3 flex-wrap"> flex items-center gap-2 md:gap-3 flex-wrap">
{/* 区块标题 */}
<div className="flex items-center gap-2 mr-3">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
</div>
{/* 搜索 */} {/* 搜索 */}
<div className="flex items-center gap-1.5 flex-1 min-w-[140px] md:min-w-[200px] max-w-[360px]"> <div className="flex items-center gap-1.5 flex-1 min-w-[140px] md:min-w-[200px] max-w-[360px]
px-3 py-1.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
focus-within:border-[var(--accent)]/40 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="2">
<circle cx="11" cy="11" r="8" /> <circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" /> <path d="M21 21l-4.35-4.35" />
@@ -128,14 +140,14 @@ export default function GalleryPage() {
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索 Prompt..." 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)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none" focus:outline-none"
/> />
</div> </div>
{/* 标签筛选 */} {/* 标签筛选 */}
<div className="flex items-center gap-1 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{tags.map((tag) => { {tags.map((tag) => {
const active = selectedTagIds.includes(tag.id); const active = selectedTagIds.includes(tag.id);
return ( return (
@@ -146,10 +158,10 @@ export default function GalleryPage() {
active ? prev.filter((id) => id !== tag.id) : [...prev, tag.id] 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={{ style={{
backgroundColor: active ? tag.color + "22" : "transparent", backgroundColor: active ? tag.color + "18" : "transparent",
borderColor: active ? tag.color : "var(--border)", borderColor: active ? tag.color + "60" : "var(--border)",
color: active ? tag.color : "var(--text-secondary)", color: active ? tag.color : "var(--text-secondary)",
}} }}
> >
@@ -164,11 +176,12 @@ export default function GalleryPage() {
{/* 收藏筛选 */} {/* 收藏筛选 */}
<button <button
onClick={() => setShowFavOnly(!showFavOnly)} onClick={() => setShowFavOnly(!showFavOnly)}
className="flex items-center gap-1 px-2 py-1 text-xs rounded cursor-pointer transition-colors" className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-lg cursor-pointer
style={{ transition-all border ${
color: showFavOnly ? "#f59e0b" : "var(--text-secondary)", showFavOnly
backgroundColor: showFavOnly ? "#f59e0b15" : "transparent", ? "border-[var(--accent)]/30 bg-[var(--accent)]/8 text-[var(--accent)]"
}} : "border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill={showFavOnly ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2"> <svg width="12" height="12" viewBox="0 0 24 24" fill={showFavOnly ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" /> <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
@@ -180,19 +193,22 @@ export default function GalleryPage() {
<select <select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortBy)} onChange={(e) => setSortBy(e.target.value as SortBy)}
className="text-xs px-2 py-1 rounded bg-[var(--bg-tertiary)] border border-[var(--border)] className="text-xs px-2.5 py-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
text-[var(--text-secondary)] cursor-pointer focus:outline-none" text-[var(--text-secondary)] cursor-pointer focus:outline-none
focus:border-[var(--accent)]/40"
> >
<option value="time"></option> <option value="time"></option>
<option value="name"></option> <option value="name"></option>
</select> </select>
{/* 视图切换 */} {/* 视图切换 */}
<div className="flex items-center gap-0.5 bg-[var(--bg-tertiary)] rounded p-0.5"> <div className="flex items-center gap-0.5 bg-[var(--bg-tertiary)] rounded-lg p-0.5 border border-[var(--border)]">
<button <button
onClick={() => setViewMode("grid")} onClick={() => setViewMode("grid")}
className={`p-1 rounded cursor-pointer transition-colors ${ className={`p-1.5 rounded-md cursor-pointer transition-all ${
viewMode === "grid" ? "bg-[var(--bg-primary)] text-[var(--text-primary)]" : "text-[var(--text-secondary)]" viewMode === "grid"
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`} }`}
title="网格视图" title="网格视图"
> >
@@ -203,8 +219,10 @@ export default function GalleryPage() {
</button> </button>
<button <button
onClick={() => setViewMode("list")} onClick={() => setViewMode("list")}
className={`p-1 rounded cursor-pointer transition-colors ${ className={`p-1.5 rounded-md cursor-pointer transition-all ${
viewMode === "list" ? "bg-[var(--bg-primary)] text-[var(--text-primary)]" : "text-[var(--text-secondary)]" viewMode === "list"
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`} }`}
title="列表视图" title="列表视图"
> >
@@ -217,11 +235,11 @@ export default function GalleryPage() {
{/* 批量操作栏 */} {/* 批量操作栏 */}
{selectedIds.size > 0 && ( {selectedIds.size > 0 && (
<div className="flex-shrink-0 px-4 py-2 bg-[var(--accent)]/10 border-b border-[var(--accent)]/30 <div className="flex-shrink-0 px-5 py-2.5 bg-[var(--accent)]/5 border-b border-[var(--accent)]/20
flex items-center gap-3"> flex items-center gap-3 backdrop-blur-sm">
<button <button
onClick={selectAll} onClick={selectAll}
className="text-xs text-[var(--accent)] cursor-pointer" className="text-xs text-[var(--accent)] cursor-pointer hover:underline"
> >
{selectedIds.size === filtered.length ? "取消全选" : "全选"} {selectedIds.size === filtered.length ? "取消全选" : "全选"}
</button> </button>
@@ -231,15 +249,19 @@ export default function GalleryPage() {
<div className="flex-1" /> <div className="flex-1" />
<button <button
onClick={handleBatchDownload} onClick={handleBatchDownload}
className="px-2.5 py-1 text-xs rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)] className="px-3 py-1.5 text-xs rounded-lg bg-[var(--bg-tertiary)]
hover:text-[var(--text-primary)] border border-[var(--border)] cursor-pointer" text-[var(--text-secondary)] hover:text-[var(--accent)]
border border-[var(--border)] hover:border-[var(--accent)]/40
cursor-pointer transition-all"
> >
</button> </button>
<button <button
onClick={handleBatchDelete} onClick={handleBatchDelete}
className="px-2.5 py-1 text-xs rounded bg-[var(--bg-tertiary)] text-red-400 className="px-3 py-1.5 text-xs rounded-lg bg-[var(--bg-tertiary)]
hover:text-red-300 border border-[var(--border)] cursor-pointer" text-[var(--hot)]/70 hover:text-[var(--hot)]
border border-[var(--border)] hover:border-[var(--hot)]/40
cursor-pointer transition-all"
> >
</button> </button>
@@ -247,45 +269,53 @@ export default function GalleryPage() {
)} )}
{/* 图片内容区 */} {/* 图片内容区 */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4 md:p-5">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">
<div className="text-center space-y-2"> <div className="text-center space-y-3">
<div className="text-3xl">📁</div> <div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
bg-[var(--accent)]/10 border border-[var(--accent)]/20">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" opacity="0.6">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
{assets.length === 0 ? "还没有生成过图片" : "没有匹配的资源"} {assets.length === 0 ? "还没有生成过图片" : "没有匹配的资源"}
</p> </p>
</div> </div>
</div> </div>
) : viewMode === "grid" ? ( ) : viewMode === "grid" ? (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 md:gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 md:gap-4">
{filtered.map((asset) => ( {filtered.map((asset) => (
<div <div
key={asset.id} key={asset.id}
className={`group relative rounded-lg overflow-hidden border transition-colors cursor-pointer ${ className={`group relative rounded-xl overflow-hidden neon-border cursor-pointer
bg-[var(--bg-card)] backdrop-blur-sm transition-all ${
selectedIds.has(asset.id) selectedIds.has(asset.id)
? "border-[var(--accent)] ring-1 ring-[var(--accent)]" ? "!border-[var(--accent)] ring-1 ring-[var(--accent)]/30 shadow-[0_0_15px_rgba(77,184,164,0.08)]"
: "border-[var(--border)] hover:border-[var(--border)]/80" : ""
}`} }`}
onClick={() => setDetailAsset(asset)} onClick={() => setDetailAsset(asset)}
> >
{/* 选择框 */} {/* 选择框 */}
<div <div
className="absolute top-2 left-2 z-10" className="absolute top-2.5 left-2.5 z-10"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
toggleSelect(asset.id); toggleSelect(asset.id);
}} }}
> >
<div <div
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${ className={`w-5 h-5 rounded-md border-2 flex items-center justify-center transition-all ${
selectedIds.has(asset.id) selectedIds.has(asset.id)
? "bg-[var(--accent)] border-[var(--accent)]" ? "bg-[var(--accent)] border-[var(--accent)] shadow-[0_0_8px_rgba(77,184,164,0.25)]"
: "border-white/50 bg-black/30 opacity-100 md:opacity-0 md:group-hover:opacity-100" : "border-white/40 bg-black/30 backdrop-blur-sm opacity-100 md:opacity-0 md:group-hover:opacity-100"
}`} }`}
> >
{selectedIds.has(asset.id) && ( {selectedIds.has(asset.id) && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3"> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="var(--bg-primary)" strokeWidth="3">
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
)} )}
@@ -295,24 +325,25 @@ export default function GalleryPage() {
<img <img
src={getImageUrl(asset.url)} src={getImageUrl(asset.url)}
alt={asset.prompt.slice(0, 50)} alt={asset.prompt.slice(0, 50)}
className="w-full aspect-square object-cover" className="w-full aspect-square object-cover
transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy" loading="lazy"
/> />
{/* 底部信息 */} {/* 底部信息 */}
<div className="p-2 bg-[var(--bg-secondary)]"> <div className="p-2.5 bg-[var(--bg-secondary)]/80 backdrop-blur-sm">
<div className="text-[10px] text-[var(--text-secondary)] truncate"> <div className="text-[11px] text-[var(--text-secondary)] truncate">
{asset.prompt || "无 prompt"} {asset.prompt || "无 prompt"}
</div> </div>
<div className="flex items-center gap-1 mt-1 flex-wrap"> <div className="flex items-center gap-1 mt-1.5 flex-wrap">
{asset.tags.slice(0, 2).map((tid) => { {asset.tags.slice(0, 2).map((tid) => {
const tag = tagMap.get(tid); const tag = tagMap.get(tid);
if (!tag) return null; if (!tag) return null;
return ( return (
<span <span
key={tid} key={tid}
className="text-[9px] px-1 py-px rounded-full" className="text-[9px] px-1.5 py-0.5 rounded-md font-medium"
style={{ backgroundColor: tag.color + "22", color: tag.color }} style={{ backgroundColor: tag.color + "18", color: tag.color }}
> >
{tag.name} {tag.name}
</span> </span>
@@ -324,16 +355,17 @@ export default function GalleryPage() {
</div> </div>
</div> </div>
{/* 操作按钮:移动端始终可见 */} {/* 操作按钮 */}
<div className="absolute top-2 right-2 flex gap-1 <div className="absolute top-2.5 right-2.5 flex gap-1.5
opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity"> opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDownload(asset); handleDownload(asset);
}} }}
className="w-7 h-7 rounded bg-black/60 text-white/80 hover:text-white className="w-7 h-7 rounded-lg bg-black/50 backdrop-blur-sm text-white/70
flex items-center justify-center cursor-pointer" hover:text-[var(--accent)] hover:bg-black/70
flex items-center justify-center cursor-pointer transition-colors"
title="下载" title="下载"
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -345,8 +377,9 @@ export default function GalleryPage() {
e.stopPropagation(); e.stopPropagation();
toggleFavorite(asset.id); toggleFavorite(asset.id);
}} }}
className="w-7 h-7 rounded bg-black/60 flex items-center justify-center cursor-pointer" className="w-7 h-7 rounded-lg bg-black/50 backdrop-blur-sm
style={{ color: asset.favorited ? "#f59e0b" : "rgba(255,255,255,0.6)" }} flex items-center justify-center cursor-pointer transition-colors"
style={{ color: asset.favorited ? "var(--accent)" : "rgba(255,255,255,0.5)" }}
title={asset.favorited ? "取消收藏" : "收藏"} title={asset.favorited ? "取消收藏" : "收藏"}
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2"> <svg width="12" height="12" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
@@ -358,15 +391,14 @@ export default function GalleryPage() {
))} ))}
</div> </div>
) : ( ) : (
/* 列表视图 */ <div className="space-y-1.5">
<div className="space-y-1">
{filtered.map((asset) => ( {filtered.map((asset) => (
<div <div
key={asset.id} key={asset.id}
className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${ className={`flex items-center gap-3 px-4 py-2.5 rounded-xl cursor-pointer transition-all ${
selectedIds.has(asset.id) selectedIds.has(asset.id)
? "bg-[var(--accent)]/10" ? "bg-[var(--accent)]/8 border border-[var(--accent)]/25"
: "hover:bg-[var(--bg-tertiary)]" : "hover:bg-[var(--bg-tertiary)] border border-transparent"
}`} }`}
onClick={() => setDetailAsset(asset)} onClick={() => setDetailAsset(asset)}
> >
@@ -377,14 +409,14 @@ export default function GalleryPage() {
}} }}
> >
<div <div
className={`w-4 h-4 rounded border flex items-center justify-center ${ className={`w-4 h-4 rounded-md border flex items-center justify-center transition-all ${
selectedIds.has(asset.id) selectedIds.has(asset.id)
? "bg-[var(--accent)] border-[var(--accent)]" ? "bg-[var(--accent)] border-[var(--accent)]"
: "border-[var(--border)]" : "border-[var(--border)]"
}`} }`}
> >
{selectedIds.has(asset.id) && ( {selectedIds.has(asset.id) && (
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3"> <svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="var(--bg-primary)" strokeWidth="3">
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
)} )}
@@ -394,7 +426,7 @@ export default function GalleryPage() {
<img <img
src={getImageUrl(asset.url)} src={getImageUrl(asset.url)}
alt="" alt=""
className="w-10 h-10 rounded object-cover border border-[var(--border)]" className="w-10 h-10 rounded-lg object-cover border border-[var(--border)]"
loading="lazy" loading="lazy"
/> />
@@ -409,8 +441,8 @@ export default function GalleryPage() {
return ( return (
<span <span
key={tid} key={tid}
className="text-[9px] px-1 py-px rounded-full" className="text-[9px] px-1.5 py-0.5 rounded-md font-medium"
style={{ backgroundColor: tag.color + "22", color: tag.color }} style={{ backgroundColor: tag.color + "18", color: tag.color }}
> >
{tag.name} {tag.name}
</span> </span>
@@ -428,8 +460,8 @@ export default function GalleryPage() {
e.stopPropagation(); e.stopPropagation();
toggleFavorite(asset.id); toggleFavorite(asset.id);
}} }}
className="cursor-pointer flex-shrink-0" className="cursor-pointer flex-shrink-0 transition-colors"
style={{ color: asset.favorited ? "#f59e0b" : "var(--text-secondary)" }} style={{ color: asset.favorited ? "var(--accent)" : "var(--text-secondary)" }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" /> <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
@@ -441,7 +473,7 @@ export default function GalleryPage() {
e.stopPropagation(); e.stopPropagation();
handleDownload(asset); 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"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" /> <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
@@ -473,7 +505,6 @@ export default function GalleryPage() {
); );
} }
// 资源详情 Modal
function GalleryDetailModal({ function GalleryDetailModal({
asset, asset,
tags, tags,
@@ -493,20 +524,22 @@ function GalleryDetailModal({
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70" className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
onClick={onClose} onClick={onClose}
> >
<div <div
className="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border)] className="glass-panel rounded-2xl max-w-3xl w-full mx-2 md:mx-4
max-w-3xl w-full mx-2 md:mx-4 max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col" max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col
shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* 头部 */} {/* 头部 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-[var(--border)]"> <div className="flex items-center justify-between px-5 py-3.5 border-b border-[var(--border)]">
<span className="text-sm font-medium text-[var(--text-primary)]"></span> <span className="text-sm font-semibold text-[var(--text-primary)]"></span>
<button <button
onClick={onClose} onClick={onClose}
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] cursor-pointer" className="text-[var(--text-secondary)] hover:text-[var(--accent)] cursor-pointer
transition-colors p-1 rounded-lg hover:bg-[var(--accent)]/5"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" /> <path d="M18 6L6 18M6 6l12 12" />
@@ -517,24 +550,23 @@ function GalleryDetailModal({
{/* 内容 */} {/* 内容 */}
<div className="flex-1 overflow-y-auto p-5"> <div className="flex-1 overflow-y-auto p-5">
<div className="flex gap-5 flex-col md:flex-row"> <div className="flex gap-5 flex-col md:flex-row">
{/* 图片 */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<img <img
src={getImageUrl(asset.url)} src={getImageUrl(asset.url)}
alt={asset.prompt} alt={asset.prompt}
className="w-full rounded-lg border border-[var(--border)]" className="w-full rounded-xl border border-[var(--border)]"
/> />
</div> </div>
{/* 信息 */}
<div className="w-full md:w-[260px] flex-shrink-0 space-y-4"> <div className="w-full md:w-[260px] flex-shrink-0 space-y-4">
{/* 操作按钮 */} {/* 操作按钮 */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
onClick={onDownload} onClick={onDownload}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] bg-[var(--accent)] text-[var(--bg-primary)]
cursor-pointer transition-colors" hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(77,184,164,0.25)]
cursor-pointer transition-all"
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" /> <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
@@ -543,13 +575,12 @@ function GalleryDetailModal({
</button> </button>
<button <button
onClick={onToggleFavorite} onClick={onToggleFavorite}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md className={`flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
border cursor-pointer transition-colors" border cursor-pointer transition-all ${
style={{ asset.favorited
backgroundColor: asset.favorited ? "#f59e0b22" : "var(--bg-tertiary)", ? "border-[var(--accent)]/40 bg-[var(--accent)]/8 text-[var(--accent)]"
borderColor: asset.favorited ? "#f59e0b" : "var(--border)", : "border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
color: asset.favorited ? "#f59e0b" : "var(--text-secondary)", }`}
}}
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2"> <svg width="12" height="12" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" /> <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
@@ -558,10 +589,10 @@ function GalleryDetailModal({
</button> </button>
<button <button
onClick={onDelete} onClick={onDelete}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--bg-tertiary)] text-red-400/70 hover:text-red-400 bg-[var(--bg-tertiary)] text-[var(--hot)]/60 hover:text-[var(--hot)]
border border-[var(--border)] hover:border-red-400/50 border border-[var(--border)] hover:border-[var(--hot)]/40
cursor-pointer transition-colors" cursor-pointer transition-all"
> >
</button> </button>
@@ -570,17 +601,17 @@ function GalleryDetailModal({
{/* Prompt */} {/* Prompt */}
{asset.prompt && ( {asset.prompt && (
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-[var(--text-secondary)]">Prompt</span> <span className="text-xs font-medium text-[var(--text-secondary)]">Prompt</span>
<button <button
onClick={() => navigator.clipboard.writeText(asset.prompt)} onClick={() => navigator.clipboard.writeText(asset.prompt)}
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)] className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
cursor-pointer" cursor-pointer transition-colors"
> >
</button> </button>
</div> </div>
<div className="p-2 rounded bg-[var(--bg-primary)] border border-[var(--border)] <div className="p-3 rounded-xl bg-[var(--bg-primary)] border border-[var(--border)]
text-xs text-[var(--text-secondary)] leading-relaxed break-all"> text-xs text-[var(--text-secondary)] leading-relaxed break-all">
{asset.prompt} {asset.prompt}
</div> </div>
@@ -590,16 +621,16 @@ function GalleryDetailModal({
{/* 标签 */} {/* 标签 */}
{asset.tags.length > 0 && ( {asset.tags.length > 0 && (
<div> <div>
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1"></span> <span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5"></span>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1.5">
{asset.tags.map((tid) => { {asset.tags.map((tid) => {
const tag = tagMap.get(tid); const tag = tagMap.get(tid);
if (!tag) return null; if (!tag) return null;
return ( return (
<span <span
key={tid} key={tid}
className="text-[10px] px-2 py-0.5 rounded-full" className="text-[10px] px-2 py-0.5 rounded-lg font-medium"
style={{ backgroundColor: tag.color + "22", color: tag.color }} style={{ backgroundColor: tag.color + "18", color: tag.color }}
> >
{tag.name} {tag.name}
</span> </span>
@@ -610,9 +641,9 @@ function GalleryDetailModal({
)} )}
{/* 元信息 */} {/* 元信息 */}
<div className="text-xs text-[var(--text-secondary)] space-y-1"> <div className="text-xs text-[var(--text-secondary)] space-y-1.5">
<div>{new Date(asset.createdAt).toLocaleString("zh-CN")}</div> <div>{new Date(asset.createdAt).toLocaleString("zh-CN")}</div>
<div> ID{asset.sessionId}</div> <div className="truncate"> ID{asset.sessionId}</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,25 +1,200 @@
@import "tailwindcss"; @import "tailwindcss";
/* ===== 青绿山水色彩体系 ===== */
:root { :root {
--bg-primary: #0a0a0a; --bg-primary: #0C1210;
--bg-secondary: #141414; --bg-secondary: #131E1A;
--bg-tertiary: #1e1e1e; --bg-tertiary: #1C2A24;
--text-primary: #e5e5e5; --bg-card: rgba(16, 30, 24, 0.65);
--text-secondary: #a3a3a3; --text-primary: #E0E8E2;
--accent: #6366f1; --text-secondary: #7A9485;
--accent-hover: #818cf8; --accent: #4DB8A4;
--border: #2e2e2e; --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 { body {
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-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; overscroll-behavior: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
/* 自定义滚动条 */ /* ===== 山雾背景 — 远景层 ===== */
@keyframes fogDriftFar {
0% { transform: translate(0, 0) scale(1); }
33% { transform: translate(2%, -1.5%) scale(1.02); }
66% { transform: translate(-1.5%, 1%) scale(0.98); }
100% { transform: translate(0, 0) scale(1); }
}
@keyframes fogDriftNear {
0% { transform: translate(0, 0) scale(1); }
25% { transform: translate(-3%, 2%) scale(1.03); }
50% { transform: translate(1%, -1%) scale(0.97); }
75% { transform: translate(2.5%, 1.5%) scale(1.01); }
100% { transform: translate(0, 0) scale(1); }
}
body::before {
content: '';
position: fixed;
inset: -10%;
background:
radial-gradient(ellipse 80% 50% at 15% 75%, rgba(77, 184, 164, 0.07) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 75% 25%, rgba(58, 143, 183, 0.05) 0%, transparent 55%),
radial-gradient(ellipse 90% 60% at 50% 50%, rgba(77, 184, 164, 0.03) 0%, transparent 70%);
animation: fogDriftFar 60s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
body::after {
content: '';
position: fixed;
inset: -15%;
background:
radial-gradient(ellipse 70% 45% at 80% 70%, rgba(77, 184, 164, 0.06) 0%, transparent 55%),
radial-gradient(ellipse 50% 60% at 25% 30%, rgba(58, 143, 183, 0.04) 0%, transparent 50%);
animation: fogDriftNear 45s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
/* ===== 山雾玻璃面板 ===== */
.glass-panel {
background:
linear-gradient(165deg, rgba(16, 30, 24, 0.15) 0%, transparent 50%),
rgba(16, 30, 24, 0.55);
backdrop-filter: blur(24px) saturate(1.1);
-webkit-backdrop-filter: blur(24px) saturate(1.1);
border: 1px solid var(--border);
}
/* ===== 静态霓虹边框(卡片等普通元素) ===== */
.neon-border {
border: 1px solid var(--border);
transition: border-color 200ms ease, box-shadow 200ms ease;
}
.neon-border:hover {
border-color: var(--border-glow);
box-shadow: 0 0 18px rgba(77, 184, 164, 0.08),
inset 0 0 12px rgba(77, 184, 164, 0.03);
}
/* ===== 云烟缭绕边框 ===== */
/*
* .glow-border — 选中态的云烟边框
* 常态:石青色微弱均匀发光
* hover云烟缓慢缭绕opacity 平滑过渡
* 移开:云烟淡散回静态
*/
@property --glow-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
@keyframes mistSpin {
to { --glow-angle: 360deg; }
}
.glow-border {
position: relative;
background: var(--bg-secondary);
border: 1px solid rgba(77, 184, 164, 0.18) !important;
box-shadow: 0 0 10px rgba(77, 184, 164, 0.05),
inset 0 0 8px rgba(77, 184, 164, 0.02);
transition: border-color 400ms ease, box-shadow 400ms ease;
z-index: 0;
}
.glow-border:hover {
border-color: rgba(77, 184, 164, 0.28) !important;
box-shadow: 0 0 16px rgba(77, 184, 164, 0.08),
inset 0 0 12px rgba(77, 184, 164, 0.03);
}
/* 云烟边框层 — 宽弧段柔化渐变6s 缓慢旋转 */
.glow-border::before {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1.5px;
background: conic-gradient(
from var(--glow-angle),
transparent 0%,
transparent 15%,
rgba(77, 184, 164, 0.2) 25%,
rgba(58, 143, 183, 0.35) 38%,
rgba(77, 184, 164, 0.4) 50%,
rgba(58, 143, 183, 0.35) 62%,
rgba(77, 184, 164, 0.2) 75%,
transparent 85%,
transparent 100%
);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
animation: mistSpin 6s linear infinite;
opacity: 0;
transition: opacity 600ms ease;
pointer-events: none;
z-index: -1;
}
/* 雾气扩散层 — 更大范围、更模糊 */
.glow-border::after {
content: '';
position: absolute;
inset: -6px;
border-radius: inherit;
background: conic-gradient(
from var(--glow-angle),
transparent 0%,
transparent 20%,
rgba(77, 184, 164, 0.06) 35%,
rgba(58, 143, 183, 0.08) 50%,
rgba(77, 184, 164, 0.06) 65%,
transparent 80%,
transparent 100%
);
filter: blur(12px);
animation: mistSpin 6s linear infinite;
opacity: 0;
transition: opacity 600ms ease;
pointer-events: none;
z-index: -2;
}
.glow-border:hover::before { opacity: 1; }
.glow-border:hover::after { opacity: 1; }
/* ===== 雾气滚动边缘 ===== */
.fog-scroll {
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0px,
black 36px,
black calc(100% - 36px),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
transparent 0px,
black 36px,
black calc(100% - 36px),
transparent 100%
);
}
/* ===== 自定义滚动条 ===== */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
@@ -28,17 +203,18 @@ body {
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--border); background: rgba(77, 184, 164, 0.15);
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover {
background: rgba(77, 184, 164, 0.28);
}
/* 原生 select 下拉框暗色 */
select option { select option {
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-primary); color: var(--text-primary);
} }
/* 移动端侧边栏遮罩动画 */
.sidebar-overlay { .sidebar-overlay {
animation: fadeIn 200ms ease-out; animation: fadeIn 200ms ease-out;
} }
@@ -46,8 +222,11 @@ select option {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
} }
@keyframes slideUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* 移动端防止 iOS 地址栏 bounce */
@supports (height: 100dvh) { @supports (height: 100dvh) {
.h-screen { .h-screen {
height: 100dvh; height: 100dvh;

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, type FormEvent } from "react"; import { useState, useEffect, type FormEvent } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
@@ -12,8 +12,13 @@ export default function LoginPage() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => {
if (isAuthenticated) {
router.replace("/");
}
}, [isAuthenticated, router]);
if (isAuthenticated) { if (isAuthenticated) {
router.replace("/");
return null; return null;
} }
@@ -33,15 +38,38 @@ export default function LoginPage() {
} }
return ( return (
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)]"> <div className="h-screen flex items-center justify-center bg-[var(--bg-primary)] relative">
<div className="w-full max-w-sm mx-4"> {/* 背景环境光 */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute inset-0"
style={{
background: `
radial-gradient(ellipse at 30% 70%, rgba(77, 184, 164, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 70% 30%, rgba(58, 143, 183, 0.06) 0%, transparent 50%)
`,
}}
/>
</div>
<div className="w-full max-w-sm mx-4 relative z-10">
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="text-4xl mb-3">🎨</div> {/* Logo 发光 */}
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">EPEEKit</h1> <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-4
<p className="text-sm text-[var(--text-secondary)] mt-1">AI </p> bg-[var(--accent)]/10 border border-[var(--accent)]/20
shadow-[0_0_40px_rgba(77,184,164,0.15)]">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="var(--accent)" />
<circle cx="23" cy="12" r="3.5" stroke="var(--accent)" strokeWidth="1.8" fill="none" />
<path d="M23 15.5v5" stroke="var(--accent)" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
</svg>
</div>
<h1 className="text-2xl font-bold text-[var(--text-primary)]">EPEEKit</h1>
<p className="text-sm text-[var(--text-secondary)] mt-1.5">AI </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit}
className="glass-panel rounded-2xl p-6 space-y-4 shadow-xl shadow-black/20">
<div> <div>
<input <input
type="text" type="text"
@@ -50,11 +78,12 @@ export default function LoginPage() {
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
autoFocus autoFocus
autoComplete="username" autoComplete="username"
className="w-full px-4 py-3 rounded-lg className="w-full px-4 py-3 rounded-xl
bg-[var(--bg-secondary)] border border-[var(--border)] bg-[var(--bg-primary)] border border-[var(--border)]
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none focus:border-[var(--accent)] focus:outline-none focus:border-[var(--accent)]/50
transition-colors" focus:shadow-[0_0_10px_rgba(77,184,164,0.06)]
transition-all"
/> />
</div> </div>
<div> <div>
@@ -64,16 +93,18 @@ export default function LoginPage() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password" autoComplete="current-password"
className="w-full px-4 py-3 rounded-lg className="w-full px-4 py-3 rounded-xl
bg-[var(--bg-secondary)] border border-[var(--border)] bg-[var(--bg-primary)] border border-[var(--border)]
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none focus:border-[var(--accent)] focus:outline-none focus:border-[var(--accent)]/50
transition-colors" focus:shadow-[0_0_10px_rgba(77,184,164,0.06)]
transition-all"
/> />
</div> </div>
{error && ( {error && (
<div className="text-sm text-red-400 bg-red-400/10 rounded-lg px-4 py-2.5"> <div className="text-sm text-[var(--hot)] bg-[var(--hot)]/10 rounded-xl px-4 py-2.5
border border-[var(--hot)]/20">
{error} {error}
</div> </div>
)} )}
@@ -81,11 +112,12 @@ export default function LoginPage() {
<button <button
type="submit" type="submit"
disabled={loading || !username.trim() || !password} disabled={loading || !username.trim() || !password}
className="w-full py-3 rounded-lg font-medium className="w-full py-3 rounded-xl font-semibold
bg-[var(--accent)] text-white bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)] hover:bg-[var(--accent-hover)]
hover:shadow-[0_0_20px_rgba(77,184,164,0.25)]
disabled:opacity-50 disabled:cursor-not-allowed disabled:opacity-50 disabled:cursor-not-allowed
transition-colors cursor-pointer" transition-all cursor-pointer"
> >
{loading ? "登录中..." : "登录"} {loading ? "登录中..." : "登录"}
</button> </button>

View File

@@ -1,8 +1,8 @@
"use client"; "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 { 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 { Sidebar } from "@/components/sidebar/sidebar";
import { TopNav } from "@/components/layout/top-nav"; import { TopNav } from "@/components/layout/top-nav";
import { ImageDetailPanel } from "@/components/detail/image-detail-panel"; import { ImageDetailPanel } from "@/components/detail/image-detail-panel";
@@ -30,6 +30,11 @@ export default function Home() {
const [streamingImages, setStreamingImages] = useState<ImageAsset[]>([]); const [streamingImages, setStreamingImages] = useState<ImageAsset[]>([]);
const [statusText, setStatusText] = useState(""); const [statusText, setStatusText] = useState("");
const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null); const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
const [lastRefImageUrls, setLastRefImageUrls] = useState<string[]>([]);
const lastRefPerSession = useRef<Map<string, string[]>>(new Map());
const [mainDragging, setMainDragging] = useState(false);
const dragCounter = useRef(0);
const chatInputRef = useRef<ChatInputHandle>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const scrollPositions = useRef<Map<string, number>>(new Map()); const scrollPositions = useRef<Map<string, number>>(new Map());
const prevSessionId = useRef<string | null>(null); const prevSessionId = useRef<string | null>(null);
@@ -67,13 +72,14 @@ export default function Home() {
return () => el.removeEventListener("scroll", handler); return () => el.removeEventListener("scroll", handler);
}, [activeSessionId]); }, [activeSessionId]);
// 会话切换:标记 switching等 DOM 更新后恢复位置 // 会话切换:标记 switching等 DOM 更新后恢复位置 + 恢复参考图状态
useEffect(() => { useEffect(() => {
if (!activeSessionId) return; if (!activeSessionId) return;
if (prevSessionId.current && prevSessionId.current !== activeSessionId) { if (prevSessionId.current && prevSessionId.current !== activeSessionId) {
isSwitching.current = true; isSwitching.current = true;
} }
prevSessionId.current = activeSessionId; prevSessionId.current = activeSessionId;
setLastRefImageUrls(lastRefPerSession.current.get(activeSessionId) ?? []);
}, [activeSessionId]); }, [activeSessionId]);
useEffect(() => { useEffect(() => {
@@ -95,11 +101,11 @@ export default function Home() {
}, [activeSessionId, messages.length]); }, [activeSessionId, messages.length]);
const handleSend = useCallback( 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; if (!activeSessionId || !activeSession) return;
let finalText = text; let finalText = text;
let finalRefServerUrl = refImageServerUrl; let finalRefServerUrls = [...refImageServerUrls];
if (pendingAnnotation) { if (pendingAnnotation) {
const annotationDescs = pendingAnnotation.annotations const annotationDescs = pendingAnnotation.annotations
@@ -116,14 +122,13 @@ export default function Home() {
finalText = `${text}\n\n--- 图片标注 ---\n${annotationDescs}`; finalText = `${text}\n\n--- 图片标注 ---\n${annotationDescs}`;
} }
// 标注截图作为参考图:需要先上传再获取 URL if (pendingAnnotation.snapshot && finalRefServerUrls.length === 0) {
if (pendingAnnotation.snapshot && !refImageServerUrl) {
try { try {
const res = await fetch(pendingAnnotation.snapshot); const res = await fetch(pendingAnnotation.snapshot);
const blob = await res.blob(); const blob = await res.blob();
const file = new File([blob], "annotation.png", { type: "image/png" }); const file = new File([blob], "annotation.png", { type: "image/png" });
const uploadResult = await uploadRefImage(file); const uploadResult = await uploadRefImage(file);
finalRefServerUrl = uploadResult.url; finalRefServerUrls = [uploadResult.url];
} catch { } catch {
// 忽略转换/上传失败 // 忽略转换/上传失败
} }
@@ -132,17 +137,18 @@ export default function Home() {
setPendingAnnotation(null); setPendingAnnotation(null);
} }
// 用户消息中的参考图预览:优先使用服务端路径(通过 getImageUrl 转为完整 URL if (finalRefServerUrls.length > 0) {
let previewUrl: string | undefined; lastRefPerSession.current.set(activeSessionId, finalRefServerUrls);
if (finalRefServerUrl) { setLastRefImageUrls(finalRefServerUrls);
previewUrl = getImageUrl(finalRefServerUrl);
} }
const previewUrls = finalRefServerUrls.map((u) => getImageUrl(u));
const userMessage: ChatMessage = { const userMessage: ChatMessage = {
id: generateId("msg-"), id: generateId("msg-"),
role: "user", role: "user",
content: finalText, content: finalText,
refImageUrl: previewUrl, refImageUrls: previewUrls.length > 0 ? previewUrls : undefined,
}; };
appendMessage(activeSessionId, userMessage); appendMessage(activeSessionId, userMessage);
setIsLoading(true); setIsLoading(true);
@@ -160,8 +166,10 @@ export default function Home() {
let collectedImages: ImageAsset[] = []; let collectedImages: ImageAsset[] = [];
let usedModelName = ""; let usedModelName = "";
const llmModel = activeSession?.llmModel ?? null;
try { 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) { switch (event.type) {
case "text_delta": case "text_delta":
assistantText += event.data.text as string; assistantText += event.data.text as string;
@@ -261,21 +269,61 @@ export default function Home() {
}, []); }, []);
return ( return (
<div className="h-screen flex flex-col"> <div className="h-screen flex flex-col relative z-[1]">
<TopNav /> <TopNav />
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
<Sidebar /> <Sidebar />
<main className="flex-1 flex flex-col min-w-0 relative"> <main
{/* 桌面端侧边栏展开按钮(移动端用顶栏汉堡菜单替代) */} className="flex-1 flex flex-col min-w-0 relative"
onDragEnter={(e: DragEvent) => {
e.preventDefault();
dragCounter.current++;
if (e.dataTransfer.types.includes("Files")) setMainDragging(true);
}}
onDragOver={(e: DragEvent) => e.preventDefault()}
onDragLeave={(e: DragEvent) => {
e.preventDefault();
dragCounter.current--;
if (dragCounter.current <= 0) { dragCounter.current = 0; setMainDragging(false); }
}}
onDrop={(e: DragEvent) => {
e.preventDefault();
dragCounter.current = 0;
setMainDragging(false);
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
files.forEach((f) => chatInputRef.current?.uploadFile(f));
}}
>
{/* 拖拽覆盖层 */}
{mainDragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center
bg-[var(--bg-primary)]/80 backdrop-blur-sm
border-2 border-dashed border-[var(--accent)]/50 rounded-xl m-2
pointer-events-none">
<div className="text-center space-y-2">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none"
stroke="var(--accent)" strokeWidth="1.5" className="mx-auto opacity-70">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<p className="text-sm text-[var(--accent)] font-medium"></p>
</div>
</div>
)}
{/* 桌面端侧边栏展开按钮 */}
{sidebarCollapsed && ( {sidebarCollapsed && (
<button <button
onClick={() => setSidebarCollapsed(false)} onClick={() => setSidebarCollapsed(false)}
className="absolute left-1 top-2 z-10 p-1.5 rounded-md className="absolute left-2 top-2 z-10 p-1.5 rounded-lg
bg-[var(--bg-secondary)] border border-[var(--border)] bg-[var(--bg-secondary)]/80 backdrop-blur-sm
text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border)]
transition-colors cursor-pointer text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:border-[var(--accent)]/40
transition-all cursor-pointer
hidden md:flex" hidden md:flex"
title="展开侧边栏" title="展开侧边栏"
> >
@@ -287,12 +335,13 @@ export default function Home() {
{/* 标注提示条 */} {/* 标注提示条 */}
{pendingAnnotation && ( {pendingAnnotation && (
<div className="flex-shrink-0 px-4 py-2 bg-[var(--accent)]/10 border-b border-[var(--accent)]/30 <div className="flex-shrink-0 px-5 py-2.5 bg-[var(--accent)]/5 border-b border-[var(--accent)]/20
flex items-center gap-3"> flex items-center gap-3 backdrop-blur-sm">
<img <img
src={pendingAnnotation.snapshot} src={pendingAnnotation.snapshot}
alt="标注预览" alt="标注预览"
className="w-10 h-10 rounded object-cover border border-[var(--accent)]" className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/40
shadow-[0_0_8px_rgba(77,184,164,0.12)]"
/> />
<span className="text-xs text-[var(--accent)]"> <span className="text-xs text-[var(--accent)]">
{pendingAnnotation.annotations.length} {pendingAnnotation.annotations.length}
@@ -300,27 +349,37 @@ export default function Home() {
<button <button
onClick={() => setPendingAnnotation(null)} onClick={() => setPendingAnnotation(null)}
className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]
cursor-pointer" cursor-pointer transition-colors"
> >
</button> </button>
</div> </div>
)} )}
<div ref={scrollRef} className="flex-1 overflow-y-auto"> <div ref={scrollRef} className="flex-1 overflow-y-auto fog-scroll">
{messages.length === 0 && !isLoading ? ( {messages.length === 0 && !isLoading ? (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">
<div className="text-center space-y-3"> <div className="text-center space-y-4">
<div className="text-4xl">🎨</div> {/* Logo 发光效果 */}
<h2 className="text-xl font-medium text-[var(--text-primary)]"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl
bg-[var(--accent)]/10 border border-[var(--accent)]/20
shadow-[0_0_30px_rgba(77,184,164,0.12)]">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="var(--accent)" />
<circle cx="23" cy="12" r="3.5" stroke="var(--accent)" strokeWidth="1.8" fill="none" />
<path d="M23 15.5v5" stroke="var(--accent)" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
</svg>
</div>
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
使 EPEEKit 使 EPEEKit
</h2> </h2>
<p className="text-sm text-[var(--text-secondary)] max-w-md"> <p className="text-sm text-[var(--text-secondary)] max-w-md leading-relaxed">
<br /> <br />
</p> </p>
<div className="flex flex-wrap justify-center gap-2 mt-4"> <div className="flex flex-wrap justify-center gap-2 mt-5">
{[ {[
"画一个赛博朋克风格的退出按钮", "画一个赛博朋克风格的退出按钮",
"设计一个卡通风格的金币图标", "设计一个卡通风格的金币图标",
@@ -328,11 +387,12 @@ export default function Home() {
].map((hint) => ( ].map((hint) => (
<button <button
key={hint} key={hint}
onClick={() => handleSend(hint, null, null)} onClick={() => handleSend(hint, [], null)}
className="text-xs px-3 py-1.5 rounded-full className="text-xs px-4 py-2 rounded-xl
border border-[var(--border)] text-[var(--text-secondary)] border border-[var(--border)] text-[var(--text-secondary)]
hover:border-[var(--accent)] hover:text-[var(--accent)] hover:border-[var(--accent)]/50 hover:text-[var(--accent)]
transition-colors cursor-pointer" hover:bg-[var(--accent)]/5
transition-all cursor-pointer"
> >
{hint} {hint}
</button> </button>
@@ -355,10 +415,10 @@ export default function Home() {
<button <button
onClick={() => scrollToBottom()} onClick={() => scrollToBottom()}
className="absolute bottom-16 right-4 z-10 className="absolute bottom-16 right-4 z-10
w-8 h-8 rounded-full flex items-center justify-center w-9 h-9 rounded-xl flex items-center justify-center
bg-[var(--bg-tertiary)] border border-[var(--border)] glass-panel text-[var(--text-secondary)]
text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:text-[var(--accent)] hover:border-[var(--accent)]/40
hover:border-[var(--accent)] shadow-lg shadow-lg shadow-black/30
transition-all duration-200 cursor-pointer transition-all duration-200 cursor-pointer
animate-[fadeIn_150ms_ease-out]" animate-[fadeIn_150ms_ease-out]"
title="回到底部" title="回到底部"
@@ -369,7 +429,21 @@ export default function Home() {
</button> </button>
)} )}
<ChatInput onSend={handleSend} disabled={isLoading} /> <ChatInput
ref={chatInputRef}
onSend={handleSend}
disabled={isLoading}
lastRefServerUrls={lastRefImageUrls}
lastRefPreviewUrls={lastRefImageUrls.map((u) => getImageUrl(u))}
onClearLastRefImage={() => {
if (activeSessionId) lastRefPerSession.current.delete(activeSessionId);
setLastRefImageUrls([]);
}}
onFileDrop={() => {
dragCounter.current = 0;
setMainDragging(false);
}}
/>
</main> </main>
{detailImage && ( {detailImage && (

View File

@@ -1,52 +1,77 @@
"use client"; "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 { ModelSelector } from "./model-selector";
import { uploadRefImage, type UploadProgress } from "@/lib/api"; import { uploadRefImage, type UploadProgress } from "@/lib/api";
type UploadStatus = "idle" | "uploading" | "done" | "error"; type UploadStatus = "idle" | "uploading" | "done" | "error";
interface UploadState { interface UploadItem {
id: string;
status: UploadStatus; status: UploadStatus;
/** 本地预览 URL */
previewUrl: string | null; previewUrl: string | null;
/** 上传成功后的服务端路径 */
serverUrl: string | null; serverUrl: string | null;
/** 上传进度 0-100 */
progress: number; progress: number;
/** 错误信息 */
errorMsg: string | null; errorMsg: string | null;
} }
const INITIAL_UPLOAD: UploadState = {
status: "idle",
previewUrl: null,
serverUrl: null,
progress: 0,
errorMsg: null,
};
interface ChatInputProps { interface ChatInputProps {
onSend: (text: string, refImageServerUrl: string | null, imageModel: string | null) => void; onSend: (text: string, refImageServerUrls: string[], imageModel: string | null) => void;
disabled: boolean; 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<ChatInputHandle, ChatInputProps>(function ChatInput(
{ onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop },
ref
) {
const [text, setText] = useState(""); const [text, setText] = useState("");
const [upload, setUpload] = useState<UploadState>(INITIAL_UPLOAD); const [uploads, setUploads] = useState<UploadItem[]>([]);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [selectedModel, setSelectedModel] = useState<string>(""); const [selectedModel, setSelectedModel] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(null); const abortRefs = useRef<Map<string, AbortController>>(new Map());
const hasActiveUpload = uploads.some((u) => u.status === "uploading");
const allDone = uploads.length > 0 && uploads.every((u) => u.status === "done" || u.status === "error");
const doneUrls = uploads.filter((u) => u.status === "done" && u.serverUrl).map((u) => u.serverUrl!);
const handleSubmit = () => { const handleSubmit = () => {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed || disabled) return; 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(""); setText("");
clearUpload(); clearAllUploads();
}; };
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -56,247 +81,263 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
} }
}; };
const clearUpload = useCallback(() => { const clearAllUploads = useCallback(() => {
abortRef.current?.abort(); abortRefs.current.forEach((c) => c.abort());
abortRef.current = null; abortRefs.current.clear();
setUpload((prev) => { setUploads((prev) => {
if (prev.previewUrl) URL.revokeObjectURL(prev.previewUrl); prev.forEach((u) => { if (u.previewUrl) URL.revokeObjectURL(u.previewUrl); });
return INITIAL_UPLOAD; return [];
}); });
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
}, []); }, []);
const removeUpload = useCallback((id: string) => {
abortRefs.current.get(id)?.abort();
abortRefs.current.delete(id);
setUploads((prev) => {
const item = prev.find((u) => u.id === id);
if (item?.previewUrl) URL.revokeObjectURL(item.previewUrl);
return prev.filter((u) => u.id !== id);
});
}, []);
const startUpload = useCallback(async (file: File) => { const startUpload = useCallback(async (file: File) => {
abortRef.current?.abort(); const id = `upload-${++_uploadCounter}`;
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRefs.current.set(id, controller);
const previewUrl = URL.createObjectURL(file); const previewUrl = URL.createObjectURL(file);
setUpload({ const newItem: UploadItem = {
id,
status: "uploading", status: "uploading",
previewUrl, previewUrl,
serverUrl: null, serverUrl: null,
progress: 0, progress: 0,
errorMsg: null, errorMsg: null,
}); };
setUploads((prev) => [...prev, newItem]);
try { try {
const result = await uploadRefImage( const result = await uploadRefImage(
file, file,
(p: UploadProgress) => { (p: UploadProgress) => {
setUpload((prev) => ({ ...prev, progress: p.percent })); setUploads((prev) =>
prev.map((u) => (u.id === id ? { ...u, progress: p.percent } : u))
);
}, },
controller.signal controller.signal
); );
setUpload((prev) => ({ setUploads((prev) =>
...prev, prev.map((u) =>
status: "done", u.id === id ? { ...u, status: "done", serverUrl: result.url, progress: 100 } : u
serverUrl: result.url, )
progress: 100, );
}));
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return; if (err instanceof DOMException && err.name === "AbortError") return;
const msg = err instanceof Error ? err.message : "上传失败"; const msg = err instanceof Error ? err.message : "上传失败";
setUpload((prev) => ({ setUploads((prev) =>
...prev, prev.map((u) =>
status: "error", u.id === id ? { ...u, status: "error", progress: 0, errorMsg: msg } : u
progress: 0, )
errorMsg: msg, );
}));
} }
}, []); }, []);
useImperativeHandle(ref, () => ({ uploadFile: startUpload }), [startUpload]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const files = e.target.files;
if (file) { if (files) {
startUpload(file); Array.from(files).forEach((f) => startUpload(f));
} }
// 重置 input value确保选同一文件也能触发
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
}; };
const retryUpload = useCallback(() => {
if (fileInputRef.current) fileInputRef.current.click();
}, []);
// 拖拽
const handleDragOver = (e: DragEvent) => { const handleDragOver = (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
setIsDragging(true); setIsDragging(true);
}; };
const handleDragLeave = () => setIsDragging(false); const handleDragLeave = () => setIsDragging(false);
const handleDrop = (e: DragEvent) => { const handleDrop = (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
setIsDragging(false); setIsDragging(false);
const file = e.dataTransfer.files[0]; onFileDrop?.();
if (file && file.type.startsWith("image/")) { const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
startUpload(file); files.forEach((f) => startUpload(f));
}
}; };
const statusLabel = (() => { const handlePasteCapture = useCallback(
switch (upload.status) { (e: ClipboardEvent) => {
case "uploading": if (disabled) return;
return `上传中 ${upload.progress}%`; const files = imageFilesFromClipboard(e.clipboardData);
case "done": if (files.length === 0) return;
return "参考图已就绪"; e.preventDefault();
case "error": e.stopPropagation();
return upload.errorMsg || "上传失败"; files.forEach((f) => startUpload(f));
default: },
return null; [disabled, startUpload]
} );
})();
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 "";
}
})();
return ( return (
<div <div
className={`border-t border-[var(--border)] bg-[var(--bg-secondary)] px-3 md:px-4 py-2 md:py-3 transition-colors ${ className={`border-t border-[var(--border)] bg-[var(--bg-secondary)]/80 backdrop-blur-xl
isDragging ? "bg-[var(--accent)]/5 border-[var(--accent)]" : "" px-3 md:px-5 py-2.5 md:py-3 transition-colors ${
isDragging ? "bg-[var(--accent)]/5 border-[var(--accent)]/40" : ""
}`} }`}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
onPasteCapture={handlePasteCapture}
> >
{/* 拖拽提示 */}
{isDragging && ( {isDragging && (
<div className="mb-2 text-center text-xs text-[var(--accent)]"> <div className="mb-2 text-center text-xs text-[var(--accent)]">
</div> </div>
)} )}
{/* 参考图上传状态区 */} {/* 沿用上次参考图提示 */}
{upload.status !== "idle" && ( {uploads.length === 0 && lastRefPreviewUrls && lastRefPreviewUrls.length > 0 && (
<div className="mb-3 flex items-start gap-2"> <div className="mb-3 flex items-center gap-2.5">
<div className="relative flex-shrink-0"> <div className="flex gap-1.5">
{upload.previewUrl && ( {lastRefPreviewUrls.map((url, i) => (
<img <img
src={upload.previewUrl} key={i}
alt="参考图" src={url}
className={`w-16 h-16 rounded-lg object-cover border border-[var(--border)] transition-opacity ${ alt={`沿用参考图 ${i + 1}`}
upload.status === "uploading" ? "opacity-60" : "" className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/30
}`} shadow-[0_0_8px_rgba(77,184,164,0.1)]"
/> />
)} ))}
{/* 上传中的遮罩进度环 */}
{upload.status === "uploading" && (
<div className="absolute inset-0 flex items-center justify-center">
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 36 36">
<circle
cx="18" cy="18" r="14"
fill="none"
stroke="rgba(255,255,255,0.2)"
strokeWidth="3"
/>
<circle
cx="18" cy="18" r="14"
fill="none"
stroke="var(--accent)"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={`${upload.progress * 0.88} 88`}
className="transition-all duration-200"
/>
</svg>
</div>
)}
{/* 成功 ✓ 角标 */}
{upload.status === "done" && (
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-green-500
text-white text-[10px] flex items-center justify-center font-bold">
</div>
)}
{/* 失败 ! 角标 */}
{upload.status === "error" && (
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-red-500
text-white text-[10px] flex items-center justify-center font-bold">
!
</div>
)}
{/* 删除按钮(非上传中时显示) */}
{upload.status !== "uploading" && (
<button
onClick={clearUpload}
className="absolute -top-1.5 -left-1.5 w-5 h-5 rounded-full
bg-[var(--bg-secondary)] border border-[var(--border)]
text-[var(--text-secondary)] text-xs flex items-center justify-center
hover:bg-red-500 hover:text-white hover:border-red-500
cursor-pointer transition-colors"
>
×
</button>
)}
</div> </div>
<span className="text-xs text-[var(--text-secondary)]">
沿{lastRefPreviewUrls.length}
</span>
<button
onClick={onClearLastRefImage}
className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--hot)]
cursor-pointer transition-colors"
title="清除参考图"
>
</button>
</div>
)}
<div className="flex flex-col gap-1 mt-0.5 min-w-0"> {/* 参考图上传状态区(多图) */}
<span className={`text-xs ${statusColor} flex items-center gap-1.5`}> {uploads.length > 0 && (
{upload.status === "uploading" && ( <div className="mb-3 flex flex-wrap gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse" /> {uploads.map((item) => (
)} <div key={item.id} className="relative flex-shrink-0">
{statusLabel} {item.previewUrl && (
</span> <img
{/* 进度条 */} src={item.previewUrl}
{upload.status === "uploading" && ( alt="参考图"
<div className="w-32 h-1 rounded-full bg-[var(--bg-tertiary)] overflow-hidden"> className={`w-16 h-16 rounded-xl object-cover border border-[var(--border)] transition-opacity ${
<div item.status === "uploading" ? "opacity-60" : ""
className="h-full bg-[var(--accent)] rounded-full transition-all duration-200" }`}
style={{ width: `${upload.progress}%` }}
/> />
</div> )}
{item.status === "uploading" && (
<div className="absolute inset-0 flex items-center justify-center">
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(77,184,164,0.12)" strokeWidth="3" />
<circle
cx="18" cy="18" r="14" fill="none"
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
strokeDasharray={`${item.progress * 0.88} 88`}
className="transition-all duration-200"
/>
</svg>
</div>
)}
{item.status === "done" && (
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--accent)]
text-[var(--bg-primary)] text-[10px] flex items-center justify-center font-bold
shadow-[0_0_8px_rgba(77,184,164,0.3)]">
</div>
)}
{item.status === "error" && (
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--hot)]
text-white text-[10px] flex items-center justify-center font-bold">
!
</div>
)}
{item.status !== "uploading" && (
<button
onClick={() => removeUpload(item.id)}
className="absolute -top-1.5 -left-1.5 w-5 h-5 rounded-full
bg-[var(--bg-secondary)] border border-[var(--border)]
text-[var(--text-secondary)] text-xs flex items-center justify-center
hover:bg-[var(--hot)] hover:text-white hover:border-[var(--hot)]
cursor-pointer transition-colors"
>
×
</button>
)}
</div>
))}
{/* 汇总状态 */}
<div className="flex flex-col justify-center gap-1 min-w-0">
{hasActiveUpload && (
<span className="text-xs text-[var(--accent)] flex items-center gap-1.5">
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
shadow-[0_0_6px_rgba(77,184,164,0.3)]" />
...
</span>
)} )}
{/* 失败时显示重试 */} {allDone && (
{upload.status === "error" && ( <span className="text-xs text-[var(--accent)]">
<button {doneUrls.length}
onClick={retryUpload} </span>
className="text-xs text-[var(--accent)] hover:underline cursor-pointer w-fit" )}
> {uploads.some((u) => u.status === "error") && (
<span className="text-xs text-[var(--hot)]">
</button> {uploads.filter((u) => u.status === "error").length}
</span>
)} )}
</div> </div>
</div> </div>
)} )}
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
{/* 模型选择器 */}
<ModelSelector value={selectedModel} onChange={setSelectedModel} /> <ModelSelector value={selectedModel} onChange={setSelectedModel} />
{/* 上传参考图按钮 */} {/* 上传参考图按钮 */}
<button <button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={disabled || upload.status === "uploading"} disabled={disabled || hasActiveUpload}
className={`flex-shrink-0 w-10 h-10 rounded-lg border className={`relative flex-shrink-0 w-10 h-10 rounded-xl border
bg-[var(--bg-tertiary)] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--text-primary)] hover:border-[var(--accent)] hover:text-[var(--accent)] hover:border-[var(--accent)]/40
flex items-center justify-center transition-colors hover:bg-[var(--accent)]/5
flex items-center justify-center transition-all
disabled:opacity-50 cursor-pointer ${ disabled:opacity-50 cursor-pointer ${
upload.status === "done" doneUrls.length > 0
? "border-green-500/50 text-green-400" ? "border-[var(--accent)]/40 text-[var(--accent)]"
: "border-[var(--border)]" : "border-[var(--border)]"
}`} }`}
title="上传参考图" title="上传参考图(可多选)"
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" /> <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" /> <circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" /> <polyline points="21 15 16 10 5 21" />
</svg> </svg>
{doneUrls.length > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--accent)]
text-[var(--bg-primary)] text-[9px] flex items-center justify-center font-bold">
{doneUrls.length}
</span>
)}
</button> </button>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/*" accept="image/*"
multiple
onChange={handleFileChange} onChange={handleFileChange}
className="hidden" className="hidden"
/> />
@@ -306,15 +347,17 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="描述你想要的美术资源..." placeholder="描述你想要的美术资源...(可粘贴图片)"
title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图"
disabled={disabled} disabled={disabled}
rows={1} 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)] bg-[var(--bg-tertiary)] text-[var(--text-primary)]
placeholder:text-[var(--text-secondary)] placeholder:text-[var(--text-secondary)]
px-4 py-2.5 text-sm leading-relaxed px-4 py-2.5 text-sm leading-relaxed
focus:outline-none focus:border-[var(--accent)] focus:outline-none focus:border-[var(--accent)]/50
disabled:opacity-50 transition-colors focus:shadow-[0_0_10px_rgba(77,184,164,0.06)]
disabled:opacity-50 transition-all
min-h-[42px] max-h-[120px]" min-h-[42px] max-h-[120px]"
style={{ fieldSizing: "content" } as React.CSSProperties} style={{ fieldSizing: "content" } as React.CSSProperties}
/> />
@@ -322,11 +365,12 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
{/* 发送按钮 */} {/* 发送按钮 */}
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={disabled || !text.trim() || upload.status === "uploading"} disabled={disabled || !text.trim() || hasActiveUpload}
className="flex-shrink-0 w-10 h-10 rounded-lg className="flex-shrink-0 w-10 h-10 rounded-xl
bg-[var(--accent)] text-white bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)] hover:bg-[var(--accent-hover)]
flex items-center justify-center transition-colors hover:shadow-[0_0_15px_rgba(77,184,164,0.25)]
flex items-center justify-center transition-all
disabled:opacity-50 cursor-pointer" disabled:opacity-50 cursor-pointer"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
@@ -336,4 +380,4 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
</div> </div>
</div> </div>
); );
} });

View File

@@ -13,19 +13,19 @@ function CopyButton({ text }: { text: string }) {
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1500); setTimeout(() => setCopied(false), 1500);
} catch { } catch {
// fallback 不做处理 // fallback
} }
}, [text]); }, [text]);
return ( return (
<button <button
onClick={handleCopy} onClick={handleCopy}
className="p-1 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] className="p-1 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:bg-[var(--bg-secondary)] transition-colors cursor-pointer" hover:bg-[var(--accent)]/5 transition-colors cursor-pointer"
title="复制文本" title="复制文本"
> >
{copied ? ( {copied ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
) : ( ) : (
@@ -54,25 +54,52 @@ export function ChatMessages({
statusText, statusText,
}: ChatMessagesProps) { }: ChatMessagesProps) {
return ( return (
<div className="flex-1 overflow-y-auto px-3 md:px-4 py-4 md:py-6 space-y-4 md:space-y-6"> <div className="flex-1 overflow-y-auto px-3 md:px-5 py-4 md:py-6 space-y-4 md:space-y-5">
{messages.map((msg) => ( {messages.map((msg) => (
<div key={msg.id} className={`group/msg flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}> <div
key={msg.id}
className={`group/msg flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
style={{ animation: "slideUp 200ms ease-out" }}
>
<div className="flex flex-col items-start gap-0.5"> <div className="flex flex-col items-start gap-0.5">
<div <div
className={`max-w-[90vw] md:max-w-[80vw] rounded-2xl px-3.5 md:px-4 py-2.5 md:py-3 ${ className={`max-w-[90vw] md:max-w-[80vw] rounded-2xl px-4 md:px-5 py-3 ${
msg.role === "user" msg.role === "user"
? "bg-[var(--accent)] text-white" ? "bg-[var(--accent)]/15 text-[var(--text-primary)] border border-[var(--accent)]/25"
: "bg-[var(--bg-tertiary)] text-[var(--text-primary)]" : "glass-panel text-[var(--text-primary)]"
}`} }`}
> >
{msg.refImageUrl && ( {/* 多图参考(新格式) */}
{msg.refImageUrls && msg.refImageUrls.length > 0 && (
<div className="mb-2">
<div className="flex flex-wrap gap-1.5">
{msg.refImageUrls.map((url, i) => (
<img
key={i}
src={url}
alt={`参考图 ${i + 1}`}
className="max-w-[120px] max-h-[90px] rounded-lg object-cover
border border-[var(--accent)]/20
shadow-[0_0_10px_rgba(77,184,164,0.08)]"
/>
))}
</div>
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block">
{msg.refImageUrls.length}
</span>
</div>
)}
{/* 兼容旧数据:单图 */}
{!msg.refImageUrls && msg.refImageUrl && (
<div className="mb-2"> <div className="mb-2">
<img <img
src={msg.refImageUrl} src={msg.refImageUrl}
alt="参考图" alt="参考图"
className="max-w-[160px] max-h-[120px] rounded-lg object-cover border border-white/20" className="max-w-[160px] max-h-[120px] rounded-lg object-cover
border border-[var(--accent)]/20
shadow-[0_0_10px_rgba(77,184,164,0.08)]"
/> />
<span className="text-[10px] opacity-70 mt-1 block"></span> <span className="text-[10px] text-[var(--accent)]/60 mt-1 block"></span>
</div> </div>
)} )}
<div className="whitespace-pre-wrap text-sm leading-relaxed">{msg.content}</div> <div className="whitespace-pre-wrap text-sm leading-relaxed">{msg.content}</div>
@@ -80,7 +107,7 @@ export function ChatMessages({
<> <>
<ImageGrid images={msg.images} /> <ImageGrid images={msg.images} />
{msg.modelName && ( {msg.modelName && (
<div className="mt-1.5 text-[10px] text-[var(--text-secondary)] opacity-70"> <div className="mt-1.5 text-[10px] text-[var(--accent)]/50">
{msg.modelName} {msg.modelName}
</div> </div>
)} )}
@@ -98,11 +125,12 @@ export function ChatMessages({
))} ))}
{isLoading && ( {isLoading && (
<div className="flex justify-start"> <div className="flex justify-start" style={{ animation: "slideUp 200ms ease-out" }}>
<div className="max-w-[90%] md:max-w-[80%] rounded-2xl px-3.5 md:px-4 py-2.5 md:py-3 bg-[var(--bg-tertiary)]"> <div className="max-w-[90%] md:max-w-[80%] rounded-2xl px-4 md:px-5 py-3 glass-panel">
{statusText && ( {statusText && (
<div className="text-xs text-[var(--accent)] mb-2 flex items-center gap-2"> <div className="text-xs text-[var(--accent)] mb-2 flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse" /> <span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
shadow-[0_0_8px_rgba(77,184,164,0.4)]" />
{statusText} {statusText}
</div> </div>
)} )}
@@ -111,10 +139,10 @@ export function ChatMessages({
)} )}
{streamingImages.length > 0 && <ImageGrid images={streamingImages} />} {streamingImages.length > 0 && <ImageGrid images={streamingImages} />}
{!streamingText && !statusText && ( {!streamingText && !statusText && (
<div className="flex gap-1"> <div className="flex gap-1.5">
<span className="w-2 h-2 rounded-full bg-[var(--text-secondary)] animate-bounce" /> <span className="w-2 h-2 rounded-full bg-[var(--accent)]/60 animate-bounce" />
<span className="w-2 h-2 rounded-full bg-[var(--text-secondary)] animate-bounce [animation-delay:0.1s]" /> <span className="w-2 h-2 rounded-full bg-[var(--accent)]/60 animate-bounce [animation-delay:0.1s]" />
<span className="w-2 h-2 rounded-full bg-[var(--text-secondary)] animate-bounce [animation-delay:0.2s]" /> <span className="w-2 h-2 rounded-full bg-[var(--accent)]/60 animate-bounce [animation-delay:0.2s]" />
</div> </div>
)} )}
</div> </div>

View File

@@ -35,30 +35,34 @@ export function ImageGrid({ images }: ImageGridProps) {
: "grid-cols-2 max-w-[320px] md:max-w-xl"; : "grid-cols-2 max-w-[320px] md:max-w-xl";
return ( return (
<div className={`grid ${gridCols} gap-2 my-3`}> <div className={`grid ${gridCols} gap-3 my-3`}>
{images.map((asset, i) => ( {images.map((asset, i) => (
<div <div
key={asset.id} key={asset.id}
className="group relative rounded-lg overflow-hidden border border-[var(--border)]" className="group relative rounded-xl overflow-hidden neon-border
bg-[var(--bg-card)] backdrop-blur-sm"
> >
<img <img
src={getImageUrl(asset.url)} src={getImageUrl(asset.url)}
alt={`生成图片 ${i + 1}`} alt={`生成图片 ${i + 1}`}
className="w-full aspect-square object-cover cursor-pointer" className="w-full aspect-square object-cover cursor-pointer
transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy" loading="lazy"
onClick={() => setDetailImage(asset)} onClick={() => setDetailImage(asset)}
/> />
{/* 操作栏:移动端始终可见,桌面端 hover 显示 */} {/* 底部渐变 + 操作栏 */}
<div <div
className="absolute bottom-0 left-0 right-0 px-2 py-1.5 className="absolute bottom-0 left-0 right-0 px-3 py-2
bg-gradient-to-t from-black/80 to-transparent bg-gradient-to-t from-black/80 via-black/40 to-transparent
opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity opacity-100 md:opacity-0 md:group-hover:opacity-100
flex items-center gap-1" transition-opacity duration-200
flex items-center gap-1.5"
> >
<button <button
onClick={() => setDetailImage(asset)} onClick={() => setDetailImage(asset)}
className="p-1 rounded text-white/80 hover:text-white cursor-pointer" className="p-1.5 rounded-lg text-white/70 hover:text-[var(--accent)]
hover:bg-[var(--accent)]/10 cursor-pointer transition-colors"
title="放大查看" title="放大查看"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -67,7 +71,8 @@ export function ImageGrid({ images }: ImageGridProps) {
</button> </button>
<button <button
onClick={() => handleDownload(asset.url, i)} onClick={() => handleDownload(asset.url, i)}
className="p-1 rounded text-white/80 hover:text-white cursor-pointer" className="p-1.5 rounded-lg text-white/70 hover:text-[var(--accent)]
hover:bg-[var(--accent)]/10 cursor-pointer transition-colors"
title="保存" title="保存"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -79,8 +84,9 @@ export function ImageGrid({ images }: ImageGridProps) {
e.stopPropagation(); e.stopPropagation();
toggleFavorite(asset.id); toggleFavorite(asset.id);
}} }}
className="p-1 rounded cursor-pointer transition-colors" className="p-1.5 rounded-lg cursor-pointer transition-colors
style={{ color: asset.favorited ? "#f59e0b" : "rgba(255,255,255,0.6)" }} hover:bg-[var(--accent)]/10"
style={{ color: asset.favorited ? "var(--accent)" : "rgba(255,255,255,0.5)" }}
title={asset.favorited ? "取消收藏" : "收藏"} title={asset.favorited ? "取消收藏" : "收藏"}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
@@ -88,6 +94,17 @@ export function ImageGrid({ images }: ImageGridProps) {
</svg> </svg>
</button> </button>
</div> </div>
{/* 最新生成标签 */}
{i === 0 && images.length > 1 && (
<div className="absolute top-2 left-2">
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-md
bg-[var(--accent)]/20 text-[var(--accent)]
border border-[var(--accent)]/30 backdrop-blur-sm">
New
</span>
</div>
)}
</div> </div>
))} ))}
</div> </div>

View File

@@ -33,14 +33,10 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
onChange(defaultId); onChange(defaultId);
} }
}) })
.catch(() => { .catch(() => {});
// 后端不可用时静默降级
});
// 仅初始化时执行一次
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// 点击外部关闭
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
@@ -61,10 +57,11 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
<button <button
type="button" type="button"
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs
border border-[var(--border)] bg-[var(--bg-tertiary)] border border-[var(--border)] bg-[var(--bg-tertiary)]
text-[var(--text-secondary)] hover:text-[var(--text-primary)] text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:border-[var(--accent)] transition-colors cursor-pointer" hover:border-[var(--accent)]/40 hover:bg-[var(--accent)]/5
transition-all cursor-pointer"
title="切换生图模型" title="切换生图模型"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -84,9 +81,8 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
{open && ( {open && (
<div <div
className="absolute bottom-full left-0 mb-1.5 w-56 rounded-lg className="absolute bottom-full left-0 mb-1.5 w-56 rounded-xl
border border-[var(--border)] bg-[var(--bg-secondary)] glass-panel shadow-xl shadow-black/40 overflow-hidden z-50"
shadow-lg overflow-hidden z-50"
> >
{models.map((m) => { {models.map((m) => {
const isActive = m.id === value; const isActive = m.id === value;
@@ -98,10 +94,10 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
localStorage.setItem(getModelStorageKey(), m.id); localStorage.setItem(getModelStorageKey(), m.id);
setOpen(false); setOpen(false);
}} }}
className={`w-full text-left px-3 py-2.5 flex flex-col gap-0.5 className={`w-full text-left px-3.5 py-2.5 flex flex-col gap-0.5
transition-colors cursor-pointer transition-all cursor-pointer
${isActive ${isActive
? "bg-[var(--accent)]/10 text-[var(--accent)]" ? "bg-[var(--accent)]/8 text-[var(--accent)]"
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]" : "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
}`} }`}
> >
@@ -109,7 +105,8 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
{m.name} {m.name}
{m.supports_ref_image && ( {m.supports_ref_image && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full <span className="text-[10px] px-1.5 py-0.5 rounded-full
bg-emerald-500/15 text-emerald-400 font-normal leading-none"> bg-[var(--accent)]/10 text-[var(--accent)] font-medium leading-none
border border-[var(--accent)]/20">
</span> </span>
)} )}

View File

@@ -54,7 +54,8 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
if (showAnnotate) { if (showAnnotate) {
return ( return (
<aside className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto <aside className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
md:w-[480px] flex-shrink-0 border-l border-[var(--border)] bg-[var(--bg-secondary)] md:w-[480px] flex-shrink-0 border-l border-[var(--border)]
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
flex flex-col overflow-hidden"> flex flex-col overflow-hidden">
<AnnotationCanvas <AnnotationCanvas
imageUrl={getImageUrl(detailImage.url)} imageUrl={getImageUrl(detailImage.url)}
@@ -67,15 +68,16 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
return ( return (
<aside className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto <aside className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
md:w-[360px] flex-shrink-0 border-l border-[var(--border)] bg-[var(--bg-secondary)] md:w-[360px] flex-shrink-0 border-l border-[var(--border)]
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
flex flex-col overflow-hidden"> flex flex-col overflow-hidden">
{/* 头部 */} {/* 头部 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--border)]"> <div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<span className="text-sm font-medium text-[var(--text-primary)]"></span> <span className="text-sm font-semibold text-[var(--text-primary)]"></span>
<button <button
onClick={() => setDetailImage(null)} onClick={() => setDetailImage(null)}
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] className="text-[var(--text-secondary)] hover:text-[var(--accent)]
cursor-pointer transition-colors" cursor-pointer transition-colors p-1 rounded-lg hover:bg-[var(--accent)]/5"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" /> <path d="M18 6L6 18M6 6l12 12" />
@@ -83,11 +85,10 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
</button> </button>
</div> </div>
{/* 内容滚动区 */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{/* 图片预览 */} {/* 图片预览 */}
<div className="p-4"> <div className="p-4">
<div className="relative rounded-lg overflow-hidden border border-[var(--border)] bg-[var(--bg-primary)]"> <div className="relative rounded-xl overflow-hidden neon-border bg-[var(--bg-primary)]">
<img <img
src={getImageUrl(detailImage.url)} src={getImageUrl(detailImage.url)}
alt="预览" alt="预览"
@@ -97,28 +98,31 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
</div> </div>
{/* 缩放控制 */} {/* 缩放控制 */}
<div className="flex items-center justify-center gap-2 mt-2"> <div className="flex items-center justify-center gap-2 mt-3">
<button <button
onClick={() => setScale((s) => Math.max(0.5, s - 0.25))} onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}
className="text-xs px-2 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)] className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--text-primary)] cursor-pointer" hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
> >
</button> </button>
<span className="text-xs text-[var(--text-secondary)] min-w-[40px] text-center"> <span className="text-xs text-[var(--text-secondary)] min-w-[40px] text-center font-medium">
{Math.round(scale * 100)}% {Math.round(scale * 100)}%
</span> </span>
<button <button
onClick={() => setScale((s) => Math.min(3, s + 0.25))} onClick={() => setScale((s) => Math.min(3, s + 0.25))}
className="text-xs px-2 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)] className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--text-primary)] cursor-pointer" hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
> >
+ +
</button> </button>
<button <button
onClick={() => setScale(1)} onClick={() => setScale(1)}
className="text-xs px-2 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)] className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--text-primary)] cursor-pointer" hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
> >
</button> </button>
@@ -129,10 +133,10 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
<div className="px-4 pb-3 flex flex-wrap gap-2"> <div className="px-4 pb-3 flex flex-wrap gap-2">
<button <button
onClick={handleDownload} onClick={handleDownload}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--bg-tertiary)] text-[var(--text-secondary)] bg-[var(--accent)] text-[var(--bg-primary)]
hover:text-[var(--text-primary)] border border-[var(--border)] hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(77,184,164,0.25)]
hover:border-[var(--accent)] transition-colors cursor-pointer" transition-all cursor-pointer"
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" /> <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
@@ -141,13 +145,12 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
</button> </button>
<button <button
onClick={() => toggleFavorite(detailImage.id)} onClick={() => toggleFavorite(detailImage.id)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md className={`flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
border transition-colors cursor-pointer" border transition-all cursor-pointer ${
style={{ detailImage.favorited
backgroundColor: detailImage.favorited ? "#f59e0b22" : "var(--bg-tertiary)", ? "border-[var(--accent)]/40 bg-[var(--accent)]/8 text-[var(--accent)]"
borderColor: detailImage.favorited ? "#f59e0b" : "var(--border)", : "border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
color: detailImage.favorited ? "#f59e0b" : "var(--text-secondary)", }`}
}}
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill={detailImage.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2"> <svg width="12" height="12" viewBox="0 0 24 24" fill={detailImage.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" /> <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
@@ -156,10 +159,10 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
</button> </button>
<button <button
onClick={() => setShowAnnotate(true)} onClick={() => setShowAnnotate(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--bg-tertiary)] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--accent)] border border-[var(--border)] hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)] transition-colors cursor-pointer" hover:border-[var(--accent)]/40 transition-all cursor-pointer"
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z" /> <path d="M12 20h9M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z" />
@@ -168,10 +171,10 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
</button> </button>
<button <button
onClick={() => deleteAssetById(detailImage.id)} onClick={() => deleteAssetById(detailImage.id)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--bg-tertiary)] text-red-400/70 bg-[var(--bg-tertiary)] text-[var(--hot)]/60
hover:text-red-400 border border-[var(--border)] hover:text-[var(--hot)] border border-[var(--border)]
hover:border-red-400/50 transition-colors cursor-pointer" hover:border-[var(--hot)]/40 transition-all cursor-pointer"
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /> <polyline points="3 6 5 6 21 6" />
@@ -181,7 +184,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
</button> </button>
</div> </div>
{/* Prompt 信息 */} {/* Prompt */}
{detailImage.prompt && ( {detailImage.prompt && (
<div className="px-4 pb-3"> <div className="px-4 pb-3">
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
@@ -194,7 +197,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
</button> </button>
</div> </div>
<div className="p-2.5 rounded-md bg-[var(--bg-primary)] border border-[var(--border)] <div className="p-3 rounded-xl bg-[var(--bg-primary)] border border-[var(--border)]
text-xs text-[var(--text-secondary)] leading-relaxed break-all"> text-xs text-[var(--text-secondary)] leading-relaxed break-all">
{detailImage.prompt} {detailImage.prompt}
</div> </div>
@@ -205,15 +208,15 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
{detailImage.tags.length > 0 && ( {detailImage.tags.length > 0 && (
<div className="px-4 pb-3"> <div className="px-4 pb-3">
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5"></span> <span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5"></span>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1.5">
{detailImage.tags.map((tid) => { {detailImage.tags.map((tid) => {
const tag = tagMap.get(tid); const tag = tagMap.get(tid);
if (!tag) return null; if (!tag) return null;
return ( return (
<span <span
key={tid} key={tid}
className="text-[10px] px-2 py-0.5 rounded-full" className="text-[10px] px-2 py-0.5 rounded-lg font-medium"
style={{ backgroundColor: tag.color + "22", color: tag.color }} style={{ backgroundColor: tag.color + "18", color: tag.color }}
> >
{tag.name} {tag.name}
</span> </span>

View File

@@ -5,10 +5,29 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useApp } from "@/lib/app-context"; import { useApp } from "@/lib/app-context";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
import { ProfileModal } from "@/components/profile-modal";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: "/", label: "对话" }, {
{ href: "/gallery", label: "资源库" }, href: "/",
label: "对话",
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
),
},
{
href: "/gallery",
label: "资源库",
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
),
},
] as const; ] as const;
export function TopNav() { export function TopNav() {
@@ -16,6 +35,7 @@ export function TopNav() {
const { sidebarCollapsed, setSidebarCollapsed } = useApp(); const { sidebarCollapsed, setSidebarCollapsed } = useApp();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -30,12 +50,13 @@ export function TopNav() {
}, [menuOpen]); }, [menuOpen]);
return ( return (
<header className="flex-shrink-0 h-12 border-b border-[var(--border)] bg-[var(--bg-secondary)] flex items-center px-3 md:px-4 gap-3 md:gap-6"> <>
<header className="relative z-10 flex-shrink-0 h-14 border-b border-[var(--border)] bg-[var(--bg-secondary)]/80 backdrop-blur-xl flex items-center px-4 md:px-5 gap-4 md:gap-6">
{/* 移动端汉堡菜单 */} {/* 移动端汉堡菜单 */}
<button <button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)} onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="md:hidden flex-shrink-0 p-1.5 rounded-md className="md:hidden flex-shrink-0 p-1.5 rounded-md
text-[var(--text-secondary)] hover:text-[var(--text-primary)] text-[var(--text-secondary)] hover:text-[var(--accent)]
transition-colors cursor-pointer" transition-colors cursor-pointer"
title="菜单" title="菜单"
> >
@@ -45,33 +66,37 @@ export function TopNav() {
</button> </button>
{/* Logo */} {/* Logo */}
<Link href="/" className="flex items-center gap-2 flex-shrink-0"> <Link href="/" className="flex items-center gap-2.5 flex-shrink-0 group">
<svg width="22" height="22" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <div className="w-8 h-8 rounded-lg bg-[var(--accent)] flex items-center justify-center
<rect width="32" height="32" rx="7" fill="var(--accent)" /> shadow-[0_0_12px_rgba(77,184,164,0.25)]
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="white" /> group-hover:shadow-[0_0_20px_rgba(77,184,164,0.4)] transition-shadow">
<circle cx="23" cy="12" r="3.5" stroke="white" strokeWidth="1.8" fill="none" /> <svg width="18" height="18" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23 15.5v5" stroke="white" strokeWidth="1.8" strokeLinecap="round" /> <path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="#0C1210" />
<circle cx="23" cy="22.5" r="1" fill="white" /> <circle cx="23" cy="12" r="3.5" stroke="#0C1210" strokeWidth="1.8" fill="none" />
</svg> <path d="M23 15.5v5" stroke="#0C1210" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="23" cy="22.5" r="1" fill="#0C1210" />
</svg>
</div>
<span className="text-base font-bold tracking-tight text-[var(--text-primary)]"> <span className="text-base font-bold tracking-tight text-[var(--text-primary)]">
EPEEKit EPEEKit
</span> </span>
</Link> </Link>
{/* 导航 Tab */} {/* 导航 Tab */}
<nav className="flex items-center gap-1"> <nav className="flex items-center gap-1.5">
{NAV_ITEMS.map(({ href, label }) => { {NAV_ITEMS.map(({ href, label, icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href); const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return ( return (
<Link <Link
key={href} key={href}
href={href} href={href}
className={`px-2.5 md:px-3 py-1.5 text-sm rounded-md transition-colors ${ className={`relative flex items-center gap-1.5 px-3 md:px-4 py-2 text-sm rounded-lg ${
active active
? "bg-[var(--bg-tertiary)] text-[var(--text-primary)] font-medium" ? "glow-border text-[var(--accent)] font-medium !bg-[var(--bg-secondary)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]" : "text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-transparent hover:border-[var(--border-glow)] transition-all"
}`} }`}
> >
{icon}
{label} {label}
</Link> </Link>
); );
@@ -85,29 +110,39 @@ export function TopNav() {
<div className="relative" ref={menuRef}> <div className="relative" ref={menuRef}>
<button <button
onClick={() => setMenuOpen(!menuOpen)} onClick={() => setMenuOpen(!menuOpen)}
className="flex items-center gap-2 px-2 md:px-3 py-1.5 rounded-md text-sm className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
text-[var(--text-secondary)] hover:text-[var(--text-primary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]
hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer" hover:bg-[var(--bg-tertiary)] border border-transparent
hover:border-[var(--border)] transition-all cursor-pointer"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <div className="w-7 h-7 rounded-full bg-[var(--accent)]/15 border border-[var(--accent)]/30
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /> flex items-center justify-center text-[var(--accent)] text-xs font-semibold">
<circle cx="12" cy="7" r="4" /> {(user.display_name || user.username).charAt(0).toUpperCase()}
</svg> </div>
<span className="hidden sm:inline max-w-[100px] truncate"> <span className="hidden sm:inline max-w-[100px] truncate">
{user.display_name || user.username} {user.display_name || user.username}
</span> </span>
</button> </button>
{menuOpen && ( {menuOpen && (
<div className="absolute right-0 top-full mt-1 w-44 rounded-lg <div className="absolute right-0 top-full mt-1.5 w-48 rounded-xl
bg-[var(--bg-secondary)] border border-[var(--border)] glass-panel shadow-xl shadow-black/30 py-1.5 z-50">
shadow-xl py-1 z-50"> <button
<div className="px-3 py-2 text-xs text-[var(--text-secondary)] border-b border-[var(--border)]"> onClick={() => { setMenuOpen(false); setProfileOpen(true); }}
{user.username} className="w-full text-left px-3.5 py-2 text-xs text-[var(--text-secondary)]
{user.is_admin && <span className="ml-1 text-[var(--accent)]">()</span>} hover:text-[var(--accent)] hover:bg-[var(--bg-tertiary)]
</div> border-b border-[var(--border)] transition-colors cursor-pointer
flex items-center gap-1.5"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span>{user.username}</span>
{user.is_admin && <span className="text-[var(--accent)]">()</span>}
</button>
<button <button
onClick={() => { setMenuOpen(false); logout(); }} onClick={() => { setMenuOpen(false); logout(); }}
className="w-full text-left px-3 py-2 text-sm text-[var(--text-secondary)] className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-secondary)]
hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]
transition-colors cursor-pointer" transition-colors cursor-pointer"
> >
@@ -118,5 +153,8 @@ export function TopNav() {
</div> </div>
)} )}
</header> </header>
{profileOpen && <ProfileModal onClose={() => setProfileOpen(false)} />}
</>
); );
} }

View File

@@ -0,0 +1,233 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useAuth, type AuthUser } from "@/lib/auth-context";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
interface MemoryItem {
id: string;
memory: string;
created_at?: string;
updated_at?: string;
}
interface GroupedMemories {
label: string;
items: MemoryItem[];
}
function groupByTime(memories: MemoryItem[]): GroupedMemories[] {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const weekAgo = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000);
const today: MemoryItem[] = [];
const week: MemoryItem[] = [];
const older: MemoryItem[] = [];
for (const m of memories) {
const d = m.created_at ? new Date(m.created_at) : null;
if (!d || isNaN(d.getTime())) {
older.push(m);
} else if (d >= todayStart) {
today.push(m);
} else if (d >= weekAgo) {
week.push(m);
} else {
older.push(m);
}
}
const groups: GroupedMemories[] = [];
if (today.length > 0) groups.push({ label: "今天", items: today });
if (week.length > 0) groups.push({ label: "最近 7 天", items: week });
if (older.length > 0) groups.push({ label: "更早", items: older });
return groups;
}
function formatDate(dateStr?: string): string {
if (!dateStr) return "";
const d = new Date(dateStr);
if (isNaN(d.getTime())) return "";
const now = new Date();
const isThisYear = d.getFullYear() === now.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const hour = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
if (isThisYear) return `${month}-${day} ${hour}:${min}`;
return `${d.getFullYear()}-${month}-${day} ${hour}:${min}`;
}
export function ProfileModal({ onClose }: { onClose: () => void }) {
const { user, token } = useAuth();
const [memories, setMemories] = useState<MemoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchMemories = useCallback(async () => {
if (!token) return;
setLoading(true);
setError(null);
try {
const resp = await fetch(`${API_URL}/api/memory/list`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
setMemories(data.memories || []);
if (data.error) setError(data.error);
} catch (e) {
setError(e instanceof Error ? e.message : "加载记忆失败");
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
fetchMemories();
}, [fetchMemories]);
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose]);
if (!user) return null;
const groups = groupByTime(memories);
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center"
onClick={onClose}
>
{/* 遮罩层 */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
{/* 弹窗主体 */}
<div
className="relative w-[90vw] max-w-[560px] max-h-[80vh] rounded-2xl glass-panel
shadow-[0_0_40px_rgba(77,184,164,0.1)] border-[var(--border-glow)]
flex flex-col overflow-hidden"
style={{ animation: "slideUp 200ms ease-out" }}
onClick={(e) => e.stopPropagation()}
>
{/* 关闭按钮 */}
<button
onClick={onClose}
className="absolute top-3 right-3 p-1.5 rounded-lg text-[var(--text-secondary)]
hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]
transition-colors cursor-pointer z-10"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
{/* 用户信息区 */}
<div className="px-6 pt-6 pb-4 flex items-center gap-4 flex-shrink-0">
<div className="w-14 h-14 rounded-full bg-[var(--accent)]/15 border-2 border-[var(--accent)]/40
flex items-center justify-center text-[var(--accent)] text-xl font-bold
shadow-[0_0_20px_rgba(77,184,164,0.12)]">
{(user.display_name || user.username).charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold text-[var(--text-primary)] truncate">
{user.display_name || user.username}
</h2>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-sm text-[var(--text-secondary)]">@{user.username}</span>
{user.is_admin && (
<span className="text-xs px-1.5 py-0.5 rounded-md bg-[var(--accent)]/10
text-[var(--accent)] border border-[var(--accent)]/30">
</span>
)}
</div>
</div>
</div>
{/* 分割线 */}
<div className="mx-6 h-px bg-[var(--border)]" />
{/* 记忆区标题 */}
<div className="px-6 pt-4 pb-2 flex items-center gap-2 flex-shrink-0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
<path d="M12 2a7 7 0 017 7c0 2.38-1.19 4.47-3 5.74V17a2 2 0 01-2 2h-4a2 2 0 01-2-2v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 017-7z" />
<path d="M10 21h4" />
</svg>
<span className="text-sm font-medium text-[var(--text-primary)]"></span>
{!loading && (
<span className="text-xs text-[var(--text-secondary)]">
{memories.length}
</span>
)}
</div>
{/* 记忆列表 */}
<div className="flex-1 overflow-y-auto px-6 pb-6 min-h-0">
{loading && (
<div className="flex items-center justify-center py-12">
<div className="w-5 h-5 border-2 border-[var(--accent)]/30 border-t-[var(--accent)]
rounded-full animate-spin" />
<span className="ml-3 text-sm text-[var(--text-secondary)]">...</span>
</div>
)}
{!loading && error && memories.length === 0 && (
<div className="text-center py-12">
<p className="text-sm text-[var(--hot)]">{error}</p>
</div>
)}
{!loading && !error && memories.length === 0 && (
<div className="text-center py-12">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)"
strokeWidth="1.5" className="mx-auto mb-3 opacity-50">
<path d="M12 2a7 7 0 017 7c0 2.38-1.19 4.47-3 5.74V17a2 2 0 01-2 2h-4a2 2 0 01-2-2v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 017-7z" />
<path d="M10 21h4" />
</svg>
<p className="text-sm text-[var(--text-secondary)]"></p>
<p className="text-xs text-[var(--text-secondary)] mt-1 opacity-60">
</p>
</div>
)}
{!loading && groups.map((group) => (
<div key={group.label} className="mb-4 last:mb-0">
<div className="text-xs font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-2">
{group.label}
</div>
<div className="space-y-1.5">
{group.items.map((item) => (
<div
key={item.id}
className="px-3 py-2.5 rounded-lg bg-[var(--bg-tertiary)]/50
border border-[var(--border)] hover:border-[var(--border-glow)]
transition-colors group"
>
<p className="text-sm text-[var(--text-primary)] leading-relaxed">
{item.memory}
</p>
{item.created_at && (
<p className="text-xs text-[var(--text-secondary)] mt-1 opacity-0
group-hover:opacity-60 transition-opacity">
{formatDate(item.created_at)}
</p>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,8 +1,9 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { useApp } from "@/lib/app-context"; 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 { function timeAgo(ts: number): string {
const diff = Date.now() - ts; const diff = Date.now() - ts;
@@ -20,41 +21,68 @@ export function SessionList() {
const { const {
sessions, activeSessionId, tags, selectedTagIds, sessions, activeSessionId, tags, selectedTagIds,
createSession, switchSession, deleteSession, renameSession, createSession, switchSession, deleteSession, renameSession,
updateSessionLlmModel,
} = useApp(); } = useApp();
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; sessionId: string } | null>(null); const [menuSessionId, setMenuSessionId] = useState<string | null>(null);
const [showModelSub, setShowModelSub] = useState(false);
const [llmModels, setLlmModels] = useState<LlmModelInfo[]>([]);
const [defaultLlmModel, setDefaultLlmModel] = useState("");
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState(""); const [editTitle, setEditTitle] = useState("");
const editRef = useRef<HTMLInputElement>(null); const editRef = useRef<HTMLInputElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
// 标签过滤
const filtered = selectedTagIds.length === 0 const filtered = selectedTagIds.length === 0
? sessions ? sessions
: sessions.filter((s) => selectedTagIds.some((tid) => s.tags.includes(tid))); : sessions.filter((s) => selectedTagIds.some((tid) => s.tags.includes(tid)));
const tagMap = new Map(tags.map((t) => [t.id, t])); const tagMap = new Map(tags.map((t) => [t.id, t]));
// 关闭右键菜单
useEffect(() => { useEffect(() => {
const close = () => setContextMenu(null); fetchLlmModels()
window.addEventListener("click", close); .then((data) => {
return () => window.removeEventListener("click", close); 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(() => { useEffect(() => {
if (editingId) editRef.current?.focus(); if (editingId) editRef.current?.focus();
}, [editingId]); }, [editingId]);
const handleContextMenu = (e: React.MouseEvent, sessionId: string) => { const closeMenu = useCallback(() => {
e.preventDefault(); setMenuSessionId(null);
setContextMenu({ x: e.clientX, y: e.clientY, sessionId }); 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) => { const startRename = (id: string, currentTitle: string) => {
setEditingId(id); setEditingId(id);
setEditTitle(currentTitle); setEditTitle(currentTitle);
setContextMenu(null); closeMenu();
}; };
const commitRename = () => { const commitRename = () => {
@@ -64,16 +92,26 @@ export function SessionList() {
setEditingId(null); setEditingId(null);
}; };
const handleSelectModel = (sessionId: string, modelId: string) => {
updateSessionLlmModel(sessionId, modelId);
closeMenu();
};
const menuSession = menuSessionId
? sessions.find((s) => s.id === menuSessionId)
: null;
return ( return (
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto fog-scroll">
{/* 新建按钮 */} {/* 新建按钮 */}
<div className="px-3 py-2"> <div className="px-3 py-3">
<button <button
onClick={() => createSession()} onClick={() => createSession()}
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 className="w-full flex items-center justify-center gap-2 px-3 py-2.5
text-sm rounded-lg border border-dashed border-[var(--border)] text-sm rounded-xl border border-dashed border-[var(--accent)]/30
text-[var(--text-secondary)] hover:border-[var(--accent)] text-[var(--accent)]/70 hover:border-[var(--accent)]
hover:text-[var(--accent)] transition-colors cursor-pointer" hover:text-[var(--accent)] hover:bg-[var(--accent)]/5
transition-all cursor-pointer"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 5v14M5 12h14" /> <path d="M12 5v14M5 12h14" />
@@ -83,114 +121,199 @@ export function SessionList() {
</div> </div>
{/* 会话列表 */} {/* 会话列表 */}
<div className="px-2 pb-2 space-y-0.5"> <div className="px-2 pb-2 space-y-1">
{filtered.map((session) => ( {filtered.map((session) => (
<div <div key={session.id} className="relative">
key={session.id} <div
onClick={() => switchSession(session.id)} onClick={() => switchSession(session.id)}
onContextMenu={(e) => handleContextMenu(e, session.id)} className={`group relative flex items-start gap-3 px-3 py-2.5 rounded-xl cursor-pointer
className={`group relative flex items-start gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-all ${
transition-colors ${ session.id === activeSessionId
session.id === activeSessionId ? "glow-border !bg-[var(--bg-secondary)]"
? "bg-[var(--bg-tertiary)]" : "hover:bg-[var(--bg-tertiary)]/50 border border-transparent"
: "hover:bg-[var(--bg-tertiary)]/50" }`}
}`} >
> {/* 缩略图 */}
{/* 缩略图 */} {session.thumbnail ? (
{session.thumbnail ? ( <img
<img src={getImageUrl(session.thumbnail)}
src={getImageUrl(session.thumbnail)} alt=""
alt="" className="w-10 h-10 rounded-lg object-cover flex-shrink-0 border border-[var(--border)]
className="w-9 h-9 rounded object-cover flex-shrink-0 border border-[var(--border)]" shadow-[0_0_8px_rgba(77,184,164,0.08)]"
/>
) : (
<div className="w-9 h-9 rounded bg-[var(--bg-primary)] border border-[var(--border)]
flex items-center justify-center flex-shrink-0 text-[var(--text-secondary)] text-xs">
💬
</div>
)}
{/* 会话信息 */}
<div className="flex-1 min-w-0">
{editingId === session.id ? (
<input
ref={editRef}
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename();
if (e.key === "Escape") setEditingId(null);
}}
className="w-full text-sm bg-transparent border-b border-[var(--accent)]
text-[var(--text-primary)] focus:outline-none"
/> />
) : ( ) : (
<div className="text-sm text-[var(--text-primary)] truncate"> <div className="w-10 h-10 rounded-lg bg-[var(--bg-primary)] border border-[var(--border)]
{session.title} flex items-center justify-center flex-shrink-0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="1.5">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</div> </div>
)} )}
{/* 标签 + 时间 */} {/* 会话信息 */}
<div className="flex items-center gap-1 mt-0.5 flex-wrap"> <div className="flex-1 min-w-0">
{session.tags.slice(0, 3).map((tid) => { {editingId === session.id ? (
const tag = tagMap.get(tid); <input
if (!tag) return null; ref={editRef}
return ( value={editTitle}
<span onChange={(e) => setEditTitle(e.target.value)}
key={tid} onBlur={commitRename}
className="text-[10px] px-1.5 py-px rounded-full" onKeyDown={(e) => {
style={{ backgroundColor: tag.color + "22", color: tag.color }} if (e.key === "Enter") commitRename();
> if (e.key === "Escape") setEditingId(null);
{tag.name} }}
className="w-full text-sm bg-transparent border-b border-[var(--accent)]
text-[var(--text-primary)] focus:outline-none"
/>
) : (
<div className={`text-sm truncate pr-5 ${
session.id === activeSessionId ? "text-[var(--text-primary)] font-medium" : "text-[var(--text-primary)]"
}`}>
{session.title}
</div>
)}
{/* 标签 + 时间 */}
<div className="flex items-center gap-1 mt-1 flex-wrap">
{session.tags.slice(0, 3).map((tid) => {
const tag = tagMap.get(tid);
if (!tag) return null;
return (
<span
key={tid}
className="text-[10px] px-1.5 py-px rounded-full font-medium"
style={{ backgroundColor: tag.color + "22", color: tag.color }}
>
{tag.name}
</span>
);
})}
{session.llmModel && (
<span className="text-[10px] px-1.5 py-px rounded-full font-medium
bg-[var(--accent)]/10 text-[var(--accent)]">
{llmModels.find((m) => m.id === session.llmModel)?.name || session.llmModel}
</span> </span>
); )}
})} <span className="text-[10px] text-[var(--text-secondary)] ml-auto flex-shrink-0">
<span className="text-[10px] text-[var(--text-secondary)] ml-auto flex-shrink-0"> {timeAgo(session.updatedAt)}
{timeAgo(session.updatedAt)} </span>
</span> </div>
</div> </div>
{/* 三点按钮 */}
<button
onClick={(e) => handleDots(e, session.id)}
className="absolute right-2 top-2.5 p-1 rounded-md
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
hover:bg-[var(--bg-tertiary)]
opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="12" cy="19" r="2" />
</svg>
</button>
</div> </div>
{/* 下拉菜单:在会话项正下方展开 */}
{menuSessionId === session.id && menuSession && (
<div
ref={menuRef}
className="mx-1 mt-1 glass-panel rounded-xl shadow-xl shadow-black/30 py-1.5
overflow-hidden z-10 relative"
onClick={(e) => e.stopPropagation()}
>
{/* 模型选择(折叠/展开式) */}
<button
onClick={(e) => {
e.stopPropagation();
setShowModelSub(!showModelSub);
}}
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-primary)]
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors
flex items-center justify-between"
>
<span></span>
<svg
width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2"
className={`transition-transform ${showModelSub ? "rotate-90" : ""}`}
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
{showModelSub && (
<div className="border-t border-[var(--border)] mx-2 mt-1 pt-1 max-h-[240px] overflow-y-auto">
{llmModels.map((model) => {
const isActive = menuSession.llmModel
? menuSession.llmModel === model.id
: model.id === defaultLlmModel;
return (
<button
key={model.id}
onClick={(e) => {
e.stopPropagation();
handleSelectModel(menuSessionId!, model.id);
}}
className={`w-full text-left px-3 py-1.5 text-sm cursor-pointer transition-colors
rounded-lg flex items-center gap-2 ${
isActive
? "text-[var(--accent)] bg-[var(--accent)]/5"
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
}`}
>
<span className="w-4 flex-shrink-0 text-center">
{isActive && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</span>
<div className="min-w-0">
<div className="truncate">{model.name}</div>
<div className="text-[10px] text-[var(--text-secondary)] truncate">{model.description}</div>
</div>
</button>
);
})}
</div>
)}
<div className="mx-2 my-1 border-t border-[var(--border)]" />
<button
onClick={() => {
if (menuSession) startRename(menuSession.id, menuSession.title);
}}
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-primary)]
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors"
>
</button>
<button
onClick={() => {
deleteSession(menuSessionId!);
closeMenu();
}}
className="w-full text-left px-3.5 py-2 text-sm text-[var(--hot)]
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors"
>
</button>
</div>
)}
</div> </div>
))} ))}
{filtered.length === 0 && ( {filtered.length === 0 && (
<div className="text-center text-xs text-[var(--text-secondary)] py-6"> <div className="text-center text-xs text-[var(--text-secondary)] py-8">
</div> </div>
)} )}
</div> </div>
{/* 右键菜单 */}
{contextMenu && (
<div
className="fixed z-50 bg-[var(--bg-secondary)] border border-[var(--border)]
rounded-lg shadow-xl py-1 min-w-[120px]"
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => {
const s = sessions.find((s) => s.id === contextMenu.sessionId);
if (s) startRename(s.id, s.title);
}}
className="w-full text-left px-3 py-1.5 text-sm text-[var(--text-primary)]
hover:bg-[var(--bg-tertiary)] cursor-pointer"
>
</button>
<button
onClick={() => {
deleteSession(contextMenu.sessionId);
setContextMenu(null);
}}
className="w-full text-left px-3 py-1.5 text-sm text-red-400
hover:bg-[var(--bg-tertiary)] cursor-pointer"
>
</button>
</div>
)}
</div> </div>
); );
} }

View File

@@ -12,30 +12,37 @@ export function Sidebar() {
{/* 移动端遮罩 */} {/* 移动端遮罩 */}
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<div <div
className="fixed inset-0 z-30 bg-black/50 sidebar-overlay md:hidden" className="fixed inset-0 z-30 bg-black/60 sidebar-overlay md:hidden"
onClick={() => setSidebarCollapsed(true)} onClick={() => setSidebarCollapsed(true)}
/> />
)} )}
<aside <aside
className={` className={`
/* 移动端:固定定位抽屉 */
fixed inset-y-0 left-0 z-40 w-[280px] fixed inset-y-0 left-0 z-40 w-[280px]
md:relative md:inset-auto md:z-auto md:relative md:inset-auto md:z-auto
flex-shrink-0 border-r border-[var(--border)] bg-[var(--bg-secondary)] flex-shrink-0 border-r border-[var(--border)]
flex flex-col transition-transform duration-200 bg-[var(--bg-secondary)]/80 backdrop-blur-xl
flex flex-col transition-transform duration-250 ease-in-out
${sidebarCollapsed ? "-translate-x-full md:-translate-x-0 md:w-0 md:border-r-0" : "translate-x-0 md:w-[280px]"} ${sidebarCollapsed ? "-translate-x-full md:-translate-x-0 md:w-0 md:border-r-0" : "translate-x-0 md:w-[280px]"}
${sidebarCollapsed ? "md:overflow-hidden" : ""} ${sidebarCollapsed ? "md:overflow-hidden" : ""}
`} `}
> >
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--border)]"> <div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<span className="text-xs font-medium text-[var(--text-secondary)] uppercase tracking-wider"> <div className="flex items-center gap-2.5">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2"
</span> style={{ filter: "drop-shadow(0 0 4px rgba(77, 184, 164, 0.35))" }}>
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
<span className="text-sm font-semibold text-[var(--text-primary)]">
</span>
</div>
<button <button
onClick={() => setSidebarCollapsed(true)} onClick={() => setSidebarCollapsed(true)}
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] className="text-[var(--text-secondary)] hover:text-[var(--accent)]
transition-colors cursor-pointer p-0.5" transition-colors cursor-pointer p-1 rounded-md
hover:bg-[var(--accent)]/5"
title="折叠侧边栏" title="折叠侧边栏"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">

View File

@@ -2,11 +2,10 @@
import { useState } from "react"; import { useState } from "react";
import { useApp } from "@/lib/app-context"; import { useApp } from "@/lib/app-context";
import type { Tag } from "@/lib/types";
const TAG_COLORS = [ const TAG_COLORS = [
"#6366f1", "#f59e0b", "#10b981", "#ec4899", "#4DB8A4", "#3A8FB7", "#8b5cf6", "#B8935A",
"#8b5cf6", "#06b6d4", "#f97316", "#84cc16", "#ec4899", "#C4654A", "#84cc16", "#6366f1",
]; ];
export function TagFilter() { export function TagFilter() {
@@ -34,15 +33,14 @@ export function TagFilter() {
}; };
return ( return (
<div className="px-3 py-2 border-b border-[var(--border)]"> <div className="px-3 py-2.5 border-b border-[var(--border)]">
{/* 标签 pills */}
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<button <button
onClick={clearFilter} onClick={clearFilter}
className={`px-2 py-0.5 text-xs rounded-full transition-colors cursor-pointer ${ className={`px-2.5 py-1 text-xs rounded-lg transition-all cursor-pointer ${
selectedTagIds.length === 0 selectedTagIds.length === 0
? "bg-[var(--accent)] text-white" ? "bg-[var(--accent)]/15 text-[var(--accent)] border border-[var(--accent)]/30"
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]" : "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-transparent"
}`} }`}
> >
@@ -53,10 +51,10 @@ export function TagFilter() {
<button <button
key={tag.id} key={tag.id}
onClick={() => toggle(tag.id)} onClick={() => toggle(tag.id)}
className="px-2 py-0.5 text-xs rounded-full transition-colors cursor-pointer border" className="px-2.5 py-1 text-xs rounded-lg transition-all cursor-pointer border"
style={{ style={{
backgroundColor: active ? tag.color + "22" : "transparent", backgroundColor: active ? tag.color + "18" : "transparent",
borderColor: active ? tag.color : "var(--border)", borderColor: active ? tag.color + "60" : "var(--border)",
color: active ? tag.color : "var(--text-secondary)", color: active ? tag.color : "var(--text-secondary)",
}} }}
> >
@@ -66,74 +64,75 @@ export function TagFilter() {
})} })}
<button <button
onClick={() => setShowManager(!showManager)} onClick={() => setShowManager(!showManager)}
className="px-2 py-0.5 text-xs rounded-full className="px-2 py-1 text-xs rounded-lg
text-[var(--text-secondary)] hover:text-[var(--accent)] text-[var(--text-secondary)] hover:text-[var(--accent)]
transition-colors cursor-pointer" hover:bg-[var(--accent)]/5
transition-all cursor-pointer"
title="管理标签" title="管理标签"
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button> </button>
</div> </div>
{/* 标签管理面板 */}
{showManager && ( {showManager && (
<div className="mt-2 p-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]"> <div className="mt-2.5 p-3 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]">
<div className="flex items-center gap-1.5 mb-2"> <div className="flex items-center gap-1.5 mb-2">
<input <input
value={newTagName} value={newTagName}
onChange={(e) => setNewTagName(e.target.value)} onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddTag()} onKeyDown={(e) => e.key === "Enter" && handleAddTag()}
placeholder="新标签名称" placeholder="新标签名称"
className="flex-1 px-2 py-1 text-xs rounded bg-[var(--bg-primary)] className="flex-1 min-w-0 px-2.5 py-1.5 text-xs rounded-lg bg-[var(--bg-primary)]
border border-[var(--border)] text-[var(--text-primary)] border border-[var(--border)] text-[var(--text-primary)]
placeholder:text-[var(--text-secondary)] placeholder:text-[var(--text-secondary)]
focus:outline-none focus:border-[var(--accent)]" focus:outline-none focus:border-[var(--accent)]/50"
/> />
<div className="flex gap-0.5">
{TAG_COLORS.map((c) => (
<button
key={c}
onClick={() => setNewTagColor(c)}
className="w-4 h-4 rounded-full cursor-pointer transition-transform"
style={{
backgroundColor: c,
transform: newTagColor === c ? "scale(1.3)" : "scale(1)",
boxShadow: newTagColor === c ? `0 0 0 2px var(--bg-tertiary), 0 0 0 3px ${c}` : "none",
}}
/>
))}
</div>
<button <button
onClick={handleAddTag} onClick={handleAddTag}
disabled={!newTagName.trim()} disabled={!newTagName.trim()}
className="px-2 py-1 text-xs rounded bg-[var(--accent)] text-white className="flex-shrink-0 px-2.5 py-1.5 text-xs rounded-lg bg-[var(--accent)] text-[var(--bg-primary)] font-medium
disabled:opacity-40 cursor-pointer hover:bg-[var(--accent-hover)] disabled:opacity-40 cursor-pointer hover:bg-[var(--accent-hover)]
transition-colors" transition-colors"
> >
</button> </button>
</div> </div>
<div className="flex items-center gap-1 mb-2">
{TAG_COLORS.map((c) => (
<button
key={c}
onClick={() => setNewTagColor(c)}
className="w-5 h-5 rounded-full cursor-pointer transition-transform"
style={{
backgroundColor: c,
transform: newTagColor === c ? "scale(1.25)" : "scale(1)",
boxShadow: newTagColor === c ? `0 0 0 2px var(--bg-tertiary), 0 0 0 3px ${c}` : "none",
}}
/>
))}
</div>
{/* 自定义标签列表(可删除) */}
{tags.filter((t) => !t.builtin).length > 0 && ( {tags.filter((t) => !t.builtin).length > 0 && (
<div className="space-y-1 mt-1"> <div className="space-y-1 mt-1.5">
{tags {tags
.filter((t) => !t.builtin) .filter((t) => !t.builtin)
.map((tag) => ( .map((tag) => (
<div <div
key={tag.id} key={tag.id}
className="flex items-center justify-between px-2 py-0.5 rounded text-xs" className="flex items-center justify-between px-2 py-1 rounded-lg text-xs
hover:bg-[var(--bg-primary)]/50 transition-colors"
> >
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span <span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: tag.color }} />
className="w-2.5 h-2.5 rounded-full" <span className="text-[var(--text-primary)]">{tag.name}</span>
style={{ backgroundColor: tag.color }}
/>
{tag.name}
</span> </span>
<button <button
onClick={() => deleteTag(tag.id)} onClick={() => deleteTag(tag.id)}
className="text-[var(--text-secondary)] hover:text-red-400 cursor-pointer" className="text-[var(--text-secondary)] hover:text-[var(--hot)] cursor-pointer
transition-colors"
> >
× ×
</button> </button>

View File

@@ -2,7 +2,7 @@
* 后端 API 调用封装 + SSE 流式读取。 * 后端 API 调用封装 + SSE 流式读取。
*/ */
import type { ApiMessage, ImageModelInfo } from "./types"; import type { ApiMessage, ImageModelInfo, LlmModelInfo } from "./types";
import { getStoredToken } from "./auth-context"; import { getStoredToken } from "./auth-context";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
@@ -108,15 +108,34 @@ export async function fetchModels(): Promise<{
return resp.json(); return resp.json();
} }
/**
* 获取可用的 LLM 对话模型列表。
*/
export async function fetchLlmModels(): Promise<{
models: LlmModelInfo[];
default: string;
}> {
const resp = await fetch(`${API_URL}/api/llm-models`, {
headers: authHeaders(),
});
if (resp.status === 401) {
window.location.href = "/login";
throw new Error("登录已过期");
}
if (!resp.ok) throw new Error(`获取 LLM 模型列表失败: ${resp.status}`);
return resp.json();
}
/** /**
* 发送对话消息到后端,返回 SSE 事件的异步迭代器。 * 发送对话消息到后端,返回 SSE 事件的异步迭代器。
* refImageUrl: 已通过 uploadRefImage 上传后的服务端路径(如 /uploads/xxx.png * refImageUrls: 已通过 uploadRefImage 上传后的服务端路径列表
*/ */
export async function* sendChat( export async function* sendChat(
messages: ApiMessage[], messages: ApiMessage[],
refImageUrl?: string | null, refImageUrls?: string[] | null,
imageModel?: string | null, imageModel?: string | null,
sessionId?: string | null sessionId?: string | null,
llmModel?: string | null,
): AsyncGenerator<SSEEvent> { ): AsyncGenerator<SSEEvent> {
const formData = new FormData(); const formData = new FormData();
@@ -126,8 +145,8 @@ export async function* sendChat(
})); }));
formData.append("messages", JSON.stringify(apiMessages)); formData.append("messages", JSON.stringify(apiMessages));
if (refImageUrl) { if (refImageUrls && refImageUrls.length > 0) {
formData.append("ref_image_url", refImageUrl); formData.append("ref_image_urls", JSON.stringify(refImageUrls));
} }
if (imageModel) { if (imageModel) {
@@ -138,6 +157,10 @@ export async function* sendChat(
formData.append("session_id", sessionId); formData.append("session_id", sessionId);
} }
if (llmModel) {
formData.append("llm_model", llmModel);
}
const response = await fetch(`${API_URL}/api/chat`, { const response = await fetch(`${API_URL}/api/chat`, {
method: "POST", method: "POST",
body: formData, body: formData,

View File

@@ -40,6 +40,7 @@ interface AppContextValue {
deleteSession: (id: string) => void; deleteSession: (id: string) => void;
renameSession: (id: string, title: string) => void; renameSession: (id: string, title: string) => void;
updateSessionTags: (id: string, tags: string[]) => void; updateSessionTags: (id: string, tags: string[]) => void;
updateSessionLlmModel: (id: string, llmModel: string) => void;
appendMessage: (sessionId: string, message: ChatMessage) => void; appendMessage: (sessionId: string, message: ChatMessage) => void;
updateSessionThumbnail: (sessionId: string, url: string) => void; updateSessionThumbnail: (sessionId: string, url: string) => void;
@@ -183,6 +184,17 @@ export function AppProvider({ children }: { children: ReactNode }) {
); );
}, []); }, []);
const updateSessionLlmModel = useCallback((id: string, llmModel: string) => {
setSessions((prev) =>
prev.map((s) => {
if (s.id !== id) return s;
const updated = { ...s, llmModel, updatedAt: Date.now() };
storeUpdateSession(updated);
return updated;
})
);
}, []);
const appendMessage = useCallback((sessionId: string, message: ChatMessage) => { const appendMessage = useCallback((sessionId: string, message: ChatMessage) => {
setSessions((prev) => setSessions((prev) =>
prev.map((s) => { prev.map((s) => {
@@ -266,6 +278,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
deleteSession: deleteSessionCb, deleteSession: deleteSessionCb,
renameSession, renameSession,
updateSessionTags, updateSessionTags,
updateSessionLlmModel,
appendMessage, appendMessage,
updateSessionThumbnail, updateSessionThumbnail,
tags, tags,
@@ -286,7 +299,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
[ [
sessions, activeSessionId, activeSession, sessions, activeSessionId, activeSession,
createSession, switchSession, deleteSessionCb, createSession, switchSession, deleteSessionCb,
renameSession, updateSessionTags, appendMessage, updateSessionThumbnail, renameSession, updateSessionTags, updateSessionLlmModel, appendMessage, updateSessionThumbnail,
tags, addTag, deleteTagCb, selectedTagIds, tags, addTag, deleteTagCb, selectedTagIds,
assets, addAssetCb, updateAssetCb, deleteAssetCb, toggleFavoriteCb, assets, addAssetCb, updateAssetCb, deleteAssetCb, toggleFavoriteCb,
detailImage, sidebarCollapsed, detailImage, sidebarCollapsed,
@@ -294,7 +307,17 @@ export function AppProvider({ children }: { children: ReactNode }) {
); );
// 未登录时(如 /login 页面)直接渲染 children不注入 AppContext // 未登录时(如 /login 页面)直接渲染 children不注入 AppContext
if (!initialized) return <>{children}</>; // 已登录但尚未初始化完成时显示加载状态,避免子组件调用 useApp() 拿到 null
if (!initialized) {
if (isAuthenticated) {
return (
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)]">
<div className="text-[var(--text-secondary)] text-sm">...</div>
</div>
);
}
return <>{children}</>;
}
return <AppContext.Provider value={value}>{children}</AppContext.Provider>; return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
} }

View File

@@ -25,7 +25,9 @@ export interface ChatMessage {
role: "user" | "assistant"; role: "user" | "assistant";
content: string; content: string;
images?: ImageAsset[]; images?: ImageAsset[];
/** 用户消息附带的参考图(本地预览 URL 或上传后的服务端路径) */ /** 用户消息附带的参考图列表(本地预览 URL 或上传后的服务端路径) */
refImageUrls?: string[];
/** @deprecated 兼容旧数据,单张参考图 */
refImageUrl?: string; refImageUrl?: string;
/** 生成图片时使用的模型名称 */ /** 生成图片时使用的模型名称 */
modelName?: string; modelName?: string;
@@ -38,6 +40,8 @@ export interface Session {
messages: ChatMessage[]; messages: ChatMessage[];
/** 最后生成的图片 URL用于缩略图 */ /** 最后生成的图片 URL用于缩略图 */
thumbnail?: string; thumbnail?: string;
/** 该对话使用的 LLM 模型短 IDundefined 时使用后端默认 */
llmModel?: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} }
@@ -57,6 +61,14 @@ export interface ImageModelInfo {
supports_ref_image?: boolean; supports_ref_image?: boolean;
} }
/** LLM 对话模型信息(从后端 GET /api/llm-models 返回) */
export interface LlmModelInfo {
id: string;
name: string;
description: string;
vision?: boolean;
}
/** 标注数据 */ /** 标注数据 */
export interface Annotation { export interface Annotation {
id: string; id: string;

View File

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