Architech #1
@@ -4,6 +4,328 @@
|
|||||||
|
|
||||||
## 记录
|
## 记录
|
||||||
|
|
||||||
|
### [CL-20260416-0830] 2026-04-16 08:30 — 生图按模型预处理 prompt(官网/社区策略对齐)
|
||||||
|
- **tags**: 后端, 生图, prompt, SDXL, Flux, GPT Image, Gemini, Replicate, IP-Adapter, image_gen, Agent
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/services/image_prompt_strategy.py
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- art-agent/backend/app/agent/tools.py
|
||||||
|
- **what**: 为当前注册的全部 7 个生图模型增加「进入 provider 前」的 prompt 预处理层;SDXL 向 Replicate 传入独立 `negative_prompt` 字段(与 stability-ai/sdxl API 一致)
|
||||||
|
- **why**: 各后端对 prompt 的惯例不同(如 SDXL 双通道、FLUX 无 negative、IP-Adapter 重参考图语义),原先同一字符串直传难以发挥模型能力
|
||||||
|
- **decisions**:
|
||||||
|
- 新模块 `image_prompt_strategy`:策略按 `IMAGE_MODELS` 的 `id` 分发,docstring 注明依据(OpenAI Image 指南、BFL FLUX 文档、Replicate API 字段、Stability 系默认负向词)
|
||||||
|
- SDXL:未拆分时使用模块内 `DEFAULT_SDXL_NEGATIVE`;可选 `default_params.negative_prompt` 覆盖;支持正文内 `---NEGATIVE---` 或 `|||NEG|||`
|
||||||
|
- Flux:剥除误粘贴的 `negative prompt:` 等段,符合 BFL「用正向描述替代负向」的指引
|
||||||
|
- `GenerateResult` 携带 `effective_prompt`/`negative_prompt` 供调试与工具回传
|
||||||
|
- **notes**: 未引入二次 LLM 调用;复杂措辞重排仍依赖对话模型在工具参数里写好英文描述
|
||||||
|
|
||||||
|
### [CL-20260416-0810] 2026-04-16 08:10 — 对话栏支持剪贴板粘贴图片为参考图
|
||||||
|
- **tags**: 前端, ChatInput, 剪贴板, paste, 参考图, UX
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/frontend/src/components/chat/chat-input.tsx
|
||||||
|
- **what**: 在输入区通过 Ctrl+V 或右键粘贴时,若剪贴板含图片文件则自动上传为参考图(与拖拽/点选上传同一路径)
|
||||||
|
- **why**: 截图或复制图片后直接粘贴比保存再选文件更快
|
||||||
|
- **decisions**: 使用根容器 `onPasteCapture` 在文本插入前拦截;仅当 `clipboardData.items` 中存在 `kind==="file"` 且 `type` 以 `image/` 开头时才 `preventDefault`,避免影响纯文本粘贴
|
||||||
|
- **notes**: 依赖浏览器将剪贴板图片暴露为 file item;多图时逐项上传
|
||||||
|
|
||||||
|
### [CL-20260416-0745] 2026-04-16 07:45 — Gemini 原生生图:延长超时 + 断连类错误自动重试
|
||||||
|
- **tags**: 后端, Gemini, httpx, 超时, 重试, RemoteProtocolError, 向量引擎, 稳定性
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- art-agent/backend/.env.example
|
||||||
|
- **what**: 缓解 Gemini 3.1 Flash Image 调用时出现 `Server disconnected without sending a response` 的失败率
|
||||||
|
- **why**: 多参考图时请求体大、生图耗时长,上游或本地代理可能在响应返回前关闭连接,httpx 抛出 `RemoteProtocolError`;原先 read 仅 180s 且单次失败即返回
|
||||||
|
- **decisions**: 默认 read=600s、write=180s、connect/pool=60s;对 `RemoteProtocolError`、`ConnectError`、`Read/Write/Connect/PoolTimeout` 最多重试 3 次、间隔 1s/2s;可通过 `VECTORENGINE_GEMINI_READ_TIMEOUT` 等环境变量覆盖;`.env.example` 增加注释说明
|
||||||
|
- **notes**: 若仍频繁断连,需排查代理 idle 超时或向量引擎侧限流;用户可暂时换 GPT Image 1.5 对比
|
||||||
|
- **source_chat**: [Gemini断连重试](eb8bd100-5a4b-439f-bdc9-077311a456ad)
|
||||||
|
|
||||||
|
### [CL-20260416-0720] 2026-04-16 07:20 — 修复拖拽参考图到 ChatInput 区域后覆盖层卡住不消失
|
||||||
|
- **tags**: bug修复, 拖拽, 覆盖层, ChatInput, stopPropagation, UX, 事件冒泡
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/frontend/src/components/chat/chat-input.tsx
|
||||||
|
- art-agent/frontend/src/app/page.tsx
|
||||||
|
- **what**: 修复拖拽图片到 ChatInput 区域松开后,全屏拖拽覆盖层("松开以添加参考图")不消失的 bug
|
||||||
|
- **why**: CL-20260416-0620 为防止重复上传在 ChatInput.handleDrop 中加了 stopPropagation,但这也阻止了事件冒泡到 page.tsx 的 main.onDrop,后者负责清除 mainDragging 状态和 dragCounter。覆盖层有 pointer-events-none,不直接接收事件,但它的显示由 mainDragging 控制
|
||||||
|
- **decisions**: 新增 onFileDrop 回调 prop 让 ChatInput 在 handleDrop 中通知父组件,而非移除 stopPropagation(移除会导致 CL-20260416-0620 修复的重复上传问题复发)
|
||||||
|
- **notes**: 这是 stopPropagation 的典型副作用 — 解决了一个冒泡问题的同时切断了另一个需要冒泡的事件链。onFileDrop 回调模式是解决此类问题的标准方式
|
||||||
|
- **source_chat**: [拖拽覆盖层卡住修复](eb8bd100-5a4b-439f-bdc9-077311a456ad)
|
||||||
|
|
||||||
|
### [CL-20260416-0700] 2026-04-16 07:00 — 新增 GeminiNativeImageProvider:Gemini 原生 generateContent 接口对接
|
||||||
|
- **tags**: 后端, Gemini, Provider, 原生API, generateContent, 多图参考, 架构, 向量引擎
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- art-agent/backend/app/config.py
|
||||||
|
- **what**: 新增 `GeminiNativeImageProvider` 类,对接 Gemini 原生 `generateContent` 接口,使 Gemini 3.1 Flash Image 恢复多图参考能力
|
||||||
|
- **why**: 向量引擎对 Gemini 不支持 OpenAI 兼容的 `images/edit` 端点(CL-20260416-0620 回退了此方案)。Gemini 原生 API 通过 `generateContent` + `inline_data` 支持文字+图片混合输入,这是唯一可行的路径
|
||||||
|
- **decisions**:
|
||||||
|
- 选择直接 HTTP 调用向量引擎中转的 `/v1beta/models/{model}:generateContent` 端点,而非引入 `google-genai` SDK 新依赖——避免依赖膨胀且向量引擎已提供中转
|
||||||
|
- 新增 `_resolve_image_base64` 公共工具函数统一 data URI / 本地路径到 (mime, base64) 的解析,Gemini inline_data 和未来其他 provider 都可复用
|
||||||
|
- Gemini 响应中同时处理 `inlineData`(camelCase)和 `inline_data`(snake_case)两种字段名,因为向量引擎中转可能改变命名风格
|
||||||
|
- **notes**:
|
||||||
|
- Provider 注册表现有三个:`replicate`、`openai`、`gemini_native`,分发逻辑无需改动
|
||||||
|
- `generate_images` 中 Replicate 独有的"无参考图报错"逻辑已有 `provider_name == "replicate"` 限定,Gemini 无参考图时正常走纯文生图
|
||||||
|
- API key 复用 `VECTORENGINE_API_KEY`,base_url 从 `VECTORENGINE_BASE_URL` 推导(去掉 `/v1` 后拼 `/v1beta/...`)
|
||||||
|
- **source_chat**: [Gemini原生Provider实现](eb8bd100-5a4b-439f-bdc9-077311a456ad)
|
||||||
|
|
||||||
|
### [CL-20260416-0620] 2026-04-16 06:20 — 修复拖拽上传重复 + Gemini 改回不支持参考图 + images.edit 参数修正
|
||||||
|
- **tags**: bug修复, 拖拽, 事件冒泡, Gemini, 向量引擎, images.edit, config, stopPropagation
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/frontend/src/components/chat/chat-input.tsx
|
||||||
|
- art-agent/backend/app/config.py
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- **what**: 三个问题修复:拖拽上传重复、Gemini 500 错误、images.edit quality 参数不支持
|
||||||
|
- **why**: 用户测试多图上传时发现拖 1 张图显示 2 张;Gemini 使用参考图时 500 报错
|
||||||
|
- **decisions**:
|
||||||
|
- 拖拽重复:ChatInput 的 handleDrop/handleDragOver 加 e.stopPropagation(),阻止事件冒泡到 page.tsx 的 onDrop
|
||||||
|
- Gemini 500:向量引擎的 Gemini 图片编辑走原生 `/v1beta/models/xxx:generateContent` 端点,不支持 OpenAI 兼容的 `/v1/images/edits`。Gemini 改回 supports_ref_image=False,有参考图时走纯文生图
|
||||||
|
- images.edit 的 quality 参数:OpenAI SDK images.edit 不接受 quality 参数(那是 images.generate 的),移除避免报错
|
||||||
|
- **notes**:
|
||||||
|
- Gemini 图片编辑需要对接原生 generateContent 接口才能支持参考图,作为未来方向
|
||||||
|
- GPT Image 1.5 通过向量引擎 /v1/images/edits 走通的前提是向量引擎正确转发 multipart/form-data
|
||||||
|
|
||||||
|
### [CL-20260416-0600] 2026-04-16 06:00 — 多张参考图全链路支持
|
||||||
|
- **tags**: 前端, 后端, 参考图, 多图, OpenAI, images.edit, Provider, API, Agent Loop, UX
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/frontend/src/components/chat/chat-input.tsx
|
||||||
|
- art-agent/frontend/src/app/page.tsx
|
||||||
|
- art-agent/frontend/src/components/chat/chat-messages.tsx
|
||||||
|
- art-agent/frontend/src/lib/api.ts
|
||||||
|
- art-agent/frontend/src/lib/types.ts
|
||||||
|
- art-agent/backend/app/api/chat.py
|
||||||
|
- art-agent/backend/app/agent/loop.py
|
||||||
|
- art-agent/backend/app/agent/tools.py
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- art-agent/backend/app/config.py
|
||||||
|
- **what**: 从单张参考图扩展为多张参考图全链路支持(前端上传/显示 + API + Agent Loop + 生图 Provider)
|
||||||
|
- **why**: GPT Image 1.5 支持最多 16 张参考图(images.edit 端点),Gemini 支持 14 张,用户需要"保留图1主体+图2视角"等多图组合控制能力
|
||||||
|
- **decisions**:
|
||||||
|
- OpenAI Provider 有参考图时从 images.generate 切换到 images.edit 端点(而非统一用 edit)
|
||||||
|
- Replicate IP-Adapter 模型仍只支持单张,取 ref_image_urls[0] 兼容
|
||||||
|
- 后端 API 新增 ref_image_urls JSON 数组字段,保留旧 ref_image_url 单值字段向后兼容
|
||||||
|
- 前端 ChatMessage 新增 refImageUrls 数组字段,保留旧 refImageUrl 兼容已有聊天记录
|
||||||
|
- System Prompt 更新:LLM 需要理解多张参考图各自的角色并在 prompt 中传达
|
||||||
|
- **notes**:
|
||||||
|
- 向量引擎中转需支持 /v1/images/edits 端点才能真正生效,待验证
|
||||||
|
- Gemini 走 OpenAI 兼容 API 中转,images.edit 是否被正确转发取决于中转层实现
|
||||||
|
- _load_image_bytes 新函数用于将本地路径/data URI 转为 bytes 供 images.edit 使用
|
||||||
|
|
||||||
|
### [CL-20260416-0510] 2026-04-16 05:10 — 生图模型新增 Gemini 3.1 Flash Image
|
||||||
|
- **tags**: 后端, 生图模型, Gemini, 向量引擎, config
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/config.py
|
||||||
|
- art-agent/backend/.env
|
||||||
|
- **what**: IMAGE_MODELS 注册表新增 gemini-3.1-flash-image 条目,model_id 为 gemini-3.1-flash-image-preview,provider 为 openai
|
||||||
|
- **why**: 用户希望使用 Google Gemini 3.1 Flash 的原生生图能力,速度快价格低
|
||||||
|
- **decisions**: 复用已有的 OpenAIImageProvider(向量引擎中转兼容 OpenAI images/generations 端点),零代码改动
|
||||||
|
- **notes**: 如向量引擎对 Gemini 生图的返回格式有差异(如只返回 b64 不返回 URL),OpenAIImageProvider 已兼容两种格式
|
||||||
|
|
||||||
|
### [CL-20260416-0500] 2026-04-16 05:00 — 全区域拖拽添加参考图
|
||||||
|
- **tags**: 前端, UX, 拖拽, 参考图, 上传
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/frontend/src/app/page.tsx
|
||||||
|
- art-agent/frontend/src/components/chat/chat-input.tsx
|
||||||
|
- **what**: 将拖拽上传参考图的区域从底部输入栏扩展到整个对话区域(main),拖入时显示全屏半透明覆盖层提示"松开以添加参考图"
|
||||||
|
- **why**: 原先拖拽事件只绑在 ChatInput 组件(底部一小条),用户拖图片到对话区域无反应,体验不直觉
|
||||||
|
- **decisions**: ChatInput 改为 forwardRef,暴露 `uploadFile` 方法(ChatInputHandle 接口)。page.tsx 的 `<main>` 处理 dragEnter/dragOver/dragLeave/drop,drop 时调用 `chatInputRef.current.uploadFile(file)` 复用已有上传逻辑。用 dragCounter ref 解决子元素 dragEnter/dragLeave 冒泡导致覆盖层闪烁的经典问题
|
||||||
|
- **notes**: ChatInput 自身的拖拽处理保留(作为 fallback),两套不冲突
|
||||||
|
|
||||||
|
### [CL-20260416-0430] 2026-04-16 04:30 — 接入 GPT Image 1.5 生图模型(向量引擎中转)
|
||||||
|
- **tags**: 后端, 生图模型, GPT-Image, OpenAI, 向量引擎, provider
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- art-agent/backend/app/config.py
|
||||||
|
- art-agent/backend/.env
|
||||||
|
- **what**: 新增 OpenAIImageProvider,通过向量引擎 API 中转调用 GPT Image 1.5 模型生图。IMAGE_MODELS 注册表新增 gpt-image-1.5 条目,放在列表首位
|
||||||
|
- **why**: 用户希望使用 OpenAI 最新的 GPT Image 1.5 模型,该模型在 prompt 理解和文字渲染方面表现最好。通过向量引擎中转可复用已有的 API Key
|
||||||
|
- **decisions**: 新建 `OpenAIImageProvider` 类(与 `ReplicateProvider` 并列),使用 OpenAI Python SDK 的 `images.generate` 端点,懒初始化客户端。支持 URL 和 base64 两种返回格式。Provider 注册键为 `"openai"`
|
||||||
|
- **notes**: GPT Image 系列不支持参考图(IP-Adapter),`supports_ref_image=False`。如果后续需要接入 gpt-image-1 或 gpt-image-1-mini,只需在注册表新增条目,Provider 代码无需改动
|
||||||
|
|
||||||
|
### [CL-20260416-0400] 2026-04-16 04:00 — Session 级参考图自动沿用 + 缺参考图前置校验
|
||||||
|
- **tags**: 前端, 后端, 参考图, UX, InstantStyle, 风格迁移
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/frontend/src/app/page.tsx
|
||||||
|
- art-agent/frontend/src/components/chat/chat-input.tsx
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- **what**: 发送消息使用参考图后,在 session 级别记住该参考图 URL,后续消息自动沿用。输入框上方显示"沿用上次参考图"提示条(含缩略图+清除按钮)。后端对需要参考图但未收到的模型(InstantStyle/Kolors 等)返回友好错误而非发给模型得到诡异结果
|
||||||
|
- **why**: 用户切换到 InstantStyle 模型后输入"再执行一次前面的任务",因前端每次发送后清除参考图状态,导致 InstantStyle 收不到 style_image 参数,模型返回 "No input, Save money" 文本。高频场景(换模型重试同任务)不应要求重新上传参考图
|
||||||
|
- **decisions**: 参考图以 per-session `Map<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 provider,model_id 使用向量引擎确认的名称;gpt-4o-mini 和 deepseek-chat 保留作为轻量/直连选项
|
||||||
|
- **notes**: 新增模型的 model_id 需向量引擎实际支持,如有出入需调整
|
||||||
|
|
||||||
|
### [CL-20260416-0200] 2026-04-16 02:00 — 接入向量引擎中转 API + 对话级 LLM 模型切换
|
||||||
|
- **tags**: 后端, 前端, LLM, 向量引擎, 中转API, 模型切换, config, UI, session
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/config.py
|
||||||
|
- art-agent/backend/app/agent/loop.py
|
||||||
|
- art-agent/backend/app/api/chat.py
|
||||||
|
- art-agent/backend/.env
|
||||||
|
- art-agent/frontend/src/lib/types.ts
|
||||||
|
- art-agent/frontend/src/lib/api.ts
|
||||||
|
- art-agent/frontend/src/lib/app-context.tsx
|
||||||
|
- art-agent/frontend/src/components/sidebar/session-list.tsx
|
||||||
|
- art-agent/frontend/src/app/page.tsx
|
||||||
|
- **what**: 接入向量引擎中转 API(OpenAI 兼容格式),实现对话级 LLM 模型切换
|
||||||
|
- **why**: 用户需要通过中转 API 使用 OpenAI 系模型(GPT-4o/4o-mini),同时保留 DeepSeek 直连。每条对话应可独立选择不同模型
|
||||||
|
- **decisions**:
|
||||||
|
- 后端采用 LLM_MODELS 注册表 + provider 分发的架构(参考已有 IMAGE_MODELS 模式)
|
||||||
|
- provider 分为 vectorengine(向量引擎中转 OpenAI 系)和 deepseek(直连),各有独立的 API key 和 base_url
|
||||||
|
- 移除旧的 OPENAI_API_KEY/OPENAI_BASE_URL 全局配置,改为 provider 级别
|
||||||
|
- vision 能力检测改为从注册表读取,不再子串匹配模型名
|
||||||
|
- 前端对话列表的右键菜单改为显式三点按钮菜单,模型选择作为子菜单项
|
||||||
|
- Session 类型新增 llmModel 字段,llmModel 为空时使用后端默认
|
||||||
|
- **notes**:
|
||||||
|
- .env 中 VECTORENGINE_API_KEY 需要用户填入自己的 key
|
||||||
|
- Mem0 记忆系统仍使用 DEEPSEEK_API_KEY,不受影响
|
||||||
|
- 三点菜单预留了分享、归档等功能的扩展位
|
||||||
|
- 旧 localStorage 中没有 llmModel 字段的 Session 自动走默认模型
|
||||||
|
|
||||||
|
### [CL-20260416-0030] 2026-04-16 00:30 — 青绿山水风格改造:从 Cyberpunk 霓虹转为千里江山图色调 + 云烟雾气动效
|
||||||
|
- **tags**: 前端, UI改造, 视觉风格, 青绿山水, 千里江山图, CSS动画, 色彩体系, 云烟, 雾气
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/frontend/src/app/globals.css
|
||||||
|
- art-agent/frontend/src/components/layout/top-nav.tsx
|
||||||
|
- art-agent/frontend/src/components/sidebar/sidebar.tsx
|
||||||
|
- art-agent/frontend/src/components/sidebar/session-list.tsx
|
||||||
|
- art-agent/frontend/src/components/chat/chat-messages.tsx
|
||||||
|
- art-agent/frontend/src/components/chat/chat-input.tsx
|
||||||
|
- art-agent/frontend/src/components/chat/image-grid.tsx
|
||||||
|
- art-agent/frontend/src/components/detail/image-detail-panel.tsx
|
||||||
|
- art-agent/frontend/src/components/profile-modal.tsx
|
||||||
|
- art-agent/frontend/src/app/page.tsx
|
||||||
|
- art-agent/frontend/src/app/gallery/page.tsx
|
||||||
|
- art-agent/frontend/src/app/login/page.tsx
|
||||||
|
- docs/art-agent/VISUAL-STYLE-GUIDE.md
|
||||||
|
- **what**: 全站视觉风格从 Cyberpunk Dark Neon 改为"青绿山水"——以《千里江山图》矿物质颜料色为灵感的现代演绎。色彩体系、动画效果、背景光晕全面重做
|
||||||
|
- **why**: 用户希望美术风格从赛博朋克调整为中国传统青绿山水的现代演绎,发光效果添加"雾气感"
|
||||||
|
- **decisions**:
|
||||||
|
- 色彩体系:深墨绿底色(#0C1210)+ 石青绿高亮(#4DB8A4)+ 石绿蓝辅助(#3A8FB7)+ 赭石红(#C4654A)+ 赭金(#B8935A)
|
||||||
|
- 新增 --mist 和 --gold CSS 变量
|
||||||
|
- 背景改为双层漂移云雾(body::before 60s + body::after 45s),inset 负值防漂移露白
|
||||||
|
- .glow-border hover 效果:conic-gradient 弧段从 20% 扩大到 40-50%(更柔和),旋转从 3s 减慢到 6s,blur 从 6px 增到 12px,transition 从 500ms 增到 600ms
|
||||||
|
- .glass-panel 加内层渐变 + blur 从 20px 增到 24px + saturate(1.1)
|
||||||
|
- 新增 .fog-scroll 类(mask-image 渐变遮罩),应用到聊天区和会话列表
|
||||||
|
- 11 个组件文件中的硬编码 rgba(0,229,160,...) 全部替换为 rgba(77,184,164,...)
|
||||||
|
- 动画命名从 glowSpin 改为 mistSpin
|
||||||
|
- **notes**:
|
||||||
|
- profile-modal.tsx 也有硬编码颜色,在计划外被一并修复
|
||||||
|
- Logo SVG 中的 #0B0E14 替换为新的 --bg-primary 色值 #0C1210
|
||||||
|
- VISUAL-STYLE-GUIDE.md 完全重写,包含新旧风格对比表
|
||||||
|
- **source_chat**: [青绿山水风格改造]
|
||||||
|
|
||||||
|
### [CL-20260415-2330] 2026-04-15 23:30 — 用户个人信息 + 记忆查看面板:右上角菜单弹窗
|
||||||
|
- **tags**: 前端, 后端, 用户信息, 记忆系统, Mem0, Modal, ProfileModal, API
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/api/memory.py
|
||||||
|
- art-agent/backend/app/main.py
|
||||||
|
- art-agent/frontend/src/components/profile-modal.tsx
|
||||||
|
- art-agent/frontend/src/components/layout/top-nav.tsx
|
||||||
|
- **what**: 新增用户个人信息弹窗,展示用户基本信息和 Mem0 记忆系统中按时间分组的记忆列表
|
||||||
|
- **why**: 用户希望能查看记忆系统为自己记录了哪些信息,右上角菜单是最自然的入口
|
||||||
|
- **decisions**:
|
||||||
|
- 后端新建独立路由文件 api/memory.py(职责分离),暴露 GET /api/memory/list 端点
|
||||||
|
- 使用 Mem0 OSS `Memory.get_all(user_id=..., limit=200)` 获取全部记忆
|
||||||
|
- 同步调用通过 `run_in_executor` 包装避免阻塞事件循环
|
||||||
|
- 前端用居中 Modal(非 Drawer/独立页),复用 glass-panel + Cyberpunk 风格
|
||||||
|
- 记忆按 created_at 时间分组(今天/最近7天/更早),不依赖 Mem0 不稳定的 categories 字段
|
||||||
|
- 只读模式,不实现删除功能
|
||||||
|
- **notes**:
|
||||||
|
- TopNav 的 username 行从静态文本改为可点击按钮,点击打开 ProfileModal
|
||||||
|
- TopNav return 改为 Fragment 以容纳 header + Modal 两个顶级元素
|
||||||
|
- ESC 键可关闭弹窗,点击遮罩层也可关闭
|
||||||
|
- **source_chat**: [用户个人信息记忆面板]
|
||||||
|
|
||||||
|
### [CL-20260415-2230] 2026-04-15 22:30 — 光影流动边框效果:导航活动项 + 活动会话项
|
||||||
|
- **tags**: 前端, CSS动画, 视觉效果, conic-gradient, 霓虹边框, 导航, 侧边栏
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/frontend/src/app/globals.css
|
||||||
|
- art-agent/frontend/src/components/layout/top-nav.tsx
|
||||||
|
- art-agent/frontend/src/components/sidebar/session-list.tsx
|
||||||
|
- art-agent/frontend/src/components/sidebar/sidebar.tsx
|
||||||
|
- **what**: 实现 Dribbble 设计图中的边框光影流动效果,应用到导航活动 Tab 和活动会话项
|
||||||
|
- **why**: 用户指出按钮/标签缺少设计图中的发光+光影流动感。需要用 CSS 动画实现旋转锥形渐变边框
|
||||||
|
- **decisions**:
|
||||||
|
- 用 `@property --glow-angle` + `conic-gradient` 实现旋转光线,3s 一圈
|
||||||
|
- 双伪元素方案:`::before` 做精确渐变边框(mask-composite 裁掉内部),`::after` 做外层模糊扩散光晕
|
||||||
|
- 提供两个 CSS 类:`.glow-border`(动画旋转,用于活动态)、`.glow-border-static`(静态发光,用于 hover)
|
||||||
|
- 导航非活动项 hover 时用静态发光,不抢活动项视觉焦点
|
||||||
|
- **notes**:
|
||||||
|
- `@property` 需要浏览器支持 CSS Houdini(Chrome 85+, Edge 85+, Safari 15.4+),对目标用户群体覆盖足够
|
||||||
|
- 光影流动 3s 周期可通过修改 `glowSpin` 动画时长调整
|
||||||
|
- **source_chat**: [光影流动边框效果实现]
|
||||||
|
|
||||||
|
### [CL-20260415-2200] 2026-04-15 22:00 — 全站 UI 风格改造:Cyberpunk Dark Neon 主题
|
||||||
|
- **tags**: 前端, UI改造, 视觉风格, Cyberpunk, Neon, 毛玻璃, 全局样式, 组件重构
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/frontend/src/app/globals.css
|
||||||
|
- art-agent/frontend/src/app/page.tsx
|
||||||
|
- art-agent/frontend/src/app/gallery/page.tsx
|
||||||
|
- art-agent/frontend/src/app/login/page.tsx
|
||||||
|
- art-agent/frontend/src/components/layout/top-nav.tsx
|
||||||
|
- art-agent/frontend/src/components/sidebar/sidebar.tsx
|
||||||
|
- art-agent/frontend/src/components/sidebar/session-list.tsx
|
||||||
|
- art-agent/frontend/src/components/sidebar/tag-filter.tsx
|
||||||
|
- art-agent/frontend/src/components/chat/chat-messages.tsx
|
||||||
|
- art-agent/frontend/src/components/chat/chat-input.tsx
|
||||||
|
- art-agent/frontend/src/components/chat/image-grid.tsx
|
||||||
|
- art-agent/frontend/src/components/chat/model-selector.tsx
|
||||||
|
- art-agent/frontend/src/components/detail/image-detail-panel.tsx
|
||||||
|
- docs/art-agent/VISUAL-STYLE-GUIDE.md
|
||||||
|
- **what**: 参考 SolCasino Dribbble 设计,将全站 UI 从朴素暗色主题改造为 Cyberpunk Dark Neon 风格
|
||||||
|
- **why**: 用户希望产品页面更具视觉冲击力,参考了 Dribbble 上的加密赌场 Cases 页面设计,提取其核心视觉语言(霓虹发光、毛玻璃、环境光晕、卡片网格)应用到美术 Agent 工具
|
||||||
|
- **decisions**:
|
||||||
|
- 主色从紫色(#6366f1)改为青绿霓虹(#00E5A0),更符合 Cyberpunk 调性
|
||||||
|
- 新增 CSS 变量:`--bg-card`, `--hot`, `--accent-secondary`, `--border-glow`
|
||||||
|
- 背景加 body::before 环境光晕(两团 radial-gradient 光斑)
|
||||||
|
- 全部面板改为 backdrop-blur 毛玻璃效果
|
||||||
|
- 圆角从 rounded-lg/2xl 统一升级为 rounded-xl/2xl
|
||||||
|
- 新增 `.glass-panel` 和 `.neon-border` 全局 CSS 类
|
||||||
|
- 图片卡片 hover 时有微缩放(1.03) + 霓虹边框发光
|
||||||
|
- 登录页加独立环境光背景 + 毛玻璃表单容器
|
||||||
|
- Gallery 卡片改为 neon-border 风格,选中时霓虹发光阴影
|
||||||
|
- 收藏色从金色改为 accent 青绿色,保持色彩一致性
|
||||||
|
- **notes**:
|
||||||
|
- 同时编写了视觉风格文档 `docs/art-agent/VISUAL-STYLE-GUIDE.md`,记录完整的设计规范
|
||||||
|
- annotation-canvas.tsx 未做改动(工具型组件,暗色已适配)
|
||||||
|
- 所有组件保留原有功能和交互逻辑,仅变更视觉层
|
||||||
|
- **source_chat**: [Cyberpunk UI 风格改造]
|
||||||
|
|
||||||
|
### [CL-20260414-2350] 2026-04-14 23:50 — 修复登录后 useApp must be used within AppProvider 报错
|
||||||
|
- **tags**: bug修复, 前端, 认证, AppProvider, 初始化时序
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/frontend/src/lib/app-context.tsx
|
||||||
|
- **what**: 修复登录成功跳转到主页时 useApp() 拿到 null context 报错
|
||||||
|
- **why**: AppProvider 在 `!initialized` 时直接渲染 `<>{children}</>`(不提供 AppContext),但已登录用户跳转到 `/` 后 `page.tsx` 立即调用 `useApp()`,而 `useEffect` 中的 `setInitialized(true)` 还没执行,导致 context 为 null
|
||||||
|
- **decisions**: 区分两种 `!initialized` 场景——已认证时显示加载状态(不渲染需要 context 的子组件),未认证时渲染 children(供 /login 页面使用)
|
||||||
|
- **notes**: 这是 React 的 useEffect 异步特性导致的时序问题,登录跳转瞬间路由已变但 state 还没更新
|
||||||
|
- **source_chat**: [修复 AppProvider 初始化时序](a33b7af6-a8c0-4e88-8372-ec4b5b9dd703)
|
||||||
|
|
||||||
### [CL-20260414-2340] 2026-04-14 23:40 — 用 pwdlib (Argon2id) 替换 passlib (bcrypt),彻底解决兼容性问题
|
### [CL-20260414-2340] 2026-04-14 23:40 — 用 pwdlib (Argon2id) 替换 passlib (bcrypt),彻底解决兼容性问题
|
||||||
- **tags**: 重构, 安全, 密码哈希, Argon2, pwdlib, passlib, 依赖升级
|
- **tags**: 重构, 安全, 密码哈希, Argon2, pwdlib, passlib, 依赖升级
|
||||||
- **affected_files**:
|
- **affected_files**:
|
||||||
|
|||||||
@@ -2,6 +2,26 @@
|
|||||||
|
|
||||||
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
|
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
|
||||||
|
|
||||||
|
- [CL-20260416-0830] 生图按模型预处理 prompt:新增 image_prompt_strategy(SDXL 默认负向词+---NEGATIVE---拆分,Flux 剥粘贴的 Negative 段,GPT/Gemini/IP-Adapter 规范化);GenerateResult 带 effective_prompt/negative_prompt;Replicate SDXL 传 negative_prompt;工具说明 SDXL 分隔符
|
||||||
|
- [CL-20260416-0810] 对话输入栏支持剪贴板粘贴图片为参考图:ChatInput根容器onPasteCapture提取image/*文件并走startUpload,placeholder/title提示
|
||||||
|
- [CL-20260416-0745] Gemini原生生图:延长读写超时+对RemoteProtocolError等可重试错误自动重试(默认3次指数退避),环境变量可微调VECTORENGINE_GEMINI_*_TIMEOUT
|
||||||
|
- [CL-20260416-0720] 修复拖拽参考图到ChatInput区域后覆盖层卡住不消失:ChatInput的stopPropagation阻止了main.onDrop,新增onFileDrop回调通知父组件清除拖拽状态
|
||||||
|
- [CL-20260416-0700] 新增GeminiNativeImageProvider:对接Gemini原生generateContent接口,支持多图参考(最多14张),Gemini恢复supports_ref_image=True
|
||||||
|
- [CL-20260416-0620] 修复拖拽上传重复(stopPropagation)+Gemini改回不支持参考图(向量引擎不支持Gemini走images/edits)+images.edit移除不支持的quality参数
|
||||||
|
- [CL-20260416-0600] 多张参考图全链路支持:前端多图上传/拖拽/沿用+后端API/AgentLoop/Provider全部改为list[str],OpenAI走images.edit端点(最多16张),Replicate取首张兼容
|
||||||
|
- [CL-20260416-0510] 生图模型注册表新增Gemini 3.1 Flash Image(gemini-3.1-flash-image-preview),走向量引擎中转复用OpenAIImageProvider
|
||||||
|
- [CL-20260416-0500] 全区域拖拽添加参考图:拖拽区域从底部输入栏扩展到整个对话区域,拖入时全屏覆盖层提示,ChatInput改forwardRef暴露uploadFile
|
||||||
|
- [CL-20260416-0430] 接入GPT Image 1.5生图模型:新增OpenAIImageProvider走向量引擎中转,IMAGE_MODELS注册表新增gpt-image-1.5
|
||||||
|
- [CL-20260416-0400] Session级参考图自动沿用:发送后记住参考图供后续消息复用+UI提示条+可清除,后端对缺参考图的风格迁移模型返回友好错误
|
||||||
|
- [CL-20260416-0300] System Prompt新增禁止LLM在回复中嵌入Markdown图片链接(sandbox:等),图片展示由image_result SSE事件处理
|
||||||
|
- [CL-20260416-0245] 修复三点菜单被右侧对话区遮挡:从fixed右侧弹出改为inline下方展开+模型列表折叠式内联,全部在侧栏内完成
|
||||||
|
- [CL-20260416-0230] LLM注册表扩充至7个模型(+gpt-5.4/claude-sonnet-4-6/claude-opus-4-6/gemini-3.1-pro-preview/glm-4.7),默认改为gpt-5.4
|
||||||
|
- [CL-20260416-0200] 接入向量引擎中转API+对话级LLM模型切换:LLM_MODELS注册表(gpt-4o-mini/gpt-4o/deepseek-chat),provider分发(vectorengine/deepseek),前端三点菜单模型选择,Session级llmModel
|
||||||
|
- [CL-20260416-0030] 青绿山水风格改造:色彩体系替换为千里江山图石青石绿色调,背景改双层漂移云雾,glow-border 柔化为云烟缭绕,新增 fog-scroll 雾气边缘,11 个组件硬编码颜色统一替换
|
||||||
|
- [CL-20260415-2230] 光影流动边框改为 hover 触发:常态微弱静态发光,hover 时旋转动画通过 opacity 500ms 平滑淡入/淡出,无跳变
|
||||||
|
- [CL-20260415-2330] 用户个人信息+记忆查看面板:右上角菜单弹窗,展示 Mem0 记忆列表(按时间分组,只读),后端新增 /api/memory/list 端点
|
||||||
|
- [CL-20260415-2200] 全站 UI 风格改造为 Cyberpunk Dark Neon:主色改为青绿霓虹,新增环境光晕/毛玻璃/霓虹发光边框,13 个组件重构 + 视觉风格文档
|
||||||
|
- [CL-20260414-2350] 修复登录后 useApp must be used within AppProvider:AppProvider 未初始化时区分已认证/未认证,避免子组件拿到 null context
|
||||||
- [CL-20260414-2340] 用 pwdlib (Argon2id) 替换 passlib (bcrypt):彻底解决兼容性问题,新密码 Argon2id,旧 bcrypt 哈希兼容验证
|
- [CL-20260414-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
|
||||||
|
|||||||
@@ -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 — 新增 GeminiNativeImageProvider:Gemini 原生 generateContent 接口对接
|
||||||
|
- **tags**: 后端, Gemini, Provider, 原生API, generateContent, 多图参考, 架构
|
||||||
|
- **affected_files**: image_gen.py, config.py
|
||||||
|
- **summary**: 新增 GeminiNativeImageProvider,通过向量引擎中转调用 Gemini 原生 `/v1beta/models/{model}:generateContent` 接口(非 OpenAI 兼容的 images/edit)。支持文字+图片混合输入(最多 14 张参考图),图片以 inline_data base64 内联传入。新增 `_resolve_image_base64` 工具函数统一处理 data URI 和本地路径到 (mime, b64) 的解析。config.py 中 Gemini 模型改用 `gemini_native` provider 并恢复 `supports_ref_image: True`。Provider 注册表新增 `gemini_native` 条目。
|
||||||
|
|
||||||
|
### [CL-20260416-0620] 2026-04-16 — 修复拖拽上传重复 + Gemini 改回不支持参考图 + images.edit 参数修正
|
||||||
|
- **tags**: bug修复, 拖拽, Gemini, 向量引擎, images.edit, config
|
||||||
|
- **affected_files**: chat-input.tsx, config.py, image_gen.py
|
||||||
|
- **summary**: 三个问题修复:(1) 拖拽到 ChatInput 区域时事件冒泡导致 page.tsx 和 ChatInput 各触发一次上传 → ChatInput handleDrop/handleDragOver 加 stopPropagation;(2) 向量引擎 Gemini 图片编辑走原生 generateContent 接口而非 OpenAI 兼容 images/edits,对 Gemini 调用 images.edit 返回 500 → Gemini 改回 supports_ref_image=False;(3) images.edit 端点不支持 quality 参数 → 从 _edit_with_refs 中移除。
|
||||||
|
|
||||||
|
### [CL-20260416-0600] 2026-04-16 — 多张参考图全链路支持
|
||||||
|
- **tags**: 前端, 后端, 参考图, 多图, OpenAI, images.edit, Provider, API, Agent Loop
|
||||||
|
- **affected_files**: chat-input.tsx, page.tsx, chat-messages.tsx, api.ts, types.ts, chat.py, loop.py, tools.py, image_gen.py, config.py
|
||||||
|
- **summary**: 从单张参考图扩展为多张参考图全链路支持。前端:ChatInput 改为多图状态数组+file input multiple+多图预览/删除/拖拽;API层 sendChat 改为 ref_image_urls: string[];消息气泡和沿用逻辑适配多图。后端:chat.py 新增 ref_image_urls JSON 数组参数(兼容旧 ref_image_url);Agent Loop 多图注入 vision LLM 上下文;OpenAIImageProvider 有参考图时改用 images.edit 端点(支持最多16张);Replicate 取首张兼容;GPT Image 1.5 和 Gemini 注册表标记 supports_ref_image=True。
|
||||||
|
|
||||||
|
### [CL-20260416-0510] 2026-04-16 — 生图模型新增 Gemini 3.1 Flash Image
|
||||||
|
- **tags**: 后端, 生图模型, Gemini, 向量引擎, config
|
||||||
|
- **affected_files**: config.py, .env
|
||||||
|
- **summary**: IMAGE_MODELS 注册表新增 gemini-3.1-flash-image(model_id: gemini-3.1-flash-image-preview),provider 为 openai,复用 OpenAIImageProvider 走向量引擎中转。无需新增 Provider 代码。
|
||||||
|
|
||||||
|
### [CL-20260416-0500] 2026-04-16 — 全区域拖拽添加参考图
|
||||||
|
- **tags**: 前端, UX, 拖拽, 参考图, 上传
|
||||||
|
- **affected_files**: page.tsx, chat-input.tsx
|
||||||
|
- **summary**: 拖拽上传参考图区域从底部输入栏扩展到整个对话区域。ChatInput 改为 forwardRef 暴露 uploadFile 方法,main 区域处理拖放事件并调用。拖入时显示全屏覆盖层提示,用 dragCounter 防止子元素冒泡导致闪烁。
|
||||||
|
|
||||||
|
### [CL-20260416-0430] 2026-04-16 — 接入 GPT Image 1.5 生图模型(向量引擎中转)
|
||||||
|
- **tags**: 后端, 生图模型, GPT-Image, OpenAI, 向量引擎, provider
|
||||||
|
- **affected_files**: image_gen.py, config.py, .env
|
||||||
|
- **summary**: 新增 OpenAIImageProvider,通过向量引擎 API 中转调用 `/v1/images/generations` 端点。IMAGE_MODELS 注册表新增 gpt-image-1.5(provider: openai),支持 size/quality 参数和 URL/base64 双格式返回。
|
||||||
|
|
||||||
|
### [CL-20260416-0400] 2026-04-16 — Session 级参考图自动沿用 + 缺参考图前置校验
|
||||||
|
- **tags**: 前端, 后端, 参考图, UX, InstantStyle, 风格迁移
|
||||||
|
- **affected_files**: page.tsx, chat-input.tsx, image_gen.py
|
||||||
|
- **summary**: 用户发送带参考图的消息后,前端在 session 级别记住该参考图 URL,后续消息自动沿用(输入框显示"沿用上次参考图"提示条,可清除)。解决切换 InstantStyle 后无参考图导致 "No input, Save money" 的问题。后端对需要参考图但未收到的模型统一返回友好错误。
|
||||||
|
|
||||||
|
### [CL-20260416-0300] 2026-04-16 — System Prompt 禁止 LLM 嵌入图片链接
|
||||||
|
- **tags**: 后端, agent-loop, system-prompt, LLM行为约束
|
||||||
|
- **affected_files**: loop.py
|
||||||
|
- **summary**: LLM(GPT 系列)在收到 generate_image 工具返回的本地路径后,自行拼凑 `sandbox:/generated/xxx.png` Markdown 图片链接。前端图片展示由 image_result SSE 事件独立处理,文字中的链接无效且多余。在 System Prompt 注意事项中新增禁止嵌入图片 Markdown 的约束。
|
||||||
|
|
||||||
|
### [CL-20260416-0245] 2026-04-16 — 修复三点菜单 z-index 层级问题
|
||||||
|
- **tags**: 前端, UI, sidebar, 菜单, z-index
|
||||||
|
- **affected_files**: session-list.tsx
|
||||||
|
- **summary**: 三点菜单原用 fixed 定位在侧栏右侧弹出,被主内容区遮挡。改为 inline 在会话项正下方展开,模型子菜单改为折叠式内联列表(带 max-h 滚动),全部在侧栏内部完成,不再溢出。移除了 menuPos 状态和 fixed 定位逻辑。
|
||||||
|
|
||||||
|
### [CL-20260416-0230] 2026-04-16 — LLM 注册表扩充 + 默认模型改 GPT-5.4
|
||||||
|
- **tags**: 后端, config, LLM, 模型注册表
|
||||||
|
- **affected_files**: config.py, .env
|
||||||
|
- **summary**: LLM_MODELS 从 3→7 个:新增 gpt-5.4、claude-sonnet-4-6、claude-opus-4-6、gemini-3.1-pro-preview、glm-4.7,全部走 vectorengine provider。默认模型从 gpt-4o-mini 改为 gpt-5.4。gpt-4o-mini/deepseek-chat 保留。
|
||||||
|
|
||||||
|
### [CL-20260416-0200] 2026-04-16 — 接入向量引擎中转 API + 对话级 LLM 模型切换
|
||||||
|
- **tags**: 后端, 前端, LLM, 向量引擎, 中转API, 模型切换, config, UI, session
|
||||||
|
- **affected_files**: config.py, loop.py, chat.py, .env, types.ts, api.ts, app-context.tsx, session-list.tsx, page.tsx
|
||||||
|
- **summary**: 新增 LLM_MODELS 注册表(gpt-4o-mini/gpt-4o/deepseek-chat),provider 分发为 vectorengine(中转)和 deepseek(直连)。loop.py 改为按 provider 创建 AsyncOpenAI 客户端,vision 检测从注册表读取。chat.py 新增 llm_model 参数和 GET /api/llm-models 端点。前端 Session 新增 llmModel 字段,侧栏三点菜单支持模型选择子菜单,sendChat 传递 llm_model。
|
||||||
|
|
||||||
|
### [CL-20260416-0030] 2026-04-16 — 青绿山水风格改造:千里江山图色调 + 云烟雾气动效
|
||||||
|
- **tags**: 前端, UI改造, 视觉风格, 青绿山水, 千里江山图, CSS动画, 色彩体系, 云烟
|
||||||
|
- **affected_files**: globals.css, top-nav.tsx, sidebar.tsx, session-list.tsx, chat-messages.tsx, chat-input.tsx, image-grid.tsx, image-detail-panel.tsx, profile-modal.tsx, page.tsx, gallery/page.tsx, login/page.tsx, VISUAL-STYLE-GUIDE.md
|
||||||
|
- **summary**: 全站从 Cyberpunk 霓虹改为青绿山水风格。色彩替换为石青绿(#4DB8A4)/石绿蓝(#3A8FB7)/墨绿底色(#0C1210)。背景改为双层漂移云雾(60s+45s)。.glow-border hover 效果柔化为云烟缭绕(6s/宽弧段/blur 12px)。.glass-panel 加绿底渐变+增强模糊。新增 .fog-scroll 雾气滚动边缘。11 个组件硬编码颜色全部替换。VISUAL-STYLE-GUIDE.md 完全重写。
|
||||||
|
|
||||||
|
### [CL-20260415-2330] 2026-04-15 — 用户个人信息 + 记忆查看面板:右上角菜单弹窗
|
||||||
|
- **tags**: 前端, 后端, 用户信息, 记忆系统, Mem0, Modal, ProfileModal
|
||||||
|
- **affected_files**: api/memory.py, main.py, profile-modal.tsx, top-nav.tsx
|
||||||
|
- **summary**: 后端新建 GET /api/memory/list 端点(Mem0 get_all + run_in_executor),前端新建 ProfileModal 组件(glass-panel 风格居中弹窗),展示用户信息 + 按时间分组的记忆列表(只读)。TopNav 的 username 行改为可点击按钮,点击打开弹窗。
|
||||||
|
|
||||||
|
### [CL-20260415-2230] 2026-04-15 — 光影流动边框效果:导航活动项 + 活动会话项
|
||||||
|
- **tags**: 前端, CSS动画, 视觉效果, conic-gradient, 霓虹边框
|
||||||
|
- **affected_files**: globals.css, top-nav.tsx, session-list.tsx, sidebar.tsx
|
||||||
|
- **summary**: 用 @property + conic-gradient 实现旋转光影边框效果。双伪元素方案:::before 做渐变边框(mask-composite 裁内部),::after 做模糊扩散光晕。提供 .glow-border(动画)和 .glow-border-static(hover 静态发光)两个类,分别应用到导航活动 Tab 和活动会话项。
|
||||||
|
|
||||||
|
### [CL-20260415-2200] 2026-04-15 — 全站 UI 风格改造:Cyberpunk Dark Neon 主题
|
||||||
|
- **tags**: 前端, UI改造, 视觉风格, Cyberpunk, Neon, 毛玻璃, 全局样式
|
||||||
|
- **affected_files**: globals.css, page.tsx, gallery/page.tsx, login/page.tsx, top-nav.tsx, sidebar.tsx, session-list.tsx, tag-filter.tsx, chat-messages.tsx, chat-input.tsx, image-grid.tsx, model-selector.tsx, image-detail-panel.tsx, VISUAL-STYLE-GUIDE.md
|
||||||
|
- **summary**: 参考 SolCasino Dribbble 设计,全站从朴素暗色改为 Cyberpunk Dark Neon 风格。主色从紫色改为青绿霓虹(#00E5A0),新增环境光晕背景、毛玻璃面板(backdrop-blur)、霓虹发光边框。所有组件统一升级圆角和过渡效果,图片卡片加 hover 缩放+发光。同时编写了完整的视觉风格文档。
|
||||||
|
|
||||||
|
### [CL-20260414-2350] 2026-04-14 — 修复登录后 useApp must be used within AppProvider 报错
|
||||||
|
- **tags**: bug修复, 前端, 认证, AppProvider, 初始化时序
|
||||||
|
- **affected_files**: app-context.tsx
|
||||||
|
- **summary**: AppProvider 在 `!initialized` 时不提供 context 但渲染了需要 useApp() 的子组件。修复:已认证但未初始化时显示加载状态,未认证时才渲染裸 children(/login)。
|
||||||
|
|
||||||
### [CL-20260414-2340] 2026-04-14 — 用 pwdlib (Argon2id) 替换 passlib (bcrypt),彻底解决兼容性问题
|
### [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
|
||||||
|
|||||||
@@ -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-XL(CivitAI):最强画风迁移(线条+着色技法),需 ComfyUI
|
- Style IPAdapter for NoobAI-XL(CivitAI):最强画风迁移(线条+着色技法),需 ComfyUI
|
||||||
- ICAS 框架(学术前沿 2025.04):IP-Adapter + ControlNet 组合,多主体风格一致性最优
|
- ICAS 框架(学术前沿 2025.04):IP-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, 自定义模型, 风格迁移
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# === 豁免检查 6:stdin 中无文件编辑证据 ===
|
||||||
|
# 防止跨会话残留 mtime 差异导致误触发:如果 ack 文件不存在(或过旧),
|
||||||
|
# 但 stdin 上下文中也没有任何文件写入/编辑操作的痕迹,说明本次会话
|
||||||
|
# 没有进行代码改动,不应触发提醒。
|
||||||
|
$hasEditEvidence = (
|
||||||
|
$input -match 'StrReplace|Write\s*tool|edit_file|file_write|write_to_file' -or
|
||||||
|
$input -match 'Created file|Modified file|Wrote contents' -or
|
||||||
|
$input -match '"tool"\s*:\s*"(str_replace|write|edit)"'
|
||||||
|
)
|
||||||
|
if (-not $hasEditEvidence) {
|
||||||
|
Write-Output '{}'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
# === 核心检查:是否有源文件比 changelog 更新 ===
|
# === 核心检查:是否有源文件比 changelog 更新 ===
|
||||||
$extensions = @("*.py", "*.tsx", "*.ts", "*.css")
|
$extensions = @("*.py", "*.tsx", "*.ts", "*.css")
|
||||||
$excludeDirs = @("node_modules", ".next", "__pycache__", "venv")
|
$excludeDirs = @("node_modules", ".next", "__pycache__", "venv")
|
||||||
|
|||||||
@@ -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;窗口必须可见,用户可直接查看日志和手动关闭
|
- **备注**: 项目专属 Skill;Ollama 必须先于后端启动(Mem0 embedding 依赖);前后端窗口必须可见
|
||||||
|
|||||||
@@ -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. 启动后端(在独立可见终端窗口中):
|
||||||
- Windows(PowerShell 或 CMD 均适用):
|
- Windows(PowerShell 或 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
|
|||||||
## 访问信息
|
## 访问信息
|
||||||
|
|
||||||
启动完成后告知用户:
|
启动完成后告知用户:
|
||||||
|
- Ollama:http://localhost:11434(Mem0 embedding 服务)
|
||||||
- 后端 API:http://localhost:8000
|
- 后端 API:http://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。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
37
art-agent/backend/app/api/memory.py
Normal file
37
art-agent/backend/app/api/memory.py
Normal 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}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
139
art-agent/backend/app/services/image_prompt_strategy.py
Normal file
139
art-agent/backend/app/services/image_prompt_strategy.py
Normal 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
|
||||||
|
|
||||||
|
# ─── SDXL(Stability / Replicate)────────────────────────────
|
||||||
|
# Replicate stability-ai/sdxl 提供独立字段 negative_prompt(见模型 API 页)。
|
||||||
|
# 下列为 CLIP 系文生图常见「质量/解剖」排除词,作未显式指定时的基线。
|
||||||
|
DEFAULT_SDXL_NEGATIVE = (
|
||||||
|
"low quality, worst quality, normal quality, lowres, blurry, jpeg artifacts, "
|
||||||
|
"watermark, signature, text, logo, deformed, disfigured, bad anatomy, bad hands, "
|
||||||
|
"extra fingers, mutated, cropped, poorly drawn face"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PreparedImagePrompt:
|
||||||
|
"""下游只读:prompt 必填;negative_prompt 仅部分后端使用(当前为 SDXL)。"""
|
||||||
|
|
||||||
|
prompt: str
|
||||||
|
negative_prompt: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_image_prompt_for_model(
|
||||||
|
model_config: dict[str, Any],
|
||||||
|
raw_prompt: str,
|
||||||
|
*,
|
||||||
|
has_reference_images: bool = False,
|
||||||
|
) -> PreparedImagePrompt:
|
||||||
|
"""根据注册表 id 选择策略。未知 id 时原样透传。"""
|
||||||
|
short_id = model_config.get("id", "")
|
||||||
|
text = (raw_prompt or "").strip()
|
||||||
|
if not text:
|
||||||
|
return PreparedImagePrompt(prompt="")
|
||||||
|
|
||||||
|
dispatch: dict[str, Any] = {
|
||||||
|
"gpt-image-1.5": _openai_gpt_image,
|
||||||
|
"gemini-3.1-flash-image": _gemini_native_image,
|
||||||
|
"flux-schnell": _flux_bfl,
|
||||||
|
"flux-dev": _flux_bfl,
|
||||||
|
"sdxl": _sdxl_replicate,
|
||||||
|
"instant-style": _replicate_ip_adapter_scene,
|
||||||
|
"kolors-ipadapter": _replicate_ip_adapter_scene,
|
||||||
|
}
|
||||||
|
fn = dispatch.get(short_id, _passthrough)
|
||||||
|
return fn(text, model_config, has_reference_images)
|
||||||
|
|
||||||
|
|
||||||
|
def _passthrough(text: str, _model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt:
|
||||||
|
return PreparedImagePrompt(prompt=text)
|
||||||
|
|
||||||
|
|
||||||
|
def _openai_gpt_image(text: str, _model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt:
|
||||||
|
"""OpenAI GPT Image:自然语言指令遵循强,宜为完整、具体的场景描述。
|
||||||
|
|
||||||
|
参考:https://platform.openai.com/docs/guides/image-generation
|
||||||
|
"""
|
||||||
|
return PreparedImagePrompt(prompt=_collapse_ws(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _gemini_native_image(text: str, _model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt:
|
||||||
|
"""Gemini 原生生图:多模态指令 + 文本;英文描述通常效果稳定。
|
||||||
|
|
||||||
|
参考:Google AI 文档 generateContent 与图像输出 modality 说明。
|
||||||
|
"""
|
||||||
|
return PreparedImagePrompt(prompt=_collapse_ws(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _flux_bfl(text: str, _model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt:
|
||||||
|
"""Black Forest Labs FLUX:Subject + Action + Style + Context;靠前放置重点;无 negative API。
|
||||||
|
|
||||||
|
参考:https://docs.bfl.ai/guides/prompting_guide_t2i_fundamentals
|
||||||
|
若用户从 SDXL 复制了 Negative 段,尽量剥掉以免干扰文意。
|
||||||
|
"""
|
||||||
|
cleaned = _strip_pasted_negative_block(text)
|
||||||
|
return PreparedImagePrompt(prompt=_collapse_ws(cleaned))
|
||||||
|
|
||||||
|
|
||||||
|
def _sdxl_replicate(text: str, model_config: dict[str, Any], _has_ref: bool) -> PreparedImagePrompt:
|
||||||
|
"""Replicate SDXL:prompt + negative_prompt 双字段;支持显式拆分。
|
||||||
|
|
||||||
|
约定(可选):正提示与负提示用单独一行分隔符,便于 Agent/用户手写。
|
||||||
|
- ---NEGATIVE--- 或 |||NEG|||
|
||||||
|
未拆分时使用 default_params.negative_prompt 或模块默认 DEFAULT_SDXL_NEGATIVE。
|
||||||
|
"""
|
||||||
|
neg_fallback = model_config.get("default_params", {}).get(
|
||||||
|
"negative_prompt", DEFAULT_SDXL_NEGATIVE
|
||||||
|
)
|
||||||
|
if "---NEGATIVE---" in text:
|
||||||
|
pos, _, neg = text.partition("---NEGATIVE---")
|
||||||
|
pos = pos.strip()
|
||||||
|
neg = neg.strip()
|
||||||
|
return PreparedImagePrompt(
|
||||||
|
prompt=_collapse_ws(pos),
|
||||||
|
negative_prompt=neg or neg_fallback,
|
||||||
|
)
|
||||||
|
if "|||NEG|||" in text:
|
||||||
|
pos, _, neg = text.partition("|||NEG|||")
|
||||||
|
pos = pos.strip()
|
||||||
|
neg = neg.strip()
|
||||||
|
return PreparedImagePrompt(
|
||||||
|
prompt=_collapse_ws(pos),
|
||||||
|
negative_prompt=neg or neg_fallback,
|
||||||
|
)
|
||||||
|
return PreparedImagePrompt(
|
||||||
|
prompt=_collapse_ws(text),
|
||||||
|
negative_prompt=neg_fallback,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _replicate_ip_adapter_scene(
|
||||||
|
text: str, _model_config: dict[str, Any], has_reference_images: bool
|
||||||
|
) -> PreparedImagePrompt:
|
||||||
|
"""IP-Adapter / InstantStyle / Kolors:参考图承担风格与纹理,prompt 侧重场景与内容语义。
|
||||||
|
|
||||||
|
Replicate 各模型 README 均强调 prompt + 参考图配合;无参考图时由上层拦截。
|
||||||
|
有参考图时不额外堆叠长前缀,避免稀释主体描述。
|
||||||
|
"""
|
||||||
|
_ = has_reference_images
|
||||||
|
return PreparedImagePrompt(prompt=_collapse_ws(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _collapse_ws(s: str) -> str:
|
||||||
|
return " ".join(s.split())
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_pasted_negative_block(text: str) -> str:
|
||||||
|
lower = text.lower()
|
||||||
|
for sep in ("\n---negative---\n", "\nnegative prompt:", "\nnegative:"):
|
||||||
|
idx = lower.find(sep)
|
||||||
|
if idx != -1:
|
||||||
|
return text[:idx].strip()
|
||||||
|
return text
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)} />}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
233
art-agent/frontend/src/components/profile-modal.tsx
Normal file
233
art-agent/frontend/src/components/profile-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 模型短 ID,undefined 时使用后端默认 */
|
||||||
|
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;
|
||||||
|
|||||||
293
docs/art-agent/VISUAL-STYLE-GUIDE.md
Normal file
293
docs/art-agent/VISUAL-STYLE-GUIDE.md
Normal 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 设计融合:
|
||||||
|
- **沉浸感**:深墨底色营造夜间远山的静谧氛围,适合创意工具
|
||||||
|
- **自然动效**:所有动画模拟自然现象(云烟飘动),而非机械闪烁
|
||||||
|
- **克制用色**:石青石绿只用于高亮和交互反馈,不滥用;赭金极少量点缀
|
||||||
|
- **雾气意境**:从边框云烟到背景漂移到滚动渐隐,统一的"雾"意象贯穿全站
|
||||||
Reference in New Issue
Block a user