diff --git a/.github/workflows/commit-message.yml b/.github/workflows/commit-message.yml new file mode 100644 index 00000000..dae0e45c --- /dev/null +++ b/.github/workflows/commit-message.yml @@ -0,0 +1,52 @@ +name: Commit Message + +on: + pull_request: + branches: + - main + - dev + +permissions: + contents: read + +jobs: + validate: + name: Validate commit messages + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check commit message subjects + shell: bash + run: | + set -euo pipefail + + pattern='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test): [A-Za-z0-9 _-]+$' + + range="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" + + invalid=0 + while IFS= read -r line; do + sha="${line%% *}" + subject="${line#* }" + + if [[ "$subject" == Merge\ * ]]; then + continue + fi + + if [[ ! "$subject" =~ $pattern ]]; then + echo "::error title=Invalid commit message::${sha} ${subject}" + invalid=1 + fi + done < <(git log --format='%H %s' "$range") + + if [[ "$invalid" -ne 0 ]]; then + echo "Commit message subjects must use: type: subject" + echo "Allowed types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test" + echo "Allowed subject characters: English letters, numbers, spaces, hyphens, and underscores" + exit 1 + fi diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 75ab7bff..cba84afb 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -4,11 +4,13 @@ on: push: branches: - main + - dev tags: - "v*.*.*" pull_request: branches: - main + - dev workflow_dispatch: permissions: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index add5515c..f9f14472 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,33 @@ Use the example configuration files for local development. Do not commit local s - Keep generated artifacts out of commits unless the project explicitly requires them. - Do not commit caches, build output, `.pyc` files, `.env` files, or local storage data. +## Commit Messages + +Use the `type: subject` format for every commit message subject. +Use only English letters, numbers, spaces, hyphens, and underscores in the subject. + +Allowed types: + +- `build` +- `chore` +- `ci` +- `docs` +- `feat` +- `fix` +- `perf` +- `refactor` +- `revert` +- `style` +- `test` + +Examples: + +- `feat: add model routing priority` +- `fix: handle expired refresh tokens` +- `refactor: simplify channel lookup` + +Commit message subjects that do not match this format will fail CI. + ## Architecture Boundaries - `frontend/` owns the user interface, client-side state, message rendering, and admin/user workflows. diff --git a/README.md b/README.md index e0ce8cd3..89e86efd 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,9 @@ The architecture is designed for simple deployment, efficient static delivery, a | Area | Capabilities | | --- | --- | | Conversations | Multi-branch chat, streaming, retries, edits, feedback, sharing, cloned shared conversations, rich markdown rendering, file cards, model metadata, usage details, and execution traces. | -| Media generation | Dedicated image generation and image edit flow with task-aware routing, OpenAI Images-compatible protocols, generated file storage, preview, download, and run history separated from text chat. | +| Media generation | Dedicated image generation and image edit flow with task-aware routing, provider-native OpenAI, Google, and xAI image protocols, generated file storage, preview, download, and run history separated from text chat. | | Model control plane | Platform model catalog, upstream channels, real upstream models, route bindings, priority and weight routing, model capability JSON, display ordering, vendor mapping, automatic icons, and circuit breaker state. | -| Provider protocols | OpenAI Responses and Chat Completions, OpenAI Images, Anthropic Messages, Google/Gemini Generate Content, xAI Responses, OpenRouter defaults, and custom OpenAI-compatible routes. | +| Provider protocols | OpenAI Responses, Chat Completions, Images Generations, and Images Edits; Anthropic Messages; Google/Gemini Generate Content and Image Generation; xAI Responses, Images Generations, and Images Edits; OpenRouter defaults; and custom OpenAI-compatible routes. | | Request governance | Protocol-aware request assembly, user option allowlists and denylists, system-protected fields, previous-response continuation where supported, and context snapshots for review. | | Files and RAG | File upload, preview, download, deletion, quota control, MIME detection, text extraction, OCR, full-context injection, image context, chunking, embeddings, and semantic retrieval. | | Memory and context | Message-window truncation, token-budget truncation, context compression, conversation memory, long-term user memory, RAG evidence records, and prompt trace inspection. | @@ -246,7 +246,9 @@ Common backend environment variables: Production mode rejects unsafe default secrets, weak encryption keys, wildcard CORS, and non-HTTPS public URLs. -The initial superadmin bootstrap credentials are built in as `deeix-chat` / `deeix-chat-2026` / `System Admin`. They are used only when the database has no superadmin account. The first login forces changing the username and password; later changes are managed from the account flow, not from `config.yaml`. +The initial superadmin username is `admin`. When the database has no superadmin account, the backend generates a random password and prints it once in the startup logs while creating the account. The first login forces changing the username and password; later changes are managed from the account flow, not from `config.yaml`. + +To retrieve the initial admin password, inspect the backend logs from the first startup and search for `bootstrap superadmin created`; the `username` and `password` fields are the initial login credentials. If a superadmin already exists in the database, the service does not regenerate or print this password again. ## Security Notes diff --git a/README.zh-CN.md b/README.zh-CN.md index 4faf434c..1ed2cf26 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -34,9 +34,9 @@ DEEIX Chat 为团队提供统一的 AI 工作台,用一个清晰的使用入 | 模块 | 能力 | | --- | --- | | 对话体验 | 多分支会话、流式响应、重试、编辑、反馈、公开分享、克隆分享会话、富文本 Markdown、文件卡片、模型元信息、用量明细和执行链路。 | -| 媒体生成 | 独立的图片生成和图片编辑链路,按任务类型路由到 OpenAI Images 兼容协议,生成结果统一入库为文件,支持预览、下载和独立运行记录。 | +| 媒体生成 | 独立的图片生成和图片编辑链路,按任务类型路由到 OpenAI、Google 和 xAI 的原生图片协议,生成结果统一入库为文件,支持预览、下载和独立运行记录。 | | 模型控制面 | 平台模型目录、上游渠道、真实上游模型、路由绑定、优先级/权重路由、能力 JSON、展示顺序、厂商映射、自动图标和熔断状态。 | -| 协议适配 | OpenAI Responses 与 Chat Completions、OpenAI Images、Anthropic Messages、Google/Gemini Generate Content、xAI Responses、OpenRouter 默认协议和自定义 OpenAI 兼容路由。 | +| 协议适配 | OpenAI Responses、Chat Completions、Images Generations 和 Images Edits,Anthropic Messages,Google/Gemini Generate Content 和 Image Generation,xAI Responses、Images Generations 和 Images Edits,OpenRouter 默认协议和自定义 OpenAI 兼容路由。 | | 请求治理 | 按协议组装上游请求,支持用户参数白名单/黑名单、系统保护字段、协议支持时的 previous response 续接,以及可回看的上下文快照。 | | 文件与 RAG | 文件上传、预览、下载、删除、配额控制、MIME 探测、文本提取、OCR、全文上下文注入、图片上下文、分片、向量嵌入和语义检索。 | | 记忆与上下文 | 消息数截断、Token 预算截断、上下文压缩、会话记忆、用户长期记忆、RAG 证据记录和提示词链路查看。 | @@ -246,7 +246,9 @@ pnpm build 生产模式会拒绝不安全的默认密钥、过短的加密密钥、通配 CORS 和非 HTTPS 公开地址。 -初始化超级管理员凭据内置为 `deeix-chat` / `deeix-chat-2026` / `System Admin`。它只在数据库中不存在超级管理员时用于首次创建账号。首次登录会强制修改用户名和密码;后续账号变更通过账户流程完成,不再通过 `config.yaml` 修改。 +初始化超级管理员用户名固定为 `admin`,密码会在数据库中不存在超级管理员时随机生成,并只在首次创建账号的后端启动日志中输出一次。首次登录会强制修改用户名和密码;后续账号变更通过账户流程完成,不再通过 `config.yaml` 修改。 + +获取初始化管理员密码时,请查看首次启动后端服务的日志,搜索 `bootstrap superadmin created`;其中的 `username` 和 `password` 分别是初始登录用户名和密码。如果数据库中已经存在超级管理员,服务不会重新生成或再次输出该密码。 ## 安全说明 diff --git a/VERSION b/VERSION index 6e8bf73a..17e51c38 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.1.1 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 0d2386da..b9693fb2 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -9,7 +9,7 @@ import ( ) // @title DEEIX Chat API -// @version 0.1.0 +// @version 0.1.1 // @description DEEIX Chat 后端 API 文档 // @BasePath /api/v1 // @securityDefinitions.apikey BearerAuth diff --git a/backend/docs/docs.go b/backend/docs/docs.go index e268d4af..ffb0deaa 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -22,7 +22,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 分页查看全量可追溯审计日志", + "description": "管理员分页查看全量可追溯审计日志", "consumes": [ "application/json" ], @@ -32,7 +32,7 @@ const docTemplate = `{ "tags": [ "admin" ], - "summary": "超级管理员查询审计日志", + "summary": "管理员查询审计日志", "parameters": [ { "type": "integer", @@ -414,7 +414,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 分页查看全量模型调用与计费用量账本", + "description": "管理员分页查看全量模型调用与计费用量账本", "consumes": [ "application/json" ], @@ -424,7 +424,7 @@ const docTemplate = `{ "tags": [ "admin" ], - "summary": "超级管理员查询模型调用日志", + "summary": "管理员查询模型调用日志", "parameters": [ { "type": "integer", @@ -510,7 +510,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 分页查询平台模型目录,可按 only_active 过滤", + "description": "管理员分页查询平台模型目录,可按 only_active 过滤", "consumes": [ "application/json" ], @@ -592,7 +592,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 新增平台模型目录项", + "description": "管理员新增平台模型目录项", "consumes": [ "application/json" ], @@ -649,7 +649,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 批量删除模型目录及其关联路由绑定,保留上游", + "description": "管理员批量删除模型目录及其关联路由绑定,保留上游", "consumes": [ "application/json" ], @@ -694,7 +694,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 调整平台模型在用户侧模型选择器中的展示顺序", + "description": "管理员调整平台模型在用户侧模型选择器中的展示顺序", "consumes": [ "application/json" ], @@ -751,7 +751,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 删除平台模型目录项及其关联路由绑定", + "description": "管理员删除平台模型目录项及其关联路由绑定", "consumes": [ "application/json" ], @@ -804,7 +804,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 更新平台模型目录项", + "description": "管理员更新平台模型目录项", "consumes": [ "application/json" ], @@ -868,7 +868,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 分页查询指定模型在各上游上的路由来源", + "description": "管理员分页查询指定模型在各上游上的路由来源", "consumes": [ "application/json" ], @@ -935,7 +935,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 快速启停指定模型在某上游上的来源", + "description": "管理员快速启停指定模型在某上游上的来源", "consumes": [ "application/json" ], @@ -1006,7 +1006,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 查询 LLM 全局设置列表", + "description": "管理员查询 LLM 全局设置列表", "consumes": [ "application/json" ], @@ -1040,7 +1040,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 更新指定 LLM 全局设置项", + "description": "管理员更新指定 LLM 全局设置项", "consumes": [ "application/json" ], @@ -1107,7 +1107,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 分页查询 LLM 上游配置", + "description": "管理员分页查询 LLM 上游配置", "consumes": [ "application/json" ], @@ -1177,7 +1177,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 新增上游来源配置,内部标识自动分配", + "description": "管理员新增上游来源配置,内部标识自动分配", "consumes": [ "application/json" ], @@ -1234,7 +1234,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 批量删除上游及其关联路由绑定,保留模型目录", + "description": "管理员批量删除上游及其关联路由绑定,保留模型目录", "consumes": [ "application/json" ], @@ -1279,7 +1279,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 删除上游配置及其关联路由绑定", + "description": "管理员删除上游配置及其关联路由绑定", "consumes": [ "application/json" ], @@ -1332,7 +1332,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 更新上游配置(地址、密钥、状态等)", + "description": "管理员更新上游配置(地址、密钥、状态等)", "consumes": [ "application/json" ], @@ -1396,7 +1396,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 手动开启上游熔断状态", + "description": "管理员手动开启上游熔断状态", "consumes": [ "application/json" ], @@ -1451,7 +1451,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 手动清空上游失败计数并关闭熔断状态", + "description": "管理员手动清空上游失败计数并关闭熔断状态", "consumes": [ "application/json" ], @@ -1506,7 +1506,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 分页查询指定上游的路由绑定列表", + "description": "管理员分页查询指定上游的路由绑定列表", "consumes": [ "application/json" ], @@ -1545,7 +1545,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "路由状态:active/inactive/unbound", + "description": "路由状态:bound/active/inactive", "name": "route_status", "in": "query" }, @@ -1601,7 +1601,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 配置平台模型到指定上游真实模型的路由绑定与覆盖请求头", + "description": "管理员配置平台模型到指定上游真实模型的路由绑定与覆盖请求头", "consumes": [ "application/json" ], @@ -1671,7 +1671,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 批量删除指定上游下的路由绑定,保留模型目录", + "description": "管理员批量删除指定上游下的路由绑定,保留模型目录", "consumes": [ "application/json" ], @@ -1915,7 +1915,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 删除指定上游的路由绑定", + "description": "管理员删除指定上游的路由绑定", "consumes": [ "application/json" ], @@ -1977,7 +1977,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 手动开启上游模型路由绑定熔断状态", + "description": "管理员手动开启上游模型路由绑定熔断状态", "consumes": [ "application/json" ], @@ -2039,7 +2039,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 手动清空上游模型路由绑定失败计数并关闭熔断状态", + "description": "管理员手动清空上游模型路由绑定失败计数并关闭熔断状态", "consumes": [ "application/json" ], @@ -2101,7 +2101,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 停用该路由绑定,后续路由不会选中", + "description": "管理员停用该路由绑定,后续路由不会选中", "produces": [ "application/json" ], @@ -2160,7 +2160,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 启用该路由绑定,使该上游模型重新参与路由", + "description": "管理员启用该路由绑定,使该上游模型重新参与路由", "produces": [ "application/json" ], @@ -2651,7 +2651,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 分页查看后台结构化系统事件", + "description": "管理员分页查看后台结构化系统事件", "consumes": [ "application/json" ], @@ -2661,7 +2661,7 @@ const docTemplate = `{ "tags": [ "admin" ], - "summary": "超级管理员查询系统事件", + "summary": "管理员查询系统事件", "parameters": [ { "type": "integer", @@ -2747,7 +2747,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 分页查询认证事件,支持 user_id/event_type/result 过滤", + "description": "管理员分页查询认证事件,支持 user_id/event_type/result 过滤", "consumes": [ "application/json" ], @@ -2757,7 +2757,7 @@ const docTemplate = `{ "tags": [ "admin" ], - "summary": "超级管理员查询用户认证事件", + "summary": "管理员查询用户认证事件", "parameters": [ { "type": "integer", @@ -2819,7 +2819,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 分页查看所有用户,实现账户隔离管理", + "description": "管理员分页查看所有用户,实现账户隔离管理", "consumes": [ "application/json" ], @@ -2829,7 +2829,7 @@ const docTemplate = `{ "tags": [ "admin" ], - "summary": "超级管理员查询用户", + "summary": "管理员查询用户", "parameters": [ { "type": "integer", @@ -2865,7 +2865,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "创建普通用户账号,superadmin 账号仅允许系统初始化唯一实例", + "description": "创建普通用户账号;需要授予管理员权限时,可在账户编辑中调整角色", "consumes": [ "application/json" ], @@ -2875,7 +2875,7 @@ const docTemplate = `{ "tags": [ "admin" ], - "summary": "超级管理员创建用户", + "summary": "管理员创建用户", "parameters": [ { "description": "用户参数", @@ -2922,7 +2922,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 硬删除指定普通用户及其主要用户域数据", + "description": "管理员硬删除指定普通用户及其主要用户域数据", "consumes": [ "application/json" ], @@ -2932,7 +2932,7 @@ const docTemplate = `{ "tags": [ "admin" ], - "summary": "超级管理员删除用户", + "summary": "管理员删除用户", "parameters": [ { "type": "integer", @@ -2981,7 +2981,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 统一维护角色、状态、时区等可编辑字段", + "description": "管理员统一维护角色、状态、时区等可编辑字段", "consumes": [ "application/json" ], @@ -2991,7 +2991,7 @@ const docTemplate = `{ "tags": [ "admin" ], - "summary": "超级管理员更新用户可编辑字段", + "summary": "管理员更新用户可编辑字段", "parameters": [ { "type": "integer", @@ -3051,7 +3051,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 重置指定用户密码并吊销其全部会话", + "description": "管理员重置指定用户密码并吊销其全部会话", "consumes": [ "application/json" ], @@ -3061,7 +3061,7 @@ const docTemplate = `{ "tags": [ "admin" ], - "summary": "超级管理员重置用户密码", + "summary": "管理员重置用户密码", "parameters": [ { "type": "integer", @@ -3121,7 +3121,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 吊销指定用户全部活跃会话,用于安全治理和风险控制", + "description": "管理员吊销指定用户全部活跃会话,用于安全治理和风险控制", "consumes": [ "application/json" ], @@ -3131,7 +3131,7 @@ const docTemplate = `{ "tags": [ "admin" ], - "summary": "超级管理员吊销用户全部会话", + "summary": "管理员吊销用户全部会话", "parameters": [ { "type": "integer", @@ -3176,7 +3176,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "superadmin 维护用户状态(active/locked/suspended/deactivated),并联动会话治理", + "description": "管理员维护用户状态(active/locked/suspended/deactivated),并联动会话治理", "consumes": [ "application/json" ], @@ -3186,7 +3186,7 @@ const docTemplate = `{ "tags": [ "admin" ], - "summary": "超级管理员更新用户状态", + "summary": "管理员更新用户状态", "parameters": [ { "type": "integer", @@ -4005,6 +4005,277 @@ const docTemplate = `{ } } }, + "/conversation-projects": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "查询当前用户的会话项目分组", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "会话项目列表", + "parameters": [ + { + "type": "string", + "description": "状态筛选: active|archived|all", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationProjectListResponseDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "创建当前用户的会话项目分组", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "创建会话项目", + "parameters": [ + { + "description": "项目参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.CreateConversationProjectRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationProjectResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + } + } + } + }, + "/conversation-projects/reorder": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "更新当前用户项目分组展示顺序", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "调整会话项目顺序", + "parameters": [ + { + "description": "排序参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ReorderConversationProjectsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationProjectListResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + } + } + } + }, + "/conversation-projects/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "删除会话项目", + "parameters": [ + { + "type": "string", + "description": "项目 public_id", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "是否同时删除项目内会话", + "name": "delete_conversations", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationDeleteResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "更新当前用户的会话项目分组", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "更新会话项目", + "parameters": [ + { + "type": "string", + "description": "项目 public_id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "项目参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.UpdateConversationProjectRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationProjectResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + } + } + } + }, "/conversation-runs/{run_id}/cancel": { "post": { "security": [ @@ -4139,13 +4410,68 @@ const docTemplate = `{ "description": "分享筛选: all|shared|unshared", "name": "share", "in": "query" + }, + { + "type": "string", + "description": "项目筛选: all|unassigned|项目 public_id", + "name": "project", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationListResponseDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "创建新的聊天会话", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "创建会话", + "parameters": [ + { + "description": "会话参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.CreateConversationRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_transport_http_conversation.ConversationListResponseDoc" + "$ref": "#/definitions/internal_transport_http_conversation.ConversationCreateResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" } }, "500": { @@ -4155,14 +4481,16 @@ const docTemplate = `{ } } } - }, + } + }, + "/conversations/project": { "post": { "security": [ { "BearerAuth": [] } ], - "description": "创建新的聊天会话", + "description": "批量设置当前用户会话的项目归属", "consumes": [ "application/json" ], @@ -4172,15 +4500,15 @@ const docTemplate = `{ "tags": [ "chat" ], - "summary": "创建会话", + "summary": "批量设置会话项目归属", "parameters": [ { - "description": "会话参数", + "description": "项目归属参数", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/internal_transport_http_conversation.CreateConversationRequest" + "$ref": "#/definitions/internal_transport_http_conversation.BatchSetConversationProjectRequest" } } ], @@ -4188,7 +4516,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_transport_http_conversation.ConversationCreateResponseDoc" + "$ref": "#/definitions/internal_transport_http_conversation.BatchSetConversationProjectResponseDoc" } }, "400": { @@ -4197,6 +4525,12 @@ const docTemplate = `{ "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" } }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -4628,6 +4962,70 @@ const docTemplate = `{ } } }, + "/conversations/{id}/project": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "设置当前用户单个会话的项目归属", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "设置会话项目归属", + "parameters": [ + { + "type": "string", + "description": "会话 public_id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "项目归属参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.SetConversationProjectRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationUpdateResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + } + } + } + }, "/conversations/{id}/runs": { "get": { "security": [ @@ -8502,6 +8900,10 @@ const docTemplate = `{ "inactive" ] }, + "systemPrompt": { + "type": "string", + "maxLength": 20000 + }, "vendor": { "type": "string", "maxLength": 64 @@ -8647,6 +9049,12 @@ const docTemplate = `{ "type": "string", "maxLength": 64 }, + "protocols": { + "type": "array", + "items": { + "type": "string" + } + }, "status": { "type": "string", "enum": [ @@ -8673,12 +9081,24 @@ const docTemplate = `{ "createdRoute": { "type": "boolean" }, + "createdRoutes": { + "type": "integer" + }, "error": { "type": "string" }, + "existingRoutes": { + "type": "integer" + }, "platformModelName": { "type": "string" }, + "protocols": { + "type": "array", + "items": { + "type": "string" + } + }, "status": { "type": "string" }, @@ -8811,6 +9231,9 @@ const docTemplate = `{ "status": { "type": "string" }, + "systemPrompt": { + "type": "string" + }, "updatedAt": { "type": "string" }, @@ -9118,6 +9541,10 @@ const docTemplate = `{ "inactive" ] }, + "systemPrompt": { + "type": "string", + "maxLength": 20000 + }, "vendor": { "type": "string", "maxLength": 64 @@ -9499,6 +9926,12 @@ const docTemplate = `{ "suggestedProtocol": { "type": "string" }, + "suggestedProtocols": { + "type": "array", + "items": { + "type": "string" + } + }, "upstreamModelName": { "type": "string" }, @@ -9626,6 +10059,44 @@ const docTemplate = `{ } } }, + "internal_transport_http_conversation.BatchSetConversationProjectRequest": { + "type": "object", + "required": [ + "conversationPublicIDs" + ], + "properties": { + "conversationPublicIDs": { + "type": "array", + "maxItems": 1000, + "items": { + "type": "string" + } + }, + "projectID": { + "type": "string", + "maxLength": 32 + } + } + }, + "internal_transport_http_conversation.BatchSetConversationProjectResponse": { + "type": "object", + "properties": { + "updated": { + "type": "integer" + } + } + }, + "internal_transport_http_conversation.BatchSetConversationProjectResponseDoc": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/internal_transport_http_conversation.BatchSetConversationProjectResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, "internal_transport_http_conversation.ContextArtifactResponse": { "type": "object", "properties": { @@ -9733,6 +10204,63 @@ const docTemplate = `{ } } }, + "internal_transport_http_conversation.ConversationProjectListResponseDoc": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationProjectResponse" + } + }, + "errorMsg": { + "type": "string" + } + } + }, + "internal_transport_http_conversation.ConversationProjectResponse": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "name": { + "type": "string" + }, + "publicID": { + "type": "string" + }, + "sortOrder": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "internal_transport_http_conversation.ConversationProjectResponseDoc": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationProjectResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, "internal_transport_http_conversation.ConversationResponse": { "type": "object", "properties": { @@ -9763,6 +10291,12 @@ const docTemplate = `{ "model": { "type": "string" }, + "projectID": { + "type": "string" + }, + "projectName": { + "type": "string" + }, "provider": { "type": "string" }, @@ -9874,6 +10408,30 @@ const docTemplate = `{ } } }, + "internal_transport_http_conversation.CreateConversationProjectRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "color": { + "type": "string", + "maxLength": 32 + }, + "description": { + "type": "string", + "maxLength": 255 + }, + "icon": { + "type": "string", + "maxLength": 32 + }, + "name": { + "type": "string", + "maxLength": 80 + } + } + }, "internal_transport_http_conversation.CreateConversationRequest": { "type": "object", "properties": { @@ -9881,6 +10439,10 @@ const docTemplate = `{ "type": "string", "maxLength": 128 }, + "projectID": { + "type": "string", + "maxLength": 32 + }, "title": { "type": "string", "maxLength": 255 @@ -10574,6 +11136,21 @@ const docTemplate = `{ } } }, + "internal_transport_http_conversation.ReorderConversationProjectsRequest": { + "type": "object", + "required": [ + "projectIDs" + ], + "properties": { + "projectIDs": { + "type": "array", + "maxItems": 200, + "items": { + "type": "string" + } + } + } + }, "internal_transport_http_conversation.RevokeConversationSharesRequest": { "type": "object", "properties": { @@ -10801,6 +11378,15 @@ const docTemplate = `{ } } }, + "internal_transport_http_conversation.SetConversationProjectRequest": { + "type": "object", + "properties": { + "projectID": { + "type": "string", + "maxLength": 32 + } + } + }, "internal_transport_http_conversation.SetConversationStarRequest": { "type": "object", "properties": { @@ -10847,6 +11433,34 @@ const docTemplate = `{ } } }, + "internal_transport_http_conversation.UpdateConversationProjectRequest": { + "type": "object", + "properties": { + "color": { + "type": "string", + "maxLength": 32 + }, + "description": { + "type": "string", + "maxLength": 255 + }, + "icon": { + "type": "string", + "maxLength": 32 + }, + "name": { + "type": "string", + "maxLength": 80 + }, + "status": { + "type": "string", + "enum": [ + "active", + "archived" + ] + } + } + }, "internal_transport_http_conversation.UpdateFileRequest": { "type": "object", "properties": { @@ -11057,7 +11671,7 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "0.1.0", + Version: "0.1.1", Host: "", BasePath: "/api/v1", Schemes: []string{}, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 5789056e..4c6710e6 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -4,7 +4,7 @@ "description": "DEEIX Chat 后端 API 文档", "title": "DEEIX Chat API", "contact": {}, - "version": "0.1.0" + "version": "0.1.1" }, "basePath": "/api/v1", "paths": { @@ -15,7 +15,7 @@ "BearerAuth": [] } ], - "description": "superadmin 分页查看全量可追溯审计日志", + "description": "管理员分页查看全量可追溯审计日志", "consumes": [ "application/json" ], @@ -25,7 +25,7 @@ "tags": [ "admin" ], - "summary": "超级管理员查询审计日志", + "summary": "管理员查询审计日志", "parameters": [ { "type": "integer", @@ -407,7 +407,7 @@ "BearerAuth": [] } ], - "description": "superadmin 分页查看全量模型调用与计费用量账本", + "description": "管理员分页查看全量模型调用与计费用量账本", "consumes": [ "application/json" ], @@ -417,7 +417,7 @@ "tags": [ "admin" ], - "summary": "超级管理员查询模型调用日志", + "summary": "管理员查询模型调用日志", "parameters": [ { "type": "integer", @@ -503,7 +503,7 @@ "BearerAuth": [] } ], - "description": "superadmin 分页查询平台模型目录,可按 only_active 过滤", + "description": "管理员分页查询平台模型目录,可按 only_active 过滤", "consumes": [ "application/json" ], @@ -585,7 +585,7 @@ "BearerAuth": [] } ], - "description": "superadmin 新增平台模型目录项", + "description": "管理员新增平台模型目录项", "consumes": [ "application/json" ], @@ -642,7 +642,7 @@ "BearerAuth": [] } ], - "description": "superadmin 批量删除模型目录及其关联路由绑定,保留上游", + "description": "管理员批量删除模型目录及其关联路由绑定,保留上游", "consumes": [ "application/json" ], @@ -687,7 +687,7 @@ "BearerAuth": [] } ], - "description": "superadmin 调整平台模型在用户侧模型选择器中的展示顺序", + "description": "管理员调整平台模型在用户侧模型选择器中的展示顺序", "consumes": [ "application/json" ], @@ -744,7 +744,7 @@ "BearerAuth": [] } ], - "description": "superadmin 删除平台模型目录项及其关联路由绑定", + "description": "管理员删除平台模型目录项及其关联路由绑定", "consumes": [ "application/json" ], @@ -797,7 +797,7 @@ "BearerAuth": [] } ], - "description": "superadmin 更新平台模型目录项", + "description": "管理员更新平台模型目录项", "consumes": [ "application/json" ], @@ -861,7 +861,7 @@ "BearerAuth": [] } ], - "description": "superadmin 分页查询指定模型在各上游上的路由来源", + "description": "管理员分页查询指定模型在各上游上的路由来源", "consumes": [ "application/json" ], @@ -928,7 +928,7 @@ "BearerAuth": [] } ], - "description": "superadmin 快速启停指定模型在某上游上的来源", + "description": "管理员快速启停指定模型在某上游上的来源", "consumes": [ "application/json" ], @@ -999,7 +999,7 @@ "BearerAuth": [] } ], - "description": "superadmin 查询 LLM 全局设置列表", + "description": "管理员查询 LLM 全局设置列表", "consumes": [ "application/json" ], @@ -1033,7 +1033,7 @@ "BearerAuth": [] } ], - "description": "superadmin 更新指定 LLM 全局设置项", + "description": "管理员更新指定 LLM 全局设置项", "consumes": [ "application/json" ], @@ -1100,7 +1100,7 @@ "BearerAuth": [] } ], - "description": "superadmin 分页查询 LLM 上游配置", + "description": "管理员分页查询 LLM 上游配置", "consumes": [ "application/json" ], @@ -1170,7 +1170,7 @@ "BearerAuth": [] } ], - "description": "superadmin 新增上游来源配置,内部标识自动分配", + "description": "管理员新增上游来源配置,内部标识自动分配", "consumes": [ "application/json" ], @@ -1227,7 +1227,7 @@ "BearerAuth": [] } ], - "description": "superadmin 批量删除上游及其关联路由绑定,保留模型目录", + "description": "管理员批量删除上游及其关联路由绑定,保留模型目录", "consumes": [ "application/json" ], @@ -1272,7 +1272,7 @@ "BearerAuth": [] } ], - "description": "superadmin 删除上游配置及其关联路由绑定", + "description": "管理员删除上游配置及其关联路由绑定", "consumes": [ "application/json" ], @@ -1325,7 +1325,7 @@ "BearerAuth": [] } ], - "description": "superadmin 更新上游配置(地址、密钥、状态等)", + "description": "管理员更新上游配置(地址、密钥、状态等)", "consumes": [ "application/json" ], @@ -1389,7 +1389,7 @@ "BearerAuth": [] } ], - "description": "superadmin 手动开启上游熔断状态", + "description": "管理员手动开启上游熔断状态", "consumes": [ "application/json" ], @@ -1444,7 +1444,7 @@ "BearerAuth": [] } ], - "description": "superadmin 手动清空上游失败计数并关闭熔断状态", + "description": "管理员手动清空上游失败计数并关闭熔断状态", "consumes": [ "application/json" ], @@ -1499,7 +1499,7 @@ "BearerAuth": [] } ], - "description": "superadmin 分页查询指定上游的路由绑定列表", + "description": "管理员分页查询指定上游的路由绑定列表", "consumes": [ "application/json" ], @@ -1538,7 +1538,7 @@ }, { "type": "string", - "description": "路由状态:active/inactive/unbound", + "description": "路由状态:bound/active/inactive", "name": "route_status", "in": "query" }, @@ -1594,7 +1594,7 @@ "BearerAuth": [] } ], - "description": "superadmin 配置平台模型到指定上游真实模型的路由绑定与覆盖请求头", + "description": "管理员配置平台模型到指定上游真实模型的路由绑定与覆盖请求头", "consumes": [ "application/json" ], @@ -1664,7 +1664,7 @@ "BearerAuth": [] } ], - "description": "superadmin 批量删除指定上游下的路由绑定,保留模型目录", + "description": "管理员批量删除指定上游下的路由绑定,保留模型目录", "consumes": [ "application/json" ], @@ -1908,7 +1908,7 @@ "BearerAuth": [] } ], - "description": "superadmin 删除指定上游的路由绑定", + "description": "管理员删除指定上游的路由绑定", "consumes": [ "application/json" ], @@ -1970,7 +1970,7 @@ "BearerAuth": [] } ], - "description": "superadmin 手动开启上游模型路由绑定熔断状态", + "description": "管理员手动开启上游模型路由绑定熔断状态", "consumes": [ "application/json" ], @@ -2032,7 +2032,7 @@ "BearerAuth": [] } ], - "description": "superadmin 手动清空上游模型路由绑定失败计数并关闭熔断状态", + "description": "管理员手动清空上游模型路由绑定失败计数并关闭熔断状态", "consumes": [ "application/json" ], @@ -2094,7 +2094,7 @@ "BearerAuth": [] } ], - "description": "superadmin 停用该路由绑定,后续路由不会选中", + "description": "管理员停用该路由绑定,后续路由不会选中", "produces": [ "application/json" ], @@ -2153,7 +2153,7 @@ "BearerAuth": [] } ], - "description": "superadmin 启用该路由绑定,使该上游模型重新参与路由", + "description": "管理员启用该路由绑定,使该上游模型重新参与路由", "produces": [ "application/json" ], @@ -2644,7 +2644,7 @@ "BearerAuth": [] } ], - "description": "superadmin 分页查看后台结构化系统事件", + "description": "管理员分页查看后台结构化系统事件", "consumes": [ "application/json" ], @@ -2654,7 +2654,7 @@ "tags": [ "admin" ], - "summary": "超级管理员查询系统事件", + "summary": "管理员查询系统事件", "parameters": [ { "type": "integer", @@ -2740,7 +2740,7 @@ "BearerAuth": [] } ], - "description": "superadmin 分页查询认证事件,支持 user_id/event_type/result 过滤", + "description": "管理员分页查询认证事件,支持 user_id/event_type/result 过滤", "consumes": [ "application/json" ], @@ -2750,7 +2750,7 @@ "tags": [ "admin" ], - "summary": "超级管理员查询用户认证事件", + "summary": "管理员查询用户认证事件", "parameters": [ { "type": "integer", @@ -2812,7 +2812,7 @@ "BearerAuth": [] } ], - "description": "superadmin 分页查看所有用户,实现账户隔离管理", + "description": "管理员分页查看所有用户,实现账户隔离管理", "consumes": [ "application/json" ], @@ -2822,7 +2822,7 @@ "tags": [ "admin" ], - "summary": "超级管理员查询用户", + "summary": "管理员查询用户", "parameters": [ { "type": "integer", @@ -2858,7 +2858,7 @@ "BearerAuth": [] } ], - "description": "创建普通用户账号,superadmin 账号仅允许系统初始化唯一实例", + "description": "创建普通用户账号;需要授予管理员权限时,可在账户编辑中调整角色", "consumes": [ "application/json" ], @@ -2868,7 +2868,7 @@ "tags": [ "admin" ], - "summary": "超级管理员创建用户", + "summary": "管理员创建用户", "parameters": [ { "description": "用户参数", @@ -2915,7 +2915,7 @@ "BearerAuth": [] } ], - "description": "superadmin 硬删除指定普通用户及其主要用户域数据", + "description": "管理员硬删除指定普通用户及其主要用户域数据", "consumes": [ "application/json" ], @@ -2925,7 +2925,7 @@ "tags": [ "admin" ], - "summary": "超级管理员删除用户", + "summary": "管理员删除用户", "parameters": [ { "type": "integer", @@ -2974,7 +2974,7 @@ "BearerAuth": [] } ], - "description": "superadmin 统一维护角色、状态、时区等可编辑字段", + "description": "管理员统一维护角色、状态、时区等可编辑字段", "consumes": [ "application/json" ], @@ -2984,7 +2984,7 @@ "tags": [ "admin" ], - "summary": "超级管理员更新用户可编辑字段", + "summary": "管理员更新用户可编辑字段", "parameters": [ { "type": "integer", @@ -3044,7 +3044,7 @@ "BearerAuth": [] } ], - "description": "superadmin 重置指定用户密码并吊销其全部会话", + "description": "管理员重置指定用户密码并吊销其全部会话", "consumes": [ "application/json" ], @@ -3054,7 +3054,7 @@ "tags": [ "admin" ], - "summary": "超级管理员重置用户密码", + "summary": "管理员重置用户密码", "parameters": [ { "type": "integer", @@ -3114,7 +3114,7 @@ "BearerAuth": [] } ], - "description": "superadmin 吊销指定用户全部活跃会话,用于安全治理和风险控制", + "description": "管理员吊销指定用户全部活跃会话,用于安全治理和风险控制", "consumes": [ "application/json" ], @@ -3124,7 +3124,7 @@ "tags": [ "admin" ], - "summary": "超级管理员吊销用户全部会话", + "summary": "管理员吊销用户全部会话", "parameters": [ { "type": "integer", @@ -3169,7 +3169,7 @@ "BearerAuth": [] } ], - "description": "superadmin 维护用户状态(active/locked/suspended/deactivated),并联动会话治理", + "description": "管理员维护用户状态(active/locked/suspended/deactivated),并联动会话治理", "consumes": [ "application/json" ], @@ -3179,7 +3179,7 @@ "tags": [ "admin" ], - "summary": "超级管理员更新用户状态", + "summary": "管理员更新用户状态", "parameters": [ { "type": "integer", @@ -3998,6 +3998,277 @@ } } }, + "/conversation-projects": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "查询当前用户的会话项目分组", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "会话项目列表", + "parameters": [ + { + "type": "string", + "description": "状态筛选: active|archived|all", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationProjectListResponseDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "创建当前用户的会话项目分组", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "创建会话项目", + "parameters": [ + { + "description": "项目参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.CreateConversationProjectRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationProjectResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + } + } + } + }, + "/conversation-projects/reorder": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "更新当前用户项目分组展示顺序", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "调整会话项目顺序", + "parameters": [ + { + "description": "排序参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ReorderConversationProjectsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationProjectListResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + } + } + } + }, + "/conversation-projects/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "删除会话项目", + "parameters": [ + { + "type": "string", + "description": "项目 public_id", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "是否同时删除项目内会话", + "name": "delete_conversations", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationDeleteResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "更新当前用户的会话项目分组", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "更新会话项目", + "parameters": [ + { + "type": "string", + "description": "项目 public_id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "项目参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.UpdateConversationProjectRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationProjectResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + } + } + } + }, "/conversation-runs/{run_id}/cancel": { "post": { "security": [ @@ -4132,13 +4403,68 @@ "description": "分享筛选: all|shared|unshared", "name": "share", "in": "query" + }, + { + "type": "string", + "description": "项目筛选: all|unassigned|项目 public_id", + "name": "project", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationListResponseDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "创建新的聊天会话", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "创建会话", + "parameters": [ + { + "description": "会话参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.CreateConversationRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_transport_http_conversation.ConversationListResponseDoc" + "$ref": "#/definitions/internal_transport_http_conversation.ConversationCreateResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" } }, "500": { @@ -4148,14 +4474,16 @@ } } } - }, + } + }, + "/conversations/project": { "post": { "security": [ { "BearerAuth": [] } ], - "description": "创建新的聊天会话", + "description": "批量设置当前用户会话的项目归属", "consumes": [ "application/json" ], @@ -4165,15 +4493,15 @@ "tags": [ "chat" ], - "summary": "创建会话", + "summary": "批量设置会话项目归属", "parameters": [ { - "description": "会话参数", + "description": "项目归属参数", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/internal_transport_http_conversation.CreateConversationRequest" + "$ref": "#/definitions/internal_transport_http_conversation.BatchSetConversationProjectRequest" } } ], @@ -4181,7 +4509,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_transport_http_conversation.ConversationCreateResponseDoc" + "$ref": "#/definitions/internal_transport_http_conversation.BatchSetConversationProjectResponseDoc" } }, "400": { @@ -4190,6 +4518,12 @@ "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" } }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -4621,6 +4955,70 @@ } } }, + "/conversations/{id}/project": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "设置当前用户单个会话的项目归属", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "设置会话项目归属", + "parameters": [ + { + "type": "string", + "description": "会话 public_id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "项目归属参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.SetConversationProjectRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationUpdateResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + } + } + } + }, "/conversations/{id}/runs": { "get": { "security": [ @@ -8495,6 +8893,10 @@ "inactive" ] }, + "systemPrompt": { + "type": "string", + "maxLength": 20000 + }, "vendor": { "type": "string", "maxLength": 64 @@ -8640,6 +9042,12 @@ "type": "string", "maxLength": 64 }, + "protocols": { + "type": "array", + "items": { + "type": "string" + } + }, "status": { "type": "string", "enum": [ @@ -8666,12 +9074,24 @@ "createdRoute": { "type": "boolean" }, + "createdRoutes": { + "type": "integer" + }, "error": { "type": "string" }, + "existingRoutes": { + "type": "integer" + }, "platformModelName": { "type": "string" }, + "protocols": { + "type": "array", + "items": { + "type": "string" + } + }, "status": { "type": "string" }, @@ -8804,6 +9224,9 @@ "status": { "type": "string" }, + "systemPrompt": { + "type": "string" + }, "updatedAt": { "type": "string" }, @@ -9111,6 +9534,10 @@ "inactive" ] }, + "systemPrompt": { + "type": "string", + "maxLength": 20000 + }, "vendor": { "type": "string", "maxLength": 64 @@ -9492,6 +9919,12 @@ "suggestedProtocol": { "type": "string" }, + "suggestedProtocols": { + "type": "array", + "items": { + "type": "string" + } + }, "upstreamModelName": { "type": "string" }, @@ -9619,6 +10052,44 @@ } } }, + "internal_transport_http_conversation.BatchSetConversationProjectRequest": { + "type": "object", + "required": [ + "conversationPublicIDs" + ], + "properties": { + "conversationPublicIDs": { + "type": "array", + "maxItems": 1000, + "items": { + "type": "string" + } + }, + "projectID": { + "type": "string", + "maxLength": 32 + } + } + }, + "internal_transport_http_conversation.BatchSetConversationProjectResponse": { + "type": "object", + "properties": { + "updated": { + "type": "integer" + } + } + }, + "internal_transport_http_conversation.BatchSetConversationProjectResponseDoc": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/internal_transport_http_conversation.BatchSetConversationProjectResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, "internal_transport_http_conversation.ContextArtifactResponse": { "type": "object", "properties": { @@ -9726,6 +10197,63 @@ } } }, + "internal_transport_http_conversation.ConversationProjectListResponseDoc": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationProjectResponse" + } + }, + "errorMsg": { + "type": "string" + } + } + }, + "internal_transport_http_conversation.ConversationProjectResponse": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "name": { + "type": "string" + }, + "publicID": { + "type": "string" + }, + "sortOrder": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "internal_transport_http_conversation.ConversationProjectResponseDoc": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/internal_transport_http_conversation.ConversationProjectResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, "internal_transport_http_conversation.ConversationResponse": { "type": "object", "properties": { @@ -9756,6 +10284,12 @@ "model": { "type": "string" }, + "projectID": { + "type": "string" + }, + "projectName": { + "type": "string" + }, "provider": { "type": "string" }, @@ -9867,6 +10401,30 @@ } } }, + "internal_transport_http_conversation.CreateConversationProjectRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "color": { + "type": "string", + "maxLength": 32 + }, + "description": { + "type": "string", + "maxLength": 255 + }, + "icon": { + "type": "string", + "maxLength": 32 + }, + "name": { + "type": "string", + "maxLength": 80 + } + } + }, "internal_transport_http_conversation.CreateConversationRequest": { "type": "object", "properties": { @@ -9874,6 +10432,10 @@ "type": "string", "maxLength": 128 }, + "projectID": { + "type": "string", + "maxLength": 32 + }, "title": { "type": "string", "maxLength": 255 @@ -10567,6 +11129,21 @@ } } }, + "internal_transport_http_conversation.ReorderConversationProjectsRequest": { + "type": "object", + "required": [ + "projectIDs" + ], + "properties": { + "projectIDs": { + "type": "array", + "maxItems": 200, + "items": { + "type": "string" + } + } + } + }, "internal_transport_http_conversation.RevokeConversationSharesRequest": { "type": "object", "properties": { @@ -10794,6 +11371,15 @@ } } }, + "internal_transport_http_conversation.SetConversationProjectRequest": { + "type": "object", + "properties": { + "projectID": { + "type": "string", + "maxLength": 32 + } + } + }, "internal_transport_http_conversation.SetConversationStarRequest": { "type": "object", "properties": { @@ -10840,6 +11426,34 @@ } } }, + "internal_transport_http_conversation.UpdateConversationProjectRequest": { + "type": "object", + "properties": { + "color": { + "type": "string", + "maxLength": 32 + }, + "description": { + "type": "string", + "maxLength": 255 + }, + "icon": { + "type": "string", + "maxLength": 32 + }, + "name": { + "type": "string", + "maxLength": 80 + }, + "status": { + "type": "string", + "enum": [ + "active", + "archived" + ] + } + } + }, "internal_transport_http_conversation.UpdateFileRequest": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index be73f047..d4edf25f 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1544,6 +1544,9 @@ definitions: - active - inactive type: string + systemPrompt: + maxLength: 20000 + type: string vendor: maxLength: 64 type: string @@ -1646,6 +1649,10 @@ definitions: protocol: maxLength: 64 type: string + protocols: + items: + type: string + type: array status: enum: - active @@ -1667,10 +1674,18 @@ definitions: type: boolean createdRoute: type: boolean + createdRoutes: + type: integer error: type: string + existingRoutes: + type: integer platformModelName: type: string + protocols: + items: + type: string + type: array status: type: string upstreamModelName: @@ -1757,6 +1772,8 @@ definitions: type: integer status: type: string + systemPrompt: + type: string updatedAt: type: string vendor: @@ -1960,6 +1977,9 @@ definitions: - active - inactive type: string + systemPrompt: + maxLength: 20000 + type: string vendor: maxLength: 64 type: string @@ -2218,6 +2238,10 @@ definitions: type: string suggestedProtocol: type: string + suggestedProtocols: + items: + type: string + type: array upstreamModelName: type: string upstreamModelStatus: @@ -2301,6 +2325,31 @@ definitions: upstreamModelName: type: string type: object + internal_transport_http_conversation.BatchSetConversationProjectRequest: + properties: + conversationPublicIDs: + items: + type: string + maxItems: 1000 + type: array + projectID: + maxLength: 32 + type: string + required: + - conversationPublicIDs + type: object + internal_transport_http_conversation.BatchSetConversationProjectResponse: + properties: + updated: + type: integer + type: object + internal_transport_http_conversation.BatchSetConversationProjectResponseDoc: + properties: + data: + $ref: '#/definitions/internal_transport_http_conversation.BatchSetConversationProjectResponse' + errorMsg: + type: string + type: object internal_transport_http_conversation.ContextArtifactResponse: properties: content: @@ -2370,6 +2419,43 @@ definitions: errorMsg: type: string type: object + internal_transport_http_conversation.ConversationProjectListResponseDoc: + properties: + data: + items: + $ref: '#/definitions/internal_transport_http_conversation.ConversationProjectResponse' + type: array + errorMsg: + type: string + type: object + internal_transport_http_conversation.ConversationProjectResponse: + properties: + color: + type: string + createdAt: + type: string + description: + type: string + icon: + type: string + name: + type: string + publicID: + type: string + sortOrder: + type: integer + status: + type: string + updatedAt: + type: string + type: object + internal_transport_http_conversation.ConversationProjectResponseDoc: + properties: + data: + $ref: '#/definitions/internal_transport_http_conversation.ConversationProjectResponse' + errorMsg: + type: string + type: object internal_transport_http_conversation.ConversationResponse: properties: contextPolicyJSON: @@ -2390,6 +2476,10 @@ definitions: type: integer model: type: string + projectID: + type: string + projectName: + type: string provider: type: string publicID: @@ -2462,11 +2552,31 @@ definitions: errorMsg: type: string type: object + internal_transport_http_conversation.CreateConversationProjectRequest: + properties: + color: + maxLength: 32 + type: string + description: + maxLength: 255 + type: string + icon: + maxLength: 32 + type: string + name: + maxLength: 80 + type: string + required: + - name + type: object internal_transport_http_conversation.CreateConversationRequest: properties: model: maxLength: 128 type: string + projectID: + maxLength: 32 + type: string title: maxLength: 255 type: string @@ -2922,6 +3032,16 @@ definitions: required: - title type: object + internal_transport_http_conversation.ReorderConversationProjectsRequest: + properties: + projectIDs: + items: + type: string + maxItems: 200 + type: array + required: + - projectIDs + type: object internal_transport_http_conversation.RevokeConversationSharesRequest: properties: conversationPublicIDs: @@ -3076,6 +3196,12 @@ definitions: archived: type: boolean type: object + internal_transport_http_conversation.SetConversationProjectRequest: + properties: + projectID: + maxLength: 32 + type: string + type: object internal_transport_http_conversation.SetConversationStarRequest: properties: starred: @@ -3106,6 +3232,26 @@ definitions: userID: type: integer type: object + internal_transport_http_conversation.UpdateConversationProjectRequest: + properties: + color: + maxLength: 32 + type: string + description: + maxLength: 255 + type: string + icon: + maxLength: 32 + type: string + name: + maxLength: 80 + type: string + status: + enum: + - active + - archived + type: string + type: object internal_transport_http_conversation.UpdateFileRequest: properties: fileName: @@ -3241,13 +3387,13 @@ info: contact: {} description: DEEIX Chat 后端 API 文档 title: DEEIX Chat API - version: 0.1.0 + version: 0.1.1 paths: /admin/audit-logs: get: consumes: - application/json - description: superadmin 分页查看全量可追溯审计日志 + description: 管理员分页查看全量可追溯审计日志 parameters: - description: 页码 in: query @@ -3298,7 +3444,7 @@ paths: $ref: '#/definitions/internal_transport_http_admin.ErrorDoc' security: - BearerAuth: [] - summary: 超级管理员查询审计日志 + summary: 管理员查询审计日志 tags: - admin /admin/billing/accounts/{user_id}/balance: @@ -3496,7 +3642,7 @@ paths: get: consumes: - application/json - description: superadmin 分页查看全量模型调用与计费用量账本 + description: 管理员分页查看全量模型调用与计费用量账本 parameters: - description: 页码 in: query @@ -3551,14 +3697,14 @@ paths: $ref: '#/definitions/internal_transport_http_admin.ErrorDoc' security: - BearerAuth: [] - summary: 超级管理员查询模型调用日志 + summary: 管理员查询模型调用日志 tags: - admin /admin/llm/models: get: consumes: - application/json - description: superadmin 分页查询平台模型目录,可按 only_active 过滤 + description: 管理员分页查询平台模型目录,可按 only_active 过滤 parameters: - description: 页码 in: query @@ -3611,7 +3757,7 @@ paths: post: consumes: - application/json - description: superadmin 新增平台模型目录项 + description: 管理员新增平台模型目录项 parameters: - description: 模型参数 in: body @@ -3647,7 +3793,7 @@ paths: delete: consumes: - application/json - description: superadmin 删除平台模型目录项及其关联路由绑定 + description: 管理员删除平台模型目录项及其关联路由绑定 parameters: - description: 模型ID in: path @@ -3681,7 +3827,7 @@ paths: patch: consumes: - application/json - description: superadmin 更新平台模型目录项 + description: 管理员更新平台模型目录项 parameters: - description: 模型ID in: path @@ -3722,7 +3868,7 @@ paths: get: consumes: - application/json - description: superadmin 分页查询指定模型在各上游上的路由来源 + description: 管理员分页查询指定模型在各上游上的路由来源 parameters: - description: 模型ID in: path @@ -3765,7 +3911,7 @@ paths: patch: consumes: - application/json - description: superadmin 快速启停指定模型在某上游上的来源 + description: 管理员快速启停指定模型在某上游上的来源 parameters: - description: 模型ID in: path @@ -3811,7 +3957,7 @@ paths: post: consumes: - application/json - description: superadmin 批量删除模型目录及其关联路由绑定,保留上游 + description: 管理员批量删除模型目录及其关联路由绑定,保留上游 parameters: - description: 批量删除请求 in: body @@ -3839,7 +3985,7 @@ paths: post: consumes: - application/json - description: superadmin 调整平台模型在用户侧模型选择器中的展示顺序 + description: 管理员调整平台模型在用户侧模型选择器中的展示顺序 parameters: - description: 模型 ID 顺序 in: body @@ -3875,7 +4021,7 @@ paths: get: consumes: - application/json - description: superadmin 查询 LLM 全局设置列表 + description: 管理员查询 LLM 全局设置列表 produces: - application/json responses: @@ -3896,7 +4042,7 @@ paths: patch: consumes: - application/json - description: superadmin 更新指定 LLM 全局设置项 + description: 管理员更新指定 LLM 全局设置项 parameters: - description: 设置键 in: path @@ -3939,7 +4085,7 @@ paths: get: consumes: - application/json - description: superadmin 分页查询 LLM 上游配置 + description: 管理员分页查询 LLM 上游配置 parameters: - description: 页码 in: query @@ -3984,7 +4130,7 @@ paths: post: consumes: - application/json - description: superadmin 新增上游来源配置,内部标识自动分配 + description: 管理员新增上游来源配置,内部标识自动分配 parameters: - description: 上游参数 in: body @@ -4020,7 +4166,7 @@ paths: delete: consumes: - application/json - description: superadmin 删除上游配置及其关联路由绑定 + description: 管理员删除上游配置及其关联路由绑定 parameters: - description: 上游ID in: path @@ -4054,7 +4200,7 @@ paths: patch: consumes: - application/json - description: superadmin 更新上游配置(地址、密钥、状态等) + description: 管理员更新上游配置(地址、密钥、状态等) parameters: - description: 上游ID in: path @@ -4095,7 +4241,7 @@ paths: post: consumes: - application/json - description: superadmin 手动开启上游熔断状态 + description: 管理员手动开启上游熔断状态 parameters: - description: 上游ID in: path @@ -4130,7 +4276,7 @@ paths: post: consumes: - application/json - description: superadmin 手动清空上游失败计数并关闭熔断状态 + description: 管理员手动清空上游失败计数并关闭熔断状态 parameters: - description: 上游ID in: path @@ -4165,7 +4311,7 @@ paths: get: consumes: - application/json - description: superadmin 分页查询指定上游的路由绑定列表 + description: 管理员分页查询指定上游的路由绑定列表 parameters: - description: 上游ID in: path @@ -4184,7 +4330,7 @@ paths: in: query name: q type: string - - description: 路由状态:active/inactive/unbound + - description: 路由状态:bound/active/inactive in: query name: route_status type: string @@ -4227,7 +4373,7 @@ paths: post: consumes: - application/json - description: superadmin 配置平台模型到指定上游真实模型的路由绑定与覆盖请求头 + description: 管理员配置平台模型到指定上游真实模型的路由绑定与覆盖请求头 parameters: - description: 上游ID in: path @@ -4272,7 +4418,7 @@ paths: delete: consumes: - application/json - description: superadmin 删除指定上游的路由绑定 + description: 管理员删除指定上游的路由绑定 parameters: - description: 上游ID in: path @@ -4312,7 +4458,7 @@ paths: post: consumes: - application/json - description: superadmin 手动开启上游模型路由绑定熔断状态 + description: 管理员手动开启上游模型路由绑定熔断状态 parameters: - description: 上游ID in: path @@ -4352,7 +4498,7 @@ paths: post: consumes: - application/json - description: superadmin 手动清空上游模型路由绑定失败计数并关闭熔断状态 + description: 管理员手动清空上游模型路由绑定失败计数并关闭熔断状态 parameters: - description: 上游ID in: path @@ -4390,7 +4536,7 @@ paths: - llm /admin/llm/upstreams/{id}/models/{route_id}/disable: patch: - description: superadmin 停用该路由绑定,后续路由不会选中 + description: 管理员停用该路由绑定,后续路由不会选中 parameters: - description: 上游ID in: path @@ -4428,7 +4574,7 @@ paths: - llm /admin/llm/upstreams/{id}/models/{route_id}/enable: patch: - description: superadmin 启用该路由绑定,使该上游模型重新参与路由 + description: 管理员启用该路由绑定,使该上游模型重新参与路由 parameters: - description: 上游ID in: path @@ -4468,7 +4614,7 @@ paths: post: consumes: - application/json - description: superadmin 批量删除指定上游下的路由绑定,保留模型目录 + description: 管理员批量删除指定上游下的路由绑定,保留模型目录 parameters: - description: 上游ID in: path @@ -4624,7 +4770,7 @@ paths: post: consumes: - application/json - description: superadmin 批量删除上游及其关联路由绑定,保留模型目录 + description: 管理员批量删除上游及其关联路由绑定,保留模型目录 parameters: - description: 批量删除请求 in: body @@ -4907,7 +5053,7 @@ paths: get: consumes: - application/json - description: superadmin 分页查看后台结构化系统事件 + description: 管理员分页查看后台结构化系统事件 parameters: - description: 页码 in: query @@ -4962,14 +5108,14 @@ paths: $ref: '#/definitions/internal_transport_http_admin.ErrorDoc' security: - BearerAuth: [] - summary: 超级管理员查询系统事件 + summary: 管理员查询系统事件 tags: - admin /admin/user-auth-events: get: consumes: - application/json - description: superadmin 分页查询认证事件,支持 user_id/event_type/result 过滤 + description: 管理员分页查询认证事件,支持 user_id/event_type/result 过滤 parameters: - description: 用户ID过滤 in: query @@ -5008,14 +5154,14 @@ paths: $ref: '#/definitions/internal_transport_http_admin.ErrorDoc' security: - BearerAuth: [] - summary: 超级管理员查询用户认证事件 + summary: 管理员查询用户认证事件 tags: - admin /admin/users: get: consumes: - application/json - description: superadmin 分页查看所有用户,实现账户隔离管理 + description: 管理员分页查看所有用户,实现账户隔离管理 parameters: - description: 页码 in: query @@ -5038,13 +5184,13 @@ paths: $ref: '#/definitions/internal_transport_http_admin.ErrorDoc' security: - BearerAuth: [] - summary: 超级管理员查询用户 + summary: 管理员查询用户 tags: - admin post: consumes: - application/json - description: 创建普通用户账号,superadmin 账号仅允许系统初始化唯一实例 + description: 创建普通用户账号;需要授予管理员权限时,可在账户编辑中调整角色 parameters: - description: 用户参数 in: body @@ -5073,14 +5219,14 @@ paths: $ref: '#/definitions/internal_transport_http_admin.ErrorDoc' security: - BearerAuth: [] - summary: 超级管理员创建用户 + summary: 管理员创建用户 tags: - admin /admin/users/{id}: delete: consumes: - application/json - description: superadmin 硬删除指定普通用户及其主要用户域数据 + description: 管理员硬删除指定普通用户及其主要用户域数据 parameters: - description: 用户ID in: path @@ -5112,13 +5258,13 @@ paths: $ref: '#/definitions/internal_transport_http_admin.ErrorDoc' security: - BearerAuth: [] - summary: 超级管理员删除用户 + summary: 管理员删除用户 tags: - admin patch: consumes: - application/json - description: superadmin 统一维护角色、状态、时区等可编辑字段 + description: 管理员统一维护角色、状态、时区等可编辑字段 parameters: - description: 用户ID in: path @@ -5156,14 +5302,14 @@ paths: $ref: '#/definitions/internal_transport_http_admin.ErrorDoc' security: - BearerAuth: [] - summary: 超级管理员更新用户可编辑字段 + summary: 管理员更新用户可编辑字段 tags: - admin /admin/users/{id}/reset-password: post: consumes: - application/json - description: superadmin 重置指定用户密码并吊销其全部会话 + description: 管理员重置指定用户密码并吊销其全部会话 parameters: - description: 用户ID in: path @@ -5201,14 +5347,14 @@ paths: $ref: '#/definitions/internal_transport_http_admin.ErrorDoc' security: - BearerAuth: [] - summary: 超级管理员重置用户密码 + summary: 管理员重置用户密码 tags: - admin /admin/users/{id}/revoke-sessions: post: consumes: - application/json - description: superadmin 吊销指定用户全部活跃会话,用于安全治理和风险控制 + description: 管理员吊销指定用户全部活跃会话,用于安全治理和风险控制 parameters: - description: 用户ID in: path @@ -5236,14 +5382,14 @@ paths: $ref: '#/definitions/internal_transport_http_admin.ErrorDoc' security: - BearerAuth: [] - summary: 超级管理员吊销用户全部会话 + summary: 管理员吊销用户全部会话 tags: - admin /admin/users/{id}/status: patch: consumes: - application/json - description: superadmin 维护用户状态(active/locked/suspended/deactivated),并联动会话治理 + description: 管理员维护用户状态(active/locked/suspended/deactivated),并联动会话治理 parameters: - description: 用户ID in: path @@ -5281,7 +5427,7 @@ paths: $ref: '#/definitions/internal_transport_http_admin.ErrorDoc' security: - BearerAuth: [] - summary: 超级管理员更新用户状态 + summary: 管理员更新用户状态 tags: - admin /auth/login: @@ -5767,6 +5913,178 @@ paths: summary: 查询上下文证据详情 tags: - chat + /conversation-projects: + get: + consumes: + - application/json + description: 查询当前用户的会话项目分组 + parameters: + - description: '状态筛选: active|archived|all' + in: query + name: status + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_transport_http_conversation.ConversationProjectListResponseDoc' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + security: + - BearerAuth: [] + summary: 会话项目列表 + tags: + - chat + post: + consumes: + - application/json + description: 创建当前用户的会话项目分组 + parameters: + - description: 项目参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_transport_http_conversation.CreateConversationProjectRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_transport_http_conversation.ConversationProjectResponseDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + security: + - BearerAuth: [] + summary: 创建会话项目 + tags: + - chat + /conversation-projects/{id}: + delete: + consumes: + - application/json + description: 删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话。 + parameters: + - description: 项目 public_id + in: path + name: id + required: true + type: string + - description: 是否同时删除项目内会话 + in: query + name: delete_conversations + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_transport_http_conversation.ConversationDeleteResponseDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + security: + - BearerAuth: [] + summary: 删除会话项目 + tags: + - chat + patch: + consumes: + - application/json + description: 更新当前用户的会话项目分组 + parameters: + - description: 项目 public_id + in: path + name: id + required: true + type: string + - description: 项目参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_transport_http_conversation.UpdateConversationProjectRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_transport_http_conversation.ConversationProjectResponseDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + security: + - BearerAuth: [] + summary: 更新会话项目 + tags: + - chat + /conversation-projects/reorder: + post: + consumes: + - application/json + description: 更新当前用户项目分组展示顺序 + parameters: + - description: 排序参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_transport_http_conversation.ReorderConversationProjectsRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_transport_http_conversation.ConversationProjectListResponseDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + security: + - BearerAuth: [] + summary: 调整会话项目顺序 + tags: + - chat /conversation-runs/{run_id}/cancel: post: description: 仅在用户显式点击暂停时取消对应 run;浏览器刷新或断开连接不会调用此接口 @@ -5847,6 +6165,10 @@ paths: in: query name: share type: string + - description: '项目筛选: all|unassigned|项目 public_id' + in: query + name: project + type: string produces: - application/json responses: @@ -6128,6 +6450,47 @@ paths: summary: 流式发送消息 tags: - chat + /conversations/{id}/project: + patch: + consumes: + - application/json + description: 设置当前用户单个会话的项目归属 + parameters: + - description: 会话 public_id + in: path + name: id + required: true + type: string + - description: 项目归属参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_transport_http_conversation.SetConversationProjectRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_transport_http_conversation.ConversationUpdateResponseDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + security: + - BearerAuth: [] + summary: 设置会话项目归属 + tags: + - chat /conversations/{id}/runs: get: consumes: @@ -6401,6 +6764,42 @@ paths: summary: 重命名会话 tags: - chat + /conversations/project: + post: + consumes: + - application/json + description: 批量设置当前用户会话的项目归属 + parameters: + - description: 项目归属参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_transport_http_conversation.BatchSetConversationProjectRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_transport_http_conversation.BatchSetConversationProjectResponseDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + security: + - BearerAuth: [] + summary: 批量设置会话项目归属 + tags: + - chat /conversations/shares/revoke: post: consumes: diff --git a/backend/internal/application/admin/errs.go b/backend/internal/application/admin/errs.go index d27b8b67..2c072eac 100644 --- a/backend/internal/application/admin/errs.go +++ b/backend/internal/application/admin/errs.go @@ -15,14 +15,14 @@ var ( ErrInvalidUserRole = errors.New("invalid user role") // ErrInvalidUserTimeZone 非法用户时区。 ErrInvalidUserTimeZone = errors.New("invalid user timezone") + // ErrAdminPermissionRequired 需要管理员权限。 + ErrAdminPermissionRequired = errors.New("admin permission required") // ErrSuperAdminStatusChangeNotAllowed 不允许修改 superadmin 状态。 ErrSuperAdminStatusChangeNotAllowed = errors.New("superadmin status change not allowed") - // ErrSuperAdminRoleChangeNotAllowed 不允许通过管理接口变更 superadmin 角色。 - ErrSuperAdminRoleChangeNotAllowed = errors.New("superadmin role change not allowed") + // ErrSuperAdminManagementNotAllowed 不允许 admin 管理 superadmin。 + ErrSuperAdminManagementNotAllowed = errors.New("superadmin management not allowed") // ErrLastSuperAdminRoleChangeNotAllowed 不允许降级最后一个 superadmin。 ErrLastSuperAdminRoleChangeNotAllowed = errors.New("last superadmin role change not allowed") - // ErrSuperAdminAlreadyExists 不允许提升第二个 superadmin。 - ErrSuperAdminAlreadyExists = errors.New("superadmin already exists") // ErrSelfRoleChangeNotAllowed 不允许修改自己的角色。 ErrSelfRoleChangeNotAllowed = errors.New("self role change not allowed") // ErrSelfStatusChangeNotAllowed 不允许修改自己的状态。 diff --git a/backend/internal/application/admin/service.go b/backend/internal/application/admin/service.go index 547881e7..94cf29e0 100644 --- a/backend/internal/application/admin/service.go +++ b/backend/internal/application/admin/service.go @@ -3,6 +3,7 @@ package admin import ( "context" "encoding/json" + "errors" "strconv" "strings" "time" @@ -428,8 +429,8 @@ func (s *Service) WriteAdminCreateUserAudit( ) } -// RevokeUserSessionsBySuperAdmin 吊销指定用户全部会话。 -func (s *Service) RevokeUserSessionsBySuperAdmin( +// RevokeUserSessionsByAdmin 吊销指定用户全部会话。 +func (s *Service) RevokeUserSessionsByAdmin( ctx context.Context, requestID string, actorUserID uint, @@ -437,7 +438,15 @@ func (s *Service) RevokeUserSessionsBySuperAdmin( ip string, userAgent string, ) error { - if _, err := s.userService.GetByID(ctx, targetUserID); err != nil { + actorUser, err := s.getActorUser(ctx, actorUserID) + if err != nil { + return err + } + targetUser, err := s.userService.GetByID(ctx, targetUserID) + if err != nil { + return err + } + if err = ensureActorCanManageTarget(actorUser, targetUser); err != nil { return err } @@ -460,8 +469,8 @@ func (s *Service) RevokeUserSessionsBySuperAdmin( return nil } -// UpdateUserStatusBySuperAdmin 修改普通用户状态。 -func (s *Service) UpdateUserStatusBySuperAdmin( +// UpdateUserStatusByAdmin 修改普通用户状态。 +func (s *Service) UpdateUserStatusByAdmin( ctx context.Context, requestID string, actorUserID uint, @@ -480,6 +489,13 @@ func (s *Service) UpdateUserStatusBySuperAdmin( if err != nil { return nil, err } + actorUser, err := s.getActorUser(ctx, actorUserID) + if err != nil { + return nil, err + } + if err = ensureActorCanManageTarget(actorUser, targetUser); err != nil { + return nil, err + } if targetUser.Role == domainuser.RoleSuperAdmin { return nil, ErrSuperAdminStatusChangeNotAllowed } @@ -533,15 +549,33 @@ func isManageableStatus(status string) bool { func isManageableRole(role string) bool { switch role { - case domainuser.RoleUser, domainuser.RoleSuperAdmin: + case domainuser.RoleUser, domainuser.RoleAdmin, domainuser.RoleSuperAdmin: return true default: return false } } -// PatchUserBySuperAdmin 统一维护头像、角色、状态和时区等可编辑字段。 -func (s *Service) PatchUserBySuperAdmin( +func (s *Service) getActorUser(ctx context.Context, actorUserID uint) (*domainuser.User, error) { + actorUser, err := s.userService.GetByID(ctx, actorUserID) + if err != nil { + return nil, err + } + if !domainuser.IsAdminRole(actorUser.Role) { + return nil, ErrAdminPermissionRequired + } + return actorUser, nil +} + +func ensureActorCanManageTarget(actorUser *domainuser.User, targetUser *domainuser.User) error { + if actorUser.Role != domainuser.RoleSuperAdmin && targetUser.Role == domainuser.RoleSuperAdmin { + return ErrSuperAdminManagementNotAllowed + } + return nil +} + +// PatchUserByAdmin 统一维护头像、角色、状态和时区等可编辑字段。 +func (s *Service) PatchUserByAdmin( ctx context.Context, requestID string, actorUserID uint, @@ -554,9 +588,17 @@ func (s *Service) PatchUserBySuperAdmin( if err != nil { return nil, err } + actorUser, err := s.getActorUser(ctx, actorUserID) + if err != nil { + return nil, err + } + if err = ensureActorCanManageTarget(actorUser, targetUser); err != nil { + return nil, err + } updateInput := repository.UpdateUserFieldsInput{} auditDetail := make(map[string]string) + roleChanged := false if req.AvatarURL != nil { nextAvatarURL := strings.TrimSpace(*req.AvatarURL) @@ -630,6 +672,9 @@ func (s *Service) PatchUserBySuperAdmin( if actorUserID == targetUserID { return nil, ErrSelfRoleChangeNotAllowed } + if nextRole == domainuser.RoleSuperAdmin && actorUser.Role != domainuser.RoleSuperAdmin { + return nil, ErrSuperAdminManagementNotAllowed + } superAdminCount, countErr := s.userService.CountSuperAdmins(ctx) if countErr != nil { @@ -638,14 +683,11 @@ func (s *Service) PatchUserBySuperAdmin( if targetUser.Role == domainuser.RoleSuperAdmin && nextRole != domainuser.RoleSuperAdmin && superAdminCount <= 1 { return nil, ErrLastSuperAdminRoleChangeNotAllowed } - if targetUser.Role != domainuser.RoleSuperAdmin && nextRole == domainuser.RoleSuperAdmin && superAdminCount >= 1 { - return nil, ErrSuperAdminAlreadyExists - } - updateInput.Role = &nextRole auditDetail["from_role"] = targetUser.Role auditDetail["to_role"] = nextRole targetUser.Role = nextRole + roleChanged = true } } @@ -790,8 +832,17 @@ func (s *Service) PatchUserBySuperAdmin( if !updateInput.IsZero() { targetUser, err = s.userService.UpdateFields(ctx, targetUserID, updateInput) if err != nil { + if errors.Is(err, repository.ErrLastSuperAdminRoleChange) { + return nil, ErrLastSuperAdminRoleChangeNotAllowed + } return nil, err } + if roleChanged { + if err = s.userService.RevokeAllSessions(ctx, targetUserID, "admin_set_role_"+targetUser.Role); err != nil { + return nil, err + } + auditDetail["sessions_revoked"] = "true" + } } if len(auditDetail) == 0 { @@ -854,8 +905,8 @@ func isASCIIAlpha(value string) bool { return true } -// ResetUserPasswordBySuperAdmin 重置用户密码并吊销全部会话。 -func (s *Service) ResetUserPasswordBySuperAdmin( +// ResetUserPasswordByAdmin 重置用户密码并吊销全部会话。 +func (s *Service) ResetUserPasswordByAdmin( ctx context.Context, requestID string, actorUserID uint, @@ -869,6 +920,13 @@ func (s *Service) ResetUserPasswordBySuperAdmin( if err != nil { return err } + actorUser, err := s.getActorUser(ctx, actorUserID) + if err != nil { + return err + } + if err = ensureActorCanManageTarget(actorUser, targetUser); err != nil { + return err + } if targetUser.Role == domainuser.RoleSuperAdmin { return ErrSuperAdminPasswordResetNotAllowed } @@ -917,7 +975,7 @@ func (s *Service) ResetUserPasswordBySuperAdmin( return nil } -func (s *Service) ResetUserTwoFactorBySuperAdmin( +func (s *Service) ResetUserTwoFactorByAdmin( ctx context.Context, requestID string, actorUserID uint, @@ -929,6 +987,13 @@ func (s *Service) ResetUserTwoFactorBySuperAdmin( if err != nil { return err } + actorUser, err := s.getActorUser(ctx, actorUserID) + if err != nil { + return err + } + if err = ensureActorCanManageTarget(actorUser, targetUser); err != nil { + return err + } if targetUser.Role == domainuser.RoleSuperAdmin { return ErrSuperAdminTwoFactorResetNotAllowed } @@ -955,8 +1020,8 @@ func (s *Service) ResetUserTwoFactorBySuperAdmin( return nil } -// DeleteUserBySuperAdmin 删除指定普通用户及其主要用户域数据。 -func (s *Service) DeleteUserBySuperAdmin( +// DeleteUserByAdmin 删除指定普通用户及其主要用户域数据。 +func (s *Service) DeleteUserByAdmin( ctx context.Context, requestID string, actorUserID uint, @@ -968,6 +1033,13 @@ func (s *Service) DeleteUserBySuperAdmin( if err != nil { return err } + actorUser, err := s.getActorUser(ctx, actorUserID) + if err != nil { + return err + } + if err = ensureActorCanManageTarget(actorUser, targetUser); err != nil { + return err + } if actorUserID == targetUserID { return ErrSelfDeleteNotAllowed } @@ -998,8 +1070,8 @@ func (s *Service) DeleteUserBySuperAdmin( return nil } -// ListUserAuthEventsBySuperAdmin 查询用户认证事件列表。 -func (s *Service) ListUserAuthEventsBySuperAdmin( +// ListUserAuthEventsByAdmin 查询用户认证事件列表。 +func (s *Service) ListUserAuthEventsByAdmin( ctx context.Context, userID uint, eventType string, diff --git a/backend/internal/application/admin/service_test.go b/backend/internal/application/admin/service_test.go new file mode 100644 index 00000000..edb35715 --- /dev/null +++ b/backend/internal/application/admin/service_test.go @@ -0,0 +1,309 @@ +package admin + +import ( + "context" + "errors" + "testing" + "time" + + auditapp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/audit" + domainaudit "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/audit" + domainuser "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/user" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" +) + +func TestPatchUserByAdminAllowsAdditionalSuperAdmin(t *testing.T) { + users := newAdminUserServiceFake(map[uint]domainuser.User{ + 1: {ID: 1, Role: domainuser.RoleSuperAdmin}, + 2: {ID: 2, Role: domainuser.RoleUser}, + }) + service := NewService(users, auditServiceFake{}) + + nextRole := domainuser.RoleSuperAdmin + updated, err := service.PatchUserByAdmin( + context.Background(), + "req_1", + 1, + 2, + PatchUserInput{Role: &nextRole}, + "127.0.0.1", + "test", + ) + if err != nil { + t.Fatalf("expected second superadmin promotion to succeed, got %v", err) + } + if updated.Role != domainuser.RoleSuperAdmin { + t.Fatalf("expected promoted role %q, got %q", domainuser.RoleSuperAdmin, updated.Role) + } +} + +func TestPatchUserByAdminKeepsLastSuperAdminProtected(t *testing.T) { + count := int64(1) + users := newAdminUserServiceFake(map[uint]domainuser.User{ + 1: {ID: 1, Role: domainuser.RoleSuperAdmin}, + 2: {ID: 2, Role: domainuser.RoleSuperAdmin}, + }) + users.superAdminCount = &count + service := NewService(users, auditServiceFake{}) + + nextRole := domainuser.RoleUser + _, err := service.PatchUserByAdmin( + context.Background(), + "req_1", + 2, + 1, + PatchUserInput{Role: &nextRole}, + "127.0.0.1", + "test", + ) + if !errors.Is(err, ErrLastSuperAdminRoleChangeNotAllowed) { + t.Fatalf("expected last superadmin protection, got %v", err) + } +} + +func TestPatchUserByAdminAllowsAdminRole(t *testing.T) { + users := newAdminUserServiceFake(map[uint]domainuser.User{ + 1: {ID: 1, Role: domainuser.RoleAdmin}, + 2: {ID: 2, Role: domainuser.RoleUser}, + }) + service := NewService(users, auditServiceFake{}) + + nextRole := domainuser.RoleAdmin + updated, err := service.PatchUserByAdmin( + context.Background(), + "req_1", + 1, + 2, + PatchUserInput{Role: &nextRole}, + "127.0.0.1", + "test", + ) + if err != nil { + t.Fatalf("expected admin role promotion to succeed, got %v", err) + } + if updated.Role != domainuser.RoleAdmin { + t.Fatalf("expected promoted role %q, got %q", domainuser.RoleAdmin, updated.Role) + } +} + +func TestPatchUserByAdminRequiresAdminActor(t *testing.T) { + users := newAdminUserServiceFake(map[uint]domainuser.User{ + 1: {ID: 1, Role: domainuser.RoleUser}, + 2: {ID: 2, Role: domainuser.RoleUser}, + }) + service := NewService(users, auditServiceFake{}) + + nextRole := domainuser.RoleAdmin + _, err := service.PatchUserByAdmin( + context.Background(), + "req_1", + 1, + 2, + PatchUserInput{Role: &nextRole}, + "127.0.0.1", + "test", + ) + if !errors.Is(err, ErrAdminPermissionRequired) { + t.Fatalf("expected admin permission protection, got %v", err) + } +} + +func TestPatchUserByAdminCannotPromoteSuperAdmin(t *testing.T) { + users := newAdminUserServiceFake(map[uint]domainuser.User{ + 1: {ID: 1, Role: domainuser.RoleAdmin}, + 2: {ID: 2, Role: domainuser.RoleUser}, + }) + service := NewService(users, auditServiceFake{}) + + nextRole := domainuser.RoleSuperAdmin + _, err := service.PatchUserByAdmin( + context.Background(), + "req_1", + 1, + 2, + PatchUserInput{Role: &nextRole}, + "127.0.0.1", + "test", + ) + if !errors.Is(err, ErrSuperAdminManagementNotAllowed) { + t.Fatalf("expected admin superadmin promotion protection, got %v", err) + } +} + +func TestPatchUserByAdminCannotManageSuperAdmin(t *testing.T) { + users := newAdminUserServiceFake(map[uint]domainuser.User{ + 1: {ID: 1, Role: domainuser.RoleAdmin}, + 2: {ID: 2, Role: domainuser.RoleSuperAdmin}, + }) + service := NewService(users, auditServiceFake{}) + + displayName := "Root" + _, err := service.PatchUserByAdmin( + context.Background(), + "req_1", + 1, + 2, + PatchUserInput{DisplayName: &displayName}, + "127.0.0.1", + "test", + ) + if !errors.Is(err, ErrSuperAdminManagementNotAllowed) { + t.Fatalf("expected admin superadmin management protection, got %v", err) + } +} + +func TestPatchUserByAdminMapsRepositoryLastSuperAdminGuard(t *testing.T) { + users := newAdminUserServiceFake(map[uint]domainuser.User{ + 1: {ID: 1, Role: domainuser.RoleSuperAdmin}, + 2: {ID: 2, Role: domainuser.RoleSuperAdmin}, + }) + users.updateFieldsErr = repository.ErrLastSuperAdminRoleChange + service := NewService(users, auditServiceFake{}) + + nextRole := domainuser.RoleUser + _, err := service.PatchUserByAdmin( + context.Background(), + "req_1", + 2, + 1, + PatchUserInput{Role: &nextRole}, + "127.0.0.1", + "test", + ) + if !errors.Is(err, ErrLastSuperAdminRoleChangeNotAllowed) { + t.Fatalf("expected repository guard to map to admin error, got %v", err) + } +} + +type adminUserServiceFake struct { + users map[uint]domainuser.User + updateFieldsErr error + superAdminCount *int64 +} + +func newAdminUserServiceFake(users map[uint]domainuser.User) *adminUserServiceFake { + copied := make(map[uint]domainuser.User, len(users)) + for id, item := range users { + copied[id] = item + } + return &adminUserServiceFake{users: copied} +} + +func (s *adminUserServiceFake) ListUsers(context.Context, int, int) ([]domainuser.User, int64, error) { + return nil, 0, nil +} + +func (s *adminUserServiceFake) CountSuperAdmins(context.Context) (int64, error) { + if s.superAdminCount != nil { + return *s.superAdminCount, nil + } + var count int64 + for _, item := range s.users { + if item.Role == domainuser.RoleSuperAdmin { + count++ + } + } + return count, nil +} + +func (s *adminUserServiceFake) CreateUser( + context.Context, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + *time.Time, +) (*domainuser.User, error) { + return nil, nil +} + +func (s *adminUserServiceFake) GetByID(_ context.Context, userID uint) (*domainuser.User, error) { + item, ok := s.users[userID] + if !ok { + return nil, errors.New("user not found") + } + return &item, nil +} + +func (s *adminUserServiceFake) RevokeAllSessions(context.Context, uint, string) error { + return nil +} + +func (s *adminUserServiceFake) UpdateUserStatus(_ context.Context, userID uint, status string) error { + item, ok := s.users[userID] + if !ok { + return errors.New("user not found") + } + item.Status = status + s.users[userID] = item + return nil +} + +func (s *adminUserServiceFake) UpdateFields(_ context.Context, userID uint, input repository.UpdateUserFieldsInput) (*domainuser.User, error) { + if s.updateFieldsErr != nil { + return nil, s.updateFieldsErr + } + item, ok := s.users[userID] + if !ok { + return nil, errors.New("user not found") + } + if input.Role != nil { + item.Role = *input.Role + } + if input.Timezone != nil { + item.Timezone = *input.Timezone + } + if input.Locale != nil { + item.Locale = *input.Locale + } + s.users[userID] = item + return &item, nil +} + +func (s *adminUserServiceFake) ResetLoginFailure(context.Context, uint) error { + return nil +} + +func (s *adminUserServiceFake) ResetPasswordByAdmin(context.Context, uint, string, bool) error { + return nil +} + +func (s *adminUserServiceFake) DeleteAccountHard(context.Context, uint) error { + return nil +} + +func (s *adminUserServiceFake) RecordAuthEvent(context.Context, uint, string, string, string, string, string, string, string) error { + return nil +} + +func (s *adminUserServiceFake) ListAuthEvents(context.Context, uint, string, string, int, int) ([]domainuser.AuthEvent, int64, error) { + return nil, 0, nil +} + +type auditServiceFake struct{} + +func (auditServiceFake) Write( + context.Context, + string, + uint, + string, + string, + string, + string, + string, + interface{}, +) { +} + +func (auditServiceFake) List(context.Context, int, int, auditapp.ListFilter) ([]domainaudit.Log, int64, error) { + return nil, 0, nil +} + +var _ userService = (*adminUserServiceFake)(nil) +var _ auditService = auditServiceFake{} diff --git a/backend/internal/application/auth/errs.go b/backend/internal/application/auth/errs.go index 77c302cc..c9e71ea1 100644 --- a/backend/internal/application/auth/errs.go +++ b/backend/internal/application/auth/errs.go @@ -37,6 +37,10 @@ var ( ErrIdentityNotFound = errors.New("identity not found") // ErrIdentityProviderDeleteConflict 表示删除身份源会让用户失去最后一种登录方式。 ErrIdentityProviderDeleteConflict = errors.New("identity provider delete conflict") + // ErrIdentityProviderLogoUnavailable 表示身份源图标不可用或不符合代理安全策略。 + ErrIdentityProviderLogoUnavailable = errors.New("identity provider logo unavailable") + // ErrIdentityProviderSuperAdminDefaultRoleNotAllowed 表示非 superadmin 不允许设置 superadmin 默认角色。 + ErrIdentityProviderSuperAdminDefaultRoleNotAllowed = errors.New("only superadmin can set superadmin default role") // ErrTwoFactorSetupExpired 两步验证设置已过期。 ErrTwoFactorSetupExpired = errors.New("two factor setup expired") // ErrTwoFactorSetupNotStarted 当前没有待确认的两步验证设置。 diff --git a/backend/internal/application/auth/provider.go b/backend/internal/application/auth/provider.go index db053920..b75e893c 100644 --- a/backend/internal/application/auth/provider.go +++ b/backend/internal/application/auth/provider.go @@ -1,6 +1,7 @@ package auth import ( + "bytes" "context" "crypto/hmac" "crypto/sha256" @@ -10,8 +11,10 @@ import ( "errors" "fmt" "io" + "mime" "net/http" "net/url" + "path" "regexp" "strings" "time" @@ -53,6 +56,7 @@ type IdentityProviderView struct { DefaultRole string SubjectField string EmailField string + EmailVerifiedField string NameField string AvatarField string CreatedAt time.Time @@ -73,7 +77,13 @@ type UserIdentityView struct { LastLoginAt *time.Time } +type IdentityProviderLogoAsset struct { + ContentType string + Content []byte +} + type UpsertIdentityProviderInput struct { + ActorRole string Type string Name string Slug string @@ -92,6 +102,7 @@ type UpsertIdentityProviderInput struct { DefaultRole string SubjectField string EmailField string + EmailVerifiedField string NameField string AvatarField string } @@ -341,10 +352,13 @@ func (s *Service) CompleteProviderLogin( if subject == "" { return nil, fmt.Errorf("provider subject is missing") } - email := claimString(profile, provider.EmailField) + email, err := normalizeProviderEmail(claimString(profile, provider.EmailField)) + if err != nil { + return nil, err + } displayName := firstNonEmpty(claimString(profile, provider.NameField), email, subject) avatarURL := claimString(profile, provider.AvatarField) - emailVerified := claimBool(profile, "email_verified", "verified_email") + emailVerified := resolveProviderEmailVerified(profile, *provider) userItem, err := s.resolveProviderUser(ctx, *provider, subject, email, displayName, avatarURL, emailVerified, string(profileJSON), verifiedState.Intent) if err != nil { @@ -451,10 +465,12 @@ func (s *Service) CompleteProviderBind( if subject == "" { return nil, fmt.Errorf("provider subject is missing") } - email := claimString(profile, provider.EmailField) - normalizedEmail := strings.TrimSpace(email) + normalizedEmail, err := normalizeProviderEmail(claimString(profile, provider.EmailField)) + if err != nil { + return nil, err + } providerDisplayName := firstNonEmpty(claimString(profile, provider.NameField), normalizedEmail, subject) - emailVerified := claimBool(profile, "email_verified", "verified_email") + emailVerified := resolveProviderEmailVerified(profile, *provider) now := time.Now() existingIdentity, err := s.repo.GetUserIdentityByProviderSubject(ctx, provider.ID, subject) @@ -563,8 +579,11 @@ func (s *Service) normalizeProviderInput(input UpsertIdentityProviderInput, curr if defaultRole == "" { defaultRole = domainuser.RoleUser } - if defaultRole != domainuser.RoleUser && defaultRole != domainuser.RoleSuperAdmin { - return nil, fmt.Errorf("default role must be user or superadmin") + if defaultRole != domainuser.RoleUser && defaultRole != domainuser.RoleAdmin && defaultRole != domainuser.RoleSuperAdmin { + return nil, fmt.Errorf("default role must be user, admin or superadmin") + } + if defaultRole == domainuser.RoleSuperAdmin && input.ActorRole != domainuser.RoleSuperAdmin { + return nil, ErrIdentityProviderSuperAdminDefaultRoleNotAllowed } logoURL := strings.TrimSpace(input.LogoURL) if logoURL != "" { @@ -594,6 +613,7 @@ func (s *Service) normalizeProviderInput(input UpsertIdentityProviderInput, curr DefaultRole: defaultRole, SubjectField: firstNonEmpty(strings.TrimSpace(input.SubjectField), "sub"), EmailField: firstNonEmpty(strings.TrimSpace(input.EmailField), "email"), + EmailVerifiedField: firstNonEmpty(strings.TrimSpace(input.EmailVerifiedField), "email_verified"), NameField: firstNonEmpty(strings.TrimSpace(input.NameField), "name"), AvatarField: firstNonEmpty(strings.TrimSpace(input.AvatarField), "picture"), SortOrder: 100, @@ -660,6 +680,7 @@ func toProviderView(item domainuser.IdentityProvider, includeSensitive bool) Ide DefaultRole: item.DefaultRole, SubjectField: item.SubjectField, EmailField: item.EmailField, + EmailVerifiedField: item.EmailVerifiedField, NameField: item.NameField, AvatarField: item.AvatarField, CreatedAt: item.CreatedAt, @@ -689,6 +710,7 @@ func providerUpdateInput(provider *domainuser.IdentityProvider) repository.Updat DefaultRole: &provider.DefaultRole, SubjectField: &provider.SubjectField, EmailField: &provider.EmailField, + EmailVerifiedField: &provider.EmailVerifiedField, NameField: &provider.NameField, AvatarField: &provider.AvatarField, } @@ -701,6 +723,7 @@ const ( providerIntentRegister = "register" providerIntentBind = "bind" providerHTTPTimeout = 10 * time.Second + providerLogoMaxBytes = 2 << 20 ) func normalizeProviderSlug(value string) string { @@ -722,6 +745,124 @@ func normalizeProviderIntent(value string) string { } } +func (s *Service) GetIdentityProviderLogo(ctx context.Context, slug string) (*IdentityProviderLogoAsset, error) { + provider, err := s.repo.GetIdentityProviderBySlug(ctx, normalizeProviderSlug(slug)) + if err != nil { + return nil, ErrIdentityProviderLogoUnavailable + } + logoURL := strings.TrimSpace(provider.LogoURL) + parsed, err := url.Parse(logoURL) + if err != nil || parsed == nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return nil, ErrIdentityProviderLogoUnavailable + } + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") + + response, err := s.providerHTTPClient.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, ErrIdentityProviderLogoUnavailable + } + + content, err := io.ReadAll(io.LimitReader(response.Body, providerLogoMaxBytes+1)) + if err != nil { + return nil, err + } + if len(content) == 0 || len(content) > providerLogoMaxBytes { + return nil, ErrIdentityProviderLogoUnavailable + } + contentType := resolveIdentityProviderLogoContentType(response.Header.Get("Content-Type"), parsed.Path, content) + if contentType == "" { + return nil, ErrIdentityProviderLogoUnavailable + } + return &IdentityProviderLogoAsset{ + ContentType: contentType, + Content: content, + }, nil +} + +func resolveIdentityProviderLogoContentType(headerValue string, requestPath string, content []byte) string { + contentType := normalizeIdentityProviderLogoContentType(headerValue) + if isAllowedIdentityProviderLogoContentType(contentType) { + return contentType + } + if contentType == "" || contentType == "application/octet-stream" { + contentType = normalizeIdentityProviderLogoContentType(http.DetectContentType(content)) + if isAllowedIdentityProviderLogoContentType(contentType) { + return contentType + } + contentType = providerLogoContentTypeByExtension(requestPath) + if contentType == "image/svg+xml" && !looksLikeSVG(content) { + return "" + } + if isAllowedIdentityProviderLogoContentType(contentType) { + return contentType + } + } + return "" +} + +func normalizeIdentityProviderLogoContentType(value string) string { + contentType, _, err := mime.ParseMediaType(strings.TrimSpace(value)) + if err != nil { + contentType = strings.TrimSpace(value) + } + return strings.ToLower(contentType) +} + +func isAllowedIdentityProviderLogoContentType(contentType string) bool { + switch contentType { + case "image/avif", + "image/gif", + "image/jpeg", + "image/png", + "image/svg+xml", + "image/vnd.microsoft.icon", + "image/webp", + "image/x-icon": + return true + default: + return false + } +} + +func providerLogoContentTypeByExtension(requestPath string) string { + switch strings.ToLower(path.Ext(requestPath)) { + case ".avif": + return "image/avif" + case ".gif": + return "image/gif" + case ".ico": + return "image/x-icon" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".svg": + return "image/svg+xml" + case ".webp": + return "image/webp" + default: + return "" + } +} + +func looksLikeSVG(content []byte) bool { + trimmed := bytes.TrimSpace(content) + if len(trimmed) == 0 { + return false + } + lowered := bytes.ToLower(trimmed) + return bytes.HasPrefix(lowered, []byte("alert(1)")) + })) + defer server.Close() + + repo := &providerLoginRepo{ + providersBySlug: map[string]*domainuser.IdentityProvider{ + "acme": { + Slug: "acme", + LogoURL: server.URL + "/logo.html", + }, + }, + } + service := NewService(config.Config{JWTSecret: "test-secret"}, repo, nil) + + if _, err := service.GetIdentityProviderLogo(context.Background(), "acme"); !errors.Is(err, ErrIdentityProviderLogoUnavailable) { + t.Fatalf("expected unavailable error, got %v", err) + } +} + func TestUnlinkCurrentUserIdentityAllowsLastIdentityWhenPasswordEnabled(t *testing.T) { repo := &providerLoginRepo{ credentialsByUserID: map[uint]*domainuser.Credential{ @@ -285,6 +502,18 @@ type providerLoginRepo struct { usersByEmail map[string]*domainuser.User credentialsByUserID map[uint]*domainuser.Credential identities []domainuser.UserIdentity + providersBySlug map[string]*domainuser.IdentityProvider +} + +func (r *providerLoginRepo) GetIdentityProviderBySlug(ctx context.Context, slug string) (*domainuser.IdentityProvider, error) { + if r.providersBySlug == nil { + return nil, repository.ErrNotFound + } + provider, ok := r.providersBySlug[slug] + if !ok { + return nil, repository.ErrNotFound + } + return provider, nil } func (r *providerLoginRepo) GetUserIdentityByProviderSubject(ctx context.Context, providerID uint, subject string) (*domainuser.UserIdentity, error) { @@ -438,6 +667,10 @@ func (r *providerLoginRepo) UpdateUserIdentityLogin(ctx context.Context, identit return nil } +func (r *providerLoginRepo) RecordAuthEvent(ctx context.Context, userID uint, requestID string, eventType string, result string, reason string, clientIP string, userAgent string, detailJSON string) error { + return nil +} + func (r *providerLoginRepo) DeleteAccountHard(ctx context.Context, userID uint) error { r.deletedUserID = userID return nil diff --git a/backend/internal/application/auth/registration.go b/backend/internal/application/auth/registration.go index 786afaef..ec2cb9d9 100644 --- a/backend/internal/application/auth/registration.go +++ b/backend/internal/application/auth/registration.go @@ -320,7 +320,6 @@ func (s *Service) RequestPasswordChangeVerification(ctx context.Context, userID } func (s *Service) ChangePassword(ctx context.Context, userID uint, currentPassword string, newPassword string, verificationMethod string, code string, requestID string, auditCtx requestmeta.SessionAuditContext) error { - cfg := s.cfg.Snapshot() normalizedPassword, err := userapp.NormalizePassword(newPassword) if err != nil { return err @@ -334,7 +333,7 @@ func (s *Service) ChangePassword(ctx context.Context, userID uint, currentPasswo return err } initialReset := credential.MustResetPassword || isBootstrapSuperAdminAdminCreatedPassword(*item, credential) - if initialReset && normalizedPassword == strings.TrimSpace(cfg.AdminPassword) { + if initialReset && isBootstrapSuperAdminAdminCreatedPassword(*item, credential) && passwordMatchesCredential(normalizedPassword, credential) { return fmt.Errorf("new password must be different from the bootstrap password") } if credential.PasswordEnabled && !initialReset { diff --git a/backend/internal/application/auth/service.go b/backend/internal/application/auth/service.go index 3bacd89d..477936ae 100644 --- a/backend/internal/application/auth/service.go +++ b/backend/internal/application/auth/service.go @@ -2,8 +2,10 @@ package auth import ( "context" + "crypto/rand" "crypto/sha256" "crypto/subtle" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -153,7 +155,14 @@ func (s *Service) warn(message string, fields ...zap.Field) { s.logger.Warn(message, fields...) } -// EnsureBootstrapSuperAdmin 确保系统存在唯一 superadmin。 +func (s *Service) info(message string, fields ...zap.Field) { + if s.logger == nil { + return + } + s.logger.Info(message, fields...) +} + +// EnsureBootstrapSuperAdmin 确保系统至少存在一个 superadmin。 func (s *Service) EnsureBootstrapSuperAdmin(ctx context.Context) error { count, err := s.repo.CountSuperAdmins(ctx) if err != nil { @@ -164,7 +173,11 @@ func (s *Service) EnsureBootstrapSuperAdmin(ctx context.Context) error { } cfg := s.cfg.Snapshot() - passwordHash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), passwordHashCost) + bootstrapPassword, err := generateBootstrapAdminPassword() + if err != nil { + return err + } + passwordHash, err := bcrypt.GenerateFromPassword([]byte(bootstrapPassword), passwordHashCost) if err != nil { return err } @@ -187,7 +200,7 @@ func (s *Service) EnsureBootstrapSuperAdmin(ctx context.Context) error { Locale: "en-US", } - return s.repo.CreateWithCredential(ctx, item, domainuser.Credential{ + if err = s.repo.CreateWithCredential(ctx, item, domainuser.Credential{ PasswordHash: string(passwordHash), PasswordAlgo: "bcrypt", PasswordEnabled: true, @@ -195,7 +208,19 @@ func (s *Service) EnsureBootstrapSuperAdmin(ctx context.Context) error { PasswordSetAt: &now, PasswordOrigin: domainuser.PasswordOriginAdminCreated, MustResetPassword: true, - }, 0, 0, nil, false) + }, 0, 0, nil, false); err != nil { + return err + } + s.info("bootstrap superadmin created", zap.String("username", username), zap.String("password", bootstrapPassword)) + return nil +} + +func generateBootstrapAdminPassword() (string, error) { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate bootstrap admin password: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil } // Login 登录鉴权并返回访问令牌,成功与失败均记录认证事件。 @@ -527,7 +552,7 @@ func (s *Service) CompleteOnboarding( if policyErr != nil { return nil, false, policyErr } - if trimmedPassword == strings.TrimSpace(cfg.AdminPassword) { + if isBootstrapSuperAdminAdminCreatedPassword(*item, credential) && passwordMatchesCredential(trimmedPassword, credential) { return nil, false, fmt.Errorf("new password must be different from the bootstrap password") } passwordHash, hashErr := bcrypt.GenerateFromPassword([]byte(trimmedPassword), passwordHashCost) @@ -568,6 +593,13 @@ func isBootstrapSuperAdminAdminCreatedPassword(item domainuser.User, credential credential.PasswordOrigin == domainuser.PasswordOriginAdminCreated } +func passwordMatchesCredential(password string, credential *domainuser.Credential) bool { + if credential == nil || strings.TrimSpace(credential.PasswordHash) == "" { + return false + } + return bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(password)) == nil +} + func (s *Service) applyTwoFactorView(ctx context.Context, view *userview.UserView) error { if view == nil { return nil diff --git a/backend/internal/application/channel/dto.go b/backend/internal/application/channel/dto.go index 83179268..064788cf 100644 --- a/backend/internal/application/channel/dto.go +++ b/backend/internal/application/channel/dto.go @@ -40,6 +40,7 @@ type UpstreamRemoteModelView struct { SuggestedPlatformModelName string SuggestedKindsJSON string SuggestedProtocol string + SuggestedProtocols []string BindingCode string BoundPlatformModels []string UpstreamModelStatus string @@ -85,6 +86,9 @@ type ImportUpstreamModelResultView struct { BindingCode string Status string CreatedRoute bool + CreatedRoutes int + ExistingRoutes int + Protocols []string CreatedPlatform bool Error string } @@ -123,6 +127,7 @@ type ModelView struct { KindsJSON string Icon string CapabilitiesJSON string + SystemPrompt string Status string Description string SortOrder int diff --git a/backend/internal/application/channel/errs.go b/backend/internal/application/channel/errs.go index 83dabe1e..2211cfb8 100644 --- a/backend/internal/application/channel/errs.go +++ b/backend/internal/application/channel/errs.go @@ -35,10 +35,14 @@ var ( ErrInvalidUpstreamBaseURL = errors.New("invalid upstream base url") // ErrInvalidKinds 模型类型无效。 ErrInvalidKinds = errors.New("invalid kinds") + // ErrSystemPromptTooLong 系统提示词长度超过允许范围。 + ErrSystemPromptTooLong = errors.New("system prompt too long") // ErrInvalidModelOrder 模型排序参数无效。 ErrInvalidModelOrder = errors.New("invalid model order") // ErrProtocolRequired 无法通过瀑布规则推断协议。 ErrProtocolRequired = errors.New("protocol required") + // ErrInvalidRouteProtocolCombination 路由协议组合无效。 + ErrInvalidRouteProtocolCombination = errors.New("invalid route protocol combination") // ErrUpstreamModelNotFound 上游模型路由绑定不存在。 ErrUpstreamModelNotFound = repository.ErrUpstreamModelNotFound // ErrUpstreamModelConflict 上游模型路由绑定冲突。 diff --git a/backend/internal/application/channel/input.go b/backend/internal/application/channel/input.go index efd82eef..880bde54 100644 --- a/backend/internal/application/channel/input.go +++ b/backend/internal/application/channel/input.go @@ -45,6 +45,7 @@ type CreateModelInput struct { KindsJSON string Icon string CapabilitiesJSON string + SystemPrompt string Status string Description string } @@ -56,6 +57,7 @@ type UpdateModelInput struct { KindsJSON *string Icon *string CapabilitiesJSON *string + SystemPrompt *string Status *string Description *string } @@ -95,6 +97,7 @@ type ImportUpstreamModelItemInput struct { PlatformModelName string UpstreamModelName string Protocol string + Protocols []string KindsJSON string Status string Priority int diff --git a/backend/internal/application/channel/model_catalog.go b/backend/internal/application/channel/model_catalog.go index 6d613de5..69b8dc3a 100644 --- a/backend/internal/application/channel/model_catalog.go +++ b/backend/internal/application/channel/model_catalog.go @@ -33,6 +33,7 @@ const ( protocolOpenAIVideoGenerations = "openai_video_generations" protocolGoogleImageGeneration = llm.AdapterGoogleImageGeneration protocolXAIImage = llm.AdapterXAIImage + protocolXAIImageEdits = llm.AdapterXAIImageEdits ) var protocolDefaultKindOrder = []string{ @@ -121,15 +122,17 @@ func systemFallbackProtocols(compatible string) map[string]string { } case compatibleGoogle: return map[string]string{ - modelKindChat: llm.AdapterGoogleGenerateContent, - modelKindAudio: llm.AdapterGoogleGenerateContent, - modelKindImageGen: protocolGoogleImageGeneration, + modelKindChat: llm.AdapterGoogleGenerateContent, + modelKindAudio: llm.AdapterGoogleGenerateContent, + modelKindImageGen: protocolGoogleImageGeneration, + modelKindImageEdit: protocolGoogleImageGeneration, } case compatibleXAI: return map[string]string{ - modelKindChat: llm.AdapterXAIResponses, - modelKindAudio: llm.AdapterXAIResponses, - modelKindImageGen: protocolXAIImage, + modelKindChat: llm.AdapterXAIResponses, + modelKindAudio: llm.AdapterXAIResponses, + modelKindImageGen: protocolXAIImage, + modelKindImageEdit: protocolXAIImageEdits, } case compatibleOpenRouter: return map[string]string{ @@ -162,7 +165,8 @@ func isKnownProtocol(raw string) bool { protocolOpenAIImageEdits, protocolOpenAIVideoGenerations, protocolGoogleImageGeneration, - protocolXAIImage: + protocolXAIImage, + protocolXAIImageEdits: return true default: return false @@ -175,7 +179,7 @@ func resolveRouteProtocol(explicit string, upCompatible string, defaultsJSON str if !isKnownProtocol(protocol) { return "", ErrInvalidAdapter } - if kind != "" && !isProtocolAllowedForKind(kind, protocol) { + if !isProtocolAllowedForKinds(kindsJSON, protocol) { return "", ErrInvalidAdapter } return protocol, nil @@ -193,6 +197,116 @@ func resolveRouteProtocol(explicit string, upCompatible string, defaultsJSON str return "", ErrProtocolRequired } +// resolveRouteProtocols 解析批量导入时的协议列表,图片生成/编辑模型会按协议能力生成一到两条绑定。 +func resolveRouteProtocols(explicit []string, upCompatible string, defaultsJSON string, kindsJSON string) ([]string, error) { + protocols := make([]string, 0, len(explicit)) + seen := make(map[string]struct{}, len(explicit)) + for _, raw := range explicit { + value := strings.TrimSpace(strings.ToLower(raw)) + if value == "" { + continue + } + protocol, err := resolveRouteProtocol(value, upCompatible, defaultsJSON, kindsJSON) + if err != nil { + return nil, err + } + if _, ok := seen[protocol]; ok { + continue + } + seen[protocol] = struct{}{} + protocols = append(protocols, protocol) + } + if len(protocols) > 0 { + if !isSupportedRouteProtocolCombination(protocols) { + return nil, ErrInvalidRouteProtocolCombination + } + return protocols, nil + } + + kinds := parseKinds(kindsJSON) + if hasModelKind(kinds, modelKindImageGen) && hasModelKind(kinds, modelKindImageEdit) { + generationProtocol := defaultRouteProtocolForKind(upCompatible, defaultsJSON, modelKindImageGen) + editProtocol := defaultRouteProtocolForKind(upCompatible, defaultsJSON, modelKindImageEdit) + if generationProtocol != "" && editProtocol != "" { + protocols = uniqueRouteProtocols(generationProtocol, editProtocol) + if isSupportedRouteProtocolCombination(protocols) { + return protocols, nil + } + } + } + + protocol, err := resolveRouteProtocol("", upCompatible, defaultsJSON, kindsJSON) + if err != nil { + return nil, err + } + return []string{protocol}, nil +} + +// uniqueRouteProtocols 保留协议声明顺序,同时避免 Google 图片这类同协议双能力模型创建重复绑定。 +func uniqueRouteProtocols(values ...string) []string { + protocols := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, raw := range values { + protocol := strings.TrimSpace(strings.ToLower(raw)) + if protocol == "" { + continue + } + if _, ok := seen[protocol]; ok { + continue + } + seen[protocol] = struct{}{} + protocols = append(protocols, protocol) + } + return protocols +} + +// defaultRouteProtocolForKind 优先使用上游配置的类型默认协议,其次回退到兼容类型内置协议。 +func defaultRouteProtocolForKind(upCompatible string, defaultsJSON string, kind string) string { + if protocol := protocolDefaultForKind(defaultsJSON, kind); protocol != "" { + return protocol + } + return systemFallbackProtocols(upCompatible)[kind] +} + +func isProtocolAllowedForKinds(kindsJSON string, protocol string) bool { + kinds := parseKinds(kindsJSON) + if len(kinds) == 0 { + return true + } + for _, kind := range kinds { + if isProtocolAllowedForKind(kind, protocol) { + return true + } + } + return false +} + +// isSupportedRouteProtocolCombination 限制同一绑定 pair 只能单协议,或图片生成/编辑双协议。 +func isSupportedRouteProtocolCombination(protocols []string) bool { + seen := make(map[string]struct{}, len(protocols)) + for _, raw := range protocols { + protocol := strings.TrimSpace(strings.ToLower(raw)) + if protocol == "" { + continue + } + seen[protocol] = struct{}{} + } + if len(seen) <= 1 { + return true + } + if len(seen) != 2 { + return false + } + _, hasGeneration := seen[protocolOpenAIImageGenerations] + _, hasEdit := seen[protocolOpenAIImageEdits] + if hasGeneration && hasEdit { + return true + } + _, hasGeneration = seen[protocolXAIImage] + _, hasEdit = seen[protocolXAIImageEdits] + return hasGeneration && hasEdit +} + func protocolDefaultForKind(defaultsJSON string, kind string) string { defaults := make(map[string]*string) if err := json.Unmarshal([]byte(strings.TrimSpace(defaultsJSON)), &defaults); err != nil { @@ -236,7 +350,9 @@ func isProtocolAllowedForKind(kind string, protocol string) bool { } case modelKindImageEdit: switch protocol { - case protocolOpenAIImageEdits: + case protocolOpenAIImageEdits, + protocolGoogleImageGeneration, + protocolXAIImageEdits: return true default: return false @@ -276,7 +392,7 @@ func IsRouteAllowedForTask(taskType string, kindsJSON string, protocol string) b case TaskTypeImageGeneration: return isProtocolAllowedForKind(modelKindImageGen, protocol) case TaskTypeImageEdit: - return protocol == protocolOpenAIImageEdits + return isProtocolAllowedForKind(modelKindImageEdit, protocol) default: return isProtocolAllowedForKind(modelKindChat, protocol) || isProtocolAllowedForKind(modelKindAudio, protocol) } @@ -285,7 +401,7 @@ func IsRouteAllowedForTask(taskType string, kindsJSON string, protocol string) b case TaskTypeImageGeneration: return hasModelKind(kinds, modelKindImageGen) && isProtocolAllowedForKind(modelKindImageGen, protocol) case TaskTypeImageEdit: - return hasModelKind(kinds, modelKindImageEdit) && protocol == protocolOpenAIImageEdits + return hasModelKind(kinds, modelKindImageEdit) && isProtocolAllowedForKind(modelKindImageEdit, protocol) default: for _, kind := range kinds { if (kind == modelKindChat || kind == modelKindAudio) && isProtocolAllowedForKind(kind, protocol) { @@ -321,9 +437,10 @@ func primaryKindFromKinds(kindsJSON string) string { func inferKindsJSON(platformModelName string) string { code := strings.ToLower(strings.TrimSpace(platformModelName)) switch { - case strings.HasPrefix(code, "gpt-image-"), code == "chatgpt-image-latest", code == "dall-e-2": + case strings.HasPrefix(code, "gpt-image-"), code == "chatgpt-image-latest", code == "dall-e-2", + isGeminiImageGenerationModel(code), isXAIImageGenerationModel(code): return `["image_gen","image_edit"]` - case code == "dall-e-3", strings.HasPrefix(code, "imagen-"), isGeminiImageGenerationModel(code), isXAIImageGenerationModel(code): + case code == "dall-e-3", strings.HasPrefix(code, "imagen-"): return `["image_gen"]` case code == "sora", code == "veo-2", strings.HasPrefix(code, "kling"): return `["video_gen"]` diff --git a/backend/internal/application/channel/model_catalog_test.go b/backend/internal/application/channel/model_catalog_test.go index 29858950..8607c68e 100644 --- a/backend/internal/application/channel/model_catalog_test.go +++ b/backend/internal/application/channel/model_catalog_test.go @@ -47,7 +47,10 @@ func TestProtocolDefaultsForXAIUsesXAIResponsesForConversationKinds(t *testing.T if defaults[modelKindImageGen] != "xai_image" { t.Fatalf("expected xAI image default, got %q in %s", defaults[modelKindImageGen], raw) } - for _, kind := range []string{modelKindImageEdit, modelKindVideoGen} { + if defaults[modelKindImageEdit] != "xai_image_edits" { + t.Fatalf("expected xAI image edit default, got %q in %s", defaults[modelKindImageEdit], raw) + } + for _, kind := range []string{modelKindVideoGen} { if _, ok := defaults[kind]; ok { t.Fatalf("unexpected xAI default protocol for %s in %s", kind, raw) } @@ -94,6 +97,9 @@ func TestProtocolDefaultsForGoogleUsesGoogleImageGeneration(t *testing.T) { if defaults[modelKindImageGen] != "google_image_generation" { t.Fatalf("expected Google image generation default, got %q in %s", defaults[modelKindImageGen], raw) } + if defaults[modelKindImageEdit] != "google_image_generation" { + t.Fatalf("expected Google image edit default, got %q in %s", defaults[modelKindImageEdit], raw) + } } func TestNormalizeCompatibleOnlyAllowsSupportedUpstreamProviders(t *testing.T) { @@ -289,8 +295,8 @@ func TestInferKindsJSONRecognizesGeminiImageModels(t *testing.T) { "gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview", } { - if got := inferKindsJSON(modelName); got != `["image_gen"]` { - t.Fatalf("expected %s to infer image generation kind, got %s", modelName, got) + if got := inferKindsJSON(modelName); got != `["image_gen","image_edit"]` { + t.Fatalf("expected %s to infer image generation and edit kinds, got %s", modelName, got) } } } @@ -302,8 +308,8 @@ func TestInferKindsJSONRecognizesXAIImageModels(t *testing.T) { "grok-imagine-image-pro", "grok-imagine-image-preview", } { - if got := inferKindsJSON(modelName); got != `["image_gen"]` { - t.Fatalf("expected %s to infer image generation kind, got %s", modelName, got) + if got := inferKindsJSON(modelName); got != `["image_gen","image_edit"]` { + t.Fatalf("expected %s to infer image generation and edit kinds, got %s", modelName, got) } } } @@ -343,6 +349,116 @@ func TestResolveRouteProtocolRejectsExplicitProtocolKindMismatch(t *testing.T) { } } +func TestResolveRouteProtocolAcceptsExplicitProtocolForAnyDeclaredKind(t *testing.T) { + resolved, err := resolveRouteProtocol("openai_image_edits", compatibleOpenAI, "", `["image_gen","image_edit"]`) + if err != nil { + t.Fatalf("expected explicit image edit protocol to be accepted for dual-kind image model: %v", err) + } + if resolved != "openai_image_edits" { + t.Fatalf("expected openai_image_edits, got %q", resolved) + } +} + +func TestSupportedRouteProtocolCombinationOnlyAllowsSameProviderImagePair(t *testing.T) { + tests := []struct { + name string + protocols []string + want bool + }{ + {name: "single chat", protocols: []string{"openai_responses"}, want: true}, + {name: "single image generation", protocols: []string{"openai_image_generations"}, want: true}, + {name: "openai image generation and edit", protocols: []string{"openai_image_generations", "openai_image_edits"}, want: true}, + {name: "xai image generation and edit", protocols: []string{"xai_image", "xai_image_edits"}, want: true}, + {name: "duplicate protocol", protocols: []string{"openai_responses", "openai_responses"}, want: true}, + {name: "two chat protocols", protocols: []string{"openai_responses", "openai_chat_completions"}, want: false}, + {name: "image generation with chat", protocols: []string{"openai_image_generations", "openai_responses"}, want: false}, + {name: "mixed provider image pair", protocols: []string{"openai_image_generations", "xai_image_edits"}, want: false}, + {name: "three protocols", protocols: []string{"openai_image_generations", "openai_image_edits", "openai_responses"}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isSupportedRouteProtocolCombination(tt.protocols); got != tt.want { + t.Fatalf("expected %v, got %v", tt.want, got) + } + }) + } +} + +func TestResolveRouteProtocolsExpandsOpenAIDualImageKinds(t *testing.T) { + protocols, err := resolveRouteProtocols(nil, compatibleOpenAI, "", `["image_gen","image_edit"]`) + if err != nil { + t.Fatalf("resolve route protocols: %v", err) + } + expected := []string{"openai_image_generations", "openai_image_edits"} + if len(protocols) != len(expected) { + t.Fatalf("expected %d protocols, got %#v", len(expected), protocols) + } + for i, expectedProtocol := range expected { + if protocols[i] != expectedProtocol { + t.Fatalf("expected protocol %d to be %q, got %#v", i, expectedProtocol, protocols) + } + } +} + +func TestResolveRouteProtocolsKeepsSingleGoogleProtocolForDualImageKinds(t *testing.T) { + protocols, err := resolveRouteProtocols(nil, compatibleGoogle, "", `["image_gen","image_edit"]`) + if err != nil { + t.Fatalf("resolve route protocols: %v", err) + } + if len(protocols) != 1 || protocols[0] != "google_image_generation" { + t.Fatalf("expected single Google image protocol, got %#v", protocols) + } +} + +func TestResolveRouteProtocolsExpandsXAIDualImageKinds(t *testing.T) { + protocols, err := resolveRouteProtocols(nil, compatibleXAI, "", `["image_gen","image_edit"]`) + if err != nil { + t.Fatalf("resolve route protocols: %v", err) + } + expected := []string{"xai_image", "xai_image_edits"} + if len(protocols) != len(expected) { + t.Fatalf("expected %d protocols, got %#v", len(expected), protocols) + } + for i, expectedProtocol := range expected { + if protocols[i] != expectedProtocol { + t.Fatalf("expected protocol %d to be %q, got %#v", i, expectedProtocol, protocols) + } + } +} + +func TestResolveRouteProtocolsKeepsSingleProtocolForGenerationOnlyModels(t *testing.T) { + tests := []struct { + name string + compatible string + kindsJSON string + expected string + }{ + {name: "openai generation", compatible: compatibleOpenAI, kindsJSON: `["image_gen"]`, expected: "openai_image_generations"}, + {name: "google generation", compatible: compatibleGoogle, kindsJSON: `["image_gen"]`, expected: "google_image_generation"}, + {name: "xai generation", compatible: compatibleXAI, kindsJSON: `["image_gen"]`, expected: "xai_image"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + protocols, err := resolveRouteProtocols(nil, tt.compatible, "", tt.kindsJSON) + if err != nil { + t.Fatalf("resolve route protocols: %v", err) + } + if len(protocols) != 1 || protocols[0] != tt.expected { + t.Fatalf("expected [%s], got %#v", tt.expected, protocols) + } + }) + } +} + +func TestResolveRouteProtocolsRejectsUnsupportedExplicitMultiProtocol(t *testing.T) { + _, err := resolveRouteProtocols([]string{"openai_responses", "openai_chat_completions"}, compatibleOpenAI, "", `["chat"]`) + if !errors.Is(err, ErrInvalidRouteProtocolCombination) { + t.Fatalf("expected ErrInvalidRouteProtocolCombination, got %v", err) + } +} + func TestResolveRouteProtocolUsesExplicitConversationProtocolAsSourceOfTruth(t *testing.T) { for _, tc := range []struct { compatible string @@ -383,6 +499,15 @@ func TestIsRouteAllowedForTaskSeparatesChatAndImageProtocols(t *testing.T) { if !IsRouteAllowedForTask(TaskTypeImageEdit, `["image_edit"]`, "openai_image_edits") { t.Fatalf("expected image edit task to allow image edit protocol") } + if !IsRouteAllowedForTask(TaskTypeImageEdit, `["image_edit"]`, "google_image_generation") { + t.Fatalf("expected image edit task to allow Google image protocol") + } + if !IsRouteAllowedForTask(TaskTypeImageEdit, `["image_edit"]`, "xai_image_edits") { + t.Fatalf("expected image edit task to allow xAI image edits protocol") + } + if IsRouteAllowedForTask(TaskTypeImageEdit, `["image_edit"]`, "xai_image") { + t.Fatalf("expected image edit task to reject xAI image generation protocol") + } } func TestDefaultRouteModelMatchesTaskFiltersByKind(t *testing.T) { @@ -436,15 +561,44 @@ func TestFilterPricedModelViewsUsesPlatformModelNameKey(t *testing.T) { } -func TestNormalizePlatformModelNameRejectsWhitespace(t *testing.T) { - if _, err := normalizePlatformModelName("claude sonnet"); err == nil { - t.Fatal("expected whitespace in platform model name to be rejected") - } - name, err := normalizePlatformModelName("claude-sonnet") - if err != nil { - t.Fatalf("normalize platform model name: %v", err) +func TestNormalizePlatformModelNameAllowsDisplaySpaces(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {name: "plain", raw: "claude-sonnet", want: "claude-sonnet"}, + {name: "display spaces", raw: "GPT Image 1", want: "GPT Image 1"}, + {name: "trim outer spaces", raw: " GPT Image 1 ", want: "GPT Image 1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizePlatformModelName(tt.raw) + if err != nil { + t.Fatalf("normalize platform model name: %v", err) + } + if got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) } - if name != "claude-sonnet" { - t.Fatalf("expected normalized platform model name, got %q", name) +} + +func TestNormalizePlatformModelNameRejectsUnsafeWhitespace(t *testing.T) { + tests := []string{ + "", + " ", + "claude\tsonnet", + "claude\nsonnet", + "claude\u00a0sonnet", + } + + for _, raw := range tests { + t.Run(raw, func(t *testing.T) { + if _, err := normalizePlatformModelName(raw); err == nil { + t.Fatal("expected unsafe platform model name to be rejected") + } + }) } } diff --git a/backend/internal/application/channel/service.go b/backend/internal/application/channel/service.go index cf127959..8f8bb7fe 100644 --- a/backend/internal/application/channel/service.go +++ b/backend/internal/application/channel/service.go @@ -58,6 +58,7 @@ type ResolvedRoute struct { ModelVendor string ModelIcon string ModelCapabilitiesJSON string + ModelSystemPrompt string UpstreamModel string UpstreamCbFailureThreshold int UpstreamCbModelThreshold int diff --git a/backend/internal/application/channel/service_helpers.go b/backend/internal/application/channel/service_helpers.go index 759e8656..e8c9274f 100644 --- a/backend/internal/application/channel/service_helpers.go +++ b/backend/internal/application/channel/service_helpers.go @@ -75,6 +75,7 @@ func toModelView(item repository.ChannelModelListRow) ModelView { KindsJSON: item.KindsJSON, Icon: item.Icon, CapabilitiesJSON: item.CapabilitiesJSON, + SystemPrompt: item.SystemPrompt, Status: item.Status, Description: item.Description, SortOrder: item.SortOrder, @@ -596,12 +597,16 @@ func normalizePlatformModelName(raw string) (string, error) { if value == "" { return "", ErrInvalidPlatformModelName } - if strings.ContainsFunc(value, unicode.IsSpace) { + if strings.ContainsFunc(value, hasUnsafeModelNameRune) { return "", ErrInvalidPlatformModelName } return value, nil } +func hasUnsafeModelNameRune(r rune) bool { + return unicode.IsControl(r) || (unicode.IsSpace(r) && r != ' ') +} + func generateBindingCode() string { return "upm_" + strings.ReplaceAll(uuid.NewString(), "-", "") } diff --git a/backend/internal/application/channel/service_model.go b/backend/internal/application/channel/service_model.go index a9c139e2..20b82d15 100644 --- a/backend/internal/application/channel/service_model.go +++ b/backend/internal/application/channel/service_model.go @@ -15,6 +15,8 @@ import ( // 模型管理 // --------------------------------------------------------------------------- +const maxSystemPromptChars = 20000 + // ListModelsInput 定义模型列表筛选排序条件。 type ListModelsInput struct { Query string @@ -220,6 +222,10 @@ func (s *Service) CreateModel(ctx context.Context, input CreateModelInput) (*Mod if err := validateOptionalJSON(strings.TrimSpace(input.CapabilitiesJSON)); err != nil { return nil, ErrInvalidJSONConfig } + systemPrompt := strings.TrimSpace(input.SystemPrompt) + if len([]rune(systemPrompt)) > maxSystemPromptChars { + return nil, ErrSystemPromptTooLong + } item := &domainchannel.PlatformModel{ PlatformModelName: platformModelName, @@ -227,6 +233,7 @@ func (s *Service) CreateModel(ctx context.Context, input CreateModelInput) (*Mod KindsJSON: kindsJSON, Icon: normalizeModelIcon(input.Icon, input.Vendor, platformModelName), CapabilitiesJSON: strings.TrimSpace(input.CapabilitiesJSON), + SystemPrompt: systemPrompt, Status: normalizeStatus(input.Status), Description: strings.TrimSpace(input.Description), } @@ -281,6 +288,13 @@ func (s *Service) UpdateModel(ctx context.Context, modelID uint, input UpdateMod } update.CapabilitiesJSON = &normalized } + if input.SystemPrompt != nil { + systemPrompt := strings.TrimSpace(*input.SystemPrompt) + if len([]rune(systemPrompt)) > maxSystemPromptChars { + return nil, ErrSystemPromptTooLong + } + update.SystemPrompt = &systemPrompt + } if input.Status != nil { status := normalizeStatus(*input.Status) update.Status = &status @@ -302,19 +316,22 @@ func (s *Service) UpdateModel(ctx context.Context, modelID uint, input UpdateMod } if update.IsZero() { - view := toModelView(repository.ChannelModelListRow{PlatformModel: *current}) - return &view, nil + return s.getModelViewByID(ctx, modelID) } if err := s.repo.UpdateModel(ctx, modelID, update); err != nil { return nil, err } s.InvalidateModelCatalog() - current, err = s.repo.GetModelByID(ctx, modelID) + return s.getModelViewByID(ctx, modelID) +} + +func (s *Service) getModelViewByID(ctx context.Context, modelID uint) (*ModelView, error) { + item, err := s.repo.GetModelListRowByID(ctx, modelID) if err != nil { return nil, err } - view := toModelView(repository.ChannelModelListRow{PlatformModel: *current}) + view := toModelView(*item) return &view, nil } @@ -419,10 +436,20 @@ func (s *Service) UpdateModelUpstreamSource(ctx context.Context, modelID uint, r if err != nil { return nil, err } + source, err := s.repo.GetModelUpstreamSourceByRouteID(ctx, modelItem.PlatformModelName, routeID) + if err != nil { + return nil, err + } updateInput := repository.UpdateChannelPlatformRouteInput{} if input.Protocol != nil { - protocol := strings.TrimSpace(*input.Protocol) + protocol, err := normalizeProtocol(*input.Protocol) + if err != nil { + return nil, err + } + if err := s.validateRouteProtocolCombination(ctx, source.UpstreamID, modelItem.ID, source.UpstreamModelID, routeID, protocol); err != nil { + return nil, err + } updateInput.Protocol = &protocol } if input.Status != nil { @@ -438,47 +465,25 @@ func (s *Service) UpdateModelUpstreamSource(ctx context.Context, modelID uint, r updateInput.Weight = &weight } - allSources, _, listErr := s.repo.ListModelUpstreamSources(ctx, modelItem.PlatformModelName, 0, 2000) - if listErr != nil { - return nil, listErr - } - var targetUpstreamID uint - for _, src := range allSources { - if src.ID == routeID { - targetUpstreamID = src.UpstreamID - break - } - } - if targetUpstreamID == 0 { - return nil, ErrUpstreamModelNotFound - } - if updateInput.IsZero() { - for _, src := range allSources { - if src.ID == routeID { - view := toModelUpstreamSourceView(src) - return &view, nil - } - } - return nil, ErrUpstreamModelNotFound + view := toModelUpstreamSourceView(*source) + return &view, nil } - if err := s.repo.UpdatePlatformModelRouteByID(ctx, routeID, targetUpstreamID, updateInput); err != nil { + if err := s.repo.UpdatePlatformModelRouteByID(ctx, routeID, source.UpstreamID, updateInput); err != nil { + if isDuplicateKeyError(err) { + return nil, ErrUpstreamModelConflict + } return nil, err } s.InvalidateModelCatalog() - allSources, _, err = s.repo.ListModelUpstreamSources(ctx, modelItem.PlatformModelName, 0, 500) + source, err = s.repo.GetModelUpstreamSourceByRouteID(ctx, modelItem.PlatformModelName, routeID) if err != nil { return nil, err } - for _, src := range allSources { - if src.ID == routeID { - view := toModelUpstreamSourceView(src) - return &view, nil - } - } - return nil, ErrUpstreamModelNotFound + view := toModelUpstreamSourceView(*source) + return &view, nil } // --------------------------------------------------------------------------- diff --git a/backend/internal/application/channel/service_routing.go b/backend/internal/application/channel/service_routing.go index f050ff93..1507aee0 100644 --- a/backend/internal/application/channel/service_routing.go +++ b/backend/internal/application/channel/service_routing.go @@ -318,6 +318,7 @@ func buildResolvedRoute(row repository.ChannelUpstreamRouteRow, apiKey string) * ModelVendor: strings.TrimSpace(row.ModelVendor), ModelIcon: strings.TrimSpace(row.ModelIcon), ModelCapabilitiesJSON: strings.TrimSpace(row.ModelCapabilitiesJSON), + ModelSystemPrompt: strings.TrimSpace(row.ModelSystemPrompt), UpstreamModel: strings.TrimSpace(row.UpstreamModelName), UpstreamCbFailureThreshold: row.UpstreamCbFailureThreshold, UpstreamCbModelThreshold: row.UpstreamCbModelThreshold, diff --git a/backend/internal/application/channel/service_sync.go b/backend/internal/application/channel/service_sync.go index 93d9a281..3b1c5977 100644 --- a/backend/internal/application/channel/service_sync.go +++ b/backend/internal/application/channel/service_sync.go @@ -11,7 +11,6 @@ import ( domainchannel "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/channel" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm" - "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" "go.uber.org/zap" ) @@ -34,7 +33,16 @@ func (s *Service) ListRemoteModels(ctx context.Context, upstreamID uint) (*Upstr return nil, err } - rows, _, _ := s.repo.ListUpstreamModels(ctx, upstreamID, repository.ListChannelUpstreamModelsInput{Offset: 0, Limit: 5000}) + remoteNames := make([]string, 0, len(items)) + for _, item := range items { + if name := strings.TrimSpace(item.ID); name != "" { + remoteNames = append(remoteNames, name) + } + } + rows, err := s.repo.ListUpstreamModelsByNames(ctx, upstreamID, remoteNames) + if err != nil { + return nil, err + } existingByName := make(map[string]repositoryUpstreamModelSnapshot, len(rows)) for _, row := range rows { name := strings.TrimSpace(row.UpstreamModelName) @@ -57,13 +65,18 @@ func (s *Service) ListRemoteModels(ctx context.Context, upstreamID uint) (*Upstr continue } kindsJSON := inferKindsJSON(name) - suggestedProtocol, _ := resolveRouteProtocol("", upstreamItem.Compatible, upstreamItem.ProtocolDefaultsJSON, kindsJSON) + suggestedProtocols, _ := resolveRouteProtocols(nil, upstreamItem.Compatible, upstreamItem.ProtocolDefaultsJSON, kindsJSON) + suggestedProtocol := "" + if len(suggestedProtocols) > 0 { + suggestedProtocol = suggestedProtocols[0] + } snapshot, alreadySynced := existingByName[name] views = append(views, UpstreamRemoteModelView{ UpstreamModelName: name, SuggestedPlatformModelName: name, SuggestedKindsJSON: kindsJSON, SuggestedProtocol: suggestedProtocol, + SuggestedProtocols: suggestedProtocols, BindingCode: snapshot.BindingCode, BoundPlatformModels: snapshot.BoundPlatformModels, UpstreamModelStatus: snapshot.Status, @@ -157,7 +170,8 @@ func (s *Service) SyncUpstreamModels(ctx context.Context, upstreamID uint) (*Syn // ImportUpstreamModels 批量把上游真实模型绑定到平台模型。 func (s *Service) ImportUpstreamModels(ctx context.Context, upstreamID uint, input ImportUpstreamModelsInput) (*ImportUpstreamModelsData, error) { - if _, err := s.repo.GetUpstreamByID(ctx, upstreamID); err != nil { + upstreamItem, err := s.repo.GetUpstreamByID(ctx, upstreamID) + if err != nil { if errors.Is(err, ErrUpstreamNotFound) { return nil, ErrUpstreamNotFound } @@ -169,7 +183,7 @@ func (s *Service) ImportUpstreamModels(ctx context.Context, upstreamID uint, inp Results: make([]ImportUpstreamModelResultView, 0, len(input.Items)), } for _, item := range input.Items { - imported, importErr := s.importSingleUpstreamModel(ctx, upstreamID, item) + imported, importErr := s.importSingleUpstreamModel(ctx, upstreamItem, item) if importErr != nil { result.FailedCount++ result.Results = append(result.Results, ImportUpstreamModelResultView{ @@ -182,11 +196,10 @@ func (s *Service) ImportUpstreamModels(ctx context.Context, upstreamID uint, inp } result.ImportedCount++ status := ImportUpstreamModelStatusExisting - if imported.CreatedRoute { - result.CreatedRoutes++ + result.CreatedRoutes += imported.CreatedRoutes + result.ExistingRoutes += imported.ExistingRoutes + if imported.CreatedRoutes > 0 { status = ImportUpstreamModelStatusCreated - } else { - result.ExistingRoutes++ } if imported.CreatedPlatform { result.CreatedPlatform++ @@ -290,7 +303,7 @@ func (s *Service) syncSingleUpstreamModel(ctx context.Context, up *domainchannel }, nil } -func (s *Service) importSingleUpstreamModel(ctx context.Context, upstreamID uint, input ImportUpstreamModelItemInput) (ImportUpstreamModelResultView, error) { +func (s *Service) importSingleUpstreamModel(ctx context.Context, upstreamItem *domainchannel.Upstream, input ImportUpstreamModelItemInput) (ImportUpstreamModelResultView, error) { platformModelName, err := normalizePlatformModelName(input.PlatformModelName) if err != nil { return ImportUpstreamModelResultView{}, err @@ -304,41 +317,54 @@ func (s *Service) importSingleUpstreamModel(ctx context.Context, upstreamID uint if platformErr != nil && !createdPlatform { return ImportUpstreamModelResultView{}, platformErr } - createdRoute := !s.routeExists(ctx, upstreamID, platformModelName, upstreamModelName) - view, err := s.UpsertUpstreamModel(ctx, upstreamID, UpsertUpstreamModelInput{ - PlatformModelName: platformModelName, - UpstreamModelName: upstreamModelName, - Protocol: input.Protocol, - KindsJSON: input.KindsJSON, - Status: input.Status, - Priority: input.Priority, - Weight: 1, - Source: "import", - }) + kindsJSON := strings.TrimSpace(input.KindsJSON) + if kindsJSON == "" { + kindsJSON = inferKindsJSON(platformModelName) + } + explicitProtocols := append([]string{}, input.Protocols...) + if len(explicitProtocols) == 0 && strings.TrimSpace(input.Protocol) != "" { + explicitProtocols = append(explicitProtocols, input.Protocol) + } + protocols, err := resolveRouteProtocols(explicitProtocols, upstreamItem.Compatible, upstreamItem.ProtocolDefaultsJSON, kindsJSON) if err != nil { return ImportUpstreamModelResultView{}, err } - return ImportUpstreamModelResultView{ - UpstreamModelName: view.UpstreamModelName, - PlatformModelName: view.PlatformModelName, - BindingCode: view.BindingCode, - CreatedRoute: createdRoute, + result := ImportUpstreamModelResultView{ + UpstreamModelName: upstreamModelName, + PlatformModelName: platformModelName, CreatedPlatform: createdPlatform, - }, nil -} - -func (s *Service) routeExists(ctx context.Context, upstreamID uint, platformModelName string, upstreamModelName string) bool { - rows, _, err := s.repo.ListUpstreamModels(ctx, upstreamID, repository.ListChannelUpstreamModelsInput{Offset: 0, Limit: 5000}) - if err != nil { - return false + Protocols: protocols, } - for _, row := range rows { - if strings.TrimSpace(row.PlatformModelName) == platformModelName && - strings.TrimSpace(row.UpstreamModelName) == upstreamModelName && - row.RouteID > 0 { - return true + for _, protocol := range protocols { + createdRoute := !s.routeExists(ctx, upstreamItem.ID, platformModelName, upstreamModelName, protocol) + view, err := s.UpsertUpstreamModel(ctx, upstreamItem.ID, UpsertUpstreamModelInput{ + PlatformModelName: platformModelName, + UpstreamModelName: upstreamModelName, + Protocol: protocol, + KindsJSON: kindsJSON, + Status: input.Status, + Priority: input.Priority, + Weight: 1, + Source: "import", + }) + if err != nil { + return ImportUpstreamModelResultView{}, err + } + if result.BindingCode == "" { + result.BindingCode = view.BindingCode + } + if createdRoute { + result.CreatedRoutes++ + result.CreatedRoute = true + } else { + result.ExistingRoutes++ } } - return false + return result, nil +} + +func (s *Service) routeExists(ctx context.Context, upstreamID uint, platformModelName string, upstreamModelName string, protocol string) bool { + _, err := s.repo.GetUpstreamModelRouteByNames(ctx, upstreamID, platformModelName, upstreamModelName, protocol) + return err == nil } diff --git a/backend/internal/application/channel/service_upstream_model.go b/backend/internal/application/channel/service_upstream_model.go index 33ceb2d1..cdfc7590 100644 --- a/backend/internal/application/channel/service_upstream_model.go +++ b/backend/internal/application/channel/service_upstream_model.go @@ -68,7 +68,9 @@ func (s *Service) UpsertUpstreamModel(ctx context.Context, upstreamID uint, inpu return nil, ErrInvalidJSONConfig } - kindsJSON := strings.TrimSpace(input.KindsJSON) + rawKindsJSON := strings.TrimSpace(input.KindsJSON) + kindsExplicit := rawKindsJSON != "" + kindsJSON := rawKindsJSON if kindsJSON == "" { kindsJSON = inferKindsJSON(platformModelName) } @@ -81,16 +83,25 @@ func (s *Service) UpsertUpstreamModel(ctx context.Context, upstreamID uint, inpu return nil, err } - platformModel, _, err := s.ensurePlatformModel(ctx, platformModelName, kindsJSON, upstreamModelName) + platformModel, platformModelCreated, err := s.ensurePlatformModel(ctx, platformModelName, kindsJSON, upstreamModelName) if err != nil { return nil, err } + if !platformModelCreated && kindsExplicit && strings.TrimSpace(platformModel.KindsJSON) != kindsJSON { + if err := s.repo.UpdateModel(ctx, platformModel.ID, repository.UpdateChannelModelInput{KindsJSON: &kindsJSON}); err != nil { + return nil, err + } + platformModel.KindsJSON = kindsJSON + } upstreamModelVendor := normalizeUpstreamModelVendor("", upstreamModelName, upstream.Name, upstream.BaseURL) upstreamModelIcon := normalizeModelIcon("", upstreamModelVendor, upstreamModelName) upstreamModel, err := s.upsertUpstreamCatalogModel(ctx, upstream.ID, upstreamModelName, protocol, kindsJSON, upstreamModelVendor, upstreamModelIcon, "active", normalizeSource(input.Source), "{}") if err != nil { return nil, err } + if err := s.validateRouteProtocolCombination(ctx, upstream.ID, platformModel.ID, upstreamModel.ID, input.RouteID, protocol); err != nil { + return nil, err + } route := &domainchannel.PlatformModelRoute{ PlatformModelID: platformModel.ID, @@ -140,6 +151,33 @@ func (s *Service) UpsertUpstreamModel(ctx context.Context, upstreamID uint, inpu return s.findUpstreamModelViewByRoute(ctx, upstream.ID, route.ID, upstreamModel.ID) } +func (s *Service) validateRouteProtocolCombination( + ctx context.Context, + upstreamID uint, + platformModelID uint, + upstreamModelID uint, + routeID uint, + protocol string, +) error { + // 同一个平台模型到同一个上游真实模型只允许单协议,或同厂商图片生成/编辑成对组合。 + routes, err := s.repo.ListPlatformModelRoutesByPair(ctx, upstreamID, platformModelID, upstreamModelID) + if err != nil { + return err + } + protocols := make([]string, 0, len(routes)+1) + for _, route := range routes { + if route.ID == routeID { + continue + } + protocols = append(protocols, route.Protocol) + } + protocols = append(protocols, protocol) + if !isSupportedRouteProtocolCombination(protocols) { + return ErrInvalidRouteProtocolCombination + } + return nil +} + func (s *Service) ensurePlatformModel(ctx context.Context, platformModelName string, kindsJSON string, candidates ...string) (*domainchannel.PlatformModel, bool, error) { if item, err := s.repo.GetModelByName(ctx, platformModelName); err == nil { return item, false, nil @@ -226,21 +264,15 @@ func (s *Service) upsertUpstreamCatalogModel( } func (s *Service) findUpstreamModelViewByRoute(ctx context.Context, upstreamID uint, routeID uint, upstreamModelID uint) (*UpstreamModelView, error) { - rows, _, err := s.repo.ListUpstreamModels(ctx, upstreamID, repository.ListChannelUpstreamModelsInput{Offset: 0, Limit: 5000}) + row, err := s.repo.GetUpstreamModelRouteByID(ctx, upstreamID, routeID) if err != nil { return nil, err } - for _, row := range rows { - if routeID > 0 && row.RouteID == routeID { - view := toUpstreamModelView(row) - return &view, nil - } - if routeID == 0 && row.ID == upstreamModelID { - view := toUpstreamModelView(row) - return &view, nil - } + if upstreamModelID > 0 && row.ID != upstreamModelID { + return nil, ErrUpstreamModelNotFound } - return nil, ErrUpstreamModelNotFound + view := toUpstreamModelView(*row) + return &view, nil } // DeleteUpstreamModel 删除平台路由绑定,保留上游真实模型清单。 @@ -316,17 +348,12 @@ func (s *Service) ResetUpstreamModelCircuit(ctx context.Context, upstreamID uint } func (s *Service) routeBindingCode(ctx context.Context, upstreamID uint, routeID uint) (string, error) { - rows, _, err := s.repo.ListUpstreamModels(ctx, upstreamID, repository.ListChannelUpstreamModelsInput{Offset: 0, Limit: 5000}) + row, err := s.repo.GetUpstreamModelRouteByID(ctx, upstreamID, routeID) if err != nil { return "", err } - for _, row := range rows { - if row.RouteID == routeID { - if strings.TrimSpace(row.BindingCode) == "" { - return "", ErrUpstreamModelNotFound - } - return row.BindingCode, nil - } + if strings.TrimSpace(row.BindingCode) == "" { + return "", ErrUpstreamModelNotFound } - return "", ErrUpstreamModelNotFound + return row.BindingCode, nil } diff --git a/backend/internal/application/conversation/context_assembler.go b/backend/internal/application/conversation/context_assembler.go index da2c3227..5cb99cba 100644 --- a/backend/internal/application/conversation/context_assembler.go +++ b/backend/internal/application/conversation/context_assembler.go @@ -12,6 +12,7 @@ import ( type ContextSlotKind string const ( + SlotSystemPrompt ContextSlotKind = "system_prompt" SlotPreference ContextSlotKind = "preference" SlotMemory ContextSlotKind = "memory" SlotSnapshot ContextSlotKind = "snapshot" @@ -24,6 +25,7 @@ const ( // slotPriority 优先级硬编码:数值越大越优先保留。 var slotPriority = map[ContextSlotKind]int{ SlotInput: 100, + SlotSystemPrompt: 95, SlotPreference: 90, SlotSnapshot: 80, SlotRAG: 70, diff --git a/backend/internal/application/conversation/errs.go b/backend/internal/application/conversation/errs.go index 542b0224..003297eb 100644 --- a/backend/internal/application/conversation/errs.go +++ b/backend/internal/application/conversation/errs.go @@ -13,6 +13,10 @@ var ( ErrConversationShareSchemaOutdated = errors.New("conversation share schema outdated") // ErrInvalidConversationTitle 会话标题不合法。 ErrInvalidConversationTitle = errors.New("invalid conversation title") + // ErrConversationProjectNotFound 会话项目不存在或无权限。 + ErrConversationProjectNotFound = errors.New("conversation project not found") + // ErrInvalidConversationProject 会话项目请求不合法。 + ErrInvalidConversationProject = errors.New("invalid conversation project") // ErrInvalidFileReference 文件引用无效。 ErrInvalidFileReference = errors.New("invalid file reference") // ErrInvalidFileName 文件名不合法。 @@ -55,8 +59,18 @@ var ( ErrMessageGenerationCanceled = errors.New("message generation canceled") // ErrInvalidMediaGenerationTask 媒体生成任务类型或输入不合法。 ErrInvalidMediaGenerationTask = errors.New("invalid media generation task") + // ErrMediaImagePromptRequired 图片任务提示词不能为空。 + ErrMediaImagePromptRequired = errors.New("image prompt is required") + // ErrMediaImageGenerationRejectsInputs 图片生成任务不能携带输入图。 + ErrMediaImageGenerationRejectsInputs = errors.New("image generation does not accept input images") + // ErrMediaImageEditInputRequired 图片编辑任务必须携带至少一张输入图。 + ErrMediaImageEditInputRequired = errors.New("image edit requires at least one input image") + // ErrMediaImageEditTooManyInputs 图片编辑输入图数量超限。 + ErrMediaImageEditTooManyInputs = errors.New("too many image edit input images") + // ErrMediaImageEditInputInvalid 图片编辑输入图不合法。 + ErrMediaImageEditInputInvalid = errors.New("image edit input image is invalid") + // ErrMediaRouteProtocolMismatch 图片任务命中的路由协议与任务类型不匹配。 + ErrMediaRouteProtocolMismatch = errors.New("media route protocol does not match task") // ErrDuplicateMessageGenerationRun 表示客户端重复提交同一个生成 run。 ErrDuplicateMessageGenerationRun = errors.New("duplicate message generation run") - // ErrMediaImageEditNotImplemented 图片编辑协议尚未实现。 - ErrMediaImageEditNotImplemented = errors.New("image edit protocol not implemented") ) diff --git a/backend/internal/application/conversation/model_option_policy.go b/backend/internal/application/conversation/model_option_policy.go index 09e99e9e..5a91b6c4 100644 --- a/backend/internal/application/conversation/model_option_policy.go +++ b/backend/internal/application/conversation/model_option_policy.go @@ -290,7 +290,7 @@ func sanitizeModelOptionValues(options map[string]interface{}, protocolKey strin default: delete(options, "service_tier") } - case "openai_image_generations": + case "openai_image_generations", "openai_image_edits": value, ok := modelParamIntFromOption(options["partial_images"]) if !ok { delete(options, "partial_images") @@ -325,10 +325,14 @@ func modelOptionPolicyProtocolKey(protocol string) string { return "openai_chat_completions" case llm.AdapterOpenAIImageGenerations: return "openai_image_generations" + case llm.AdapterOpenAIImageEdits: + return "openai_image_edits" case llm.AdapterAnthropicMessages: return "anthropic_messages" case llm.AdapterXAIImage: return "xai_image" + case llm.AdapterXAIImageEdits: + return "xai_image_edits" case llm.AdapterXAIResponses: return "xai_responses" default: diff --git a/backend/internal/application/conversation/model_option_policy_test.go b/backend/internal/application/conversation/model_option_policy_test.go index 7588089f..5707d54a 100644 --- a/backend/internal/application/conversation/model_option_policy_test.go +++ b/backend/internal/application/conversation/model_option_policy_test.go @@ -279,6 +279,37 @@ func TestFilterModelOptionsOpenAIImageGenerationsAllowsImageParams(t *testing.T) } } +func TestFilterModelOptionsOpenAIImageEditsAllowsEditParams(t *testing.T) { + filtered := filterModelOptions(map[string]interface{}{ + "background": "transparent", + "input_fidelity": "high", + "n": 1, + "output_compression": 80, + "output_format": "webp", + "partial_images": 2, + "quality": "high", + "size": "1024x1024", + "prompt": "override", + "stream": true, + }, llm.AdapterOpenAIImageEdits, modelOptionPolicyConfig{ + Mode: modelOptionPolicyAllowlist, + AllowedPathsJSON: config.DefaultModelOptionAllowedPathsJSON(), + DeniedPathsJSON: config.DefaultModelOptionDeniedPathsJSON(), + }) + + if filtered["background"] != "transparent" || filtered["input_fidelity"] != "high" { + t.Fatalf("expected image edit params to pass, got %#v", filtered) + } + if filtered["partial_images"] != 2 || filtered["output_format"] != "webp" { + t.Fatalf("expected image edit output params to pass, got %#v", filtered) + } + for _, key := range []string{"prompt", "stream"} { + if _, ok := filtered[key]; ok { + t.Fatalf("expected %s to be hard denied, got %#v", key, filtered) + } + } +} + func TestFilterModelOptionsXAIImageAllowsImageParams(t *testing.T) { filtered := filterModelOptions(map[string]interface{}{ "aspect_ratio": "16:9", diff --git a/backend/internal/application/conversation/service_conversation.go b/backend/internal/application/conversation/service_conversation.go index 2f69cd48..42d65dc6 100644 --- a/backend/internal/application/conversation/service_conversation.go +++ b/backend/internal/application/conversation/service_conversation.go @@ -16,16 +16,28 @@ const ( ) // CreateConversation 创建用户新会话。 -func (s *Service) CreateConversation(ctx context.Context, userID uint, title string, modelName string) (*model.Conversation, error) { +func (s *Service) CreateConversation(ctx context.Context, userID uint, title string, modelName string, projectPublicID string) (*model.Conversation, error) { normalizedTitle := strings.TrimSpace(title) if normalizedTitle == "" { normalizedTitle = "新会话" } normalizedModel := strings.TrimSpace(modelName) + var projectID *uint + if normalizedProjectID := strings.TrimSpace(projectPublicID); normalizedProjectID != "" { + project, err := s.repo.GetConversationProjectByPublicID(ctx, userID, normalizedProjectID) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil, ErrConversationProjectNotFound + } + return nil, err + } + projectID = &project.ID + } item := &model.Conversation{ UserID: userID, + ProjectID: projectID, PublicID: normalizePublicID(uuid.NewString()), Title: normalizedTitle, LabelsJSON: "[]", @@ -53,9 +65,10 @@ func (s *Service) ListConversations( statusFilter string, starredFilter string, shareFilter string, + projectFilter string, ) ([]model.Conversation, int64, error) { offset, limit := normalizePage(page, pageSize) - return s.repo.ListConversationsByUser(ctx, userID, offset, limit, statusFilter, starredFilter, shareFilter) + return s.repo.ListConversationsByUser(ctx, userID, offset, limit, statusFilter, starredFilter, shareFilter, normalizeConversationProjectFilter(projectFilter)) } // ListMessages 查询会话消息(分页)。 diff --git a/backend/internal/application/conversation/service_helpers.go b/backend/internal/application/conversation/service_helpers.go index b76f8bef..e4b6cc71 100644 --- a/backend/internal/application/conversation/service_helpers.go +++ b/backend/internal/application/conversation/service_helpers.go @@ -173,6 +173,18 @@ func classifyRunErrorCode(err error) string { return "upstream_empty_response" case errors.Is(err, ErrMessageGenerationCanceled): return "generation_canceled" + case errors.Is(err, ErrMediaImagePromptRequired): + return "media_image_prompt_required" + case errors.Is(err, ErrMediaImageGenerationRejectsInputs): + return "media_image_generation_rejects_inputs" + case errors.Is(err, ErrMediaImageEditInputRequired): + return "media_image_edit_input_required" + case errors.Is(err, ErrMediaImageEditTooManyInputs): + return "media_image_edit_too_many_inputs" + case errors.Is(err, ErrMediaImageEditInputInvalid): + return "media_image_edit_input_invalid" + case errors.Is(err, ErrMediaRouteProtocolMismatch): + return "media_route_protocol_mismatch" case errors.Is(err, ErrUpstreamRequestFailed): return "upstream_request_failed" default: diff --git a/backend/internal/application/conversation/service_media_generation.go b/backend/internal/application/conversation/service_media_generation.go index 97e52b23..1f4eb083 100644 --- a/backend/internal/application/conversation/service_media_generation.go +++ b/backend/internal/application/conversation/service_media_generation.go @@ -15,6 +15,7 @@ import ( model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/pkg/traceid" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/security" "github.com/google/uuid" "go.uber.org/zap" @@ -26,10 +27,12 @@ type MediaImageTaskType string const ( // MediaImageTaskGeneration 表示纯文本提示词生成图片任务。 MediaImageTaskGeneration MediaImageTaskType = "image_generation" - // MediaImageTaskEdit 表示基于输入图片的编辑任务,当前接口已预留但 adapter 尚未落地。 + // MediaImageTaskEdit 表示基于输入图片的编辑任务。 MediaImageTaskEdit MediaImageTaskType = "image_edit" ) +const maxMediaImageEditInputImages = 16 + // MediaImageInput 定义媒体图片任务的应用层入参。 type MediaImageInput struct { UserID uint @@ -41,6 +44,7 @@ type MediaImageInput struct { Options map[string]interface{} ClientRunID string FileIDs []string + MaskFileID string ParentMessagePublicID string SourceMessagePublicID string BranchReason string @@ -50,17 +54,17 @@ type MediaImageInput struct { // StreamMediaImage 执行图片生成任务并把结果保存为文件对象。 // 图片能力不复用聊天生成链路,只通过图片任务类型和图片协议路由。 func (s *Service) StreamMediaImage(ctx context.Context, input MediaImageInput) (*SendMessageResult, error) { - if input.TaskType == MediaImageTaskEdit { - return nil, ErrMediaImageEditNotImplemented - } - if input.TaskType != MediaImageTaskGeneration { + if input.TaskType != MediaImageTaskGeneration && input.TaskType != MediaImageTaskEdit { return nil, ErrInvalidMediaGenerationTask } if strings.TrimSpace(input.Prompt) == "" { - return nil, ErrInvalidMediaGenerationTask + return nil, ErrMediaImagePromptRequired } - if len(input.FileIDs) > 0 { - return nil, ErrInvalidMediaGenerationTask + if input.TaskType == MediaImageTaskGeneration && len(input.FileIDs) > 0 { + return nil, ErrMediaImageGenerationRejectsInputs + } + if input.TaskType == MediaImageTaskEdit && len(input.FileIDs) == 0 { + return nil, ErrMediaImageEditInputRequired } if s.routeResolver == nil || s.llmClient == nil { return nil, ErrModelRouteNotConfigured @@ -96,9 +100,15 @@ func (s *Service) StreamMediaImage(ctx context.Context, input MediaImageInput) ( if platformModelName == "" { return nil, ErrModelRouteNotConfigured } + taskRouteType := channel.TaskTypeImageGeneration + endpoint := llm.EndpointImageGenerations + if input.TaskType == MediaImageTaskEdit { + taskRouteType = channel.TaskTypeImageEdit + endpoint = llm.EndpointImageEdits + } route, err := s.routeResolver.ResolveRoute(ctx, channel.ResolveRouteInput{ PlatformModelName: platformModelName, - TaskType: channel.TaskTypeImageGeneration, + TaskType: taskRouteType, UserID: input.UserID, ConversationID: input.ConversationID, RequestID: strings.TrimSpace(input.RequestID), @@ -106,8 +116,11 @@ func (s *Service) StreamMediaImage(ctx context.Context, input MediaImageInput) ( if err != nil { return nil, ErrModelRouteNotConfigured } - if !llm.IsImageGenerationAdapter(route.Protocol) { - return nil, ErrInvalidMediaGenerationTask + if input.TaskType == MediaImageTaskGeneration && !llm.IsImageGenerationAdapter(route.Protocol) { + return nil, ErrMediaRouteProtocolMismatch + } + if input.TaskType == MediaImageTaskEdit && !llm.IsImageEditAdapter(route.Protocol) { + return nil, ErrMediaRouteProtocolMismatch } // 图片任务会把会话当前模型更新为实际执行的图片模型;标题、标签等内部文本任务会单独回退到聊天模型。 if strings.TrimSpace(conversation.Model) != strings.TrimSpace(route.PlatformModelName) { @@ -124,13 +137,23 @@ func (s *Service) StreamMediaImage(ctx context.Context, input MediaImageInput) ( return nil, err } + resolvedAttachments, imageEditParts, err := s.resolveMediaImageEditInputs(ctx, input) + if err != nil { + return nil, err + } + maskPart, err := s.resolveMediaImageEditMask(ctx, input.UserID, input.MaskFileID) + if err != nil { + return nil, err + } + attachmentsJSON := marshalAttachmentSnapshots(resolvedAttachments) + run := &model.Run{ RunID: runID, RequestID: strings.TrimSpace(input.RequestID), UserID: input.UserID, ConversationID: input.ConversationID, TaskType: string(input.TaskType), - Endpoint: llm.EndpointImageGenerations, + Endpoint: endpoint, Provider: strings.TrimSpace(conversation.Provider), ProviderProtocol: route.Protocol, UpstreamID: route.UpstreamID, @@ -173,39 +196,56 @@ func (s *Service) StreamMediaImage(ctx context.Context, input MediaImageInput) ( ParentMessageID: branchState.ParentMessageID, RunID: runID, Role: "user", - ContentType: "text", + ContentType: mediaImageUserContentType(input.TaskType), Content: strings.TrimSpace(input.Prompt), BranchReason: normalizedBranchReason, SourceMessageID: branchState.SourceMessageID, TokenUsage: estimateTokens(input.Prompt), InputTokens: estimateTokens(input.Prompt), Status: "success", - Attachments: "[]", - } - if err = s.repo.CreateMessage(ctx, userMessage); err != nil { - retErr = err - return nil, err + Attachments: attachmentsJSON, + } + userAttachmentRows := make([]model.Attachment, 0, len(resolvedAttachments)) + if len(resolvedAttachments) > 0 { + now := time.Now() + for _, item := range resolvedAttachments { + userAttachmentRows = append(userAttachmentRows, model.Attachment{ + ConversationID: input.ConversationID, + UserID: input.UserID, + FileID: strings.TrimSpace(item.FileID), + Kind: normalizeAttachmentKind(item.Kind, item.MimeType), + FileName: strings.TrimSpace(item.FileName), + MimeType: strings.TrimSpace(item.MimeType), + FileSize: item.FileSize, + SHA256: strings.TrimSpace(item.SHA256), + StoragePath: strings.TrimSpace(item.StoragePath), + Status: "active", + MetaJSON: strings.TrimSpace(item.MetaJSON), + UploadedAt: now, + }) + } } - userMessage.ParentPublicID = branchState.ParentPublicID - userMessage.SourcePublicID = branchState.SourcePublicID assistantMessage := &model.Message{ - ConversationID: input.ConversationID, - UserID: input.UserID, - PublicID: normalizePublicID(uuid.NewString()), - ParentMessageID: &userMessage.ID, - RunID: runID, - Role: "assistant", - ContentType: "image", - Content: "", - BranchReason: normalizedBranchReason, - Status: "pending", - Attachments: "[]", - } - if err = s.repo.CreateMessage(ctx, assistantMessage); err != nil { + ConversationID: input.ConversationID, + UserID: input.UserID, + PublicID: normalizePublicID(uuid.NewString()), + RunID: runID, + Role: "assistant", + ContentType: "image", + Content: "", + BranchReason: normalizedBranchReason, + Status: "pending", + Attachments: "[]", + } + // 媒体任务同样产生一个完整消息回合,初始本地写入必须原子提交。 + if err = s.repo.CreateMessagePairWithUserAttachments(ctx, userMessage, assistantMessage, userAttachmentRows); err != nil { retErr = err return nil, err } + userMessage.ParentPublicID = branchState.ParentPublicID + userMessage.SourcePublicID = branchState.SourcePublicID + assistantMessage.ParentPublicID = userMessage.PublicID traceRecorder := newMessageTraceRecorder(s, ctx, assistantMessage, input.OnEvent) defer func() { if retErr != nil && traceRecorder != nil { @@ -213,11 +253,6 @@ func (s *Service) StreamMediaImage(ctx context.Context, input MediaImageInput) ( traceRecorder.attachToMessage(assistantMessage) } }() - // 媒体任务同样产生用户消息和助手消息,计数语义与普通聊天保持一致。 - if err = s.repo.IncrementMessageCount(ctx, input.ConversationID, 2); err != nil { - retErr = err - return nil, err - } emitMediaEvent(input.OnEvent, "queued", "image task queued") cfg := s.cfg.Snapshot() @@ -230,7 +265,7 @@ func (s *Service) StreamMediaImage(ctx context.Context, input MediaImageInput) ( ConnectTimeoutMS: route.ConnectTimeoutMS, ReadTimeoutMS: route.ReadTimeoutMS, StreamIdleTimeoutMS: route.StreamIdleTimeoutMS, - Endpoint: llm.EndpointImageGenerations, + Endpoint: endpoint, UpstreamModel: route.UpstreamModel, AttributionReferer: attributionReferer, AttributionTitle: attributionTitle, @@ -242,7 +277,7 @@ func (s *Service) StreamMediaImage(ctx context.Context, input MediaImageInput) ( NativeToolAllowedTypesJSON: cfg.NativeToolAllowedTypes, }) - emitMediaEvent(input.OnEvent, "running", "generating image") + emitMediaEvent(input.OnEvent, "running", mediaImageRunningMessage(input.TaskType)) generateInput := llm.GenerateInput{ RequestID: strings.TrimSpace(input.RequestID), ConversationID: input.ConversationID, @@ -252,6 +287,19 @@ func (s *Service) StreamMediaImage(ctx context.Context, input MediaImageInput) ( }}, Options: filteredOptions, } + if input.TaskType == MediaImageTaskEdit { + parts := make([]llm.ContentPart, 0, 1+len(imageEditParts)) + parts = append(parts, llm.ContentPart{ + Kind: llm.ContentPartText, + Text: strings.TrimSpace(input.Prompt), + }) + parts = append(parts, imageEditParts...) + generateInput.Messages = []llm.Message{{ + Role: "user", + Parts: parts, + }} + generateInput.ImageEditMask = maskPart + } var output *llm.GenerateOutput if llm.SupportsImageGenerationStream(routeConfig.Protocol, routeConfig.UpstreamModel) { output, err = s.llmClient.GenerateStream(ctx, routeConfig, generateInput, func(event llm.GenerateStreamEvent) error { @@ -329,32 +377,32 @@ func (s *Service) StreamMediaImage(ctx context.Context, input MediaImageInput) ( UploadedAt: now, }) } - if err = s.repo.CreateAttachments(ctx, attachmentRows); err != nil { - retErr = err - return nil, err - } - usage := output.Usage userMessage.InputTokens = usage.InputTokens userMessage.CacheReadTokens = usage.CacheReadTokens userMessage.CacheWriteTokens = usage.CacheWriteTokens userMessage.TokenUsage = usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens - if err = s.repo.UpdateMessageUsage( - ctx, - userMessage.ID, - usage.InputTokens, - 0, - usage.CacheReadTokens, - usage.CacheWriteTokens, - 0, - ); err != nil { - retErr = err - return nil, err - } content := generatedImageMarkdown(uploaded) latencyMS := time.Since(startedAt).Milliseconds() - if err = s.repo.UpdateAssistantMessageCompletion(ctx, assistantMessage.ID, content, usage.OutputTokens, usage.ReasoningTokens, latencyMS, "success", "", ""); err != nil { + // 上游与文件上传已完成后,数据库侧的附件、用量和完成态仍需保持原子一致。 + if err = s.repo.CompleteAssistantMessageWithAttachments(ctx, + userMessage.ID, + repository.MessageUsageUpdate{ + InputTokens: usage.InputTokens, + CacheReadTokens: usage.CacheReadTokens, + CacheWriteTokens: usage.CacheWriteTokens, + }, + assistantMessage.ID, + repository.AssistantMessageCompletionUpdate{ + Content: content, + OutputTokens: usage.OutputTokens, + ReasoningTokens: usage.ReasoningTokens, + LatencyMS: latencyMS, + Status: "success", + }, + attachmentRows, + ); err != nil { retErr = err return nil, err } @@ -391,6 +439,95 @@ func (s *Service) StreamMediaImage(ctx context.Context, input MediaImageInput) ( }, nil } +func mediaImageUserContentType(taskType MediaImageTaskType) string { + if taskType == MediaImageTaskEdit { + return "mixed" + } + return "text" +} + +func mediaImageRunningMessage(taskType MediaImageTaskType) string { + if taskType == MediaImageTaskEdit { + return "editing image" + } + return "generating image" +} + +// resolveMediaImageEditInputs 读取图片编辑输入图,确保只有图片文件进入图片编辑协议。 +func (s *Service) resolveMediaImageEditInputs(ctx context.Context, input MediaImageInput) ([]AttachmentInput, []llm.ContentPart, error) { + if input.TaskType != MediaImageTaskEdit { + return nil, nil, nil + } + attachments, err := s.resolveAttachments(ctx, input.UserID, input.FileIDs) + if err != nil { + return nil, nil, err + } + if len(attachments) == 0 || len(attachments) > maxMediaImageEditInputImages { + if len(attachments) == 0 { + return nil, nil, ErrMediaImageEditInputRequired + } + return nil, nil, ErrMediaImageEditTooManyInputs + } + parts := make([]llm.ContentPart, 0, len(attachments)) + for _, attachment := range attachments { + if normalizeAttachmentKind(attachment.Kind, attachment.MimeType) != "image" { + return nil, nil, ErrMediaImageEditInputInvalid + } + part, readErr := s.readMediaImageEditFile(ctx, input.UserID, attachment.FileID) + if readErr != nil { + return nil, nil, readErr + } + part.FileName = strings.TrimSpace(attachment.FileName) + parts = append(parts, part) + } + return attachments, parts, nil +} + +func (s *Service) resolveMediaImageEditMask(ctx context.Context, userID uint, fileID string) (*llm.ContentPart, error) { + if strings.TrimSpace(fileID) == "" { + return nil, nil + } + part, err := s.readMediaImageEditFile(ctx, userID, fileID) + if err != nil { + return nil, err + } + return &part, nil +} + +func (s *Service) readMediaImageEditFile(ctx context.Context, userID uint, fileID string) (llm.ContentPart, error) { + content, err := s.OpenFileContent(ctx, userID, strings.TrimSpace(fileID)) + if err != nil { + return llm.ContentPart{}, err + } + defer content.Reader.Close() //nolint:errcheck + + limit := s.cfg.Snapshot().MaxUploadFileBytes + if limit <= 0 { + limit = 20 * 1024 * 1024 + } + data, err := io.ReadAll(io.LimitReader(content.Reader, limit+1)) + if err != nil { + return llm.ContentPart{}, err + } + if int64(len(data)) > limit { + return llm.ContentPart{}, ErrFileTooLarge + } + mimeType := strings.TrimSpace(content.ContentType) + if mimeType == "" { + mimeType = strings.TrimSpace(content.File.DetectedMIME) + } + data, mimeType, err = validateGeneratedImageBytes(data, mimeType) + if err != nil { + return llm.ContentPart{}, ErrMediaImageEditInputInvalid + } + return llm.ContentPart{ + Kind: llm.ContentPartImage, + MimeType: mimeType, + Data: data, + FileName: strings.TrimSpace(content.File.FileName), + }, nil +} + // emitMediaEvent 输出媒体任务状态事件;失败不影响主流程。 func emitMediaEvent(onEvent func(string, map[string]interface{}) error, status string, message string) { if onEvent == nil { diff --git a/backend/internal/application/conversation/service_message_send.go b/backend/internal/application/conversation/service_message_send.go index 06fed9ff..9c34b6c0 100644 --- a/backend/internal/application/conversation/service_message_send.go +++ b/backend/internal/application/conversation/service_message_send.go @@ -228,19 +228,11 @@ func (s *Service) sendMessageInternal( ErrorMessage: "", Attachments: string(attachmentsJSON), } - if err = s.repo.CreateMessage(ctx, userMessage); err != nil { - retErr = err - return nil, err - } - userMessage.ParentPublicID = branchState.ParentPublicID - userMessage.SourcePublicID = branchState.SourcePublicID - attachmentRows := make([]model.Attachment, 0, len(resolvedAttachments)) now := time.Now() for _, item := range resolvedAttachments { attachmentRows = append(attachmentRows, model.Attachment{ ConversationID: input.ConversationID, - MessageID: userMessage.ID, UserID: input.UserID, FileID: strings.TrimSpace(item.FileID), Kind: normalizeAttachmentKind(item.Kind, item.MimeType), @@ -254,16 +246,11 @@ func (s *Service) sendMessageInternal( UploadedAt: now, }) } - if err = s.repo.CreateAttachments(ctx, attachmentRows); err != nil { - retErr = err - return nil, err - } assistantMessage = &model.Message{ ConversationID: input.ConversationID, UserID: input.UserID, PublicID: normalizePublicID(uuid.NewString()), - ParentMessageID: &userMessage.ID, RunID: runID, Role: "assistant", ContentType: "text", @@ -281,16 +268,14 @@ func (s *Service) sendMessageInternal( ErrorMessage: "", Attachments: "[]", } - if err = s.repo.CreateMessage(ctx, assistantMessage); err != nil { + // 用户消息、助手占位、用户附件与消息计数必须一起提交,避免失败时留下半个回合。 + if err = s.repo.CreateMessagePairWithUserAttachments(ctx, userMessage, assistantMessage, attachmentRows); err != nil { retErr = err return nil, err } + userMessage.ParentPublicID = branchState.ParentPublicID + userMessage.SourcePublicID = branchState.SourcePublicID assistantMessage.ParentPublicID = userMessage.PublicID - // 两条消息同批次创建,合并为一次 +2,减少一个 DB 往返 - if err = s.repo.IncrementMessageCount(ctx, input.ConversationID, 2); err != nil { - retErr = err - return nil, err - } traceRecorder = newMessageTraceRecorder(s, ctx, assistantMessage, input.OnEvent) if s.routeResolver == nil || s.llmClient == nil { @@ -418,6 +403,14 @@ func (s *Service) sendMessageInternal( // ContextAssembler 只承载真正的系统级行为指令;资料型上下文稍后进入用户 XML。 assembler := NewContextAssembler(int64(cfg.ContextMaxInputTokens)) + systemPrompt := resolveSystemPromptInjection(cfg, route) + if systemPrompt.Content != "" { + if systemPrompt.InlineToUser { + historyMsgs = inlineSystemPromptIntoLatestUserMessage(historyMsgs, systemPrompt.Content) + } else { + assembler.Add(ContextSlot{Kind: SlotSystemPrompt, Content: systemPrompt.Content, Required: true}) + } + } userCtx := userContextInput{} var prefixMemories []domainmemory.UserMemory if prefetch.snapshot != nil { diff --git a/backend/internal/application/conversation/service_project.go b/backend/internal/application/conversation/service_project.go new file mode 100644 index 00000000..05aa9440 --- /dev/null +++ b/backend/internal/application/conversation/service_project.go @@ -0,0 +1,288 @@ +package conversation + +import ( + "context" + "errors" + "strings" + "unicode/utf8" + + model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "github.com/google/uuid" +) + +const ( + conversationProjectNameMaxChars = 80 + conversationProjectDescriptionMaxChars = 255 + conversationProjectMetaMaxChars = 32 +) + +// ConversationProjectInput 定义新建项目分组输入。 +type ConversationProjectInput struct { + Name string + Description string + Color string + Icon string +} + +// ConversationProjectPatchInput 定义项目分组局部更新输入。 +type ConversationProjectPatchInput struct { + Name *string + Description *string + Color *string + Icon *string + Status *string +} + +// CreateConversationProject 创建当前用户的会话项目分组。 +func (s *Service) CreateConversationProject(ctx context.Context, userID uint, input ConversationProjectInput) (*model.ConversationProject, error) { + normalized, err := normalizeConversationProjectInput(input) + if err != nil { + return nil, err + } + item := &model.ConversationProject{ + UserID: userID, + PublicID: normalizePublicID(uuid.NewString()), + Name: normalized.Name, + Description: normalized.Description, + Color: normalized.Color, + Icon: normalized.Icon, + Status: "active", + } + if err = s.repo.CreateConversationProject(ctx, item); err != nil { + return nil, err + } + return item, nil +} + +// ListConversationProjects 查询当前用户项目分组。 +func (s *Service) ListConversationProjects(ctx context.Context, userID uint, statusFilter string) ([]model.ConversationProject, error) { + return s.repo.ListConversationProjects(ctx, userID, normalizeConversationProjectStatusFilter(statusFilter)) +} + +// UpdateConversationProject 更新当前用户项目分组。 +func (s *Service) UpdateConversationProject( + ctx context.Context, + userID uint, + publicID string, + input ConversationProjectPatchInput, +) (*model.ConversationProject, error) { + patch, err := normalizeConversationProjectPatch(input) + if err != nil { + return nil, err + } + item, err := s.repo.UpdateConversationProjectMetadataByPublicID(ctx, userID, strings.TrimSpace(publicID), patch) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil, ErrConversationProjectNotFound + } + return nil, err + } + return item, nil +} + +// DeleteConversationProject 删除当前用户项目分组。 +func (s *Service) DeleteConversationProject(ctx context.Context, userID uint, publicID string, deleteConversations bool) error { + if err := s.repo.DeleteConversationProjectByPublicID(ctx, userID, strings.TrimSpace(publicID), deleteConversations); err != nil { + if errors.Is(err, repository.ErrNotFound) { + return ErrConversationProjectNotFound + } + return err + } + return nil +} + +// ReorderConversationProjects 更新当前用户项目展示顺序。 +func (s *Service) ReorderConversationProjects(ctx context.Context, userID uint, publicIDs []string) error { + normalizedIDs := normalizeProjectPublicIDs(publicIDs) + if len(normalizedIDs) == 0 || len(normalizedIDs) != len(publicIDs) { + return ErrInvalidConversationProject + } + if err := s.repo.ReorderConversationProjects(ctx, userID, normalizedIDs); err != nil { + if errors.Is(err, repository.ErrNotFound) { + return ErrConversationProjectNotFound + } + return err + } + return nil +} + +// SetConversationProject 设置当前用户单个会话的项目归属,空项目 ID 表示解除归属。 +func (s *Service) SetConversationProject( + ctx context.Context, + userID uint, + conversationPublicID string, + projectPublicID string, +) (*model.Conversation, error) { + projectID, err := s.resolveConversationProjectID(ctx, userID, projectPublicID) + if err != nil { + return nil, err + } + item, err := s.repo.UpdateConversationProjectAssignmentByPublicID(ctx, userID, strings.TrimSpace(conversationPublicID), projectID) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil, ErrConversationNotFound + } + return nil, err + } + return item, nil +} + +// BatchSetConversationProject 批量设置当前用户会话项目归属。 +func (s *Service) BatchSetConversationProject( + ctx context.Context, + userID uint, + conversationPublicIDs []string, + projectPublicID string, +) (int64, error) { + normalizedConversationIDs := normalizeProjectPublicIDs(conversationPublicIDs) + if len(normalizedConversationIDs) == 0 || len(normalizedConversationIDs) != len(conversationPublicIDs) { + return 0, ErrInvalidConversationProject + } + projectID, err := s.resolveConversationProjectID(ctx, userID, projectPublicID) + if err != nil { + return 0, err + } + updated, err := s.repo.BatchUpdateConversationProjectByPublicIDs(ctx, userID, normalizedConversationIDs, projectID) + if err != nil { + return 0, err + } + if updated != int64(len(normalizedConversationIDs)) { + return updated, ErrConversationNotFound + } + return updated, nil +} + +func (s *Service) resolveConversationProjectID(ctx context.Context, userID uint, publicID string) (*uint, error) { + normalizedPublicID := strings.TrimSpace(publicID) + if normalizedPublicID == "" || normalizedPublicID == "unassigned" { + return nil, nil + } + project, err := s.repo.GetConversationProjectByPublicID(ctx, userID, normalizedPublicID) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil, ErrConversationProjectNotFound + } + return nil, err + } + return &project.ID, nil +} + +func normalizeConversationProjectInput(input ConversationProjectInput) (ConversationProjectInput, error) { + normalized := ConversationProjectInput{ + Name: strings.TrimSpace(input.Name), + Description: strings.TrimSpace(input.Description), + Color: strings.TrimSpace(input.Color), + Icon: strings.TrimSpace(input.Icon), + } + if normalized.Name == "" || exceedsRuneLimit(normalized.Name, conversationProjectNameMaxChars) { + return ConversationProjectInput{}, ErrInvalidConversationProject + } + if exceedsRuneLimit(normalized.Description, conversationProjectDescriptionMaxChars) || + exceedsRuneLimit(normalized.Color, conversationProjectMetaMaxChars) || + exceedsRuneLimit(normalized.Icon, conversationProjectMetaMaxChars) { + return ConversationProjectInput{}, ErrInvalidConversationProject + } + return normalized, nil +} + +func normalizeConversationProjectPatch(input ConversationProjectPatchInput) (model.ConversationProjectPatch, error) { + var patch model.ConversationProjectPatch + if input.Name != nil { + value := strings.TrimSpace(*input.Name) + if value == "" || exceedsRuneLimit(value, conversationProjectNameMaxChars) { + return model.ConversationProjectPatch{}, ErrInvalidConversationProject + } + patch.Name = &value + } + if input.Description != nil { + value := strings.TrimSpace(*input.Description) + if exceedsRuneLimit(value, conversationProjectDescriptionMaxChars) { + return model.ConversationProjectPatch{}, ErrInvalidConversationProject + } + patch.Description = &value + } + if input.Color != nil { + value := strings.TrimSpace(*input.Color) + if exceedsRuneLimit(value, conversationProjectMetaMaxChars) { + return model.ConversationProjectPatch{}, ErrInvalidConversationProject + } + patch.Color = &value + } + if input.Icon != nil { + value := strings.TrimSpace(*input.Icon) + if exceedsRuneLimit(value, conversationProjectMetaMaxChars) { + return model.ConversationProjectPatch{}, ErrInvalidConversationProject + } + patch.Icon = &value + } + if input.Status != nil { + value := normalizeConversationProjectStatus(*input.Status) + if value == "" { + return model.ConversationProjectPatch{}, ErrInvalidConversationProject + } + patch.Status = &value + } + if patch.Name == nil && patch.Description == nil && patch.Color == nil && patch.Icon == nil && patch.Status == nil { + return model.ConversationProjectPatch{}, ErrInvalidConversationProject + } + return patch, nil +} + +func normalizeProjectPublicIDs(values []string) []string { + results := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + normalized := strings.TrimSpace(value) + if normalized == "" { + continue + } + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + results = append(results, normalized) + } + return results +} + +func normalizeConversationProjectStatusFilter(value string) string { + switch normalizeConversationProjectStatus(value) { + case "archived": + return "archived" + case "active": + return "active" + default: + if strings.TrimSpace(value) == "all" { + return "all" + } + return "active" + } +} + +func normalizeConversationProjectStatus(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "active": + return "active" + case "archived": + return "archived" + default: + return "" + } +} + +func normalizeConversationProjectFilter(value string) string { + normalized := strings.TrimSpace(value) + switch normalized { + case "", "all": + return "all" + case "unassigned": + return "unassigned" + default: + return normalized + } +} + +func exceedsRuneLimit(value string, limit int) bool { + return limit >= 0 && utf8.RuneCountInString(value) > limit +} diff --git a/backend/internal/application/conversation/system_prompt.go b/backend/internal/application/conversation/system_prompt.go new file mode 100644 index 00000000..7ce5c8fb --- /dev/null +++ b/backend/internal/application/conversation/system_prompt.go @@ -0,0 +1,211 @@ +package conversation + +import ( + "encoding/json" + "strings" + + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/channel" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm" +) + +const ( + systemPromptModeNative = "native" + systemPromptModeUser = "user" + systemPromptModeInlineUser = "inline_user" +) + +type systemPromptInjection struct { + Content string + InlineToUser bool +} + +type systemPromptCapabilities struct { + SupportsSystemPrompt *bool `json:"supportsSystemPrompt"` + SupportsSystemPromptSnake *bool `json:"supports_system_prompt"` + SystemPromptMode string `json:"systemPromptMode"` + SystemPromptModeSnake string `json:"system_prompt_mode"` +} + +// resolveSystemPromptInjection 合并全局和模型级系统提示词,并按路由能力决定注入方式。 +func resolveSystemPromptInjection(cfg config.Config, route *channel.ResolvedRoute) systemPromptInjection { + if route == nil { + return systemPromptInjection{} + } + content := buildResolvedSystemPrompt(cfg.DefaultSystemPrompt, route.ModelSystemPrompt) + if content == "" { + return systemPromptInjection{} + } + return systemPromptInjection{ + Content: content, + InlineToUser: shouldInlineSystemPromptToUser(*route), + } +} + +// buildResolvedSystemPrompt 把全局指令放在模型指令之前,确保平台级约束优先表达。 +func buildResolvedSystemPrompt(globalPrompt string, modelPrompt string) string { + layers := []struct { + title string + content string + }{ + {title: "Global instructions", content: globalPrompt}, + {title: "Model instructions", content: modelPrompt}, + } + + sections := make([]string, 0, len(layers)+1) + for _, layer := range layers { + content := strings.TrimSpace(layer.content) + if content == "" { + continue + } + sections = append(sections, "# "+layer.title+"\n"+content) + } + if len(sections) == 0 { + return "" + } + return strings.Join(append([]string{ + "The following instruction layers are ordered from highest to lowest priority. Higher-priority layers override lower-priority layers.", + }, sections...), "\n\n") +} + +// shouldInlineSystemPromptToUser 判断模型是否需要把系统提示词降级写入用户消息。 +func shouldInlineSystemPromptToUser(route channel.ResolvedRoute) bool { + mode, modeSet := systemPromptModeFromCapabilities(route.ModelCapabilitiesJSON) + if modeSet { + switch mode { + case systemPromptModeUser, systemPromptModeInlineUser: + return true + case systemPromptModeNative: + return !chatProtocolSupportsNativeSystemPrompt(route.Protocol) + } + } + if supports, ok := supportsSystemPromptFromCapabilities(route.ModelCapabilitiesJSON); ok { + return !supports || !chatProtocolSupportsNativeSystemPrompt(route.Protocol) + } + if routeLooksLikeGemma(route) { + return true + } + return !chatProtocolSupportsNativeSystemPrompt(route.Protocol) +} + +// chatProtocolSupportsNativeSystemPrompt 只列出已经确认能承载 system 角色的聊天协议。 +func chatProtocolSupportsNativeSystemPrompt(protocol string) bool { + switch llm.NormalizeAdapter(protocol) { + case llm.AdapterOpenAIResponses, + llm.AdapterOpenAIChatCompletions, + llm.AdapterAnthropicMessages, + llm.AdapterGoogleGenerateContent, + llm.AdapterXAIResponses: + return true + default: + return false + } +} + +func supportsSystemPromptFromCapabilities(raw string) (bool, bool) { + payload, ok := decodeSystemPromptCapabilities(raw) + if !ok { + return false, false + } + if payload.SupportsSystemPrompt != nil { + return *payload.SupportsSystemPrompt, true + } + if payload.SupportsSystemPromptSnake != nil { + return *payload.SupportsSystemPromptSnake, true + } + return false, false +} + +func systemPromptModeFromCapabilities(raw string) (string, bool) { + payload, ok := decodeSystemPromptCapabilities(raw) + if !ok { + return "", false + } + for _, value := range []string{payload.SystemPromptMode, payload.SystemPromptModeSnake} { + mode := strings.TrimSpace(strings.ToLower(value)) + if mode != "" { + return mode, true + } + } + return "", false +} + +func decodeSystemPromptCapabilities(raw string) (systemPromptCapabilities, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return systemPromptCapabilities{}, false + } + var payload systemPromptCapabilities + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return systemPromptCapabilities{}, false + } + return payload, true +} + +func routeLooksLikeGemma(route channel.ResolvedRoute) bool { + values := []string{ + route.PlatformModelName, + route.UpstreamModel, + route.ModelVendor, + } + for _, value := range values { + if strings.Contains(strings.ToLower(strings.TrimSpace(value)), "gemma") { + return true + } + } + return false +} + +// inlineSystemPromptIntoLatestUserMessage 面向不支持 system 角色的模型,把指令注入最近一条用户消息。 +func inlineSystemPromptIntoLatestUserMessage(messages []llm.Message, prompt string) []llm.Message { + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return messages + } + result := cloneLLMMessages(messages) + for index := len(result) - 1; index >= 0; index-- { + if result[index].Role != "user" { + continue + } + result[index] = prependUserPromptInstruction(result[index], prompt) + return result + } + return append([]llm.Message{{ + Role: "user", + Content: formatInlineSystemPrompt(prompt, ""), + }}, result...) +} + +func prependUserPromptInstruction(message llm.Message, prompt string) llm.Message { + if len(message.Parts) == 0 { + message.Content = formatInlineSystemPrompt(prompt, message.Content) + return message + } + + parts := make([]llm.ContentPart, 0, len(message.Parts)+1) + inserted := false + for _, part := range message.Parts { + if !inserted && part.Kind == llm.ContentPartText { + part.Text = formatInlineSystemPrompt(prompt, part.Text) + inserted = true + } + parts = append(parts, part) + } + if !inserted { + parts = append([]llm.ContentPart{{ + Kind: llm.ContentPartText, + Text: formatInlineSystemPrompt(prompt, message.Content), + }}, parts...) + } + message.Parts = parts + return message +} + +func formatInlineSystemPrompt(prompt string, userContent string) string { + prompt = strings.TrimSpace(prompt) + userContent = strings.TrimSpace(userContent) + if userContent == "" { + return "\n" + prompt + "\n" + } + return "\n" + prompt + "\n\n\n\n" + userContent + "\n" +} diff --git a/backend/internal/application/conversation/system_prompt_test.go b/backend/internal/application/conversation/system_prompt_test.go new file mode 100644 index 00000000..94fb9907 --- /dev/null +++ b/backend/internal/application/conversation/system_prompt_test.go @@ -0,0 +1,95 @@ +package conversation + +import ( + "strings" + "testing" + + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/channel" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm" +) + +func TestResolveSystemPromptInjectionUsesNativeSystemPrompt(t *testing.T) { + route := &channel.ResolvedRoute{ + Protocol: llm.AdapterOpenAIResponses, + ModelSystemPrompt: "model rule", + ModelCapabilitiesJSON: `{"supportsSystemPrompt":true}`, + } + + got := resolveSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route) + if got.Content == "" { + t.Fatal("expected system prompt content") + } + if got.InlineToUser { + t.Fatal("expected native system prompt") + } + for _, want := range []string{"Global instructions", "global rule", "Model instructions", "model rule"} { + if !strings.Contains(got.Content, want) { + t.Fatalf("expected content to contain %q, got %q", want, got.Content) + } + } +} + +func TestResolveSystemPromptInjectionFallsBackWhenCapabilitiesDisableSystemPrompt(t *testing.T) { + route := &channel.ResolvedRoute{ + Protocol: llm.AdapterOpenAIResponses, + ModelCapabilitiesJSON: `{"supportsSystemPrompt":false}`, + } + + got := resolveSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route) + if !got.InlineToUser { + t.Fatal("expected user prompt fallback") + } +} + +func TestResolveSystemPromptInjectionFallsBackWithSnakeCaseCapabilities(t *testing.T) { + route := &channel.ResolvedRoute{ + Protocol: llm.AdapterOpenAIResponses, + ModelCapabilitiesJSON: `{"supports_system_prompt":false}`, + } + + got := resolveSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route) + if !got.InlineToUser { + t.Fatal("expected snake_case capability to use user prompt fallback") + } +} + +func TestResolveSystemPromptInjectionFallsBackWhenModeRequestsUserPrompt(t *testing.T) { + route := &channel.ResolvedRoute{ + Protocol: llm.AdapterOpenAIResponses, + ModelCapabilitiesJSON: `{"systemPromptMode":"user"}`, + } + + got := resolveSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route) + if !got.InlineToUser { + t.Fatal("expected systemPromptMode=user to use user prompt fallback") + } +} + +func TestResolveSystemPromptInjectionFallsBackForGemma(t *testing.T) { + route := &channel.ResolvedRoute{ + PlatformModelName: "gemma-3-27b", + Protocol: llm.AdapterGoogleGenerateContent, + } + + got := resolveSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route) + if !got.InlineToUser { + t.Fatal("expected Gemma to inline system prompt into user prompt") + } +} + +func TestInlineSystemPromptIntoLatestUserMessage(t *testing.T) { + messages := []llm.Message{ + {Role: "user", Content: "first"}, + {Role: "assistant", Content: "answer"}, + {Role: "user", Content: "second"}, + } + + got := inlineSystemPromptIntoLatestUserMessage(messages, "system rule") + if got[0].Content != "first" { + t.Fatalf("expected first user message to stay unchanged, got %q", got[0].Content) + } + if !strings.Contains(got[2].Content, "") || !strings.Contains(got[2].Content, "system rule") || !strings.Contains(got[2].Content, "second") { + t.Fatalf("expected latest user message to include inline system prompt and original content, got %q", got[2].Content) + } +} diff --git a/backend/internal/application/settings/model_option_policy.go b/backend/internal/application/settings/model_option_policy.go index f0f4fcfa..0849bf71 100644 --- a/backend/internal/application/settings/model_option_policy.go +++ b/backend/internal/application/settings/model_option_policy.go @@ -10,10 +10,12 @@ var validModelOptionProtocolKeys = map[string]struct{}{ "default": {}, "openai_chat_completions": {}, "openai_image_generations": {}, + "openai_image_edits": {}, "openai_responses": {}, "anthropic_messages": {}, "xai_responses": {}, "xai_image": {}, + "xai_image_edits": {}, "gemini_generate_content": {}, "google_image_generation": {}, } diff --git a/backend/internal/application/settings/runtime_settings.go b/backend/internal/application/settings/runtime_settings.go index f8e94980..836ec775 100644 --- a/backend/internal/application/settings/runtime_settings.go +++ b/backend/internal/application/settings/runtime_settings.go @@ -132,6 +132,8 @@ func (r *RuntimeSettings) applyItem(cfg *config.Config, item domainsettings.Syst cfg.ConversationTitlePrompt = item.Value case "chat:conversation_labels_prompt": cfg.ConversationLabelsPrompt = item.Value + case "chat:default_system_prompt": + cfg.DefaultSystemPrompt = item.Value case "chat:model_option_policy_mode": cfg.ModelOptionPolicyMode = strings.TrimSpace(item.Value) case "chat:model_option_allowed_paths": @@ -152,16 +154,18 @@ func (r *RuntimeSettings) applyItem(cfg *config.Config, item domainsettings.Syst // 文件处理配置 case "file:image_max_dimension": cfg.ImageMaxDimension = toInt(item.Value, cfg.ImageMaxDimension) + case "file:full_context_limit_enabled": + cfg.FileFullContextLimitEnabled = toBool(item.Value, cfg.FileFullContextLimitEnabled) case "file:file_full_context_max_bytes": - cfg.FileFullContextMaxBytes = toInt64(item.Value, cfg.FileFullContextMaxBytes) + cfg.FileFullContextMaxBytes = toOptionalInt64(item.Value, cfg.FileFullContextMaxBytes) case "file:full_context_max_tokens": - cfg.FileFullContextMaxTokens = toInt(item.Value, cfg.FileFullContextMaxTokens) + cfg.FileFullContextMaxTokens = toOptionalInt(item.Value, cfg.FileFullContextMaxTokens) case "file:image_max_bytes": cfg.FileImageMaxBytes = toOptionalInt64(item.Value, cfg.FileImageMaxBytes) case "file:doc_max_bytes": cfg.FileDocMaxBytes = toOptionalInt64(item.Value, cfg.FileDocMaxBytes) case "file:full_context_pdf_max_pages": - cfg.FileFullContextPDFMaxPages = toInt(item.Value, cfg.FileFullContextPDFMaxPages) + cfg.FileFullContextPDFMaxPages = toOptionalInt(item.Value, cfg.FileFullContextPDFMaxPages) case "file:allowed_mime_types": cfg.FileAllowedMIMETypes = item.Value case "extract:engine": @@ -368,18 +372,30 @@ func (r *RuntimeSettings) normalizeConfig(cfg *config.Config) { if strings.TrimSpace(cfg.NativeToolAllowedTypes) == "" { cfg.NativeToolAllowedTypes = config.DefaultNativeToolAllowedTypesJSON() } + if !cfg.FileFullContextLimitEnabled { + cfg.FileFullContextMaxBytes = 0 + cfg.FileFullContextMaxTokens = 0 + cfg.FileFullContextPDFMaxPages = 0 + } } func toInt(s string, fallback int) int { - v, err := strconv.Atoi(s) + v, err := strconv.Atoi(strings.TrimSpace(s)) if err != nil { return fallback } return v } +func toOptionalInt(s string, fallback int) int { + if strings.TrimSpace(s) == "" { + return 0 + } + return toInt(s, fallback) +} + func toInt64(s string, fallback int64) int64 { - v, err := strconv.ParseInt(s, 10, 64) + v, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64) if err != nil { return fallback } @@ -402,7 +418,7 @@ func toBool(s string, fallback bool) bool { } func toFloat(s string, fallback float64) float64 { - v, err := strconv.ParseFloat(s, 64) + v, err := strconv.ParseFloat(strings.TrimSpace(s), 64) if err != nil { return fallback } diff --git a/backend/internal/application/settings/seed.go b/backend/internal/application/settings/seed.go index 966bb820..6614574f 100644 --- a/backend/internal/application/settings/seed.go +++ b/backend/internal/application/settings/seed.go @@ -59,6 +59,7 @@ func defaultSettings() []domainsettings.SystemSetting { {Namespace: "chat", Key: "conversation_task_model", Value: "follow", ValueType: "string", Description: "会话标题/标签生成任务使用的聊天模型,follow 表示跟随当前会话模型;图片模型不会用于标题/标签生成"}, {Namespace: "chat", Key: "conversation_title_prompt", Value: "", ValueType: "string", Description: "会话标题生成提示词,支持 {{MESSAGES}} 占位符;空串使用内置默认值"}, {Namespace: "chat", Key: "conversation_labels_prompt", Value: "", ValueType: "string", Description: "会话标签生成提示词,支持 {{MESSAGES}} 占位符;空串使用内置默认值"}, + {Namespace: "chat", Key: "default_system_prompt", Value: "", ValueType: "string", Description: "全局默认系统提示词,仅对聊天任务生效;空串表示不注入"}, {Namespace: "chat", Key: "model_option_policy_mode", Value: "allowlist", ValueType: "string", Description: "模型 options 透传策略:allowlist=仅白名单,denylist=黑名单拦截,disabled=禁止透传"}, {Namespace: "chat", Key: "model_option_allowed_paths", Value: config.DefaultModelOptionAllowedPathsJSON(), ValueType: "json", Description: "模型 options 白名单路径 JSON,default 对所有协议生效"}, {Namespace: "chat", Key: "model_option_denied_paths", Value: config.DefaultModelOptionDeniedPathsJSON(), ValueType: "json", Description: "模型 options 黑名单路径 JSON,default 对所有协议生效"}, @@ -71,11 +72,12 @@ func defaultSettings() []domainsettings.SystemSetting { // 文件处理配置 {Namespace: "file", Key: "image_max_dimension", Value: "1024", ValueType: "int", Description: "图片发送前缩放最大边长(px),0=不缩放"}, - {Namespace: "file", Key: "file_full_context_max_bytes", Value: "51200", ValueType: "int", Description: "文本文件全文注入最大字节数(50KB)"}, - {Namespace: "file", Key: "full_context_max_tokens", Value: "12000", ValueType: "int", Description: "全文注入最大token预算"}, + {Namespace: "file", Key: "full_context_limit_enabled", Value: "true", ValueType: "bool", Description: "是否启用全文注入大小、Token、PDF页数限制"}, + {Namespace: "file", Key: "file_full_context_max_bytes", Value: "51200", ValueType: "int", Description: "文本文件全文注入最大字节数(50KB),留空或0表示不限制"}, + {Namespace: "file", Key: "full_context_max_tokens", Value: "12000", ValueType: "int", Description: "全文注入最大token预算,留空或0表示不限制"}, {Namespace: "file", Key: "image_max_bytes", Value: "", ValueType: "int", Description: "图片单文件大小上限(字节),留空则回退默认附件大小上限"}, {Namespace: "file", Key: "doc_max_bytes", Value: "", ValueType: "int", Description: "文档单文件大小上限(字节),留空则回退默认附件大小上限"}, - {Namespace: "file", Key: "full_context_pdf_max_pages", Value: "20", ValueType: "int", Description: "PDF Full Context最大页数,超出走RAG"}, + {Namespace: "file", Key: "full_context_pdf_max_pages", Value: "20", ValueType: "int", Description: "PDF Full Context最大页数,留空或0表示不限制"}, {Namespace: "file", Key: "allowed_mime_types", Value: defaultAllowedMIMETypes, ValueType: "string", Description: "白名单MIME类型(逗号分隔)"}, {Namespace: "extract", Key: "engine", Value: "builtin", ValueType: "string", Description: "提取主引擎枚举(builtin/tika/docling/mineru)"}, {Namespace: "extract", Key: "ocr_engine", Value: "rapidocr", ValueType: "string", Description: "OCR 引擎枚举(rapidocr/tesseract/paddle/tencent/aliyun/llm)"}, diff --git a/backend/internal/application/settings/service.go b/backend/internal/application/settings/service.go index 5c5a2ede..b02d00c8 100644 --- a/backend/internal/application/settings/service.go +++ b/backend/internal/application/settings/service.go @@ -270,12 +270,16 @@ func validatePatchItem(item PatchItem) error { return validateStringMax(value, 255, key) case "auth:smtp_username", "auth:smtp_password", "auth:smtp_from": return validateStringMax(value, 255, key) + case "chat:default_system_prompt": + return validateStringMax(value, 20000, key) case "auth:smtp_port": return validateIntMinMax(value, 1, 65535, key) case "auth:email_registration_allowed_domains": return validateEmailDomainList(value, key) - case "storage:max_upload_file_bytes", "storage:user_storage_quota_bytes", "file:file_full_context_max_bytes": + case "storage:max_upload_file_bytes", "storage:user_storage_quota_bytes": return validateInt64Min(value, 1, key) + case "file:file_full_context_max_bytes": + return validateOptionalInt64Min(value, 0, key) case "file:image_max_bytes", "file:doc_max_bytes": if value == "" { return nil @@ -286,9 +290,9 @@ func validatePatchItem(item PatchItem) error { case "file:image_max_dimension": return validateIntMinMax(value, 0, 8192, key) case "file:full_context_max_tokens": - return validateIntMinMax(value, 128, 1000000, key) + return validateOptionalIntZeroOrMinMax(value, 128, 1000000, key) case "file:full_context_pdf_max_pages": - return validateIntMinMax(value, 1, 500, key) + return validateOptionalIntZeroOrMinMax(value, 1, 500, key) case "chat:rag_wait_ready_ms": return validateIntMinMax(value, 1000, 120000, key) case "chat:context_artifact_retention_days": @@ -372,7 +376,7 @@ func validatePatchItem(item PatchItem) error { return validateStringMax(value, 255, key) case "extract:tencent_ocr_secret_id", "extract:tencent_ocr_secret_key", "extract:aliyun_ocr_access_key_id", "extract:aliyun_ocr_access_key_secret": return validateStringMax(value, 512, key) - case "auth:username_login_enabled", "auth:email_login_enabled", "auth:third_party_login_enabled", "auth:email_registration_enabled", "auth:email_verification_enabled", "auth:email_registration_block_plus_alias", "auth:auto_link_verified_email", "chat:rag_enabled", "chat:message_embedding_enabled", "chat:semantic_context_enabled", "file:embedding_enabled", "file:embed_trigger_on_upload", "file:embedding_normalize", "extract:image_ocr_enabled", "extract:pdf_ocr_fallback_enabled", "mcp:mcp_enable": + case "auth:username_login_enabled", "auth:email_login_enabled", "auth:third_party_login_enabled", "auth:email_registration_enabled", "auth:email_verification_enabled", "auth:email_registration_block_plus_alias", "auth:auto_link_verified_email", "chat:rag_enabled", "chat:message_embedding_enabled", "chat:semantic_context_enabled", "file:full_context_limit_enabled", "file:embedding_enabled", "file:embed_trigger_on_upload", "file:embedding_normalize", "extract:image_ocr_enabled", "extract:pdf_ocr_fallback_enabled", "mcp:mcp_enable": if _, err := strconv.ParseBool(value); err != nil { return fmt.Errorf("%s must be bool", key) } @@ -814,6 +818,28 @@ func validateInt64Min(value string, min int64, key string) error { return nil } +func validateOptionalInt64Min(value string, min int64, key string) error { + if strings.TrimSpace(value) == "" { + return nil + } + v, err := strconv.ParseInt(value, 10, 64) + if err != nil || v < min { + return fmt.Errorf("%s must be empty or >= %d", key, min) + } + return nil +} + +func validateOptionalIntZeroOrMinMax(value string, min int, max int, key string) error { + if strings.TrimSpace(value) == "" { + return nil + } + v, err := strconv.Atoi(value) + if err != nil || v < 0 || (v > 0 && (v < min || v > max)) { + return fmt.Errorf("%s must be empty, 0, or between %d and %d", key, min, max) + } + return nil +} + func validateFloatMinMax(value string, min float64, max float64, key string) error { v, err := strconv.ParseFloat(strings.TrimSpace(value), 64) if err != nil || v < min || v > max { diff --git a/backend/internal/application/settings/service_test.go b/backend/internal/application/settings/service_test.go index 7f180cc7..dce741fa 100644 --- a/backend/internal/application/settings/service_test.go +++ b/backend/internal/application/settings/service_test.go @@ -135,11 +135,120 @@ func TestValidateModelOptionPolicySettings(t *testing.T) { } } -func TestValidateFullContextMaxTokensAllowsLargeContextWindows(t *testing.T) { - if err := validatePatchItem(PatchItem{Namespace: "file", Key: "full_context_max_tokens", Value: "1000000"}); err != nil { - t.Fatalf("expected 1M full context token limit to pass, got %v", err) +func TestValidateFullContextLimitsAllowUnlimitedValues(t *testing.T) { + cases := []PatchItem{ + {Namespace: "file", Key: "full_context_limit_enabled", Value: "true"}, + {Namespace: "file", Key: "full_context_limit_enabled", Value: "false"}, + {Namespace: "file", Key: "file_full_context_max_bytes", Value: ""}, + {Namespace: "file", Key: "file_full_context_max_bytes", Value: "0"}, + {Namespace: "file", Key: "full_context_max_tokens", Value: ""}, + {Namespace: "file", Key: "full_context_max_tokens", Value: "0"}, + {Namespace: "file", Key: "full_context_pdf_max_pages", Value: ""}, + {Namespace: "file", Key: "full_context_pdf_max_pages", Value: "0"}, + } + + for _, item := range cases { + if err := validatePatchItem(item); err != nil { + t.Fatalf("expected %s:%s=%q to pass, got %v", item.Namespace, item.Key, item.Value, err) + } + } +} + +func TestValidateFullContextLimitsEnforcesConfiguredRanges(t *testing.T) { + cases := []struct { + name string + item PatchItem + want bool + }{ + { + name: "full context limit mode must be boolean", + item: PatchItem{Namespace: "file", Key: "full_context_limit_enabled", Value: "disabled"}, + want: false, + }, + { + name: "1M token limit passes", + item: PatchItem{Namespace: "file", Key: "full_context_max_tokens", Value: "1000000"}, + want: true, + }, + { + name: "token limit below minimum fails", + item: PatchItem{Namespace: "file", Key: "full_context_max_tokens", Value: "127"}, + want: false, + }, + { + name: "token limit above maximum fails", + item: PatchItem{Namespace: "file", Key: "full_context_max_tokens", Value: "1000001"}, + want: false, + }, + { + name: "negative byte limit fails", + item: PatchItem{Namespace: "file", Key: "file_full_context_max_bytes", Value: "-1"}, + want: false, + }, + { + name: "pdf page limit above maximum fails", + item: PatchItem{Namespace: "file", Key: "full_context_pdf_max_pages", Value: "501"}, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validatePatchItem(tc.item) + if tc.want && err != nil { + t.Fatalf("expected validation to pass, got %v", err) + } + if !tc.want && err == nil { + t.Fatal("expected validation to fail") + } + }) + } +} + +func TestRuntimeSettingsDisablesFullContextLimits(t *testing.T) { + runtimeSettings := NewRuntimeSettings(nil, nil, "test-data-encryption-key") + cfg := config.Config{ + FileFullContextLimitEnabled: true, + FileFullContextMaxBytes: 51200, + FileFullContextMaxTokens: 12000, + FileFullContextPDFMaxPages: 20, } - if err := validatePatchItem(PatchItem{Namespace: "file", Key: "full_context_max_tokens", Value: "1000001"}); err == nil { - t.Fatal("expected full context token limit above 1M to fail") + + runtimeSettings.applyItem(&cfg, domainsettings.SystemSetting{Namespace: "file", Key: "full_context_limit_enabled", Value: "false"}) + runtimeSettings.normalizeConfig(&cfg) + + if cfg.FileFullContextLimitEnabled { + t.Fatal("expected full context limit switch to be disabled") + } + if cfg.FileFullContextMaxBytes != 0 || cfg.FileFullContextMaxTokens != 0 || cfg.FileFullContextPDFMaxPages != 0 { + t.Fatalf( + "expected disabled full context limits to be unlimited, got bytes=%d tokens=%d pdfPages=%d", + cfg.FileFullContextMaxBytes, + cfg.FileFullContextMaxTokens, + cfg.FileFullContextPDFMaxPages, + ) + } +} + +func TestRuntimeSettingsTreatsEmptyFullContextLimitsAsUnlimited(t *testing.T) { + runtimeSettings := NewRuntimeSettings(nil, nil, "test-data-encryption-key") + cfg := config.Config{ + FileFullContextLimitEnabled: true, + FileFullContextMaxBytes: 51200, + FileFullContextMaxTokens: 12000, + FileFullContextPDFMaxPages: 20, + } + + runtimeSettings.applyItem(&cfg, domainsettings.SystemSetting{Namespace: "file", Key: "file_full_context_max_bytes", Value: ""}) + runtimeSettings.applyItem(&cfg, domainsettings.SystemSetting{Namespace: "file", Key: "full_context_max_tokens", Value: ""}) + runtimeSettings.applyItem(&cfg, domainsettings.SystemSetting{Namespace: "file", Key: "full_context_pdf_max_pages", Value: ""}) + + if cfg.FileFullContextMaxBytes != 0 || cfg.FileFullContextMaxTokens != 0 || cfg.FileFullContextPDFMaxPages != 0 { + t.Fatalf( + "expected empty full context limits to be unlimited, got bytes=%d tokens=%d pdfPages=%d", + cfg.FileFullContextMaxBytes, + cfg.FileFullContextMaxTokens, + cfg.FileFullContextPDFMaxPages, + ) } } diff --git a/backend/internal/domain/channel/types.go b/backend/internal/domain/channel/types.go index bea1992a..08e99ed9 100644 --- a/backend/internal/domain/channel/types.go +++ b/backend/internal/domain/channel/types.go @@ -71,6 +71,7 @@ type PlatformModel struct { KindsJSON string Icon string CapabilitiesJSON string + SystemPrompt string Status string Description string SortOrder int diff --git a/backend/internal/domain/conversation/types.go b/backend/internal/domain/conversation/types.go index 10be0992..a8525bcf 100644 --- a/backend/internal/domain/conversation/types.go +++ b/backend/internal/domain/conversation/types.go @@ -6,6 +6,9 @@ import "time" type Conversation struct { ID uint UserID uint + ProjectID *uint + ProjectPublicID string + ProjectName string PublicID string Title string LabelsJSON string @@ -28,6 +31,30 @@ type Conversation struct { UpdatedAt time.Time } +// ConversationProject 表示用户会话项目分组。 +type ConversationProject struct { + ID uint + UserID uint + PublicID string + Name string + Description string + Color string + Icon string + SortOrder int + Status string + CreatedAt time.Time + UpdatedAt time.Time +} + +// ConversationProjectPatch 表示项目分组的局部更新。 +type ConversationProjectPatch struct { + Name *string + Description *string + Color *string + Icon *string + Status *string +} + // ConversationShare 表示会话公开分享快照。 type ConversationShare struct { ID uint diff --git a/backend/internal/domain/user/types.go b/backend/internal/domain/user/types.go index 22585e31..edee4bd5 100644 --- a/backend/internal/domain/user/types.go +++ b/backend/internal/domain/user/types.go @@ -3,12 +3,18 @@ package user import "time" const ( - // RoleSuperAdmin 是唯一超级管理员角色。 + // RoleSuperAdmin 是超级管理员角色。 RoleSuperAdmin = "superadmin" + // RoleAdmin 是后台管理员角色。 + RoleAdmin = "admin" // RoleUser 是普通用户角色。 RoleUser = "user" ) +func IsAdminRole(role string) bool { + return role == RoleAdmin || role == RoleSuperAdmin +} + const ( // StatusPendingActivation 表示用户待激活。 StatusPendingActivation = "pending_activation" @@ -210,6 +216,7 @@ type IdentityProvider struct { DefaultRole string SubjectField string EmailField string + EmailVerifiedField string NameField string AvatarField string SortOrder int diff --git a/backend/internal/infra/config/config.go b/backend/internal/infra/config/config.go index 7e534fc8..59b3e466 100644 --- a/backend/internal/infra/config/config.go +++ b/backend/internal/infra/config/config.go @@ -16,8 +16,7 @@ import ( const ( defaultJWTSecret = "deeix-chat-dev-secret" defaultDataEncryptionKey = "deeix-chat-dev-data-encryption-key" - defaultAdminUsername = "deeix-chat" - defaultAdminPassword = "deeix-chat-2026" + defaultAdminUsername = "admin" defaultAdminDisplayName = "System Admin" defaultGeoIPMaxBytes = 100 * 1024 * 1024 defaultHTTPReadHeaderTimeoutSeconds = 10 @@ -66,6 +65,18 @@ func DefaultModelOptionAllowedPathsJSON() string { "style", "user" ], + "openai_image_edits": [ + "background", + "input_fidelity", + "n", + "output_compression", + "output_format", + "partial_images", + "quality", + "response_format", + "size", + "user" + ], "google_image_generation": [ "aspect_ratio", "aspectRatio", @@ -95,6 +106,12 @@ func DefaultModelOptionAllowedPathsJSON() string { "resolution", "response_format" ], + "xai_image_edits": [ + "aspect_ratio", + "n", + "resolution", + "response_format" + ], "gemini_generate_content": [ "generationConfig.temperature", "generationConfig.topP", @@ -271,7 +288,6 @@ type Config struct { StorageS3SecretAccessKey string StorageS3ForcePathStyle bool AdminUsername string - AdminPassword string AdminDisplayName string GeoIPProvider string GeoIPBaseURL string @@ -317,6 +333,7 @@ type Config struct { ConversationTaskModel string ConversationTitlePrompt string ConversationLabelsPrompt string + DefaultSystemPrompt string ModelOptionPolicyMode string ModelOptionAllowedPaths string ModelOptionDeniedPaths string @@ -327,6 +344,7 @@ type Config struct { MaxMessageFiles int // 文件处理配置 ImageMaxDimension int // 图片缩放最大边长(像素),0 = 不缩放 + FileFullContextLimitEnabled bool // 是否启用全文注入阈值限制 FileFullContextMaxBytes int64 // 文本文件全文注入阈值(字节),超出不注入 FileFullContextMaxTokens int // 文本文件全文注入阈值(token) FileImageMaxBytes int64 // 图片单文件上限(字节) @@ -457,7 +475,7 @@ func Load() Config { PostgresConnMaxLifetimeMin: envOrInt("POSTGRES_CONN_MAX_LIFETIME_MINUTES", yc.Database.Postgres.ConnMaxLifetimeMin, 60), PostgresConnMaxIdleTimeMin: envOrInt("POSTGRES_CONN_MAX_IDLE_TIME_MINUTES", yc.Database.Postgres.ConnMaxIdleTimeMin, 10), RedisAddr: envOr("REDIS_ADDR", yc.Database.Redis.Addr, "127.0.0.1:6379"), - RedisPassword: envOr("REDIS_PASSWORD", yc.Database.Redis.Password, "deeix_chat_redis_dev"), + RedisPassword: envOr("REDIS_PASSWORD", yc.Database.Redis.Password, ""), RedisDB: envOrInt("REDIS_DB", yc.Database.Redis.DB, 0), StorageBackend: envOr("STORAGE_BACKEND", yc.Storage.Backend, "local"), StorageRootDir: envOrPath("STORAGE_ROOT_DIR", yc.Storage.Local.RootDir, "./storage", yc.sourceDir), @@ -469,7 +487,6 @@ func Load() Config { StorageS3SecretAccessKey: envOr("STORAGE_S3_SECRET_ACCESS_KEY", yc.Storage.S3.SecretAccessKey, ""), StorageS3ForcePathStyle: envOrBoolPtr("STORAGE_S3_FORCE_PATH_STYLE", yc.Storage.S3.ForcePathStyle, true), AdminUsername: defaultAdminUsername, - AdminPassword: defaultAdminPassword, AdminDisplayName: defaultAdminDisplayName, GeoIPProvider: envOr("GEOIP_PROVIDER", yc.GeoIP.Provider, "ipwhois"), GeoIPBaseURL: envOr("GEOIP_BASE_URL", yc.GeoIP.BaseURL, "https://ipwho.is"), @@ -513,6 +530,7 @@ func Load() Config { ConversationTaskModel: "follow", ConversationTitlePrompt: "", ConversationLabelsPrompt: "", + DefaultSystemPrompt: "", ModelOptionPolicyMode: "allowlist", ModelOptionAllowedPaths: DefaultModelOptionAllowedPathsJSON(), ModelOptionDeniedPaths: DefaultModelOptionDeniedPathsJSON(), @@ -521,6 +539,7 @@ func Load() Config { MaxUploadFileBytes: 20971520, MaxMessageFiles: 10, ImageMaxDimension: 1024, + FileFullContextLimitEnabled: true, FileFullContextMaxBytes: 51200, // 50KB FileFullContextMaxTokens: 12000, FileImageMaxBytes: 0, diff --git a/backend/internal/infra/config/config_test.go b/backend/internal/infra/config/config_test.go index c5240de2..6735c09d 100644 --- a/backend/internal/infra/config/config_test.go +++ b/backend/internal/infra/config/config_test.go @@ -17,9 +17,6 @@ func TestLoadDefaultsUseBootstrapAdmin(t *testing.T) { if cfg.AdminUsername != defaultAdminUsername { t.Fatalf("expected default admin username %q, got %q", defaultAdminUsername, cfg.AdminUsername) } - if cfg.AdminPassword != defaultAdminPassword { - t.Fatalf("expected default admin password %q, got %q", defaultAdminPassword, cfg.AdminPassword) - } if cfg.AdminDisplayName != defaultAdminDisplayName { t.Fatalf("expected default admin display name %q, got %q", defaultAdminDisplayName, cfg.AdminDisplayName) } @@ -92,9 +89,6 @@ geoip: if cfg.AdminUsername != defaultAdminUsername { t.Fatalf("expected built-in admin username, got %q", cfg.AdminUsername) } - if cfg.AdminPassword != defaultAdminPassword { - t.Fatalf("expected built-in admin password, got %q", cfg.AdminPassword) - } if cfg.AdminDisplayName != defaultAdminDisplayName { t.Fatalf("expected built-in admin display name, got %q", cfg.AdminDisplayName) } diff --git a/backend/internal/infra/llm/adapter.go b/backend/internal/infra/llm/adapter.go index 9db85904..2c1e8fa5 100644 --- a/backend/internal/infra/llm/adapter.go +++ b/backend/internal/infra/llm/adapter.go @@ -7,7 +7,7 @@ import ( "strings" ) -// 已支持的协议常量。每个协议固定对应一个 HTTP 端点,不再有「两用」模式。 +// 已支持的协议常量。每个协议固定对应一个 HTTP 端点,任务能力由模型类别和路由规则约束。 const ( AdapterOpenAIResponses = "openai_responses" // POST /v1/responses AdapterOpenAIChatCompletions = "openai_chat_completions" // POST /v1/chat/completions @@ -18,6 +18,7 @@ const ( AdapterGoogleImageGeneration = "google_image_generation" // POST /v1beta/models/{model}:generateContent AdapterXAIResponses = "xai_responses" // POST /v1/responses(OpenAI 兼容) AdapterXAIImage = "xai_image" // POST /v1/images/generations + AdapterXAIImageEdits = "xai_image_edits" // POST /v1/images/edits ) var ( @@ -54,7 +55,8 @@ func IsKnownAdapter(raw string) bool { AdapterGoogleGenerateContent, AdapterGoogleImageGeneration, AdapterXAIResponses, - AdapterXAIImage: + AdapterXAIImage, + AdapterXAIImageEdits: return true default: return false @@ -64,8 +66,8 @@ func IsKnownAdapter(raw string) bool { // IsImplementedAdapter 返回协议是否已有可用的传输层实现。 func IsImplementedAdapter(raw string) bool { switch NormalizeAdapter(raw) { - case AdapterOpenAIResponses, AdapterOpenAIChatCompletions, AdapterOpenAIImageGenerations, AdapterXAIResponses, - AdapterAnthropicMessages, AdapterGoogleGenerateContent, AdapterGoogleImageGeneration, AdapterXAIImage: + case AdapterOpenAIResponses, AdapterOpenAIChatCompletions, AdapterOpenAIImageGenerations, AdapterOpenAIImageEdits, AdapterXAIResponses, + AdapterAnthropicMessages, AdapterGoogleGenerateContent, AdapterGoogleImageGeneration, AdapterXAIImage, AdapterXAIImageEdits: return true default: return false @@ -78,6 +80,7 @@ func SupportsStreamingAdapter(raw string) bool { case AdapterOpenAIResponses, AdapterOpenAIChatCompletions, AdapterOpenAIImageGenerations, + AdapterOpenAIImageEdits, AdapterAnthropicMessages, AdapterGoogleGenerateContent, AdapterGoogleImageGeneration, @@ -88,13 +91,15 @@ func SupportsStreamingAdapter(raw string) bool { } } -// SupportsImageGenerationStream 返回图片生成协议和模型是否支持真实上游流式。 +// SupportsImageGenerationStream 返回图片媒体协议和模型是否支持真实上游流式。 func SupportsImageGenerationStream(protocol string, model string) bool { switch NormalizeAdapter(protocol) { case AdapterOpenAIImageGenerations: return openAIImageGenerationModelSupportsStream(model) case AdapterGoogleImageGeneration: return true + case AdapterOpenAIImageEdits: + return openAIImageEditModelSupportsStream(model) default: return false } @@ -110,6 +115,16 @@ func IsImageGenerationAdapter(raw string) bool { } } +// IsImageEditAdapter 返回协议是否属于独立图片编辑链路。 +func IsImageEditAdapter(raw string) bool { + switch NormalizeAdapter(raw) { + case AdapterOpenAIImageEdits, AdapterGoogleImageGeneration, AdapterXAIImageEdits: + return true + default: + return false + } +} + // DefaultEndpointForAdapter 返回协议对应的固定端点标识。 func DefaultEndpointForAdapter(adapter string) string { switch NormalizeAdapter(adapter) { @@ -117,6 +132,8 @@ func DefaultEndpointForAdapter(adapter string) string { return EndpointChatCompletions case AdapterOpenAIImageGenerations, AdapterGoogleImageGeneration, AdapterXAIImage: return EndpointImageGenerations + case AdapterOpenAIImageEdits, AdapterXAIImageEdits: + return EndpointImageEdits default: // openai_responses、xai_responses 及所有未知值均使用 Responses 端点。 return EndpointResponses diff --git a/backend/internal/infra/llm/adapter_test.go b/backend/internal/infra/llm/adapter_test.go index ae89a655..ba7d0ef7 100644 --- a/backend/internal/infra/llm/adapter_test.go +++ b/backend/internal/infra/llm/adapter_test.go @@ -8,6 +8,9 @@ func TestSupportsStreamingAdapter(t *testing.T) { if !SupportsStreamingAdapter(AdapterOpenAIImageGenerations) { t.Fatalf("expected image generations adapter to support upstream streaming") } + if !SupportsStreamingAdapter(AdapterOpenAIImageEdits) { + t.Fatalf("expected image edits adapter to support upstream streaming") + } if !SupportsStreamingAdapter(AdapterOpenAIResponses) { t.Fatalf("expected responses adapter to support streaming") } @@ -26,10 +29,34 @@ func TestSupportsImageGenerationStream(t *testing.T) { if SupportsStreamingAdapter(AdapterXAIImage) { t.Fatalf("expected xAI image adapter to use non-streaming media flow") } + if SupportsStreamingAdapter(AdapterXAIImageEdits) { + t.Fatalf("expected xAI image edits adapter to use non-streaming media flow") + } if SupportsImageGenerationStream(AdapterOpenAIImageGenerations, "dall-e-3") { t.Fatalf("expected DALL-E models to remain non-streaming") } if SupportsImageGenerationStream(AdapterOpenAIResponses, "gpt-image-1") { t.Fatalf("expected non-image protocol to remain non-streaming for image generation") } + if !SupportsImageGenerationStream(AdapterOpenAIImageEdits, "gpt-image-1") { + t.Fatalf("expected gpt-image edits to support image edit streaming") + } +} + +func TestImageAdapterCapabilities(t *testing.T) { + if !IsImageGenerationAdapter(AdapterGoogleImageGeneration) { + t.Fatalf("expected google image protocol to support image generation") + } + if !IsImageEditAdapter(AdapterGoogleImageGeneration) { + t.Fatalf("expected google image protocol to support image editing") + } + if !IsImageGenerationAdapter(AdapterXAIImage) { + t.Fatalf("expected xAI image protocol to support image generation") + } + if IsImageEditAdapter(AdapterXAIImage) { + t.Fatalf("expected xAI image protocol to stay generation-only") + } + if !IsImageEditAdapter(AdapterXAIImageEdits) { + t.Fatalf("expected xAI image edits protocol to support image editing") + } } diff --git a/backend/internal/infra/llm/client.go b/backend/internal/infra/llm/client.go index c0fbb121..175ece31 100644 --- a/backend/internal/infra/llm/client.go +++ b/backend/internal/infra/llm/client.go @@ -25,6 +25,8 @@ const ( EndpointChatCompletions = "chat_completions" // EndpointImageGenerations 表示 OpenAI Images API 生成端点。 EndpointImageGenerations = "image_generations" + // EndpointImageEdits 表示 OpenAI Images API 编辑端点。 + EndpointImageEdits = "image_edits" ) // 超时默认值。 @@ -133,6 +135,8 @@ type GenerateInput struct { // 非空时:仅在 input 中发送本轮新消息,服务端从存储状态续接历史。 // 空串时:退回全量发送模式,适用于所有 adapter。 PreviousResponseID string + // ImageEditMask 仅供图片编辑 adapter 使用,表示透明区域掩码。 + ImageEditMask *ContentPart } // ToolDefinition 是模型可调用工具的统一声明。 @@ -644,8 +648,10 @@ func NewClientWithEnv(env string, ssrfProtectionEnabled bool) *Client { AdapterOpenAIResponses: &openAIResponsesAdapter{client: client}, AdapterOpenAIChatCompletions: &openAIChatCompletionsAdapter{client: client}, AdapterOpenAIImageGenerations: &openAIImageGenerationsAdapter{client: client}, + AdapterOpenAIImageEdits: &openAIImageEditsAdapter{client: client}, AdapterXAIResponses: &xAIResponsesAdapter{client: client}, AdapterXAIImage: &xAIImageAdapter{client: client}, + AdapterXAIImageEdits: &xAIImageEditsAdapter{client: client}, AdapterAnthropicMessages: &anthropicMessagesAdapter{client: client}, AdapterGoogleGenerateContent: &geminiGenerateContentAdapter{client: client}, AdapterGoogleImageGeneration: &geminiImageGenerationAdapter{client: client}, @@ -1386,6 +1392,8 @@ func normalizeEndpoint(raw string) string { return EndpointChatCompletions case EndpointImageGenerations: return EndpointImageGenerations + case EndpointImageEdits: + return EndpointImageEdits default: return EndpointResponses } diff --git a/backend/internal/infra/llm/endpoint_url_test.go b/backend/internal/infra/llm/endpoint_url_test.go index b9dc0859..c5fb8e8e 100644 --- a/backend/internal/infra/llm/endpoint_url_test.go +++ b/backend/internal/infra/llm/endpoint_url_test.go @@ -27,6 +27,12 @@ func TestBuildOpenAICompatibleURLsRespectVersionedBasePath(t *testing.T) { endpoint: EndpointImageGenerations, want: "https://api.openai.com/v1/images/generations", }, + { + name: "openai image edits endpoint", + baseURL: "https://api.openai.com/v1", + endpoint: EndpointImageEdits, + want: "https://api.openai.com/v1/images/edits", + }, { name: "xai v1 base is not duplicated", baseURL: "https://api.x.ai/v1", diff --git a/backend/internal/infra/llm/gemini_images.go b/backend/internal/infra/llm/gemini_images.go index 7fc5ae25..b2fe98dc 100644 --- a/backend/internal/infra/llm/gemini_images.go +++ b/backend/internal/infra/llm/gemini_images.go @@ -3,6 +3,7 @@ package llm import ( "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "net/http" @@ -10,14 +11,14 @@ import ( "time" ) -// geminiImageGenerationAdapter 实现 Google Gemini 图片生成协议。 +// geminiImageGenerationAdapter 实现 Google Gemini 图片生成/编辑协议。 type geminiImageGenerationAdapter struct { client *Client } func (a *geminiImageGenerationAdapter) Name() string { return AdapterGoogleImageGeneration } -// Generate 调用 Gemini generateContent 图片生成能力,返回结构化图片结果。 +// Generate 调用 Gemini generateContent 图片能力,返回结构化图片结果。 func (a *geminiImageGenerationAdapter) Generate(ctx context.Context, route RouteConfig, input GenerateInput) (*GenerateOutput, error) { return a.client.generateGeminiImageGeneration(ctx, route, input) } @@ -147,7 +148,7 @@ func normalizeGeminiImageGenerationModel(model string) string { } } -// buildGeminiImageGenerationRequestBody 只构造图片生成端点需要的 Gemini 请求字段。 +// buildGeminiImageGenerationRequestBody 构造 Gemini 图片生成/编辑请求字段。 func buildGeminiImageGenerationRequestBody(input GenerateInput) (map[string]interface{}, error) { prompt := buildOpenAIImageGenerationPrompt(input.Messages) if strings.TrimSpace(prompt) == "" { @@ -162,16 +163,37 @@ func buildGeminiImageGenerationRequestBody(input GenerateInput) (map[string]inte return map[string]interface{}{ "contents": []map[string]interface{}{ { - "role": "user", - "parts": []map[string]interface{}{ - {"text": strings.TrimSpace(prompt)}, - }, + "role": "user", + "parts": buildGeminiImageGenerationParts(prompt, collectImageInputParts(input.Messages)), }, }, "generationConfig": generationConfig, }, nil } +// buildGeminiImageGenerationParts 按 Google GenerateContent 格式组合文本提示词和编辑输入图。 +func buildGeminiImageGenerationParts(prompt string, images []ContentPart) []map[string]interface{} { + parts := []map[string]interface{}{ + {"text": strings.TrimSpace(prompt)}, + } + for _, image := range images { + if len(image.Data) == 0 { + continue + } + mimeType := strings.TrimSpace(image.MimeType) + if mimeType == "" { + mimeType = "image/jpeg" + } + parts = append(parts, map[string]interface{}{ + "inline_data": map[string]interface{}{ + "mime_type": mimeType, + "data": base64.StdEncoding.EncodeToString(image.Data), + }, + }) + } + return parts +} + // applyGeminiImageGenerationParams 映射 Google 图片生成文档中的 responseFormat.image 参数。 func applyGeminiImageGenerationParams(generationConfig map[string]interface{}, options map[string]interface{}) { if len(options) == 0 { diff --git a/backend/internal/infra/llm/gemini_test.go b/backend/internal/infra/llm/gemini_test.go index cc4e55c6..a68209bc 100644 --- a/backend/internal/infra/llm/gemini_test.go +++ b/backend/internal/infra/llm/gemini_test.go @@ -130,6 +130,36 @@ func TestBuildGeminiImageGenerationRequestBody(t *testing.T) { } } +func TestBuildGeminiImageGenerationRequestBodyIncludesInlineImages(t *testing.T) { + payload, err := buildGeminiImageGenerationRequestBody(GenerateInput{ + Messages: []Message{ + { + Role: "user", + Parts: []ContentPart{ + {Kind: ContentPartText, Text: "Replace the background"}, + {Kind: ContentPartImage, MimeType: "image/png", Data: []byte("source")}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("build gemini image edit request body: %v", err) + } + + contents := payload["contents"].([]map[string]interface{}) + parts := contents[0]["parts"].([]map[string]interface{}) + if len(parts) != 2 { + t.Fatalf("expected text and image parts, got %#v", parts) + } + if parts[0]["text"] != "Replace the background" { + t.Fatalf("expected prompt text part, got %#v", parts[0]) + } + inlineData := asMap(parts[1]["inline_data"]) + if inlineData["mime_type"] != "image/png" || inlineData["data"] != "c291cmNl" { + t.Fatalf("expected inline image data, got %#v", inlineData) + } +} + func TestNormalizeGeminiImageGenerationModelAliases(t *testing.T) { tests := map[string]string{ "nano-banana-2": "gemini-3.1-flash-image-preview", diff --git a/backend/internal/infra/llm/image_parts.go b/backend/internal/infra/llm/image_parts.go new file mode 100644 index 00000000..ca53e855 --- /dev/null +++ b/backend/internal/infra/llm/image_parts.go @@ -0,0 +1,15 @@ +package llm + +// collectImageInputParts 收集消息中可发送给图片编辑类协议的原始图片输入。 +func collectImageInputParts(messages []Message) []ContentPart { + images := make([]ContentPart, 0) + for _, msg := range messages { + for _, part := range msg.Parts { + if part.Kind != ContentPartImage || len(part.Data) == 0 { + continue + } + images = append(images, part) + } + } + return images +} diff --git a/backend/internal/infra/llm/openai.go b/backend/internal/infra/llm/openai.go index 1e96532c..ca9d8901 100644 --- a/backend/internal/infra/llm/openai.go +++ b/backend/internal/infra/llm/openai.go @@ -303,6 +303,8 @@ func buildOpenAIRequestURL(baseURL string, endpoint string) string { return buildVersionedEndpointURL(baseURL, "v1", "/chat/completions") case EndpointImageGenerations: return buildVersionedEndpointURL(baseURL, "v1", "/images/generations") + case EndpointImageEdits: + return buildVersionedEndpointURL(baseURL, "v1", "/images/edits") default: return buildVersionedEndpointURL(baseURL, "v1", "/responses") } diff --git a/backend/internal/infra/llm/openai_chat_completions.go b/backend/internal/infra/llm/openai_chat_completions.go index e4c3c941..646a021d 100644 --- a/backend/internal/infra/llm/openai_chat_completions.go +++ b/backend/internal/infra/llm/openai_chat_completions.go @@ -440,7 +440,7 @@ func parseOpenAICompatibleUsageForAdapter(adapter string, parsed map[string]inte func openAICompatibleOutputIncludesReasoning(adapter string) bool { switch NormalizeAdapter(adapter) { - case AdapterXAIResponses, AdapterXAIImage: + case AdapterXAIResponses, AdapterXAIImage, AdapterXAIImageEdits: return false default: return true diff --git a/backend/internal/infra/llm/openai_images.go b/backend/internal/infra/llm/openai_images.go index 186aa70a..b625de42 100644 --- a/backend/internal/infra/llm/openai_images.go +++ b/backend/internal/infra/llm/openai_images.go @@ -8,7 +8,9 @@ import ( "errors" "fmt" "io" + "mime/multipart" "net/http" + "net/textproto" "strings" "time" ) @@ -42,6 +44,35 @@ func (a *openAIImageGenerationsAdapter) ListModels(ctx context.Context, route Ro return a.client.listModelsOpenAICompatible(ctx, route) } +// openAIImageEditsAdapter 负责 OpenAI Images API 的图片编辑端点。 +type openAIImageEditsAdapter struct { + client *Client +} + +func (a *openAIImageEditsAdapter) Name() string { return AdapterOpenAIImageEdits } + +// Generate 调用 OpenAI 图片编辑接口,返回结构化图片结果。 +func (a *openAIImageEditsAdapter) Generate(ctx context.Context, route RouteConfig, input GenerateInput) (*GenerateOutput, error) { + route.Endpoint = EndpointImageEdits + return a.client.generateOpenAIImageEdits(ctx, route, input) +} + +// GenerateStream 调用 OpenAI 图片编辑流式接口,事件只输出图片增量,不进入聊天 token delta 链路。 +func (a *openAIImageEditsAdapter) GenerateStream( + ctx context.Context, + route RouteConfig, + input GenerateInput, + onEvent func(GenerateStreamEvent) error, +) (*GenerateOutput, error) { + route.Endpoint = EndpointImageEdits + return a.client.generateOpenAIImageEditsStream(ctx, route, input, onEvent) +} + +// ListModels 复用 OpenAI 兼容 models 目录,供渠道校验和展示使用。 +func (a *openAIImageEditsAdapter) ListModels(ctx context.Context, route RouteConfig) ([]ModelItem, error) { + return a.client.listModelsOpenAICompatible(ctx, route) +} + // generateOpenAIImageGenerations 构造并执行 OpenAI 图片生成请求。 func (c *Client) generateOpenAIImageGenerations(ctx context.Context, route RouteConfig, input GenerateInput) (*GenerateOutput, error) { requestURL := buildOpenAIRequestURL(route.BaseURL, EndpointImageGenerations) @@ -86,7 +117,7 @@ func (c *Client) generateOpenAIImageGenerations(ctx context.Context, route Route return nil, parseUpstreamError(resp.StatusCode, body, upstreamDebugSnapshot(req, payload, resp, body)) } - return parseOpenAIImageGenerationOutput(body, modelParamString(input.Options, "output_format")) + return parseOpenAIImageOutput(body, modelParamString(input.Options, "output_format")) } // generateOpenAIImageGenerationsStream 构造并执行 OpenAI 图片生成流式请求。 @@ -155,7 +186,7 @@ func (c *Client) generateOpenAIImageGenerationsStream( if readErr != nil { return nil, readErr } - output, parseErr := parseOpenAIImageGenerationOutput(body, outputFormat) + output, parseErr := parseOpenAIImageOutput(body, outputFormat) if parseErr != nil { return nil, parseErr } @@ -176,7 +207,7 @@ func (c *Client) generateOpenAIImageGenerationsStream( idleTimeout := resolveStreamIdleTimeout(route.StreamIdleTimeoutMS) idleReader := newIdleTimeoutReader(resp.Body, idleTimeout) streamBody := newUpstreamBodyRecorder(idleReader) - if err = consumeOpenAIImageGenerationStream(streamBody, outputFormat, result, onEvent); err != nil { + if err = consumeOpenAIImageStream(streamBody, outputFormat, result, onEvent); err != nil { return nil, attachUpstreamDebug(err, upstreamDebugSnapshot(req, payload, resp, streamErrorBody(streamBody, err))) } return result, nil @@ -186,6 +217,138 @@ func isEventStreamContentType(contentType string) bool { return strings.Contains(strings.ToLower(strings.TrimSpace(contentType)), "text/event-stream") } +// generateOpenAIImageEdits 构造并执行 OpenAI 图片编辑请求。 +func (c *Client) generateOpenAIImageEdits(ctx context.Context, route RouteConfig, input GenerateInput) (*GenerateOutput, error) { + requestURL := buildOpenAIRequestURL(route.BaseURL, EndpointImageEdits) + if requestURL == "" { + return nil, fmt.Errorf("invalid base url") + } + + payload, contentType, debugBody, err := buildOpenAIImageEditMultipartRequest(route.UpstreamModel, input, false) + if err != nil { + return nil, err + } + + requestCtx, cancel := context.WithTimeout(ctx, resolveReadTimeout(route.ReadTimeoutMS)) + defer cancel() + + req, err := http.NewRequestWithContext(requestCtx, http.MethodPost, requestURL, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", contentType) + if apiKey := strings.TrimSpace(route.APIKey); apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + setOpenRouterAttributionHeaders(req, route) + setAdditionalHeaders(req, route.HeadersJSON) + + resp, err := c.httpClientForRoute(route).Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() //nolint:errcheck + + body, err := readUpstreamBody(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, parseUpstreamError(resp.StatusCode, body, upstreamDebugSnapshot(req, debugBody, resp, body)) + } + + return parseOpenAIImageOutput(body, modelParamString(input.Options, "output_format")) +} + +// generateOpenAIImageEditsStream 构造并执行 OpenAI 图片编辑流式请求。 +func (c *Client) generateOpenAIImageEditsStream( + ctx context.Context, + route RouteConfig, + input GenerateInput, + onEvent func(GenerateStreamEvent) error, +) (*GenerateOutput, error) { + if !SupportsImageGenerationStream(route.Protocol, route.UpstreamModel) { + return nil, fmt.Errorf("%w: %s", ErrUnsupportedStream, AdapterOpenAIImageEdits) + } + requestURL := buildOpenAIRequestURL(route.BaseURL, EndpointImageEdits) + if requestURL == "" { + return nil, fmt.Errorf("invalid base url") + } + + payload, contentType, debugBody, err := buildOpenAIImageEditMultipartRequest(route.UpstreamModel, input, true) + if err != nil { + return nil, err + } + + firstByteCtx, firstByteCancel := context.WithCancel(ctx) + defer firstByteCancel() + + readTimeout := resolveReadTimeout(route.ReadTimeoutMS) + firstByteTimer := time.AfterFunc(readTimeout, func() { + firstByteCancel() + }) + + req, err := http.NewRequestWithContext(firstByteCtx, http.MethodPost, requestURL, bytes.NewReader(payload)) + if err != nil { + firstByteTimer.Stop() + return nil, err + } + req.Header.Set("Content-Type", contentType) + req.Header.Set("Accept", "text/event-stream") + if apiKey := strings.TrimSpace(route.APIKey); apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + setOpenRouterAttributionHeaders(req, route) + setAdditionalHeaders(req, route.HeadersJSON) + + resp, err := c.httpClientForRoute(route).Do(req) + firstByteTimer.Stop() + if err != nil { + return nil, err + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, readErr := readUpstreamBody(resp.Body) + if readErr != nil { + return nil, readErr + } + return nil, parseUpstreamError(resp.StatusCode, body, upstreamDebugSnapshot(req, debugBody, resp, body)) + } + + outputFormat := modelParamString(input.Options, "output_format") + if !isEventStreamContentType(resp.Header.Get("Content-Type")) { + body, readErr := readUpstreamBody(resp.Body) + if readErr != nil { + return nil, readErr + } + output, parseErr := parseOpenAIImageOutput(body, outputFormat) + if parseErr != nil { + return nil, parseErr + } + if output.Usage != (Usage{}) && onEvent != nil { + if err := onEvent(GenerateStreamEvent{Usage: output.Usage}); err != nil { + return nil, err + } + } + return output, nil + } + + result := &GenerateOutput{ + ResponseID: "", + Usage: Usage{}, + ToolCalls: make([]ToolCall, 0), + ServerToolCalls: make([]ToolCall, 0), + } + idleTimeout := resolveStreamIdleTimeout(route.StreamIdleTimeoutMS) + idleReader := newIdleTimeoutReader(resp.Body, idleTimeout) + streamBody := newUpstreamBodyRecorder(idleReader) + if err = consumeOpenAIImageStream(streamBody, outputFormat, result, onEvent); err != nil { + return nil, attachUpstreamDebug(err, upstreamDebugSnapshot(req, debugBody, resp, streamErrorBody(streamBody, err))) + } + return result, nil +} + // buildOpenAIImageGenerationRequestBody 只允许图片端点支持的请求字段进入上游。 func buildOpenAIImageGenerationRequestBody(model string, input GenerateInput) (map[string]interface{}, error) { prompt := buildOpenAIImageGenerationPrompt(input.Messages) @@ -214,6 +377,143 @@ func buildOpenAIImageGenerationStreamRequestBody(model string, input GenerateInp return payload, nil } +// buildOpenAIImageEditMultipartRequest 构造 OpenAI Images Edits 官方 multipart 请求体。 +func buildOpenAIImageEditMultipartRequest(model string, input GenerateInput, stream bool) ([]byte, string, []byte, error) { + prompt := buildOpenAIImageGenerationPrompt(input.Messages) + if strings.TrimSpace(prompt) == "" { + return nil, "", nil, fmt.Errorf("image edit prompt required") + } + images := collectImageInputParts(input.Messages) + if len(images) == 0 { + return nil, "", nil, fmt.Errorf("image edit input image required") + } + if stream && !openAIImageEditModelSupportsStream(model) { + return nil, "", nil, fmt.Errorf("%w: %s", ErrUnsupportedStream, AdapterOpenAIImageEdits) + } + + formFields := map[string]string{ + "model": strings.TrimSpace(model), + "prompt": prompt, + } + applyOpenAIImageEditParams(formFields, strings.TrimSpace(model), input.Options) + if stream { + formFields["stream"] = "true" + applyOpenAIImageEditStreamParams(formFields, input.Options) + } + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for key, value := range formFields { + if strings.TrimSpace(value) == "" { + continue + } + if err := writer.WriteField(key, value); err != nil { + return nil, "", nil, err + } + } + for index, image := range images { + fileName := image.FileName + if strings.TrimSpace(fileName) == "" { + fileName = fmt.Sprintf("image-%02d%s", index+1, openAIImageFileExtension(image.MimeType)) + } + if err := writeOpenAIMultipartFile(writer, "image[]", fileName, image.MimeType, image.Data); err != nil { + return nil, "", nil, err + } + } + if input.ImageEditMask != nil && len(input.ImageEditMask.Data) > 0 { + mask := *input.ImageEditMask + fileName := mask.FileName + if strings.TrimSpace(fileName) == "" { + fileName = "mask" + openAIImageFileExtension(mask.MimeType) + } + if err := writeOpenAIMultipartFile(writer, "mask", fileName, mask.MimeType, mask.Data); err != nil { + return nil, "", nil, err + } + } + if err := writer.Close(); err != nil { + return nil, "", nil, err + } + debugBody := buildOpenAIImageEditDebugBody(formFields, len(images), input.ImageEditMask != nil && len(input.ImageEditMask.Data) > 0) + return body.Bytes(), writer.FormDataContentType(), debugBody, nil +} + +func applyOpenAIImageEditParams(fields map[string]string, model string, options map[string]interface{}) { + if len(options) == 0 { + return + } + for _, key := range []string{"quality", "size", "user"} { + if value := modelParamString(options, key); value != "" { + fields[key] = value + } + } + if value := modelParamInt(options, "n"); value > 0 { + fields["n"] = fmt.Sprintf("%d", value) + } + if openAIImageEditModelSupportsGPTImageParams(model) { + for _, key := range []string{"background", "input_fidelity", "output_format"} { + if value := modelParamString(options, key); value != "" { + fields[key] = value + } + } + if value := modelParamInt(options, "output_compression"); value > 0 { + fields["output_compression"] = fmt.Sprintf("%d", value) + } + } + if openAIImageGenerationModelSupportsResponseFormat(model) { + if value := modelParamString(options, "response_format"); value != "" { + fields["response_format"] = value + } + } +} + +func applyOpenAIImageEditStreamParams(fields map[string]string, options map[string]interface{}) { + value, ok := modelParamIntValue(options, "partial_images") + if ok { + if value > 0 { + fields["partial_images"] = fmt.Sprintf("%d", value) + } + return + } + fields["partial_images"] = "1" +} + +func writeOpenAIMultipartFile(writer *multipart.Writer, fieldName string, fileName string, mimeType string, data []byte) error { + if len(data) == 0 { + return fmt.Errorf("multipart file %s is empty", fieldName) + } + normalizedMIME := strings.TrimSpace(mimeType) + if normalizedMIME == "" { + normalizedMIME = "image/png" + } + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, escapeMultipartQuote(fieldName), escapeMultipartQuote(fileName))) + header.Set("Content-Type", normalizedMIME) + part, err := writer.CreatePart(header) + if err != nil { + return err + } + _, err = part.Write(data) + return err +} + +func buildOpenAIImageEditDebugBody(fields map[string]string, imageCount int, hasMask bool) []byte { + payload := make(map[string]interface{}, len(fields)+2) + for key, value := range fields { + payload[key] = value + } + payload["image_count"] = imageCount + payload["mask"] = hasMask + raw, err := json.Marshal(payload) + if err != nil { + return []byte(`{"multipart":true}`) + } + return raw +} + +func escapeMultipartQuote(value string) string { + return strings.NewReplacer("\\", "\\\\", `"`, "\\\"").Replace(strings.TrimSpace(value)) +} + // applyOpenAIImageGenerationParams 从 options 中提取官方 Images API 参数。 func applyOpenAIImageGenerationParams(payload map[string]interface{}, model string, options map[string]interface{}) { if len(options) == 0 { @@ -269,6 +569,15 @@ func openAIImageGenerationModelSupportsGPTImageParams(model string) bool { return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-image-") } +func openAIImageEditModelSupportsStream(model string) bool { + return openAIImageEditModelSupportsGPTImageParams(model) +} + +func openAIImageEditModelSupportsGPTImageParams(model string) bool { + normalized := strings.ToLower(strings.TrimSpace(model)) + return strings.HasPrefix(normalized, "gpt-image-") || normalized == "chatgpt-image-latest" +} + func openAIImageGenerationModelSupportsResponseFormat(model string) bool { switch strings.ToLower(strings.TrimSpace(model)) { case "dall-e-2", "dall-e-3": @@ -318,9 +627,9 @@ func messagePromptText(msg Message) string { return strings.Join(parts, "\n\n") } -// parseOpenAIImageGenerationOutput 解析 OpenAI 图片响应。 +// parseOpenAIImageOutput 解析 OpenAI 图片响应。 // 图片字节只放入 GeneratedImages,避免把 data URL 写入普通文本链路。 -func parseOpenAIImageGenerationOutput(body []byte, outputFormat string) (*GenerateOutput, error) { +func parseOpenAIImageOutput(body []byte, outputFormat string) (*GenerateOutput, error) { parsed := make(map[string]interface{}) if err := json.Unmarshal(body, &parsed); err != nil { return nil, err @@ -330,11 +639,11 @@ func parseOpenAIImageGenerationOutput(body []byte, outputFormat string) (*Genera ServerToolCalls: make([]ToolCall, 0), RawJSON: string(body), } - applyOpenAIImageGenerationCompletedPayload(parsed, outputFormat, result) + applyOpenAIImageCompletedPayload(parsed, outputFormat, result) return result, nil } -func applyOpenAIImageGenerationCompletedPayload(parsed map[string]interface{}, outputFormat string, result *GenerateOutput) { +func applyOpenAIImageCompletedPayload(parsed map[string]interface{}, outputFormat string, result *GenerateOutput) { if result == nil { return } @@ -359,7 +668,7 @@ func applyOpenAIImageGenerationCompletedPayload(parsed map[string]interface{}, o } if len(data) == 0 { if response := asMap(parsed["response"]); len(response) > 0 { - applyOpenAIImageGenerationCompletedPayload(response, outputFormat, result) + applyOpenAIImageCompletedPayload(response, outputFormat, result) return } } @@ -405,7 +714,7 @@ func parseOpenAIImagePayload(payload map[string]interface{}, outputFormat string return GeneratedImage{}, false } -func consumeOpenAIImageGenerationStream( +func consumeOpenAIImageStream( reader io.Reader, outputFormat string, result *GenerateOutput, @@ -474,9 +783,9 @@ func consumeOpenAIImageGenerationStream( switch { case strings.Contains(eventType, "partial_image"): - return emitOpenAIImageGenerationPartial(parsed, outputFormat, onEvent) + return emitOpenAIImagePartial(parsed, outputFormat, onEvent) case strings.Contains(eventType, "completed") || strings.Contains(eventType, "final"): - applyOpenAIImageGenerationCompletedPayload(parsed, outputFormat, result) + applyOpenAIImageCompletedPayload(parsed, outputFormat, result) } return nil } @@ -529,7 +838,7 @@ func consumeOpenAIImageGenerationStream( result.RawJSON += "\n" } result.RawJSON += payloadText - applyOpenAIImageGenerationCompletedPayload(parsed, outputFormat, result) + applyOpenAIImageCompletedPayload(parsed, outputFormat, result) if result.Usage != (Usage{}) && onEvent != nil { if err := onEvent(GenerateStreamEvent{Usage: result.Usage}); err != nil { return err @@ -539,7 +848,7 @@ func consumeOpenAIImageGenerationStream( return nil } -func emitOpenAIImageGenerationPartial( +func emitOpenAIImagePartial( parsed map[string]interface{}, outputFormat string, onEvent func(GenerateStreamEvent) error, @@ -574,3 +883,16 @@ func openAIImageMIMEType(outputFormat string) string { return "image/png" } } + +func openAIImageFileExtension(mimeType string) string { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "image/jpeg", "image/jpg": + return ".jpg" + case "image/webp": + return ".webp" + case "image/gif": + return ".gif" + default: + return ".png" + } +} diff --git a/backend/internal/infra/llm/openai_images_test.go b/backend/internal/infra/llm/openai_images_test.go index fb83cba0..42db2bfb 100644 --- a/backend/internal/infra/llm/openai_images_test.go +++ b/backend/internal/infra/llm/openai_images_test.go @@ -1,6 +1,7 @@ package llm import ( + "bytes" "context" "encoding/json" "net/http" @@ -101,8 +102,86 @@ func TestBuildOpenAIImageGenerationRequestBodyDallEParams(t *testing.T) { } } +func TestBuildOpenAIImageEditMultipartRequest(t *testing.T) { + body, contentType, debugBody, err := buildOpenAIImageEditMultipartRequest("gpt-image-1", GenerateInput{ + Messages: []Message{{ + Role: "user", + Parts: []ContentPart{ + {Kind: ContentPartText, Text: "Replace the background"}, + {Kind: ContentPartImage, MimeType: "image/png", FileName: "source.png", Data: []byte("source")}, + }, + }}, + ImageEditMask: &ContentPart{Kind: ContentPartImage, MimeType: "image/png", FileName: "mask.png", Data: []byte("mask")}, + Options: map[string]interface{}{ + "size": "1024x1024", + "quality": "high", + "output_format": "webp", + "output_compression": 80, + "input_fidelity": "high", + "partial_images": 2, + "stream": true, + "prompt": "override", + }, + }, false) + if err != nil { + t.Fatalf("build image edit multipart request: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(body)) + req.Header.Set("Content-Type", contentType) + if err = req.ParseMultipartForm(10 << 20); err != nil { + t.Fatalf("parse multipart body: %v", err) + } + form := req.MultipartForm + if form.Value["model"][0] != "gpt-image-1" || form.Value["prompt"][0] != "Replace the background" { + t.Fatalf("unexpected edit form fields: %#v", form.Value) + } + if form.Value["output_format"][0] != "webp" || form.Value["input_fidelity"][0] != "high" { + t.Fatalf("expected edit params, got %#v", form.Value) + } + if _, ok := form.Value["stream"]; ok { + t.Fatalf("stream must not be passed by non-streaming edit adapter: %#v", form.Value) + } + if _, ok := form.Value["partial_images"]; ok { + t.Fatalf("partial_images must not be passed without upstream image edit streaming: %#v", form.Value) + } + if len(form.File["image[]"]) != 1 || len(form.File["mask"]) != 1 { + t.Fatalf("expected image[] and mask files, got %#v", form.File) + } + var debug map[string]interface{} + if err = json.Unmarshal(debugBody, &debug); err != nil { + t.Fatalf("parse debug body: %v", err) + } + if debug["image_count"] != float64(1) || debug["mask"] != true { + t.Fatalf("expected sanitized debug body, got %#v", debug) + } +} + +func TestBuildOpenAIImageEditStreamMultipartRequest(t *testing.T) { + body, contentType, _, err := buildOpenAIImageEditMultipartRequest("gpt-image-1", GenerateInput{ + Messages: []Message{{ + Role: "user", + Parts: []ContentPart{ + {Kind: ContentPartText, Text: "Replace the background"}, + {Kind: ContentPartImage, MimeType: "image/png", Data: []byte("source")}, + }, + }}, + Options: map[string]interface{}{"partial_images": 2}, + }, true) + if err != nil { + t.Fatalf("build image edit stream multipart request: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(body)) + req.Header.Set("Content-Type", contentType) + if err = req.ParseMultipartForm(10 << 20); err != nil { + t.Fatalf("parse multipart body: %v", err) + } + if req.MultipartForm.Value["stream"][0] != "true" || req.MultipartForm.Value["partial_images"][0] != "2" { + t.Fatalf("expected edit stream fields, got %#v", req.MultipartForm.Value) + } +} + func TestParseOpenAIImageGenerationOutput(t *testing.T) { - output, err := parseOpenAIImageGenerationOutput([]byte(`{ + output, err := parseOpenAIImageOutput([]byte(`{ "created": 1713833628, "data": [ {"url": "https://example.com/a.png", "revised_prompt": "A revised render"}, @@ -139,7 +218,7 @@ func TestParseOpenAIImageGenerationOutput(t *testing.T) { } func TestParseOpenAIImageGenerationOutputDoesNotInventUsage(t *testing.T) { - output, err := parseOpenAIImageGenerationOutput([]byte(`{ + output, err := parseOpenAIImageOutput([]byte(`{ "data": [{"b64_json": "aGVsbG8="}] }`), "png") if err != nil { @@ -255,3 +334,71 @@ func TestOpenAIImageGenerationStreamFallsBackToJSONResponse(t *testing.T) { t.Fatalf("expected usage event from json fallback, got %#v", usageEvents) } } + +func TestOpenAIImageEditStream(t *testing.T) { + var requestValues map[string][]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/images/edits" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Accept") != "text/event-stream" { + t.Fatalf("expected event stream accept header, got %q", r.Header.Get("Accept")) + } + if err := r.ParseMultipartForm(10 << 20); err != nil { + t.Fatalf("parse multipart request: %v", err) + } + requestValues = r.MultipartForm.Value + if len(r.MultipartForm.File["image[]"]) != 1 { + t.Fatalf("expected one edit image, got %#v", r.MultipartForm.File) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: image_edit.partial_image\n")) + _, _ = w.Write([]byte("data: {\"type\":\"image_edit.partial_image\",\"partial_image_index\":1,\"b64_json\":\"cGFydGlhbA==\"}\n\n")) + _, _ = w.Write([]byte("event: image_edit.completed\n")) + _, _ = w.Write([]byte("data: {\"type\":\"image_edit.completed\",\"id\":\"img_edit_1\",\"b64_json\":\"ZmluYWw=\",\"usage\":{\"input_tokens\":22,\"output_tokens\":44}}\n\n")) + })) + defer server.Close() + + client := NewClient() + var partials []GenerateStreamEvent + output, err := client.GenerateStream(context.Background(), RouteConfig{ + Protocol: AdapterOpenAIImageEdits, + BaseURL: server.URL, + UpstreamModel: "gpt-image-1", + }, GenerateInput{ + Messages: []Message{{ + Role: "user", + Parts: []ContentPart{ + {Kind: ContentPartText, Text: "Replace the background"}, + {Kind: ContentPartImage, MimeType: "image/png", Data: []byte("source")}, + }, + }}, + Options: map[string]interface{}{ + "output_format": "webp", + "partial_images": 2, + }, + }, func(event GenerateStreamEvent) error { + if event.GeneratedImage != nil { + partials = append(partials, event) + } + return nil + }) + if err != nil { + t.Fatalf("generate image edit stream: %v", err) + } + if requestValues["stream"][0] != "true" || requestValues["partial_images"][0] != "2" { + t.Fatalf("expected stream request values, got %#v", requestValues) + } + if len(partials) != 1 || partials[0].GeneratedImage == nil || partials[0].GeneratedImage.B64JSON != "cGFydGlhbA==" { + t.Fatalf("expected partial edit image event, got %#v", partials) + } + if len(output.GeneratedImages) != 1 || output.GeneratedImages[0].B64JSON != "ZmluYWw=" { + t.Fatalf("expected final edit image, got %#v", output.GeneratedImages) + } + if output.ResponseID != "img_edit_1" { + t.Fatalf("expected edit response id, got %q", output.ResponseID) + } + if output.Usage.InputTokens != 22 || output.Usage.OutputTokens != 44 { + t.Fatalf("expected edit usage, got %#v", output.Usage) + } +} diff --git a/backend/internal/infra/llm/xai_images.go b/backend/internal/infra/llm/xai_images.go index fa9b97d7..a4629865 100644 --- a/backend/internal/infra/llm/xai_images.go +++ b/backend/internal/infra/llm/xai_images.go @@ -3,6 +3,7 @@ package llm import ( "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "net/http" @@ -18,6 +19,8 @@ func (a *xAIImageAdapter) Name() string { return AdapterXAIImage } // Generate 调用 xAI 图片生成接口,返回结构化图片结果。 func (a *xAIImageAdapter) Generate(ctx context.Context, route RouteConfig, input GenerateInput) (*GenerateOutput, error) { + route.Protocol = AdapterXAIImage + route.Endpoint = EndpointImageGenerations return a.client.generateXAIImage(ctx, route, input) } @@ -37,16 +40,50 @@ func (a *xAIImageAdapter) ListModels(ctx context.Context, route RouteConfig) ([] return a.client.listModelsOpenAICompatible(ctx, route) } +// xAIImageEditsAdapter 实现 xAI 图片编辑协议。 +type xAIImageEditsAdapter struct { + client *Client +} + +func (a *xAIImageEditsAdapter) Name() string { return AdapterXAIImageEdits } + +// Generate 调用 xAI 图片编辑接口,返回结构化图片结果。 +func (a *xAIImageEditsAdapter) Generate(ctx context.Context, route RouteConfig, input GenerateInput) (*GenerateOutput, error) { + route.Protocol = AdapterXAIImageEdits + route.Endpoint = EndpointImageEdits + return a.client.generateXAIImage(ctx, route, input) +} + +// GenerateStream 当前不伪造图片流式;媒体任务会通过非流式调用落库生成结果。 +func (a *xAIImageEditsAdapter) GenerateStream( + ctx context.Context, + route RouteConfig, + input GenerateInput, + onEvent func(GenerateStreamEvent) error, +) (*GenerateOutput, error) { + return nil, fmt.Errorf("%w: %s", ErrUnsupportedStream, AdapterXAIImageEdits) +} + +// ListModels 复用 xAI models 目录,供渠道校验和展示使用。 +func (a *xAIImageEditsAdapter) ListModels(ctx context.Context, route RouteConfig) ([]ModelItem, error) { + route.Protocol = AdapterXAIImageEdits + return a.client.listModelsOpenAICompatible(ctx, route) +} + // generateXAIImage 构造并执行 xAI Images API 请求。 func (c *Client) generateXAIImage(ctx context.Context, route RouteConfig, input GenerateInput) (*GenerateOutput, error) { - route.Protocol = AdapterXAIImage - route.Endpoint = EndpointImageGenerations - requestURL := buildOpenAIRequestURL(route.BaseURL, EndpointImageGenerations) + protocol := NormalizeAdapter(route.Protocol) + if protocol != AdapterXAIImage && protocol != AdapterXAIImageEdits { + protocol = AdapterXAIImage + } + route.Protocol = protocol + endpoint := DefaultEndpointForAdapter(protocol) + requestURL := buildOpenAIRequestURL(route.BaseURL, endpoint) if requestURL == "" { return nil, fmt.Errorf("invalid base url") } - requestBody, err := buildXAIImageRequestBody(route.UpstreamModel, input) + requestBody, debugBody, err := buildXAIImageRequest(route.UpstreamModel, endpoint, input) if err != nil { return nil, err } @@ -54,6 +91,10 @@ func (c *Client) generateXAIImage(ctx context.Context, route RouteConfig, input if err != nil { return nil, err } + debugPayload := payload + if len(debugBody) > 0 { + debugPayload = debugBody + } requestCtx, cancel := context.WithTimeout(ctx, resolveReadTimeout(route.ReadTimeoutMS)) defer cancel() @@ -79,10 +120,19 @@ func (c *Client) generateXAIImage(ctx context.Context, route RouteConfig, input return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, parseUpstreamError(resp.StatusCode, body, upstreamDebugSnapshot(req, payload, resp, body)) + return nil, parseUpstreamError(resp.StatusCode, body, upstreamDebugSnapshot(req, debugPayload, resp, body)) } - return parseXAIImageOutput(body, modelParamString(input.Options, "response_format")) + return parseXAIImageOutput(body, modelParamString(input.Options, "response_format"), protocol) +} + +// buildXAIImageRequest 根据任务端点构造 xAI 图片生成或编辑请求。 +func buildXAIImageRequest(model string, endpoint string, input GenerateInput) (map[string]interface{}, []byte, error) { + if endpoint == EndpointImageEdits { + return buildXAIImageEditRequestBody(model, input) + } + payload, err := buildXAIImageRequestBody(model, input) + return payload, nil, err } // buildXAIImageRequestBody 只允许 xAI 图片生成端点支持的字段进入上游。 @@ -99,6 +149,53 @@ func buildXAIImageRequestBody(model string, input GenerateInput) (map[string]int return payload, nil } +// buildXAIImageEditRequestBody 只允许 xAI 图片编辑端点支持的字段进入上游。 +func buildXAIImageEditRequestBody(model string, input GenerateInput) (map[string]interface{}, []byte, error) { + prompt := buildOpenAIImageGenerationPrompt(input.Messages) + if strings.TrimSpace(prompt) == "" { + return nil, nil, fmt.Errorf("image edit prompt required") + } + images := collectImageInputParts(input.Messages) + if len(images) == 0 { + return nil, nil, fmt.Errorf("image edit input image required") + } + if len(images) > 3 { + return nil, nil, fmt.Errorf("too many image edit input images") + } + imageInputs := make([]map[string]interface{}, 0, len(images)) + for _, image := range images { + imageInputs = append(imageInputs, xAIImageURLPayload(image)) + } + payload := map[string]interface{}{ + "model": strings.TrimSpace(model), + "prompt": prompt, + } + if len(imageInputs) == 1 { + payload["image"] = imageInputs[0] + } else { + payload["image"] = imageInputs + } + applyXAIImageParams(payload, input.Options) + debugBody, _ := json.Marshal(map[string]interface{}{ + "model": payload["model"], + "prompt": payload["prompt"], + "image_count": len(imageInputs), + }) + return payload, debugBody, nil +} + +// xAIImageURLPayload 将内部图片输入转换为 xAI 文档要求的 image_url 对象。 +func xAIImageURLPayload(image ContentPart) map[string]interface{} { + mimeType := strings.TrimSpace(image.MimeType) + if mimeType == "" { + mimeType = "image/jpeg" + } + return map[string]interface{}{ + "url": "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(image.Data), + "type": "image_url", + } +} + // applyXAIImageParams 从 options 中提取 xAI 图片生成官方参数。 func applyXAIImageParams(payload map[string]interface{}, options map[string]interface{}) { if len(options) == 0 { @@ -115,7 +212,7 @@ func applyXAIImageParams(payload map[string]interface{}, options map[string]inte } // parseXAIImageOutput 解析 xAI 图片响应;图片字节只进入 GeneratedImages。 -func parseXAIImageOutput(body []byte, responseFormat string) (*GenerateOutput, error) { +func parseXAIImageOutput(body []byte, responseFormat string, protocol string) (*GenerateOutput, error) { parsed := make(map[string]interface{}) if err := json.Unmarshal(body, &parsed); err != nil { return nil, err @@ -126,7 +223,7 @@ func parseXAIImageOutput(body []byte, responseFormat string) (*GenerateOutput, e ServerToolCalls: make([]ToolCall, 0), RawJSON: string(body), } - if usage := parseOpenAICompatibleUsageForAdapter(AdapterXAIImage, parsed); usage != (Usage{}) { + if usage := parseOpenAICompatibleUsageForAdapter(protocol, parsed); usage != (Usage{}) { result.Usage = usage } data := asSlice(parsed["data"]) diff --git a/backend/internal/infra/llm/xai_images_test.go b/backend/internal/infra/llm/xai_images_test.go index 376005fa..4538ac62 100644 --- a/backend/internal/infra/llm/xai_images_test.go +++ b/backend/internal/infra/llm/xai_images_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -40,6 +41,66 @@ func TestBuildXAIImageRequestBody(t *testing.T) { } } +func TestBuildXAIImageEditRequestBody(t *testing.T) { + payload, debugBody, err := buildXAIImageEditRequestBody("grok-imagine-image-quality", GenerateInput{ + Messages: []Message{ + { + Role: "user", + Parts: []ContentPart{ + {Kind: ContentPartText, Text: "Render this as a pencil sketch"}, + {Kind: ContentPartImage, MimeType: "image/png", Data: []byte("source")}, + }, + }, + }, + Options: map[string]interface{}{ + "aspect_ratio": "1:1", + "resolution": "2k", + }, + }) + if err != nil { + t.Fatalf("build xAI image edit request body: %v", err) + } + if payload["model"] != "grok-imagine-image-quality" || payload["prompt"] != "Render this as a pencil sketch" { + t.Fatalf("unexpected model or prompt: %#v", payload) + } + if payload["aspect_ratio"] != "1:1" || payload["resolution"] != "2k" { + t.Fatalf("expected xAI edit params, got %#v", payload) + } + image := payload["image"].(map[string]interface{}) + if image["type"] != "image_url" { + t.Fatalf("expected image_url type, got %#v", image) + } + if url := image["url"].(string); !strings.HasPrefix(url, "data:image/png;base64,c291cmNl") { + t.Fatalf("expected base64 data uri, got %q", url) + } + if strings.Contains(string(debugBody), "c291cmNl") { + t.Fatalf("debug body must not include source image bytes: %s", string(debugBody)) + } +} + +func TestBuildXAIImageEditRequestBodyAllowsUpToThreeImages(t *testing.T) { + payload, _, err := buildXAIImageEditRequestBody("grok-imagine-image-quality", GenerateInput{ + Messages: []Message{ + { + Role: "user", + Parts: []ContentPart{ + {Kind: ContentPartText, Text: "Combine these"}, + {Kind: ContentPartImage, MimeType: "image/png", Data: []byte("one")}, + {Kind: ContentPartImage, MimeType: "image/jpeg", Data: []byte("two")}, + {Kind: ContentPartImage, MimeType: "image/webp", Data: []byte("three")}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("build xAI multi-image edit request body: %v", err) + } + images := payload["image"].([]map[string]interface{}) + if len(images) != 3 { + t.Fatalf("expected three ordered image inputs, got %#v", images) + } +} + func TestGenerateXAIImageUsesImageEndpoint(t *testing.T) { var requestPath string var requestBody map[string]interface{} @@ -90,6 +151,88 @@ func TestGenerateXAIImageUsesImageEndpoint(t *testing.T) { } } +func TestGenerateXAIImageGenerationAdapterKeepsGenerationEndpoint(t *testing.T) { + var requestPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"url":"https://example.com/generated.jpg"}]}`)) + })) + defer server.Close() + + _, err := NewClient().Generate(context.Background(), RouteConfig{ + Protocol: AdapterXAIImage, + Endpoint: EndpointImageEdits, + BaseURL: server.URL + "/v1", + UpstreamModel: "grok-imagine-image-quality", + }, GenerateInput{ + Messages: []Message{{Role: "user", Content: "A clean product render"}}, + }) + if err != nil { + t.Fatalf("generate xAI image: %v", err) + } + if requestPath != "/v1/images/generations" { + t.Fatalf("expected xAI image generation endpoint, got %q", requestPath) + } +} + +func TestGenerateXAIImageEditUsesImageEditsEndpoint(t *testing.T) { + var requestPath string + var requestBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + if got := r.Header.Get("Authorization"); got != "Bearer xai-key" { + t.Fatalf("unexpected auth header %q", got) + } + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id": "img_xai_edit_1", + "data": [ + {"b64_json": "ZWRpdGVk", "revised_prompt": "Edited prompt"} + ], + "usage": {"input_tokens": 13, "output_tokens": 2} + }`)) + })) + defer server.Close() + + output, err := NewClient().Generate(context.Background(), RouteConfig{ + Protocol: AdapterXAIImageEdits, + BaseURL: server.URL + "/v1", + APIKey: "xai-key", + UpstreamModel: "grok-imagine-image-quality", + }, GenerateInput{ + Messages: []Message{{ + Role: "user", + Parts: []ContentPart{ + {Kind: ContentPartText, Text: "Render this as a pencil sketch"}, + {Kind: ContentPartImage, MimeType: "image/png", Data: []byte("source")}, + }, + }}, + Options: map[string]interface{}{ + "response_format": "b64_json", + }, + }) + if err != nil { + t.Fatalf("generate xAI image edit: %v", err) + } + if requestPath != "/v1/images/edits" { + t.Fatalf("expected xAI image edits endpoint, got %q", requestPath) + } + image := asMap(requestBody["image"]) + if image["type"] != "image_url" || !strings.HasPrefix(getString(image["url"]), "data:image/png;base64,c291cmNl") { + t.Fatalf("unexpected edit image payload: %#v", requestBody) + } + if output.ResponseID != "img_xai_edit_1" || len(output.GeneratedImages) != 1 || output.GeneratedImages[0].B64JSON != "ZWRpdGVk" { + t.Fatalf("unexpected edited image output: %#v", output) + } + if output.Usage.InputTokens != 13 || output.Usage.OutputTokens != 2 { + t.Fatalf("expected upstream usage, got %#v", output.Usage) + } +} + func TestParseXAIImageOutput(t *testing.T) { output, err := parseXAIImageOutput([]byte(`{ "id": "img_xai_1", @@ -97,7 +240,7 @@ func TestParseXAIImageOutput(t *testing.T) { {"url": "https://example.com/a.jpg"}, {"b64_json": "aGVsbG8=", "revised_prompt": "A revised render"} ] - }`), "b64_json") + }`), "b64_json", AdapterXAIImage) if err != nil { t.Fatalf("parse xAI image output: %v", err) } diff --git a/backend/internal/infra/persistence/models/channel.go b/backend/internal/infra/persistence/models/channel.go index bd8b890f..fd031c0d 100644 --- a/backend/internal/infra/persistence/models/channel.go +++ b/backend/internal/infra/persistence/models/channel.go @@ -36,6 +36,7 @@ type LLMPlatformModel struct { Vendor string `gorm:"size:64;not null;default:'';index:idx_llm_platform_models_vendor;comment:平台展示厂商"` KindsJSON string `gorm:"type:text;not null;default:'[\"chat\"]';comment:模型类型JSON数组"` CapabilitiesJSON string `gorm:"type:text;not null;default:'{}';comment:平台能力配置JSON"` + SystemPrompt string `gorm:"type:text;not null;default:'';comment:模型级系统提示词"` Icon string `gorm:"size:64;comment:模型图标标识"` Description string `gorm:"type:text;comment:模型说明"` Status string `gorm:"size:32;not null;default:'active';index:idx_llm_platform_models_status;comment:平台模型状态"` @@ -73,7 +74,7 @@ type LLMPlatformModelRoute struct { ControlPlaneModel PlatformModelID uint `gorm:"not null;default:0;index:idx_llm_model_routes_model;uniqueIndex:idx_llm_model_routes_unique;comment:平台模型ID"` UpstreamModelID uint `gorm:"not null;default:0;index:idx_llm_model_routes_upstream_model;uniqueIndex:idx_llm_model_routes_unique;comment:上游模型ID"` - Protocol string `gorm:"size:64;not null;index:idx_llm_model_routes_protocol;comment:最终适配器协议"` + Protocol string `gorm:"size:64;not null;index:idx_llm_model_routes_protocol;uniqueIndex:idx_llm_model_routes_unique;comment:最终适配器协议"` Status string `gorm:"size:32;not null;default:'active';index:idx_llm_model_routes_status;comment:路由状态"` Priority int `gorm:"not null;default:1;index:idx_llm_model_routes_priority;comment:路由优先级"` Weight int `gorm:"not null;default:1;comment:负载均衡权重"` diff --git a/backend/internal/infra/persistence/models/chat.go b/backend/internal/infra/persistence/models/chat.go index 7a6d00ae..d4e82546 100644 --- a/backend/internal/infra/persistence/models/chat.go +++ b/backend/internal/infra/persistence/models/chat.go @@ -6,6 +6,7 @@ import "time" type Conversation struct { BaseModel UserID uint `gorm:"not null;index:idx_chat_conversations_user_id;comment:用户ID"` + ProjectID *uint `gorm:"index:idx_chat_conversations_project_id;comment:项目分组ID"` PublicID string `gorm:"size:32;not null;default:'';index:idx_chat_conversations_public_id;comment:公开会话ID"` Title string `gorm:"size:255;not null;default:'';comment:会话标题"` LabelsJSON string `gorm:"type:text;not null;default:'[]';comment:会话标签JSON"` @@ -27,6 +28,24 @@ func (Conversation) TableName() string { return "chat_conversations" } +// ConversationProject 存储用户会话项目分组。 +type ConversationProject struct { + BaseModel + UserID uint `gorm:"not null;index:idx_chat_conversation_projects_user_id;comment:用户ID"` + PublicID string `gorm:"size:32;not null;default:'';uniqueIndex:idx_chat_conversation_projects_public_id;comment:公开项目ID"` + Name string `gorm:"size:80;not null;default:'';comment:项目名称"` + Description string `gorm:"size:255;not null;default:'';comment:项目描述"` + Color string `gorm:"size:32;not null;default:'';comment:项目颜色"` + Icon string `gorm:"size:32;not null;default:'';comment:项目图标"` + SortOrder int `gorm:"not null;default:0;index:idx_chat_conversation_projects_sort_order;comment:展示顺序"` + Status string `gorm:"size:32;not null;default:'active';index:idx_chat_conversation_projects_status;comment:项目状态(active/archived)"` +} + +// TableName 指定表名。 +func (ConversationProject) TableName() string { + return "chat_conversation_projects" +} + // ConversationShare 存储会话公开分享快照。 type ConversationShare struct { BaseModel diff --git a/backend/internal/infra/persistence/models/user.go b/backend/internal/infra/persistence/models/user.go index 51588bd5..dcf4a98d 100644 --- a/backend/internal/infra/persistence/models/user.go +++ b/backend/internal/infra/persistence/models/user.go @@ -3,8 +3,10 @@ package model import "time" const ( - // RoleSuperAdmin 是唯一超级管理员角色。 + // RoleSuperAdmin 是超级管理员角色。 RoleSuperAdmin = "superadmin" + // RoleAdmin 是后台管理员角色。 + RoleAdmin = "admin" // RoleUser 是普通用户角色。 RoleUser = "user" ) @@ -31,7 +33,7 @@ type User struct { AvatarURL string `gorm:"size:2048;not null;default:'';comment:头像地址"` Email string `gorm:"size:128;not null;default:'';index:idx_identity_users_email;comment:邮箱"` Phone string `gorm:"size:32;not null;default:'';index:idx_identity_users_phone;comment:手机号"` - Role string `gorm:"size:32;not null;default:'user';index:idx_identity_users_role;comment:角色(superadmin/user)"` + Role string `gorm:"size:32;not null;default:'user';index:idx_identity_users_role;comment:角色(superadmin/admin/user)"` Status string `gorm:"size:32;not null;default:'active';index:idx_identity_users_status;comment:账户状态"` Timezone string `gorm:"size:64;not null;default:'Etc/UTC';comment:时区"` Locale string `gorm:"size:16;not null;default:'en-US';comment:语言区域"` @@ -190,6 +192,7 @@ type AuthIdentityProvider struct { DefaultRole string `gorm:"size:32;not null;default:'user';comment:自动创建用户默认角色"` SubjectField string `gorm:"size:64;not null;default:'sub';comment:用户唯一ID字段"` EmailField string `gorm:"size:64;not null;default:'email';comment:邮箱字段"` + EmailVerifiedField string `gorm:"size:64;not null;default:'email_verified';comment:邮箱验证状态字段"` NameField string `gorm:"size:64;not null;default:'name';comment:昵称字段"` AvatarField string `gorm:"size:64;not null;default:'picture';comment:头像字段"` SortOrder int `gorm:"not null;default:100;index:idx_identity_providers_sort_order;comment:展示顺序"` diff --git a/backend/internal/infra/persistence/postgres/channel/repository.go b/backend/internal/infra/persistence/postgres/channel/repository.go index 11ad636e..9f61ae8c 100644 --- a/backend/internal/infra/persistence/postgres/channel/repository.go +++ b/backend/internal/infra/persistence/postgres/channel/repository.go @@ -15,12 +15,34 @@ import ( // channel 包内部的语义错误(ErrUpstreamNotFound 等)优先在调用点直接返回, // 此函数处理未在调用点明确转换的 gorm 错误。 func translateError(err error) error { + if err == nil { + return nil + } if errors.Is(err, gorm.ErrRecordNotFound) { return repository.ErrNotFound } + if isUniqueConstraintError(err) { + return repository.ErrDuplicate + } return err } +type sqlStateError interface { + SQLState() string +} + +func isUniqueConstraintError(err error) bool { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return true + } + var stateErr sqlStateError + if errors.As(err, &stateErr) && stateErr.SQLState() == "23505" { + return true + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "duplicate key") || strings.Contains(msg, "unique constraint") +} + // Repo 封装上游域数据访问。 type Repo struct { db *gorm.DB @@ -150,11 +172,12 @@ func (r *Repo) ListUpstreams(ctx context.Context, input repository.ListChannelUp ). Joins( `LEFT JOIN ( - SELECT upstream_id, - COUNT(*) AS models_count, - COUNT(*) FILTER (WHERE status = 'active') AS active_models_count - FROM llm_upstream_models - GROUP BY upstream_id + SELECT um.upstream_id, + COUNT(r.id) AS models_count, + COUNT(r.id) FILTER (WHERE r.status = 'active' AND um.status = 'active') AS active_models_count + FROM llm_upstream_models um + LEFT JOIN llm_model_routes r ON r.upstream_model_id = um.id + GROUP BY um.upstream_id ) AS stats ON stats.upstream_id = u.id`, ) listQuery = applyUpstreamListFilters(listQuery, input) @@ -247,6 +270,9 @@ func (r *Repo) UpdateModel(ctx context.Context, modelID uint, input repository.U if input.CapabilitiesJSON != nil { updates["capabilities_json"] = *input.CapabilitiesJSON } + if input.SystemPrompt != nil { + updates["system_prompt"] = *input.SystemPrompt + } if input.Status != nil { updates["status"] = *input.Status } @@ -371,6 +397,18 @@ func (r *Repo) GetActiveModelByName(ctx context.Context, platformModelName strin return &result, nil } +// GetModelListRowByID 按 ID 获取带来源统计的平台模型列表行。 +func (r *Repo) GetModelListRowByID(ctx context.Context, modelID uint) (*ModelListRow, error) { + var item ModelListRow + if err := r.modelListQuery(ctx).Where("m.id = ?", modelID).Take(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrModelNotFound + } + return nil, translateError(err) + } + return &item, nil +} + // ListModels 分页查询平台模型。 func (r *Repo) ListModels(ctx context.Context, input repository.ListChannelModelsInput) ([]ModelListRow, int64, error) { items := make([]ModelListRow, 0) @@ -381,10 +419,23 @@ func (r *Repo) ListModels(ctx context.Context, input repository.ListChannelModel return nil, 0, translateError(err) } - listQuery := r.db.WithContext(ctx). + listQuery := r.modelListQuery(ctx) + listQuery = applyModelListFilters(listQuery, input) + if err := listQuery. + Order(modelListOrder(input.Sort)). + Offset(input.Offset). + Limit(input.Limit). + Scan(&items).Error; err != nil { + return nil, 0, translateError(err) + } + return items, total, nil +} + +func (r *Repo) modelListQuery(ctx context.Context) *gorm.DB { + return r.db.WithContext(ctx). Table("llm_platform_models AS m"). Select( - "m.id, m.name AS platform_model_name, m.vendor, m.kinds_json, m.icon, m.capabilities_json, m.status, m.description, m.sort_order, m.created_at, m.updated_at, " + + "m.id, m.name AS platform_model_name, m.vendor, m.kinds_json, m.icon, m.capabilities_json, m.system_prompt, m.status, m.description, m.sort_order, m.created_at, m.updated_at, " + "COALESCE(stats.source_count, 0) AS source_count, COALESCE(stats.active_source_count, 0) AS active_source_count, COALESCE(stats.protocols_json, '[]') AS protocols_json", ). Joins( @@ -399,15 +450,6 @@ func (r *Repo) ListModels(ctx context.Context, input repository.ListChannelModel GROUP BY r.platform_model_id ) AS stats ON stats.platform_model_id = m.id`, ) - listQuery = applyModelListFilters(listQuery, input) - if err := listQuery. - Order(modelListOrder(input.Sort)). - Offset(input.Offset). - Limit(input.Limit). - Scan(&items).Error; err != nil { - return nil, 0, translateError(err) - } - return items, total, nil } func applyModelListFilters(query *gorm.DB, input repository.ListChannelModelsInput) *gorm.DB { @@ -664,6 +706,95 @@ func (r *Repo) ListUpstreamModels(ctx context.Context, upstreamID uint, input re return items, total, nil } +// ListUpstreamModelsByNames 按远端模型名集合查询已有上游模型和绑定快照。 +func (r *Repo) ListUpstreamModelsByNames(ctx context.Context, upstreamID uint, upstreamModelNames []string) ([]UpstreamModelListRow, error) { + names := make([]string, 0, len(upstreamModelNames)) + seen := make(map[string]struct{}, len(upstreamModelNames)) + for _, raw := range upstreamModelNames { + name := strings.TrimSpace(raw) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + names = append(names, name) + } + if len(names) == 0 { + return []UpstreamModelListRow{}, nil + } + items := make([]UpstreamModelListRow, 0) + if err := r.db.WithContext(ctx). + Table("llm_upstream_models AS um"). + Select( + "um.*, r.id AS route_id, r.platform_model_id, pm.name AS platform_model_name, pm.vendor AS model_vendor, pm.kinds_json AS model_kinds_json, pm.icon AS model_icon, "+ + "r.protocol, r.status AS route_status, r.priority, r.weight, r.source AS route_source, "+ + "r.cb_failure_threshold, r.cb_duration_min, r.cb_window_min, r.headers_json", + ). + Joins("LEFT JOIN llm_model_routes r ON r.upstream_model_id = um.id"). + Joins("LEFT JOIN llm_platform_models pm ON pm.id = r.platform_model_id"). + Where("um.upstream_id = ? AND um.upstream_model_name IN ?", upstreamID, names). + Order("um.upstream_model_name ASC, r.id ASC NULLS LAST"). + Scan(&items).Error; err != nil { + return nil, translateError(err) + } + return items, nil +} + +// GetUpstreamModelRouteByID 按路由 ID 精确查询上游模型绑定行。 +func (r *Repo) GetUpstreamModelRouteByID(ctx context.Context, upstreamID uint, routeID uint) (*UpstreamModelListRow, error) { + var item UpstreamModelListRow + if err := r.db.WithContext(ctx). + Table("llm_upstream_models AS um"). + Select( + "um.*, r.id AS route_id, r.platform_model_id, pm.name AS platform_model_name, pm.vendor AS model_vendor, pm.kinds_json AS model_kinds_json, pm.icon AS model_icon, "+ + "r.protocol, r.status AS route_status, r.priority, r.weight, r.source AS route_source, "+ + "r.cb_failure_threshold, r.cb_duration_min, r.cb_window_min, r.headers_json", + ). + Joins("JOIN llm_model_routes r ON r.upstream_model_id = um.id"). + Joins("JOIN llm_platform_models pm ON pm.id = r.platform_model_id"). + Where("um.upstream_id = ? AND r.id = ?", upstreamID, routeID). + Take(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUpstreamModelNotFound + } + return nil, translateError(err) + } + return &item, nil +} + +// GetUpstreamModelRouteByNames 按平台模型、上游模型和协议精确查询绑定行。 +func (r *Repo) GetUpstreamModelRouteByNames( + ctx context.Context, + upstreamID uint, + platformModelName string, + upstreamModelName string, + protocol string, +) (*UpstreamModelListRow, error) { + var item UpstreamModelListRow + query := r.db.WithContext(ctx). + Table("llm_upstream_models AS um"). + Select( + "um.*, r.id AS route_id, r.platform_model_id, pm.name AS platform_model_name, pm.vendor AS model_vendor, pm.kinds_json AS model_kinds_json, pm.icon AS model_icon, "+ + "r.protocol, r.status AS route_status, r.priority, r.weight, r.source AS route_source, "+ + "r.cb_failure_threshold, r.cb_duration_min, r.cb_window_min, r.headers_json", + ). + Joins("JOIN llm_model_routes r ON r.upstream_model_id = um.id"). + Joins("JOIN llm_platform_models pm ON pm.id = r.platform_model_id"). + Where("um.upstream_id = ? AND pm.name = ? AND um.upstream_model_name = ?", upstreamID, strings.TrimSpace(platformModelName), strings.TrimSpace(upstreamModelName)) + if normalizedProtocol := strings.TrimSpace(protocol); normalizedProtocol != "" { + query = query.Where("r.protocol = ?", normalizedProtocol) + } + if err := query.Take(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUpstreamModelNotFound + } + return nil, translateError(err) + } + return &item, nil +} + func applyUpstreamModelListFilters(query *gorm.DB, input repository.ListChannelUpstreamModelsInput) *gorm.DB { if keyword := strings.TrimSpace(input.Query); keyword != "" { like := "%" + keyword + "%" @@ -676,10 +807,10 @@ func applyUpstreamModelListFilters(query *gorm.DB, input repository.ListChannelU ) } switch strings.TrimSpace(input.RouteStatus) { + case "bound": + query = query.Where("r.id IS NOT NULL") case "active", "inactive": query = query.Where("r.id IS NOT NULL AND r.status = ?", input.RouteStatus) - case "unbound": - query = query.Where("r.id IS NULL") } if status := strings.TrimSpace(input.UpstreamStatus); status == "active" || status == "inactive" { query = query.Where("um.status = ?", status) @@ -717,7 +848,12 @@ func (r *Repo) UpsertPlatformModelRoute(ctx context.Context, item *domainchannel entity := toPlatformModelRouteModel(item) var existing model.LLMPlatformModelRoute query := r.db.WithContext(ctx). - Where("platform_model_id = ? AND upstream_model_id = ?", entity.PlatformModelID, entity.UpstreamModelID). + Where( + "platform_model_id = ? AND upstream_model_id = ? AND protocol = ?", + entity.PlatformModelID, + entity.UpstreamModelID, + entity.Protocol, + ). Limit(1). Find(&existing) if query.Error != nil { @@ -751,6 +887,30 @@ func (r *Repo) UpsertPlatformModelRoute(ctx context.Context, item *domainchannel return nil } +// ListPlatformModelRoutesByPair 查询同一平台模型和同一上游真实模型之间的全部协议绑定。 +func (r *Repo) ListPlatformModelRoutesByPair( + ctx context.Context, + upstreamID uint, + platformModelID uint, + upstreamModelID uint, +) ([]domainchannel.PlatformModelRoute, error) { + items := make([]model.LLMPlatformModelRoute, 0) + if err := r.db.WithContext(ctx). + Table("llm_model_routes AS r"). + Select("r.*"). + Joins("JOIN llm_upstream_models um ON um.id = r.upstream_model_id"). + Where("um.upstream_id = ? AND r.platform_model_id = ? AND r.upstream_model_id = ?", upstreamID, platformModelID, upstreamModelID). + Order("r.id ASC"). + Scan(&items).Error; err != nil { + return nil, translateError(err) + } + results := make([]domainchannel.PlatformModelRoute, 0, len(items)) + for _, item := range items { + results = append(results, toPlatformModelRouteDomain(item)) + } + return results, nil +} + func (r *Repo) GetPlatformModelRouteByID(ctx context.Context, routeID uint, upstreamID uint) (*domainchannel.PlatformModelRoute, error) { var item model.LLMPlatformModelRoute if err := r.db.WithContext(ctx). @@ -881,6 +1041,29 @@ func (r *Repo) ListModelUpstreamSources(ctx context.Context, platformModelName s return items, total, nil } +// GetModelUpstreamSourceByRouteID 按平台模型名和路由 ID 精确查询模型来源。 +func (r *Repo) GetModelUpstreamSourceByRouteID(ctx context.Context, platformModelName string, routeID uint) (*ModelSourceRow, error) { + var item ModelSourceRow + if err := r.db.WithContext(ctx). + Table("llm_model_routes AS r"). + Select( + "r.*, um.upstream_id, u.name AS upstream_name, u.base_url AS base_url, "+ + "um.binding_code, um.upstream_model_name, um.vendor AS upstream_model_vendor, um.icon AS upstream_model_icon, "+ + "um.kinds_json AS upstream_model_kinds_json, um.suggested_protocol, um.status AS upstream_model_status", + ). + Joins("JOIN llm_platform_models pm ON pm.id = r.platform_model_id"). + Joins("JOIN llm_upstream_models um ON um.id = r.upstream_model_id"). + Joins("JOIN llm_upstreams u ON u.id = um.upstream_id"). + Where("pm.name = ? AND r.id = ?", strings.TrimSpace(platformModelName), routeID). + Take(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUpstreamModelNotFound + } + return nil, translateError(err) + } + return &item, nil +} + // routeScanRow 是 ListActiveRoutesByModel 查询的原始扫描结构体。 // 仅限 infra 层内部使用,扫描后映射到 UpstreamRouteRow。 type routeScanRow struct { @@ -894,6 +1077,7 @@ type routeScanRow struct { ModelIcon string ModelKindsJSON string ModelCapabilitiesJSON string + ModelSystemPrompt string Protocol string BaseURL string APIKeysEnc string @@ -923,7 +1107,7 @@ func (r *Repo) ListActiveRoutesByModel(ctx context.Context, platformModelName st Table("llm_model_routes AS r"). Select( "r.id AS route_id, um.id AS upstream_model_id, u.id AS upstream_id, u.name AS upstream_name, "+ - "pm.id AS platform_model_id, pm.name AS platform_model_name, pm.vendor AS model_vendor, pm.icon AS model_icon, pm.kinds_json AS model_kinds_json, pm.capabilities_json AS model_capabilities_json, "+ + "pm.id AS platform_model_id, pm.name AS platform_model_name, pm.vendor AS model_vendor, pm.icon AS model_icon, pm.kinds_json AS model_kinds_json, pm.capabilities_json AS model_capabilities_json, pm.system_prompt AS model_system_prompt, "+ "r.protocol, u.base_url, u.api_keys_enc, "+ "u.connect_timeout_ms, u.read_timeout_ms, u.stream_idle_timeout_ms, "+ "u.headers_json, r.headers_json AS route_headers_json, "+ @@ -959,6 +1143,7 @@ func (r *Repo) ListActiveRoutesByModel(ctx context.Context, platformModelName st ModelIcon: s.ModelIcon, ModelKindsJSON: s.ModelKindsJSON, ModelCapabilitiesJSON: s.ModelCapabilitiesJSON, + ModelSystemPrompt: s.ModelSystemPrompt, Protocol: s.Protocol, BaseURL: s.BaseURL, APIKeysEnc: s.APIKeysEnc, @@ -1169,6 +1354,7 @@ func toPlatformModelDomain(item model.LLMPlatformModel) domainchannel.PlatformMo KindsJSON: item.KindsJSON, Icon: item.Icon, CapabilitiesJSON: item.CapabilitiesJSON, + SystemPrompt: item.SystemPrompt, Status: item.Status, Description: item.Description, SortOrder: item.SortOrder, @@ -1187,6 +1373,7 @@ func toPlatformModelModel(item *domainchannel.PlatformModel) model.LLMPlatformMo KindsJSON: item.KindsJSON, Icon: item.Icon, CapabilitiesJSON: item.CapabilitiesJSON, + SystemPrompt: item.SystemPrompt, Status: item.Status, Description: item.Description, SortOrder: item.SortOrder, diff --git a/backend/internal/infra/persistence/postgres/conversation/repository.go b/backend/internal/infra/persistence/postgres/conversation/repository.go index 8b1e4ea6..066255f7 100644 --- a/backend/internal/infra/persistence/postgres/conversation/repository.go +++ b/backend/internal/infra/persistence/postgres/conversation/repository.go @@ -87,6 +87,7 @@ func (r *Repo) ListConversationsByUser( statusFilter string, starredFilter string, shareFilter string, + projectFilter string, ) ([]domainconversation.Conversation, int64, error) { items := make([]models.Conversation, 0) var total int64 @@ -122,6 +123,22 @@ func (r *Repo) ListConversationsByUser( query = query.Where("NOT "+activeShareExistsSQL, "active") } + switch normalizedProjectFilter := strings.TrimSpace(projectFilter); normalizedProjectFilter { + case "", "all": + // 保留全部项目归属。 + case "unassigned": + query = query.Where("project_id IS NULL") + default: + project, err := r.GetConversationProjectByPublicID(ctx, userID, normalizedProjectFilter) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return []domainconversation.Conversation{}, 0, nil + } + return nil, 0, err + } + query = query.Where("project_id = ?", project.ID) + } + if err := query.Count(&total).Error; err != nil { return nil, 0, translateError(err) } @@ -144,6 +161,9 @@ func (r *Repo) ListConversationsByUser( if err := r.hydrateConversationShareSummaries(ctx, results); err != nil { return nil, 0, err } + if err := r.hydrateConversationProjectSummaries(ctx, results); err != nil { + return nil, 0, err + } return results, total, nil } @@ -202,6 +222,61 @@ func (r *Repo) hydrateConversationShareSummary(ctx context.Context, item *domain return nil } +func (r *Repo) hydrateConversationProjectSummaries(ctx context.Context, items []domainconversation.Conversation) error { + if len(items) == 0 { + return nil + } + projectIDs := make([]uint, 0, len(items)) + seen := make(map[uint]struct{}, len(items)) + for _, item := range items { + if item.ProjectID == nil || *item.ProjectID == 0 { + continue + } + if _, exists := seen[*item.ProjectID]; exists { + continue + } + seen[*item.ProjectID] = struct{}{} + projectIDs = append(projectIDs, *item.ProjectID) + } + if len(projectIDs) == 0 { + return nil + } + projects := make([]models.ConversationProject, 0, len(projectIDs)) + if err := r.db.WithContext(ctx). + Where("id IN ?", projectIDs). + Find(&projects).Error; err != nil { + return translateError(err) + } + byID := make(map[uint]models.ConversationProject, len(projects)) + for _, project := range projects { + byID[project.ID] = project + } + for index := range items { + if items[index].ProjectID == nil { + continue + } + project, ok := byID[*items[index].ProjectID] + if !ok { + continue + } + items[index].ProjectPublicID = project.PublicID + items[index].ProjectName = project.Name + } + return nil +} + +func (r *Repo) hydrateConversationProjectSummary(ctx context.Context, item *domainconversation.Conversation) error { + if item == nil { + return nil + } + items := []domainconversation.Conversation{*item} + if err := r.hydrateConversationProjectSummaries(ctx, items); err != nil { + return err + } + *item = items[0] + return nil +} + // GetConversationByUser 查询归属用户会话。 func (r *Repo) GetConversationByUser(ctx context.Context, conversationID uint, userID uint) (*domainconversation.Conversation, error) { var item models.Conversation @@ -214,6 +289,9 @@ func (r *Repo) GetConversationByUser(ctx context.Context, conversationID uint, u if err := r.hydrateConversationShareSummary(ctx, &result); err != nil { return nil, err } + if err := r.hydrateConversationProjectSummary(ctx, &result); err != nil { + return nil, err + } return &result, nil } @@ -229,6 +307,9 @@ func (r *Repo) GetConversationByPublicID(ctx context.Context, publicID string, u if err := r.hydrateConversationShareSummary(ctx, &result); err != nil { return nil, err } + if err := r.hydrateConversationProjectSummary(ctx, &result); err != nil { + return nil, err + } return &result, nil } @@ -556,6 +637,62 @@ func (r *Repo) CreateMessage(ctx context.Context, item *domainconversation.Messa return nil } +// CreateMessagePairWithUserAttachments 原子创建用户消息、助手占位消息、用户附件并递增会话消息数。 +func (r *Repo) CreateMessagePairWithUserAttachments( + ctx context.Context, + userMessage *domainconversation.Message, + assistantMessage *domainconversation.Message, + userAttachments []domainconversation.Attachment, +) error { + if userMessage == nil || assistantMessage == nil { + return repository.ErrInvalidInput + } + userAttachmentSnapshot := userMessage.Attachments + assistantAttachmentSnapshot := assistantMessage.Attachments + return translateError(r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + userEntity := toMessageModel(userMessage) + if err := tx.Create(&userEntity).Error; err != nil { + return err + } + *userMessage = toMessageDomain(userEntity) + userMessage.Attachments = userAttachmentSnapshot + + if len(userAttachments) > 0 { + entities := make([]models.Attachment, 0, len(userAttachments)) + for i := range userAttachments { + item := userAttachments[i] + item.ConversationID = userMessage.ConversationID + item.MessageID = userMessage.ID + item.UserID = userMessage.UserID + entities = append(entities, toAttachmentModel(&item)) + } + if err := tx.Create(&entities).Error; err != nil { + return err + } + } + + parentMessageID := userMessage.ID + assistantMessage.ParentMessageID = &parentMessageID + assistantEntity := toMessageModel(assistantMessage) + if err := tx.Create(&assistantEntity).Error; err != nil { + return err + } + *assistantMessage = toMessageDomain(assistantEntity) + assistantMessage.Attachments = assistantAttachmentSnapshot + + result := tx.Model(&models.Conversation{}). + Where("id = ?", userMessage.ConversationID). + Update("message_count", gorm.Expr("message_count + ?", 2)) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil + })) +} + // GetMessageByPublicID 查询归属会话的消息。 func (r *Repo) GetMessageByPublicID( ctx context.Context, @@ -699,6 +836,68 @@ func (r *Repo) UpdateAssistantMessageCompletion( Error) } +// CompleteAssistantMessageWithAttachments 原子写入助手附件,并同步用户用量与助手完成态。 +func (r *Repo) CompleteAssistantMessageWithAttachments( + ctx context.Context, + userMessageID uint, + userUsage repository.MessageUsageUpdate, + assistantMessageID uint, + assistantCompletion repository.AssistantMessageCompletionUpdate, + assistantAttachments []domainconversation.Attachment, +) error { + return translateError(r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if len(assistantAttachments) > 0 { + entities := make([]models.Attachment, 0, len(assistantAttachments)) + for i := range assistantAttachments { + item := assistantAttachments[i] + item.MessageID = assistantMessageID + entities = append(entities, toAttachmentModel(&item)) + } + if err := tx.Create(&entities).Error; err != nil { + return err + } + } + + userTokenUsage := userUsage.InputTokens + userUsage.CacheReadTokens + userUsage.CacheWriteTokens + userUsage.OutputTokens + userUsage.ReasoningTokens + if userTokenUsage < 0 { + userTokenUsage = 0 + } + if err := tx.Model(&models.Message{}). + Where("id = ?", userMessageID). + Updates(map[string]interface{}{ + "token_usage": userTokenUsage, + "input_tokens": userUsage.InputTokens, + "output_tokens": userUsage.OutputTokens, + "cache_read_tokens": userUsage.CacheReadTokens, + "cache_write_tokens": userUsage.CacheWriteTokens, + "reasoning_tokens": userUsage.ReasoningTokens, + }).Error; err != nil { + return err + } + + assistantTokenUsage := assistantCompletion.OutputTokens + assistantCompletion.ReasoningTokens + if assistantTokenUsage < 0 { + assistantTokenUsage = 0 + } + latencyMS := assistantCompletion.LatencyMS + if latencyMS < 0 { + latencyMS = 0 + } + return tx.Model(&models.Message{}). + Where("id = ?", assistantMessageID). + Updates(map[string]interface{}{ + "content": assistantCompletion.Content, + "token_usage": assistantTokenUsage, + "output_tokens": assistantCompletion.OutputTokens, + "reasoning_tokens": assistantCompletion.ReasoningTokens, + "latency_ms": latencyMS, + "status": assistantCompletion.Status, + "error_code": assistantCompletion.ErrorCode, + "error_message": assistantCompletion.ErrorMessage, + }).Error + })) +} + // UpdateMessageBilling 回填消息计费金额与计费快照。 func (r *Repo) UpdateMessageBilling(ctx context.Context, messageID uint, billedCurrency string, billedNanousd int64, pricingSnapshot string) error { if billedNanousd < 0 { @@ -2131,6 +2330,7 @@ func toConversationDomain(item models.Conversation) domainconversation.Conversat return domainconversation.Conversation{ ID: item.ID, UserID: item.UserID, + ProjectID: item.ProjectID, PublicID: item.PublicID, Title: item.Title, LabelsJSON: labelsJSON, @@ -2187,6 +2387,7 @@ func toConversationModel(item *domainconversation.Conversation) models.Conversat } return models.Conversation{ UserID: item.UserID, + ProjectID: item.ProjectID, PublicID: item.PublicID, Title: item.Title, LabelsJSON: labelsJSON, diff --git a/backend/internal/infra/persistence/postgres/conversation/repository_project.go b/backend/internal/infra/persistence/postgres/conversation/repository_project.go new file mode 100644 index 00000000..b36759b8 --- /dev/null +++ b/backend/internal/infra/persistence/postgres/conversation/repository_project.go @@ -0,0 +1,224 @@ +package conversation + +import ( + "context" + "strings" + + domainconversation "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation" + models "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "gorm.io/gorm" +) + +// CreateConversationProject 创建会话项目分组。 +func (r *Repo) CreateConversationProject(ctx context.Context, item *domainconversation.ConversationProject) error { + entity := toConversationProjectModel(item) + if err := r.db.WithContext(ctx).Create(&entity).Error; err != nil { + return translateError(err) + } + *item = toConversationProjectDomain(entity) + return nil +} + +// ListConversationProjects 查询用户项目分组。 +func (r *Repo) ListConversationProjects(ctx context.Context, userID uint, statusFilter string) ([]domainconversation.ConversationProject, error) { + items := make([]models.ConversationProject, 0) + query := r.db.WithContext(ctx). + Where("user_id = ?", userID) + switch strings.TrimSpace(statusFilter) { + case "archived": + query = query.Where("status = ?", "archived") + case "all": + // 保留全部状态。 + default: + query = query.Where("status = ?", "active") + } + if err := query. + Order("sort_order ASC"). + Order("id DESC"). + Find(&items).Error; err != nil { + return nil, translateError(err) + } + return toConversationProjectDomains(items), nil +} + +// GetConversationProjectByPublicID 查询用户项目分组。 +func (r *Repo) GetConversationProjectByPublicID(ctx context.Context, userID uint, publicID string) (*domainconversation.ConversationProject, error) { + var item models.ConversationProject + if err := r.db.WithContext(ctx). + Where("user_id = ? AND public_id = ?", userID, strings.TrimSpace(publicID)). + First(&item).Error; err != nil { + return nil, translateError(err) + } + result := toConversationProjectDomain(item) + return &result, nil +} + +// UpdateConversationProjectMetadataByPublicID 更新项目分组元信息。 +func (r *Repo) UpdateConversationProjectMetadataByPublicID( + ctx context.Context, + userID uint, + publicID string, + patch domainconversation.ConversationProjectPatch, +) (*domainconversation.ConversationProject, error) { + updates := make(map[string]interface{}) + if patch.Name != nil { + updates["name"] = *patch.Name + } + if patch.Description != nil { + updates["description"] = *patch.Description + } + if patch.Color != nil { + updates["color"] = *patch.Color + } + if patch.Icon != nil { + updates["icon"] = *patch.Icon + } + if patch.Status != nil { + updates["status"] = *patch.Status + } + if len(updates) == 0 { + return r.GetConversationProjectByPublicID(ctx, userID, publicID) + } + result := r.db.WithContext(ctx). + Model(&models.ConversationProject{}). + Where("user_id = ? AND public_id = ?", userID, strings.TrimSpace(publicID)). + Updates(updates) + if result.Error != nil { + return nil, translateError(result.Error) + } + if result.RowsAffected == 0 { + return nil, repository.ErrNotFound + } + return r.GetConversationProjectByPublicID(ctx, userID, publicID) +} + +// DeleteConversationProjectByPublicID 删除项目分组,可选择一并软删除其下会话。 +func (r *Repo) DeleteConversationProjectByPublicID(ctx context.Context, userID uint, publicID string, deleteConversations bool) error { + normalizedPublicID := strings.TrimSpace(publicID) + return translateError(r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var project models.ConversationProject + if err := tx.Where("user_id = ? AND public_id = ?", userID, normalizedPublicID).First(&project).Error; err != nil { + return translateError(err) + } + // 项目删除与会话归属处理必须保持原子性,避免项目删除后留下不可见的项目引用。 + if deleteConversations { + if err := tx. + Where("user_id = ? AND project_id = ?", userID, project.ID). + Delete(&models.Conversation{}).Error; err != nil { + return translateError(err) + } + } else { + if err := tx.Model(&models.Conversation{}). + Where("user_id = ? AND project_id = ?", userID, project.ID). + Update("project_id", nil).Error; err != nil { + return translateError(err) + } + } + if err := tx.Delete(&project).Error; err != nil { + return translateError(err) + } + return nil + })) +} + +// ReorderConversationProjects 更新项目展示顺序。 +func (r *Repo) ReorderConversationProjects(ctx context.Context, userID uint, publicIDs []string) error { + if len(publicIDs) == 0 { + return nil + } + return translateError(r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for index, publicID := range publicIDs { + result := tx.Model(&models.ConversationProject{}). + Where("user_id = ? AND public_id = ?", userID, strings.TrimSpace(publicID)). + Update("sort_order", index+1) + if result.Error != nil { + return translateError(result.Error) + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + } + return nil + })) +} + +// UpdateConversationProjectAssignmentByPublicID 更新单个会话的项目归属。 +func (r *Repo) UpdateConversationProjectAssignmentByPublicID( + ctx context.Context, + userID uint, + conversationPublicID string, + projectID *uint, +) (*domainconversation.Conversation, error) { + result := r.db.WithContext(ctx). + Model(&models.Conversation{}). + Where("user_id = ? AND public_id = ?", userID, strings.TrimSpace(conversationPublicID)). + Update("project_id", projectID) + if result.Error != nil { + return nil, translateError(result.Error) + } + if result.RowsAffected == 0 { + return nil, repository.ErrNotFound + } + return r.GetConversationByPublicID(ctx, conversationPublicID, userID) +} + +// BatchUpdateConversationProjectByPublicIDs 批量更新会话项目归属。 +func (r *Repo) BatchUpdateConversationProjectByPublicIDs( + ctx context.Context, + userID uint, + conversationPublicIDs []string, + projectID *uint, +) (int64, error) { + if len(conversationPublicIDs) == 0 { + return 0, nil + } + result := r.db.WithContext(ctx). + Model(&models.Conversation{}). + Where("user_id = ? AND public_id IN ?", userID, conversationPublicIDs). + Update("project_id", projectID) + if result.Error != nil { + return 0, translateError(result.Error) + } + return result.RowsAffected, nil +} + +func toConversationProjectDomain(item models.ConversationProject) domainconversation.ConversationProject { + return domainconversation.ConversationProject{ + ID: item.ID, + UserID: item.UserID, + PublicID: item.PublicID, + Name: item.Name, + Description: item.Description, + Color: item.Color, + Icon: item.Icon, + SortOrder: item.SortOrder, + Status: item.Status, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +func toConversationProjectDomains(items []models.ConversationProject) []domainconversation.ConversationProject { + results := make([]domainconversation.ConversationProject, 0, len(items)) + for _, item := range items { + results = append(results, toConversationProjectDomain(item)) + } + return results +} + +func toConversationProjectModel(item *domainconversation.ConversationProject) models.ConversationProject { + if item == nil { + return models.ConversationProject{} + } + return models.ConversationProject{ + UserID: item.UserID, + PublicID: item.PublicID, + Name: item.Name, + Description: item.Description, + Color: item.Color, + Icon: item.Icon, + SortOrder: item.SortOrder, + Status: item.Status, + } +} diff --git a/backend/internal/infra/persistence/postgres/postgres.go b/backend/internal/infra/persistence/postgres/postgres.go index aa374571..fc0986f9 100644 --- a/backend/internal/infra/persistence/postgres/postgres.go +++ b/backend/internal/infra/persistence/postgres/postgres.go @@ -118,6 +118,7 @@ func migrate(db *gorm.DB, cfg config.Config) error { "mcp_servers": "MCP服务配置表", "mcp_tools": "MCP工具发现表", "chat_conversations": "聊天会话表", + "chat_conversation_projects": "会话项目分组表", "chat_conversation_shares": "会话公开分享快照表", "chat_messages": "会话消息表", "chat_feedback": "会话消息反馈表", @@ -191,6 +192,7 @@ func applySchemaBaseline(db *gorm.DB) error { &model.MCPServer{}, &model.MCPTool{}, &model.Conversation{}, + &model.ConversationProject{}, &model.ConversationShare{}, &model.Message{}, &model.ConversationMessageFeedback{}, @@ -233,14 +235,21 @@ func escapeSQLLiteral(input string) string { func applyLLMBaselineIndexes(db *gorm.DB) error { statements := []string{ + `ALTER TABLE "llm_upstreams" + ADD COLUMN IF NOT EXISTS "protocol_defaults_json" text NOT NULL DEFAULT '{}'`, + `COMMENT ON COLUMN "llm_upstreams"."protocol_defaults_json" IS '按模型类型配置的默认协议JSON'`, + `ALTER TABLE "llm_platform_models" + ADD COLUMN IF NOT EXISTS "system_prompt" text NOT NULL DEFAULT ''`, + `COMMENT ON COLUMN "llm_platform_models"."system_prompt" IS '模型级系统提示词'`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_upstream_models_upstream_name ON "llm_upstream_models" ("upstream_id", "upstream_model_name")`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_upstream_models_binding_code ON "llm_upstream_models" ("binding_code")`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_platform_models_name ON "llm_platform_models" ("name")`, + `DROP INDEX IF EXISTS idx_llm_model_routes_unique`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_model_routes_unique - ON "llm_model_routes" ("platform_model_id", "upstream_model_id")`, + ON "llm_model_routes" ("platform_model_id", "upstream_model_id", "protocol")`, `CREATE INDEX IF NOT EXISTS idx_llm_model_routes_routing ON "llm_model_routes" ("platform_model_id", "status", "priority", "weight") WHERE status = 'active'`, @@ -279,9 +288,7 @@ func applyIdentityBaselineConstraints(db *gorm.DB) error { `ALTER TABLE "identity_users" ADD COLUMN IF NOT EXISTS "appearance_preferences" text NOT NULL DEFAULT ''`, `COMMENT ON COLUMN "identity_users"."appearance_preferences" IS '外观偏好JSON'`, - `CREATE UNIQUE INDEX IF NOT EXISTS uk_identity_users_single_superadmin - ON "identity_users" ("role") - WHERE role = 'superadmin' AND deleted_at IS NULL`, + `DROP INDEX IF EXISTS uk_identity_users_single_superadmin`, } for _, statement := range statements { if err := db.Exec(statement).Error; err != nil { @@ -293,10 +300,22 @@ func applyIdentityBaselineConstraints(db *gorm.DB) error { func applyConversationBaselineIndexes(db *gorm.DB) error { statements := []string{ + `ALTER TABLE "chat_conversations" + ADD COLUMN IF NOT EXISTS "project_id" bigint`, + `COMMENT ON COLUMN "chat_conversations"."project_id" IS '项目分组ID'`, `CREATE INDEX IF NOT EXISTS idx_chat_conversations_user_status_starred_updated_at ON "chat_conversations" ("user_id", "status", "is_starred", "updated_at" DESC, "id" DESC)`, `CREATE INDEX IF NOT EXISTS idx_chat_conversations_user_status_starred_starred_at ON "chat_conversations" ("user_id", "status", "is_starred", "starred_at" DESC, "id" DESC)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_conversation_projects_public_id + ON "chat_conversation_projects" ("public_id") + WHERE deleted_at IS NULL`, + `CREATE INDEX IF NOT EXISTS idx_chat_conversation_projects_user_status_sort + ON "chat_conversation_projects" ("user_id", "status", "sort_order" ASC, "id" DESC) + WHERE deleted_at IS NULL`, + `CREATE INDEX IF NOT EXISTS idx_chat_conversations_user_project_status_updated + ON "chat_conversations" ("user_id", "project_id", "status", "updated_at" DESC, "id" DESC) + WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_chat_conversation_shares_active_conversation ON "chat_conversation_shares" ("conversation_id", "updated_at" DESC, "id" DESC) WHERE status = 'active'`, diff --git a/backend/internal/infra/persistence/postgres/user/repository.go b/backend/internal/infra/persistence/postgres/user/repository.go index f3a27669..f7384bcb 100644 --- a/backend/internal/infra/persistence/postgres/user/repository.go +++ b/backend/internal/infra/persistence/postgres/user/repository.go @@ -152,6 +152,61 @@ func (r *Repo) updateUserFields(ctx context.Context, userID uint, input reposito return r.GetByID(ctx, userID) } + if input.Role != nil && *input.Role != model.RoleSuperAdmin { + return r.updateUserFieldsWithSuperAdminGuard(ctx, userID, updates) + } + + return r.applyUserFieldUpdates(ctx, userID, updates) +} + +func (r *Repo) updateUserFieldsWithSuperAdminGuard( + ctx context.Context, + userID uint, + updates map[string]interface{}, +) (*domainuser.User, error) { + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var superAdmins []model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Select("id"). + Where("role = ?", model.RoleSuperAdmin). + Order("id ASC"). + Find(&superAdmins).Error; err != nil { + return translateError(err) + } + + targetIsSuperAdmin := false + for _, item := range superAdmins { + if item.ID == userID { + targetIsSuperAdmin = true + break + } + } + if targetIsSuperAdmin && len(superAdmins) <= 1 { + return repository.ErrLastSuperAdminRoleChange + } + + result := tx.Model(&model.User{}). + Where("id = ?", userID). + Updates(updates) + if result.Error != nil { + return translateError(result.Error) + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil + }) + if err != nil { + return nil, err + } + return r.GetByID(ctx, userID) +} + +func (r *Repo) applyUserFieldUpdates( + ctx context.Context, + userID uint, + updates map[string]interface{}, +) (*domainuser.User, error) { result := r.db.WithContext(ctx). Model(&model.User{}). Where("id = ?", userID). @@ -1164,6 +1219,9 @@ func identityProviderUpdates(input repository.UpdateIdentityProviderInput) map[s if input.EmailField != nil { updates["email_field"] = *input.EmailField } + if input.EmailVerifiedField != nil { + updates["email_verified_field"] = *input.EmailVerifiedField + } if input.NameField != nil { updates["name_field"] = *input.NameField } @@ -1800,6 +1858,7 @@ func toDomainIdentityProvider(item model.AuthIdentityProvider) *domainuser.Ident DefaultRole: item.DefaultRole, SubjectField: item.SubjectField, EmailField: item.EmailField, + EmailVerifiedField: item.EmailVerifiedField, NameField: item.NameField, AvatarField: item.AvatarField, SortOrder: item.SortOrder, @@ -1838,6 +1897,7 @@ func toModelIdentityProvider(item *domainuser.IdentityProvider) *model.AuthIdent DefaultRole: item.DefaultRole, SubjectField: item.SubjectField, EmailField: item.EmailField, + EmailVerifiedField: item.EmailVerifiedField, NameField: item.NameField, AvatarField: item.AvatarField, SortOrder: item.SortOrder, diff --git a/backend/internal/infra/persistence/postgres/user/repository_superadmin_guard_test.go b/backend/internal/infra/persistence/postgres/user/repository_superadmin_guard_test.go new file mode 100644 index 00000000..672c6e57 --- /dev/null +++ b/backend/internal/infra/persistence/postgres/user/repository_superadmin_guard_test.go @@ -0,0 +1,87 @@ +package user + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestUpdateFieldsKeepsLastSuperAdminProtected(t *testing.T) { + dsn := strings.TrimSpace(os.Getenv("DEEIX_TEST_DATABASE_DSN")) + if dsn == "" { + t.Skip("set DEEIX_TEST_DATABASE_DSN to run PostgreSQL repository guard integration test") + } + + db, cleanup := openUserRepositoryIntegrationDB(t, dsn) + defer cleanup() + + userItem := model.User{ + PublicID: "superadmin_public_id", + Username: "root", + Role: model.RoleSuperAdmin, + Status: model.UserStatusActive, + Timezone: "Etc/UTC", + Locale: "en-US", + } + if err := db.Create(&userItem).Error; err != nil { + t.Fatalf("create superadmin: %v", err) + } + + nextRole := model.RoleAdmin + _, err := NewRepo(db).UpdateFields(context.Background(), userItem.ID, repository.UpdateUserFieldsInput{ + Role: &nextRole, + }) + if !errors.Is(err, repository.ErrLastSuperAdminRoleChange) { + t.Fatalf("expected last superadmin guard, got %v", err) + } + + var persisted model.User + if err := db.First(&persisted, userItem.ID).Error; err != nil { + t.Fatalf("reload user: %v", err) + } + if persisted.Role != model.RoleSuperAdmin { + t.Fatalf("expected role to remain %q, got %q", model.RoleSuperAdmin, persisted.Role) + } +} + +func openUserRepositoryIntegrationDB(t *testing.T, dsn string) (*gorm.DB, func()) { + t.Helper() + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("open postgres: %v", err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("resolve sql db: %v", err) + } + sqlDB.SetMaxOpenConns(1) + + schemaName := fmt.Sprintf("deeix_test_superadmin_guard_%d", time.Now().UnixNano()) + if err := db.Exec(`CREATE SCHEMA ` + schemaName).Error; err != nil { + _ = sqlDB.Close() + t.Fatalf("create schema: %v", err) + } + cleanup := func() { + _ = db.Exec(`DROP SCHEMA IF EXISTS ` + schemaName + ` CASCADE`).Error + _ = sqlDB.Close() + } + if err := db.Exec(`SET search_path TO ` + schemaName).Error; err != nil { + cleanup() + t.Fatalf("set search_path: %v", err) + } + if err := db.AutoMigrate(&model.User{}); err != nil { + cleanup() + t.Fatalf("migrate user table: %v", err) + } + return db, cleanup +} diff --git a/backend/internal/repository/channel.go b/backend/internal/repository/channel.go index 0f8bccf1..733cb658 100644 --- a/backend/internal/repository/channel.go +++ b/backend/internal/repository/channel.go @@ -100,6 +100,7 @@ type ChannelUpstreamRouteRow struct { ModelIcon string ModelKindsJSON string ModelCapabilitiesJSON string + ModelSystemPrompt string Protocol string BaseURL string APIKeysEnc string @@ -212,6 +213,7 @@ type UpdateChannelModelInput struct { KindsJSON *string Icon *string CapabilitiesJSON *string + SystemPrompt *string Status *string Description *string } @@ -312,6 +314,7 @@ func (input UpdateChannelModelInput) IsZero() bool { input.KindsJSON == nil && input.Icon == nil && input.CapabilitiesJSON == nil && + input.SystemPrompt == nil && input.Status == nil && input.Description == nil } @@ -326,6 +329,7 @@ type ChannelRepository interface { UpdateModel(ctx context.Context, modelID uint, input UpdateChannelModelInput) error ReorderModels(ctx context.Context, orderedModelIDs []uint) error GetModelByID(ctx context.Context, modelID uint) (*domainchannel.PlatformModel, error) + GetModelListRowByID(ctx context.Context, modelID uint) (*ChannelModelListRow, error) GetModelByName(ctx context.Context, platformModelName string) (*domainchannel.PlatformModel, error) GetActiveModelByName(ctx context.Context, platformModelName string) (*domainchannel.PlatformModel, error) ListModels(ctx context.Context, input ListChannelModelsInput) ([]ChannelModelListRow, int64, error) @@ -336,7 +340,12 @@ type ChannelRepository interface { DeleteUpstreamModel(ctx context.Context, sourceID uint, upstreamID uint) error MarkMissingSyncedUpstreamModelsInactive(ctx context.Context, upstreamID uint, activeNames []string) (int64, error) ListUpstreamModels(ctx context.Context, upstreamID uint, input ListChannelUpstreamModelsInput) ([]ChannelUpstreamModelListRow, int64, error) + ListUpstreamModelsByNames(ctx context.Context, upstreamID uint, upstreamModelNames []string) ([]ChannelUpstreamModelListRow, error) + GetUpstreamModelRouteByID(ctx context.Context, upstreamID uint, routeID uint) (*ChannelUpstreamModelListRow, error) + GetUpstreamModelRouteByNames(ctx context.Context, upstreamID uint, platformModelName string, upstreamModelName string, protocol string) (*ChannelUpstreamModelListRow, error) UpsertPlatformModelRoute(ctx context.Context, item *domainchannel.PlatformModelRoute) error + GetModelUpstreamSourceByRouteID(ctx context.Context, platformModelName string, routeID uint) (*ChannelModelSourceRow, error) + ListPlatformModelRoutesByPair(ctx context.Context, upstreamID uint, platformModelID uint, upstreamModelID uint) ([]domainchannel.PlatformModelRoute, error) GetPlatformModelRouteByID(ctx context.Context, routeID uint, upstreamID uint) (*domainchannel.PlatformModelRoute, error) UpdatePlatformModelRouteByID(ctx context.Context, routeID uint, upstreamID uint, input UpdateChannelPlatformRouteInput) error DeletePlatformModelRoute(ctx context.Context, routeID uint, upstreamID uint) error diff --git a/backend/internal/repository/conversation_core.go b/backend/internal/repository/conversation_core.go index b367f4a1..4ccbc933 100644 --- a/backend/internal/repository/conversation_core.go +++ b/backend/internal/repository/conversation_core.go @@ -8,12 +8,40 @@ import ( domainuser "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/user" ) +// MessageUsageUpdate 定义消息 token 用量更新字段。 +type MessageUsageUpdate struct { + InputTokens int64 + OutputTokens int64 + CacheReadTokens int64 + CacheWriteTokens int64 + ReasoningTokens int64 +} + +// AssistantMessageCompletionUpdate 定义助手消息完成态更新字段。 +type AssistantMessageCompletionUpdate struct { + Content string + OutputTokens int64 + ReasoningTokens int64 + LatencyMS int64 + Status string + ErrorCode string + ErrorMessage string +} + // ConversationMetadataRepository 封装会话元信息与用户访问能力。 type ConversationMetadataRepository interface { CreateConversation(ctx context.Context, item *domainconversation.Conversation) error - ListConversationsByUser(ctx context.Context, userID uint, offset int, limit int, statusFilter string, starredFilter string, shareFilter string) ([]domainconversation.Conversation, int64, error) + ListConversationsByUser(ctx context.Context, userID uint, offset int, limit int, statusFilter string, starredFilter string, shareFilter string, projectFilter string) ([]domainconversation.Conversation, int64, error) GetConversationByUser(ctx context.Context, conversationID uint, userID uint) (*domainconversation.Conversation, error) GetConversationByPublicID(ctx context.Context, publicID string, userID uint) (*domainconversation.Conversation, error) + CreateConversationProject(ctx context.Context, item *domainconversation.ConversationProject) error + ListConversationProjects(ctx context.Context, userID uint, statusFilter string) ([]domainconversation.ConversationProject, error) + GetConversationProjectByPublicID(ctx context.Context, userID uint, publicID string) (*domainconversation.ConversationProject, error) + UpdateConversationProjectMetadataByPublicID(ctx context.Context, userID uint, publicID string, patch domainconversation.ConversationProjectPatch) (*domainconversation.ConversationProject, error) + DeleteConversationProjectByPublicID(ctx context.Context, userID uint, publicID string, deleteConversations bool) error + ReorderConversationProjects(ctx context.Context, userID uint, publicIDs []string) error + UpdateConversationProjectAssignmentByPublicID(ctx context.Context, userID uint, conversationPublicID string, projectID *uint) (*domainconversation.Conversation, error) + BatchUpdateConversationProjectByPublicIDs(ctx context.Context, userID uint, conversationPublicIDs []string, projectID *uint) (int64, error) GetActiveConversationShareByConversation(ctx context.Context, userID uint, conversationID uint) (*domainconversation.ConversationShare, error) GetLatestConversationShareByConversation(ctx context.Context, userID uint, conversationID uint) (*domainconversation.ConversationShare, error) GetActiveConversationShareByShareID(ctx context.Context, shareID string) (*domainconversation.ConversationShare, *domainconversation.Conversation, error) @@ -36,6 +64,8 @@ type ConversationMetadataRepository interface { // MessageRepository 封装消息读写能力。 type MessageRepository interface { CreateMessage(ctx context.Context, item *domainconversation.Message) error + CreateMessagePairWithUserAttachments(ctx context.Context, userMessage *domainconversation.Message, assistantMessage *domainconversation.Message, userAttachments []domainconversation.Attachment) error + CompleteAssistantMessageWithAttachments(ctx context.Context, userMessageID uint, userUsage MessageUsageUpdate, assistantMessageID uint, assistantCompletion AssistantMessageCompletionUpdate, assistantAttachments []domainconversation.Attachment) error GetMessageByPublicID(ctx context.Context, conversationID uint, userID uint, publicID string) (*domainconversation.Message, error) GetMessageByPublicIDForUser(ctx context.Context, userID uint, publicID string) (*domainconversation.Message, error) UpdateMessageUsage(ctx context.Context, messageID uint, inputTokens int64, outputTokens int64, cacheReadTokens int64, cacheWriteTokens int64, reasoningTokens int64) error diff --git a/backend/internal/repository/errors.go b/backend/internal/repository/errors.go index 0b96328e..5432ba60 100644 --- a/backend/internal/repository/errors.go +++ b/backend/internal/repository/errors.go @@ -17,6 +17,8 @@ var ( ErrInvalidInput = errors.New("invalid input") // ErrInsufficientBalance 表示余额不足,无法完成扣费。 ErrInsufficientBalance = errors.New("insufficient balance") + // ErrLastSuperAdminRoleChange 表示操作会移除最后一个超级管理员。 + ErrLastSuperAdminRoleChange = errors.New("last superadmin role change not allowed") // 上游与模型仓储语义错误。 ErrUpstreamNotFound = errors.New("upstream not found") diff --git a/backend/internal/repository/user.go b/backend/internal/repository/user.go index c2801280..78637019 100644 --- a/backend/internal/repository/user.go +++ b/backend/internal/repository/user.go @@ -82,6 +82,7 @@ type UpdateIdentityProviderInput struct { DefaultRole *string SubjectField *string EmailField *string + EmailVerifiedField *string NameField *string AvatarField *string } @@ -107,6 +108,7 @@ func (input UpdateIdentityProviderInput) IsZero() bool { input.DefaultRole == nil && input.SubjectField == nil && input.EmailField == nil && + input.EmailVerifiedField == nil && input.NameField == nil && input.AvatarField == nil } diff --git a/backend/internal/shared/response/error_code.go b/backend/internal/shared/response/error_code.go index 6200214e..e2531f18 100644 --- a/backend/internal/shared/response/error_code.go +++ b/backend/internal/shared/response/error_code.go @@ -45,6 +45,7 @@ type errorSpec struct { var exactErrorSpecs = map[string]errorSpec{ "unauthorized": {Code: CodeAuthUnauthorized, Message: "unauthorized"}, "forbidden": {Code: CodeAuthForbidden, Message: "forbidden"}, + "admin permission required": {Code: "auth.admin_required", Message: "admin permission required"}, "superadmin permission required": {Code: "auth.superadmin_required", Message: "superadmin permission required"}, "missing authorization header": {Code: CodeAuthInvalidToken, Message: "authorization header is required"}, "invalid authorization header": {Code: CodeAuthInvalidToken, Message: "invalid authorization header"}, @@ -97,7 +98,8 @@ var exactErrorSpecs = map[string]errorSpec{ "provider type must be oidc or oauth2": {Code: "auth.provider_type_invalid", Message: "provider type must be oidc or oauth2"}, "provider name is required": {Code: "auth.provider_name_required", Message: "provider name is required"}, "provider slug is required": {Code: "auth.provider_slug_required", Message: "provider slug is required"}, - "default role must be user or superadmin": {Code: "auth.provider_default_role_invalid", Message: "default role must be user or superadmin"}, + "default role must be user, admin or superadmin": {Code: "auth.provider_default_role_invalid", Message: "default role must be user, admin or superadmin"}, + "only superadmin can set superadmin default role": {Code: "auth.provider_superadmin_default_role_protected", Message: "only superadmin can set superadmin default role"}, "logo url must be a valid http(s) or absolute path": {Code: "auth.provider_logo_url_invalid", Message: "logo url must be a valid http(s) or absolute path"}, "provider registration requires provider login to be enabled": {Code: "auth.provider_registration_requires_login", Message: "provider registration requires provider login to be enabled"}, "client id is required": {Code: "auth.provider_client_id_required", Message: "client id is required"}, @@ -126,9 +128,8 @@ var exactErrorSpecs = map[string]errorSpec{ "superadmin status change not allowed": {Code: "user.superadmin_status_protected", Message: "superadmin status change is not allowed"}, "superadmin password reset not allowed": {Code: "user.superadmin_password_reset_protected", Message: "superadmin password reset is not allowed"}, "superadmin two factor reset not allowed": {Code: "user.superadmin_two_factor_reset_protected", Message: "superadmin two factor reset is not allowed"}, - "superadmin role change not allowed": {Code: "user.superadmin_role_protected", Message: "superadmin role change is not allowed"}, + "superadmin management not allowed": {Code: "user.superadmin_management_protected", Message: "superadmin management is not allowed"}, "last superadmin role change not allowed": {Code: "user.last_superadmin_role_protected", Message: "last superadmin role change is not allowed"}, - "superadmin already exists": {Code: "user.superadmin_already_exists", Message: "superadmin already exists"}, "self role change not allowed": {Code: "user.self_role_change_not_allowed", Message: "self role change is not allowed"}, "self status change not allowed": {Code: "user.self_status_change_not_allowed", Message: "self status change is not allowed"}, "self delete not allowed": {Code: "user.self_delete_not_allowed", Message: "self delete is not allowed"}, @@ -144,6 +145,13 @@ var exactErrorSpecs = map[string]errorSpec{ "message generation canceled": {Code: "conversation_run.canceled", Message: "message generation canceled"}, "too many files in one message": {Code: "message.too_many_files", Message: "too many files in one message"}, "generation stream not found": {Code: "conversation_run.stream_not_found", Message: "generation stream not found"}, + "image prompt is required": {Code: "media.image_prompt_required", Message: "image prompt is required"}, + "image generation does not accept input images": {Code: "media.image_generation_rejects_inputs", Message: "image generation does not accept input images"}, + "image edit requires at least one input image": {Code: "media.image_edit_input_required", Message: "image edit requires at least one input image"}, + "too many image edit input images": {Code: "media.image_edit_too_many_inputs", Message: "too many image edit input images"}, + "image edit input image is invalid": {Code: "media.image_edit_input_invalid", Message: "image edit input image is invalid"}, + "media route protocol does not match task": {Code: "media.route_protocol_mismatch", Message: "media route protocol does not match task"}, + "invalid media generation task": {Code: "media.invalid_task", Message: "invalid media generation task"}, "file is required": {Code: "file.required", Message: "file is required"}, "invalid file stream": {Code: "file.invalid_stream", Message: "invalid file stream"}, @@ -174,7 +182,9 @@ var exactErrorSpecs = map[string]errorSpec{ "invalid api keys config": {Code: "llm.invalid_api_keys_config", Message: "invalid api keys config"}, "invalid protocol defaults config": {Code: "llm.invalid_protocol_defaults_config", Message: "invalid protocol defaults config"}, "invalid kinds": {Code: "llm.invalid_kinds", Message: "invalid model kinds"}, + "invalid route protocol combination": {Code: "llm.invalid_route_protocol_combination", Message: "invalid route protocol combination"}, "invalid platform model name": {Code: "llm.invalid_platform_model_name", Message: "invalid platform model name"}, + "system prompt too long": {Code: "llm.system_prompt_too_long", Message: "system prompt too long"}, "platform model name is required": {Code: "llm.platform_model_name_required", Message: "platform model name is required"}, "protocol required": {Code: "llm.protocol_required", Message: "protocol is required"}, "platform model name already exists": {Code: "llm.platform_model_name_exists", Message: "platform model name already exists"}, @@ -432,122 +442,127 @@ func fallbackMessage(status int, code string) string { } var fallbackMessages = map[string]string{ - CodeRequestInvalidQuery: "invalid query parameter", - "auth.superadmin_required": "superadmin permission required", - "auth.password_reset_required": "password reset required", - "auth.username_change_required": "username change required", - "auth.invalid_password": "invalid password", - "auth.password_reuse_not_allowed": "new password must be different", - "auth.provider_callback_misconfigured": "configure the provider callback URL to the frontend callback endpoint", - "auth.verification_code_invalid": "verification code is invalid or expired", - "auth.verification_code_recent": "verification code was sent recently", - "auth.email_registration_disabled": "email registration is disabled", - "auth.email_verification_disabled": "email verification is disabled", - "auth.email_already_exists": "email already exists", - "auth.email_not_verified": "email is not verified", - "auth.email_unchanged": "new email must be different", - "auth.email_alias_not_allowed": "email aliases are not allowed", - "auth.email_domain_not_allowed": "email domain is not allowed", - "auth.email_bootstrap_not_allowed": "email bootstrap is not allowed", - "auth.provider_login_disabled": "provider login is disabled", - "auth.provider_registration_disabled": "provider registration is disabled", - "auth.authorization_code_required": "authorization code is required", - "auth.two_factor_already_enabled": "two factor authentication is already enabled", - "auth.provider_bind_endpoint_required": "provider bind must use account binding endpoint", - "auth.provider_email_conflict": "provider email belongs to another account", - "auth.provider_invalid": "provider authentication failed", - "auth.provider_upstream_failed": "provider authentication failed", - "auth.provider_subject_missing": "provider subject is missing", - "auth.provider_identity_conflict": "provider identity is already bound to another account", - "auth.provider_already_bound": "provider is already bound", - "auth.provider_account_not_registered": "provider account is not registered", - "auth.oauth_intent_mismatch": "oauth intent mismatch", - "auth.oauth_state_invalid": "invalid oauth state", - "auth.oauth_state_expired": "oauth state expired", - "auth.invalid_redirect_uri": "invalid redirect uri", - "auth.invalid_pkce": "invalid pkce parameters", - "auth.provider_id_required": "provider id is required", - "auth.provider_order_invalid": "provider ids must be unique", - "auth.provider_type_invalid": "provider type must be oidc or oauth2", - "auth.provider_name_required": "provider name is required", - "auth.provider_slug_required": "provider slug is required", - "auth.provider_default_role_invalid": "default role must be user or superadmin", - "auth.provider_logo_url_invalid": "logo url must be a valid http(s) or absolute path", - "auth.provider_registration_requires_login": "provider registration requires provider login to be enabled", - "auth.provider_client_id_required": "client id is required", - "auth.provider_client_secret_required": "client secret is required", - "auth.provider_oidc_issuer_required": "OIDC issuer url or discovery url is required", - "auth.provider_oauth_urls_required": "OAuth2 auth url, token url and userinfo url are required", - "auth.provider_auth_url_not_configured": "provider auth url is not configured", - "user.invalid_time_zone": "invalid time zone", - "user.invalid_avatar_url": "invalid avatar url", - "user.invalid_username": "invalid username", - "user.invalid_location": "invalid location", - "user.invalid_email": "invalid user email", - "user.invalid_phone": "invalid user phone", - "user.invalid_locale": "invalid user locale", - "user.invalid_status": "invalid user status", - "user.invalid_role": "invalid user role", - "user.username_already_exists": "username already exists", - "user.username_change_used": "username change already used", - "conversation.invalid_id": "invalid conversation id", - "conversation.not_found": "conversation not found", - "conversation.invalid_title": "invalid conversation title", - "conversation_share.invalid": "invalid conversation share", - "conversation_share.not_found": "conversation share not found", - "conversation_share.invalid_id": "invalid share id", - "message.invalid_id": "invalid message id", - "message.not_found": "message not found", - "file.invalid_id": "invalid file id", - "file.not_found": "file not found", - "file.required": "file is required", - "file.invalid_stream": "invalid file stream", - "file.invalid_reference": "invalid file reference", - "context_artifact.invalid_id": "invalid context artifact id", - "context_artifact.not_found": "context artifact not found", - "llm.model_route_not_configured": "model route is not configured", - "llm.remote_models_unavailable": "remote models unavailable", - "llm.no_active_api_key": "no active api key", - "llm.invalid_adapter": "invalid adapter", - "llm.invalid_compatible": "invalid compatible", - "llm.invalid_platform_model_name": "invalid platform model name", - "llm.platform_model_name_required": "platform model name is required", - "llm.protocol_required": "protocol is required", - "billing.period_credit_exceeded": "period usage credit exceeded", - "billing.invalid_subscription_tier": "invalid subscription tier", - "billing.subscription_expiry_required": "subscription expiry required", - "billing.invalid_subscription_expiry": "invalid subscription expiry", - "billing.invalid_model_pricing": "invalid model pricing", - "billing.invalid_daily_usage_date_range": "invalid daily usage date range", - "billing.invalid_daily_usage_days": "invalid daily usage days", - "payment.provider_unavailable": "payment provider is unavailable", - "payment.checkout_failed": "create checkout failed", - "payment.notification_mismatch": "payment notification does not match the order", - "payment.epay_type_unsupported": "epay payment type is not supported", - "payment.return_url_invalid": "payment return url is invalid", - "payment.return_url_cross_origin": "payment return url must use the configured public web origin", - "payment.webhook_not_configured": "stripe webhook is not configured", - "payment.invalid_webhook_body": "invalid webhook body", - "payment.webhook_body_too_large": "webhook body too large", - "payment.invalid_signature": "invalid stripe signature", - "payment.invalid_event": "invalid stripe event", - "payment.order_no_required": "order_no is required", - "settings.invalid_namespace": "invalid namespace", - "settings.invalid_key": "invalid setting key", - "settings.not_found": "setting not found", - "settings.invalid_value": "invalid setting value", - "settings.smtp_invalid": "invalid smtp settings", - "settings.billing_payment_invalid": "invalid billing payment settings", - "settings.model_option_policy_invalid": "invalid model option policy settings", - "settings.embedding_invalid": "invalid embedding settings", - "settings.extract_invalid": "invalid file extraction settings", - "embedding.service_unavailable": "embedding service is not available", - "user_settings.unknown_key": "unknown setting key", - "user_settings.invalid_value": "invalid user setting value", - "memory.key_required": "memory_key is required", - "rate_limit.refresh_exceeded": "too many refresh attempts", - "rate_limit.authentication_exceeded": "too many authentication attempts", - "cors.origin_forbidden": "origin is not allowed", + CodeRequestInvalidQuery: "invalid query parameter", + "auth.admin_required": "admin permission required", + "auth.superadmin_required": "superadmin permission required", + "auth.password_reset_required": "password reset required", + "auth.username_change_required": "username change required", + "auth.invalid_password": "invalid password", + "auth.password_reuse_not_allowed": "new password must be different", + "auth.provider_callback_misconfigured": "configure the provider callback URL to the frontend callback endpoint", + "auth.verification_code_invalid": "verification code is invalid or expired", + "auth.verification_code_recent": "verification code was sent recently", + "auth.email_registration_disabled": "email registration is disabled", + "auth.email_verification_disabled": "email verification is disabled", + "auth.email_already_exists": "email already exists", + "auth.email_not_verified": "email is not verified", + "auth.email_unchanged": "new email must be different", + "auth.email_alias_not_allowed": "email aliases are not allowed", + "auth.email_domain_not_allowed": "email domain is not allowed", + "auth.email_bootstrap_not_allowed": "email bootstrap is not allowed", + "auth.provider_login_disabled": "provider login is disabled", + "auth.provider_registration_disabled": "provider registration is disabled", + "auth.authorization_code_required": "authorization code is required", + "auth.two_factor_already_enabled": "two factor authentication is already enabled", + "auth.provider_bind_endpoint_required": "provider bind must use account binding endpoint", + "auth.provider_email_conflict": "provider email belongs to another account", + "auth.provider_invalid": "provider authentication failed", + "auth.provider_upstream_failed": "provider authentication failed", + "auth.provider_subject_missing": "provider subject is missing", + "auth.provider_identity_conflict": "provider identity is already bound to another account", + "auth.provider_already_bound": "provider is already bound", + "auth.provider_account_not_registered": "provider account is not registered", + "auth.oauth_intent_mismatch": "oauth intent mismatch", + "auth.oauth_state_invalid": "invalid oauth state", + "auth.oauth_state_expired": "oauth state expired", + "auth.invalid_redirect_uri": "invalid redirect uri", + "auth.invalid_pkce": "invalid pkce parameters", + "auth.provider_id_required": "provider id is required", + "auth.provider_order_invalid": "provider ids must be unique", + "auth.provider_type_invalid": "provider type must be oidc or oauth2", + "auth.provider_name_required": "provider name is required", + "auth.provider_slug_required": "provider slug is required", + "auth.provider_default_role_invalid": "default role must be user, admin or superadmin", + "auth.provider_superadmin_default_role_protected": "only superadmin can set superadmin default role", + "auth.provider_logo_url_invalid": "logo url must be a valid http(s) or absolute path", + "auth.provider_registration_requires_login": "provider registration requires provider login to be enabled", + "auth.provider_client_id_required": "client id is required", + "auth.provider_client_secret_required": "client secret is required", + "auth.provider_oidc_issuer_required": "OIDC issuer url or discovery url is required", + "auth.provider_oauth_urls_required": "OAuth2 auth url, token url and userinfo url are required", + "auth.provider_auth_url_not_configured": "provider auth url is not configured", + "user.invalid_time_zone": "invalid time zone", + "user.invalid_avatar_url": "invalid avatar url", + "user.invalid_username": "invalid username", + "user.invalid_location": "invalid location", + "user.invalid_email": "invalid user email", + "user.invalid_phone": "invalid user phone", + "user.invalid_locale": "invalid user locale", + "user.invalid_status": "invalid user status", + "user.invalid_role": "invalid user role", + "user.username_already_exists": "username already exists", + "user.username_change_used": "username change already used", + "user.superadmin_management_protected": "superadmin management is not allowed", + "conversation.invalid_id": "invalid conversation id", + "conversation.not_found": "conversation not found", + "conversation.invalid_title": "invalid conversation title", + "conversation_share.invalid": "invalid conversation share", + "conversation_share.not_found": "conversation share not found", + "conversation_share.invalid_id": "invalid share id", + "message.invalid_id": "invalid message id", + "message.not_found": "message not found", + "file.invalid_id": "invalid file id", + "file.not_found": "file not found", + "file.required": "file is required", + "file.invalid_stream": "invalid file stream", + "file.invalid_reference": "invalid file reference", + "context_artifact.invalid_id": "invalid context artifact id", + "context_artifact.not_found": "context artifact not found", + "llm.model_route_not_configured": "model route is not configured", + "llm.remote_models_unavailable": "remote models unavailable", + "llm.no_active_api_key": "no active api key", + "llm.invalid_adapter": "invalid adapter", + "llm.invalid_compatible": "invalid compatible", + "llm.invalid_platform_model_name": "invalid platform model name", + "llm.invalid_route_protocol_combination": "invalid route protocol combination", + "llm.system_prompt_too_long": "system prompt too long", + "llm.platform_model_name_required": "platform model name is required", + "llm.protocol_required": "protocol is required", + "billing.period_credit_exceeded": "period usage credit exceeded", + "billing.invalid_subscription_tier": "invalid subscription tier", + "billing.subscription_expiry_required": "subscription expiry required", + "billing.invalid_subscription_expiry": "invalid subscription expiry", + "billing.invalid_model_pricing": "invalid model pricing", + "billing.invalid_daily_usage_date_range": "invalid daily usage date range", + "billing.invalid_daily_usage_days": "invalid daily usage days", + "payment.provider_unavailable": "payment provider is unavailable", + "payment.checkout_failed": "create checkout failed", + "payment.notification_mismatch": "payment notification does not match the order", + "payment.epay_type_unsupported": "epay payment type is not supported", + "payment.return_url_invalid": "payment return url is invalid", + "payment.return_url_cross_origin": "payment return url must use the configured public web origin", + "payment.webhook_not_configured": "stripe webhook is not configured", + "payment.invalid_webhook_body": "invalid webhook body", + "payment.webhook_body_too_large": "webhook body too large", + "payment.invalid_signature": "invalid stripe signature", + "payment.invalid_event": "invalid stripe event", + "payment.order_no_required": "order_no is required", + "settings.invalid_namespace": "invalid namespace", + "settings.invalid_key": "invalid setting key", + "settings.not_found": "setting not found", + "settings.invalid_value": "invalid setting value", + "settings.smtp_invalid": "invalid smtp settings", + "settings.billing_payment_invalid": "invalid billing payment settings", + "settings.model_option_policy_invalid": "invalid model option policy settings", + "settings.embedding_invalid": "invalid embedding settings", + "settings.extract_invalid": "invalid file extraction settings", + "embedding.service_unavailable": "embedding service is not available", + "user_settings.unknown_key": "unknown setting key", + "user_settings.invalid_value": "invalid user setting value", + "memory.key_required": "memory_key is required", + "rate_limit.refresh_exceeded": "too many refresh attempts", + "rate_limit.authentication_exceeded": "too many authentication attempts", + "cors.origin_forbidden": "origin is not allowed", } func resolveErrorSpec(status int, msg string) (errorSpec, bool) { diff --git a/backend/internal/transport/http/admin/handler.go b/backend/internal/transport/http/admin/handler.go index 080aa20e..e655ea1a 100644 --- a/backend/internal/transport/http/admin/handler.go +++ b/backend/internal/transport/http/admin/handler.go @@ -27,8 +27,8 @@ func NewHandler(service *appadmin.Service) *Handler { } // ListUsers godoc -// @Summary 超级管理员查询用户 -// @Description superadmin 分页查看所有用户,实现账户隔离管理 +// @Summary 管理员查询用户 +// @Description 管理员分页查看所有用户,实现账户隔离管理 // @Tags admin // @Accept json // @Produce json @@ -54,8 +54,8 @@ func (h *Handler) ListUsers(c *gin.Context) { } // CreateUser godoc -// @Summary 超级管理员创建用户 -// @Description 创建普通用户账号,superadmin 账号仅允许系统初始化唯一实例 +// @Summary 管理员创建用户 +// @Description 创建普通用户账号;需要授予管理员权限时,可在账户编辑中调整角色 // @Tags admin // @Accept json // @Produce json @@ -135,8 +135,8 @@ func (h *Handler) CreateUser(c *gin.Context) { } // PatchUser godoc -// @Summary 超级管理员更新用户可编辑字段 -// @Description superadmin 统一维护角色、状态、时区等可编辑字段 +// @Summary 管理员更新用户可编辑字段 +// @Description 管理员统一维护角色、状态、时区等可编辑字段 // @Tags admin // @Accept json // @Produce json @@ -165,7 +165,7 @@ func (h *Handler) PatchUser(c *gin.Context) { return } - item, err := h.service.PatchUserBySuperAdmin( + item, err := h.service.PatchUserByAdmin( c.Request.Context(), middleware.MustRequestID(c), actorUserID, @@ -195,10 +195,12 @@ func (h *Handler) PatchUser(c *gin.Context) { case errors.Is(err, user.ErrUserNotFound): response.Error(c, http.StatusNotFound, "user not found") return + case errors.Is(err, appadmin.ErrAdminPermissionRequired), + errors.Is(err, appadmin.ErrSuperAdminManagementNotAllowed): + response.ErrorFrom(c, http.StatusForbidden, err) + return case errors.Is(err, appadmin.ErrSuperAdminStatusChangeNotAllowed), - errors.Is(err, appadmin.ErrSuperAdminRoleChangeNotAllowed), errors.Is(err, appadmin.ErrLastSuperAdminRoleChangeNotAllowed), - errors.Is(err, appadmin.ErrSuperAdminAlreadyExists), errors.Is(err, appadmin.ErrSelfRoleChangeNotAllowed), errors.Is(err, appadmin.ErrSelfStatusChangeNotAllowed): response.ErrorFrom(c, http.StatusConflict, err) @@ -219,8 +221,8 @@ func (h *Handler) PatchUser(c *gin.Context) { } // ListAuditLogs godoc -// @Summary 超级管理员查询审计日志 -// @Description superadmin 分页查看全量可追溯审计日志 +// @Summary 管理员查询审计日志 +// @Description 管理员分页查看全量可追溯审计日志 // @Tags admin // @Accept json // @Produce json @@ -278,8 +280,8 @@ func (h *Handler) ListAuditLogs(c *gin.Context) { } // ListUsageLogs godoc -// @Summary 超级管理员查询模型调用日志 -// @Description superadmin 分页查看全量模型调用与计费用量账本 +// @Summary 管理员查询模型调用日志 +// @Description 管理员分页查看全量模型调用与计费用量账本 // @Tags admin // @Accept json // @Produce json @@ -338,8 +340,8 @@ func (h *Handler) ListUsageLogs(c *gin.Context) { } // ListSystemEvents godoc -// @Summary 超级管理员查询系统事件 -// @Description superadmin 分页查看后台结构化系统事件 +// @Summary 管理员查询系统事件 +// @Description 管理员分页查看后台结构化系统事件 // @Tags admin // @Accept json // @Produce json @@ -414,8 +416,8 @@ func parseOptionalTimeQuery(c *gin.Context, key string) (*time.Time, bool) { } // RevokeUserSessions godoc -// @Summary 超级管理员吊销用户全部会话 -// @Description superadmin 吊销指定用户全部活跃会话,用于安全治理和风险控制 +// @Summary 管理员吊销用户全部会话 +// @Description 管理员吊销指定用户全部活跃会话,用于安全治理和风险控制 // @Tags admin // @Accept json // @Produce json @@ -436,7 +438,7 @@ func (h *Handler) RevokeUserSessions(c *gin.Context) { return } - if err = h.service.RevokeUserSessionsBySuperAdmin( + if err = h.service.RevokeUserSessionsByAdmin( c.Request.Context(), middleware.MustRequestID(c), actorUserID, @@ -448,6 +450,10 @@ func (h *Handler) RevokeUserSessions(c *gin.Context) { response.Error(c, http.StatusNotFound, "user not found") return } + if errors.Is(err, appadmin.ErrAdminPermissionRequired) || errors.Is(err, appadmin.ErrSuperAdminManagementNotAllowed) { + response.ErrorFrom(c, http.StatusForbidden, err) + return + } response.Error(c, http.StatusInternalServerError, "revoke user sessions failed") return } @@ -456,8 +462,8 @@ func (h *Handler) RevokeUserSessions(c *gin.Context) { } // UpdateUserStatus godoc -// @Summary 超级管理员更新用户状态 -// @Description superadmin 维护用户状态(active/locked/suspended/deactivated),并联动会话治理 +// @Summary 管理员更新用户状态 +// @Description 管理员维护用户状态(active/locked/suspended/deactivated),并联动会话治理 // @Tags admin // @Accept json // @Produce json @@ -486,7 +492,7 @@ func (h *Handler) UpdateUserStatus(c *gin.Context) { return } - item, err := h.service.UpdateUserStatusBySuperAdmin( + item, err := h.service.UpdateUserStatusByAdmin( c.Request.Context(), middleware.MustRequestID(c), actorUserID, @@ -509,6 +515,10 @@ func (h *Handler) UpdateUserStatus(c *gin.Context) { response.Error(c, http.StatusConflict, "superadmin status change not allowed") return } + if errors.Is(err, appadmin.ErrAdminPermissionRequired) || errors.Is(err, appadmin.ErrSuperAdminManagementNotAllowed) { + response.ErrorFrom(c, http.StatusForbidden, err) + return + } response.Error(c, http.StatusInternalServerError, "update user status failed") return } @@ -523,8 +533,8 @@ func (h *Handler) UpdateUserStatus(c *gin.Context) { } // ResetUserPassword godoc -// @Summary 超级管理员重置用户密码 -// @Description superadmin 重置指定用户密码并吊销其全部会话 +// @Summary 管理员重置用户密码 +// @Description 管理员重置指定用户密码并吊销其全部会话 // @Tags admin // @Accept json // @Produce json @@ -558,7 +568,7 @@ func (h *Handler) ResetUserPassword(c *gin.Context) { mustResetPassword = *req.MustResetPassword } - if err = h.service.ResetUserPasswordBySuperAdmin( + if err = h.service.ResetUserPasswordByAdmin( c.Request.Context(), middleware.MustRequestID(c), actorUserID, @@ -576,6 +586,10 @@ func (h *Handler) ResetUserPassword(c *gin.Context) { response.Error(c, http.StatusConflict, "superadmin password reset not allowed") return } + if errors.Is(err, appadmin.ErrAdminPermissionRequired) || errors.Is(err, appadmin.ErrSuperAdminManagementNotAllowed) { + response.ErrorFrom(c, http.StatusForbidden, err) + return + } if errors.Is(err, user.ErrInvalidPassword) { response.ErrorFrom(c, http.StatusBadRequest, err) return @@ -595,7 +609,7 @@ func (h *Handler) ResetUserTwoFactor(c *gin.Context) { response.Error(c, http.StatusBadRequest, "invalid user id") return } - if err = h.service.ResetUserTwoFactorBySuperAdmin( + if err = h.service.ResetUserTwoFactorByAdmin( c.Request.Context(), middleware.MustRequestID(c), actorUserID, @@ -611,6 +625,10 @@ func (h *Handler) ResetUserTwoFactor(c *gin.Context) { response.Error(c, http.StatusConflict, "superadmin two factor reset not allowed") return } + if errors.Is(err, appadmin.ErrAdminPermissionRequired) || errors.Is(err, appadmin.ErrSuperAdminManagementNotAllowed) { + response.ErrorFrom(c, http.StatusForbidden, err) + return + } response.ErrorFrom(c, http.StatusBadRequest, err) return } @@ -618,8 +636,8 @@ func (h *Handler) ResetUserTwoFactor(c *gin.Context) { } // DeleteUser godoc -// @Summary 超级管理员删除用户 -// @Description superadmin 硬删除指定普通用户及其主要用户域数据 +// @Summary 管理员删除用户 +// @Description 管理员硬删除指定普通用户及其主要用户域数据 // @Tags admin // @Accept json // @Produce json @@ -641,7 +659,7 @@ func (h *Handler) DeleteUser(c *gin.Context) { return } - if err = h.service.DeleteUserBySuperAdmin( + if err = h.service.DeleteUserByAdmin( c.Request.Context(), middleware.MustRequestID(c), actorUserID, @@ -653,6 +671,10 @@ func (h *Handler) DeleteUser(c *gin.Context) { case errors.Is(err, user.ErrUserNotFound): response.Error(c, http.StatusNotFound, "user not found") return + case errors.Is(err, appadmin.ErrAdminPermissionRequired), + errors.Is(err, appadmin.ErrSuperAdminManagementNotAllowed): + response.ErrorFrom(c, http.StatusForbidden, err) + return case errors.Is(err, appadmin.ErrSuperAdminDeleteNotAllowed), errors.Is(err, appadmin.ErrSelfDeleteNotAllowed): response.ErrorFrom(c, http.StatusConflict, err) @@ -667,8 +689,8 @@ func (h *Handler) DeleteUser(c *gin.Context) { } // ListUserAuthEvents godoc -// @Summary 超级管理员查询用户认证事件 -// @Description superadmin 分页查询认证事件,支持 user_id/event_type/result 过滤 +// @Summary 管理员查询用户认证事件 +// @Description 管理员分页查询认证事件,支持 user_id/event_type/result 过滤 // @Tags admin // @Accept json // @Produce json @@ -694,7 +716,7 @@ func (h *Handler) ListUserAuthEvents(c *gin.Context) { } page, pageSize := pageParams(c) - items, total, err := h.service.ListUserAuthEventsBySuperAdmin( + items, total, err := h.service.ListUserAuthEventsByAdmin( c.Request.Context(), userID, c.Query("event_type"), diff --git a/backend/internal/transport/http/admin/handler_security_test.go b/backend/internal/transport/http/admin/handler_security_test.go new file mode 100644 index 00000000..8fa4e3ce --- /dev/null +++ b/backend/internal/transport/http/admin/handler_security_test.go @@ -0,0 +1,132 @@ +package admin + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + appadmin "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/admin" + auditapp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/audit" + domainaudit "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/audit" + domainuser "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/user" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/middleware" + "github.com/gin-gonic/gin" +) + +func TestPatchUserReturnsForbiddenWhenAdminManagesSuperAdmin(t *testing.T) { + gin.SetMode(gin.TestMode) + + users := &handlerUserServiceFake{users: map[uint]domainuser.User{ + 1: {ID: 1, Role: domainuser.RoleAdmin}, + 2: {ID: 2, Role: domainuser.RoleSuperAdmin}, + }} + handler := NewHandler(appadmin.NewService(users, handlerAuditServiceFake{})) + + router := gin.New() + router.PATCH("/admin/users/:id", func(c *gin.Context) { + c.Set(middleware.ContextKeyUserID, uint(1)) + c.Set(middleware.ContextKeyRequestID, "req_test") + handler.PatchUser(c) + }) + + request := httptest.NewRequest(http.MethodPatch, "/admin/users/2", strings.NewReader(`{"displayName":"Root"}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusForbidden { + t.Fatalf("expected forbidden, got status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), "user.superadmin_management_protected") { + t.Fatalf("expected superadmin management error code, got body=%s", recorder.Body.String()) + } +} + +type handlerUserServiceFake struct { + users map[uint]domainuser.User +} + +func (s *handlerUserServiceFake) ListUsers(context.Context, int, int) ([]domainuser.User, int64, error) { + return nil, 0, nil +} + +func (s *handlerUserServiceFake) CountSuperAdmins(context.Context) (int64, error) { + var count int64 + for _, item := range s.users { + if item.Role == domainuser.RoleSuperAdmin { + count++ + } + } + return count, nil +} + +func (s *handlerUserServiceFake) CreateUser( + context.Context, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + *time.Time, +) (*domainuser.User, error) { + return nil, nil +} + +func (s *handlerUserServiceFake) GetByID(_ context.Context, userID uint) (*domainuser.User, error) { + item, ok := s.users[userID] + if !ok { + return nil, repository.ErrNotFound + } + return &item, nil +} + +func (s *handlerUserServiceFake) RevokeAllSessions(context.Context, uint, string) error { + return nil +} + +func (s *handlerUserServiceFake) UpdateUserStatus(context.Context, uint, string) error { + return nil +} + +func (s *handlerUserServiceFake) UpdateFields(context.Context, uint, repository.UpdateUserFieldsInput) (*domainuser.User, error) { + return nil, nil +} + +func (s *handlerUserServiceFake) ResetLoginFailure(context.Context, uint) error { + return nil +} + +func (s *handlerUserServiceFake) ResetPasswordByAdmin(context.Context, uint, string, bool) error { + return nil +} + +func (s *handlerUserServiceFake) DeleteAccountHard(context.Context, uint) error { + return nil +} + +func (s *handlerUserServiceFake) RecordAuthEvent(context.Context, uint, string, string, string, string, string, string, string) error { + return nil +} + +func (s *handlerUserServiceFake) ListAuthEvents(context.Context, uint, string, string, int, int) ([]domainuser.AuthEvent, int64, error) { + return nil, 0, nil +} + +type handlerAuditServiceFake struct{} + +func (handlerAuditServiceFake) Write(context.Context, string, uint, string, string, string, string, string, interface{}) { +} + +func (handlerAuditServiceFake) List(context.Context, int, int, auditapp.ListFilter) ([]domainaudit.Log, int64, error) { + return nil, 0, nil +} diff --git a/backend/internal/transport/http/admin/router.go b/backend/internal/transport/http/admin/router.go index af0edc9e..68b24a98 100644 --- a/backend/internal/transport/http/admin/router.go +++ b/backend/internal/transport/http/admin/router.go @@ -2,7 +2,7 @@ package admin import "github.com/gin-gonic/gin" -// RegisterRoutes 注册后台管理路由(由 superadmin 中间件保护)。 +// RegisterRoutes 注册后台管理路由(由管理员中间件保护)。 func (m *Module) RegisterRoutes(adminGroup *gin.RouterGroup) { adminGroup.POST("/users", m.Handler.CreateUser) adminGroup.GET("/users", m.Handler.ListUsers) diff --git a/backend/internal/transport/http/auth/dto.go b/backend/internal/transport/http/auth/dto.go index 10620fe2..ee88314e 100644 --- a/backend/internal/transport/http/auth/dto.go +++ b/backend/internal/transport/http/auth/dto.go @@ -146,6 +146,7 @@ type IdentityProviderResponse struct { DefaultRole string `json:"defaultRole"` SubjectField string `json:"subjectField"` EmailField string `json:"emailField"` + EmailVerifiedField string `json:"emailVerifiedField"` NameField string `json:"nameField"` AvatarField string `json:"avatarField"` CreatedAt time.Time `json:"createdAt"` @@ -211,9 +212,10 @@ type UpsertIdentityProviderRequest struct { UserInfoURL string `json:"userinfoURL" binding:"omitempty,max=512"` JWKSURL string `json:"jwksURL" binding:"omitempty,max=512"` Scopes string `json:"scopes" binding:"omitempty,max=255"` - DefaultRole string `json:"defaultRole" binding:"omitempty,oneof=user superadmin"` + DefaultRole string `json:"defaultRole" binding:"omitempty,oneof=user admin superadmin"` SubjectField string `json:"subjectField" binding:"omitempty,max=64"` EmailField string `json:"emailField" binding:"omitempty,max=64"` + EmailVerifiedField string `json:"emailVerifiedField" binding:"omitempty,max=64"` NameField string `json:"nameField" binding:"omitempty,max=64"` AvatarField string `json:"avatarField" binding:"omitempty,max=64"` } @@ -602,6 +604,7 @@ func toIdentityProviderResponse(item appauth.IdentityProviderView) IdentityProvi DefaultRole: item.DefaultRole, SubjectField: item.SubjectField, EmailField: item.EmailField, + EmailVerifiedField: item.EmailVerifiedField, NameField: item.NameField, AvatarField: item.AvatarField, CreatedAt: item.CreatedAt, @@ -609,8 +612,9 @@ func toIdentityProviderResponse(item appauth.IdentityProviderView) IdentityProvi } } -func toUpsertIdentityProviderInput(req UpsertIdentityProviderRequest) appauth.UpsertIdentityProviderInput { +func toUpsertIdentityProviderInput(req UpsertIdentityProviderRequest, actorRole string) appauth.UpsertIdentityProviderInput { return appauth.UpsertIdentityProviderInput{ + ActorRole: actorRole, Type: req.Type, Name: req.Name, Slug: req.Slug, @@ -629,6 +633,7 @@ func toUpsertIdentityProviderInput(req UpsertIdentityProviderRequest) appauth.Up DefaultRole: req.DefaultRole, SubjectField: req.SubjectField, EmailField: req.EmailField, + EmailVerifiedField: req.EmailVerifiedField, NameField: req.NameField, AvatarField: req.AvatarField, } diff --git a/backend/internal/transport/http/auth/handler.go b/backend/internal/transport/http/auth/handler.go index 63a3575e..ab7449ef 100644 --- a/backend/internal/transport/http/auth/handler.go +++ b/backend/internal/transport/http/auth/handler.go @@ -105,6 +105,22 @@ func (h *Handler) LoginOptions(c *gin.Context) { response.Success(c, toLoginOptionsResponse(result)) } +func (h *Handler) IdentityProviderLogo(c *gin.Context) { + asset, err := h.service.GetIdentityProviderLogo(c.Request.Context(), c.Param("slug")) + if err != nil { + status := http.StatusBadGateway + if errors.Is(err, appauth.ErrIdentityProviderLogoUnavailable) { + status = http.StatusNotFound + } + response.ErrorFrom(c, status, err) + return + } + c.Header("Cache-Control", "public, max-age=3600") + c.Header("Content-Security-Policy", "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'; script-src 'none'; object-src 'none'; frame-ancestors 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'") + c.Header("X-Content-Type-Options", "nosniff") + c.Data(http.StatusOK, asset.ContentType, asset.Content) +} + func (h *Handler) StartEmailRegistration(c *gin.Context) { var req EmailRegistrationStartRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -695,8 +711,12 @@ func (h *Handler) CreateIdentityProvider(c *gin.Context) { response.InvalidRequestBody(c, err) return } - item, err := h.service.CreateIdentityProvider(c.Request.Context(), toUpsertIdentityProviderInput(req)) + item, err := h.service.CreateIdentityProvider(c.Request.Context(), toUpsertIdentityProviderInput(req, middleware.MustUserRole(c))) if err != nil { + if errors.Is(err, appauth.ErrIdentityProviderSuperAdminDefaultRoleNotAllowed) { + response.ErrorFrom(c, http.StatusForbidden, err) + return + } response.ErrorFrom(c, http.StatusBadRequest, err) return } @@ -709,8 +729,12 @@ func (h *Handler) UpdateIdentityProvider(c *gin.Context) { response.InvalidRequestBody(c, err) return } - item, err := h.service.UpdateIdentityProvider(c.Request.Context(), c.Param("provider_id"), toUpsertIdentityProviderInput(req)) + item, err := h.service.UpdateIdentityProvider(c.Request.Context(), c.Param("provider_id"), toUpsertIdentityProviderInput(req, middleware.MustUserRole(c))) if err != nil { + if errors.Is(err, appauth.ErrIdentityProviderSuperAdminDefaultRoleNotAllowed) { + response.ErrorFrom(c, http.StatusForbidden, err) + return + } response.ErrorFrom(c, http.StatusBadRequest, err) return } diff --git a/backend/internal/transport/http/auth/router.go b/backend/internal/transport/http/auth/router.go index e419c8f6..3640259a 100644 --- a/backend/internal/transport/http/auth/router.go +++ b/backend/internal/transport/http/auth/router.go @@ -14,6 +14,7 @@ func (m *Module) RegisterPublicRoutes(api *gin.RouterGroup) { api.GET("/auth/providers/:slug/start", m.Handler.StartProviderLogin) api.GET("/auth/providers/:slug/callback", m.Handler.ProviderCallback) api.POST("/auth/providers/:slug/callback", m.Handler.CompleteProviderLogin) + api.GET("/auth/providers/:slug/logo", m.Handler.IdentityProviderLogo) } // RegisterProtectedRoutes 注册需登录的鉴权路由。 diff --git a/backend/internal/transport/http/channel/dto_request.go b/backend/internal/transport/http/channel/dto_request.go index d1012ed0..16e8f0c6 100644 --- a/backend/internal/transport/http/channel/dto_request.go +++ b/backend/internal/transport/http/channel/dto_request.go @@ -50,6 +50,7 @@ type CreateModelRequest struct { KindsJSON string `json:"kindsJSON" binding:"omitempty,max=1000"` Icon string `json:"icon" binding:"max=128"` CapabilitiesJSON string `json:"capabilitiesJSON" binding:"max=10000"` + SystemPrompt string `json:"systemPrompt" binding:"max=20000"` Status string `json:"status" binding:"omitempty,oneof=active inactive"` Description string `json:"description" binding:"max=10000"` } @@ -61,6 +62,7 @@ type UpdateModelRequest struct { KindsJSON *string `json:"kindsJSON" binding:"omitempty,max=1000"` Icon *string `json:"icon" binding:"omitempty,max=128"` CapabilitiesJSON *string `json:"capabilitiesJSON" binding:"omitempty,max=10000"` + SystemPrompt *string `json:"systemPrompt" binding:"omitempty,max=20000"` Status *string `json:"status" binding:"omitempty,oneof=active inactive"` Description *string `json:"description" binding:"omitempty,max=10000"` } @@ -104,10 +106,11 @@ type ImportUpstreamModelsRequest struct { // ImportUpstreamModelItemRequest 单个导入项请求。 type ImportUpstreamModelItemRequest struct { - PlatformModelName string `json:"platformModelName" binding:"required,min=2,max=128"` - UpstreamModelName string `json:"upstreamModelName" binding:"required,min=1,max=128"` - Protocol string `json:"protocol" binding:"omitempty,max=64"` - KindsJSON string `json:"kindsJSON" binding:"omitempty,max=1000"` - Status string `json:"status" binding:"omitempty,oneof=active inactive"` - Priority int `json:"priority"` + PlatformModelName string `json:"platformModelName" binding:"required,min=2,max=128"` + UpstreamModelName string `json:"upstreamModelName" binding:"required,min=1,max=128"` + Protocol string `json:"protocol" binding:"omitempty,max=64"` + Protocols []string `json:"protocols" binding:"omitempty,dive,max=64"` + KindsJSON string `json:"kindsJSON" binding:"omitempty,max=1000"` + Status string `json:"status" binding:"omitempty,oneof=active inactive"` + Priority int `json:"priority"` } diff --git a/backend/internal/transport/http/channel/dto_response.go b/backend/internal/transport/http/channel/dto_response.go index e3a6f38e..9fe80bb0 100644 --- a/backend/internal/transport/http/channel/dto_response.go +++ b/backend/internal/transport/http/channel/dto_response.go @@ -70,6 +70,7 @@ type ModelResponse struct { KindsJSON string `json:"kindsJSON"` Icon string `json:"icon"` CapabilitiesJSON string `json:"capabilitiesJSON"` + SystemPrompt string `json:"systemPrompt"` Status string `json:"status"` Description string `json:"description"` SortOrder int `json:"sortOrder"` @@ -88,6 +89,7 @@ func toModelResponse(v appchannel.ModelView) ModelResponse { KindsJSON: v.KindsJSON, Icon: v.Icon, CapabilitiesJSON: v.CapabilitiesJSON, + SystemPrompt: v.SystemPrompt, Status: v.Status, Description: v.Description, SortOrder: v.SortOrder, @@ -248,6 +250,7 @@ type UpstreamRemoteModelResponse struct { SuggestedPlatformModelName string `json:"suggestedPlatformModelName"` SuggestedKindsJSON string `json:"suggestedKindsJSON"` SuggestedProtocol string `json:"suggestedProtocol"` + SuggestedProtocols []string `json:"suggestedProtocols"` BindingCode string `json:"bindingCode"` BoundPlatformModels []string `json:"boundPlatformModels"` UpstreamModelStatus string `json:"upstreamModelStatus"` @@ -269,6 +272,7 @@ func toUpstreamRemoteModelsResponse(d appchannel.UpstreamRemoteModelsData) Upstr SuggestedPlatformModelName: item.SuggestedPlatformModelName, SuggestedKindsJSON: item.SuggestedKindsJSON, SuggestedProtocol: item.SuggestedProtocol, + SuggestedProtocols: item.SuggestedProtocols, BindingCode: item.BindingCode, BoundPlatformModels: item.BoundPlatformModels, UpstreamModelStatus: item.UpstreamModelStatus, @@ -333,13 +337,16 @@ type ImportUpstreamModelsResponse struct { } type ImportUpstreamModelResultResponse struct { - UpstreamModelName string `json:"upstreamModelName"` - PlatformModelName string `json:"platformModelName"` - BindingCode string `json:"bindingCode"` - Status string `json:"status"` - CreatedRoute bool `json:"createdRoute"` - CreatedPlatform bool `json:"createdPlatform"` - Error string `json:"error,omitempty"` + UpstreamModelName string `json:"upstreamModelName"` + PlatformModelName string `json:"platformModelName"` + BindingCode string `json:"bindingCode"` + Status string `json:"status"` + CreatedRoute bool `json:"createdRoute"` + CreatedRoutes int `json:"createdRoutes"` + ExistingRoutes int `json:"existingRoutes"` + Protocols []string `json:"protocols"` + CreatedPlatform bool `json:"createdPlatform"` + Error string `json:"error,omitempty"` } func toImportUpstreamModelsResponse(d appchannel.ImportUpstreamModelsData) ImportUpstreamModelsResponse { @@ -351,6 +358,9 @@ func toImportUpstreamModelsResponse(d appchannel.ImportUpstreamModelsData) Impor BindingCode: item.BindingCode, Status: item.Status, CreatedRoute: item.CreatedRoute, + CreatedRoutes: item.CreatedRoutes, + ExistingRoutes: item.ExistingRoutes, + Protocols: item.Protocols, CreatedPlatform: item.CreatedPlatform, Error: item.Error, }) diff --git a/backend/internal/transport/http/channel/handler.go b/backend/internal/transport/http/channel/handler.go index afbfd126..1f11ccb0 100644 --- a/backend/internal/transport/http/channel/handler.go +++ b/backend/internal/transport/http/channel/handler.go @@ -71,7 +71,7 @@ func (h *Handler) ListPublicModels(c *gin.Context) { // ListUpstreams godoc // @Summary 管理员查询上游列表 -// @Description superadmin 分页查询 LLM 上游配置 +// @Description 管理员分页查询 LLM 上游配置 // @Tags llm // @Accept json // @Produce json @@ -106,7 +106,7 @@ func (h *Handler) ListUpstreams(c *gin.Context) { // CreateUpstream godoc // @Summary 管理员创建上游 -// @Description superadmin 新增上游来源配置,内部标识自动分配 +// @Description 管理员新增上游来源配置,内部标识自动分配 // @Tags llm // @Accept json // @Produce json @@ -161,7 +161,7 @@ func (h *Handler) CreateUpstream(c *gin.Context) { // UpdateUpstream godoc // @Summary 管理员更新上游 -// @Description superadmin 更新上游配置(地址、密钥、状态等) +// @Description 管理员更新上游配置(地址、密钥、状态等) // @Tags llm // @Accept json // @Produce json @@ -225,7 +225,7 @@ func (h *Handler) UpdateUpstream(c *gin.Context) { // DeleteUpstream godoc // @Summary 管理员删除上游 -// @Description superadmin 删除上游配置及其关联路由绑定 +// @Description 管理员删除上游配置及其关联路由绑定 // @Tags llm // @Accept json // @Produce json @@ -256,7 +256,7 @@ func (h *Handler) DeleteUpstream(c *gin.Context) { // BatchDeleteUpstreams godoc // @Summary 管理员批量删除上游 -// @Description superadmin 批量删除上游及其关联路由绑定,保留模型目录 +// @Description 管理员批量删除上游及其关联路由绑定,保留模型目录 // @Tags llm // @Accept json // @Produce json @@ -277,7 +277,7 @@ func (h *Handler) BatchDeleteUpstreams(c *gin.Context) { // OpenUpstreamCircuit godoc // @Summary 管理员手动触发上游熔断 -// @Description superadmin 手动开启上游熔断状态 +// @Description 管理员手动开启上游熔断状态 // @Tags llm // @Accept json // @Produce json @@ -308,7 +308,7 @@ func (h *Handler) OpenUpstreamCircuit(c *gin.Context) { // ResetUpstreamCircuit godoc // @Summary 管理员重置上游熔断 -// @Description superadmin 手动清空上游失败计数并关闭熔断状态 +// @Description 管理员手动清空上游失败计数并关闭熔断状态 // @Tags llm // @Accept json // @Produce json @@ -343,7 +343,7 @@ func (h *Handler) ResetUpstreamCircuit(c *gin.Context) { // ListUpstreamModels godoc // @Summary 管理员查询上游模型路由绑定 -// @Description superadmin 分页查询指定上游的路由绑定列表 +// @Description 管理员分页查询指定上游的路由绑定列表 // @Tags llm // @Accept json // @Produce json @@ -352,7 +352,7 @@ func (h *Handler) ResetUpstreamCircuit(c *gin.Context) { // @Param page query int false "页码" // @Param page_size query int false "每页数量" // @Param q query string false "搜索关键词" -// @Param route_status query string false "路由状态:active/inactive/unbound" +// @Param route_status query string false "路由状态:bound/active/inactive" // @Param upstream_status query string false "上游模型状态:active/inactive" // @Param protocol query string false "接口协议" // @Param sort query string false "排序:upstream_asc/upstream_desc/platform_asc/platform_desc/status_asc/protocol_asc" @@ -393,7 +393,7 @@ func (h *Handler) ListUpstreamModels(c *gin.Context) { // UpsertUpstreamModel godoc // @Summary 管理员新增或更新上游模型路由绑定 -// @Description superadmin 配置平台模型到指定上游真实模型的路由绑定与覆盖请求头 +// @Description 管理员配置平台模型到指定上游真实模型的路由绑定与覆盖请求头 // @Tags llm // @Accept json // @Produce json @@ -446,6 +446,8 @@ func (h *Handler) UpsertUpstreamModel(c *gin.Context) { response.Error(c, http.StatusBadRequest, "invalid json config") case errors.Is(err, appchannel.ErrInvalidAdapter): response.Error(c, http.StatusBadRequest, "invalid adapter") + case errors.Is(err, appchannel.ErrInvalidRouteProtocolCombination): + response.Error(c, http.StatusBadRequest, "invalid route protocol combination") case errors.Is(err, appchannel.ErrInvalidKinds): response.Error(c, http.StatusBadRequest, "invalid kinds") case errors.Is(err, appchannel.ErrInvalidPlatformModelName): @@ -462,7 +464,7 @@ func (h *Handler) UpsertUpstreamModel(c *gin.Context) { // DeleteUpstreamModel godoc // @Summary 管理员删除上游模型路由绑定 -// @Description superadmin 删除指定上游的路由绑定 +// @Description 管理员删除指定上游的路由绑定 // @Tags llm // @Accept json // @Produce json @@ -499,7 +501,7 @@ func (h *Handler) DeleteUpstreamModel(c *gin.Context) { // DisableUpstreamModel godoc // @Summary 管理员停用上游模型路由绑定 -// @Description superadmin 停用该路由绑定,后续路由不会选中 +// @Description 管理员停用该路由绑定,后续路由不会选中 // @Tags llm // @Produce json // @Security BearerAuth @@ -535,7 +537,7 @@ func (h *Handler) DisableUpstreamModel(c *gin.Context) { // EnableUpstreamModel godoc // @Summary 管理员启用上游模型路由绑定 -// @Description superadmin 启用该路由绑定,使该上游模型重新参与路由 +// @Description 管理员启用该路由绑定,使该上游模型重新参与路由 // @Tags llm // @Produce json // @Security BearerAuth @@ -571,7 +573,7 @@ func (h *Handler) EnableUpstreamModel(c *gin.Context) { // BatchDeleteUpstreamModels godoc // @Summary 管理员批量删除上游模型路由绑定 -// @Description superadmin 批量删除指定上游下的路由绑定,保留模型目录 +// @Description 管理员批量删除指定上游下的路由绑定,保留模型目录 // @Tags llm // @Accept json // @Produce json @@ -599,7 +601,7 @@ func (h *Handler) BatchDeleteUpstreamModels(c *gin.Context) { // OpenUpstreamModelCircuit godoc // @Summary 管理员手动触发上游模型路由熔断 -// @Description superadmin 手动开启上游模型路由绑定熔断状态 +// @Description 管理员手动开启上游模型路由绑定熔断状态 // @Tags llm // @Accept json // @Produce json @@ -636,7 +638,7 @@ func (h *Handler) OpenUpstreamModelCircuit(c *gin.Context) { // ResetUpstreamModelCircuit godoc // @Summary 管理员重置上游模型路由熔断 -// @Description superadmin 手动清空上游模型路由绑定失败计数并关闭熔断状态 +// @Description 管理员手动清空上游模型路由绑定失败计数并关闭熔断状态 // @Tags llm // @Accept json // @Produce json @@ -785,6 +787,7 @@ func (h *Handler) ImportUpstreamModels(c *gin.Context) { PlatformModelName: item.PlatformModelName, UpstreamModelName: item.UpstreamModelName, Protocol: item.Protocol, + Protocols: item.Protocols, KindsJSON: item.KindsJSON, Status: item.Status, Priority: item.Priority, @@ -800,6 +803,8 @@ func (h *Handler) ImportUpstreamModels(c *gin.Context) { response.Error(c, http.StatusNotFound, "upstream not found") case errors.Is(err, appchannel.ErrInvalidAdapter): response.ErrorFrom(c, http.StatusBadRequest, err) + case errors.Is(err, appchannel.ErrInvalidRouteProtocolCombination): + response.ErrorFrom(c, http.StatusBadRequest, err) case errors.Is(err, appchannel.ErrInvalidPlatformModelName): response.ErrorFrom(c, http.StatusBadRequest, err) case errors.Is(err, appchannel.ErrNoActiveKey): @@ -820,7 +825,7 @@ func (h *Handler) ImportUpstreamModels(c *gin.Context) { // ListModels godoc // @Summary 管理员查询模型目录 -// @Description superadmin 分页查询平台模型目录,可按 only_active 过滤 +// @Description 管理员分页查询平台模型目录,可按 only_active 过滤 // @Tags llm // @Accept json // @Produce json @@ -859,7 +864,7 @@ func (h *Handler) ListModels(c *gin.Context) { // CreateModel godoc // @Summary 管理员创建模型 -// @Description superadmin 新增平台模型目录项 +// @Description 管理员新增平台模型目录项 // @Tags llm // @Accept json // @Produce json @@ -883,6 +888,7 @@ func (h *Handler) CreateModel(c *gin.Context) { KindsJSON: req.KindsJSON, Icon: req.Icon, CapabilitiesJSON: req.CapabilitiesJSON, + SystemPrompt: req.SystemPrompt, Status: req.Status, Description: req.Description, }) @@ -894,6 +900,8 @@ func (h *Handler) CreateModel(c *gin.Context) { response.Error(c, http.StatusBadRequest, "invalid json config") case errors.Is(err, appchannel.ErrInvalidKinds): response.Error(c, http.StatusBadRequest, "invalid kinds") + case errors.Is(err, appchannel.ErrSystemPromptTooLong): + response.Error(c, http.StatusBadRequest, "system prompt too long") case errors.Is(err, appchannel.ErrInvalidPlatformModelName): response.Error(c, http.StatusBadRequest, "invalid platform model name") default: @@ -906,7 +914,7 @@ func (h *Handler) CreateModel(c *gin.Context) { // UpdateModel godoc // @Summary 管理员更新模型 -// @Description superadmin 更新平台模型目录项 +// @Description 管理员更新平台模型目录项 // @Tags llm // @Accept json // @Produce json @@ -937,6 +945,7 @@ func (h *Handler) UpdateModel(c *gin.Context) { KindsJSON: req.KindsJSON, Icon: req.Icon, CapabilitiesJSON: req.CapabilitiesJSON, + SystemPrompt: req.SystemPrompt, Status: req.Status, Description: req.Description, }) @@ -948,6 +957,8 @@ func (h *Handler) UpdateModel(c *gin.Context) { response.Error(c, http.StatusBadRequest, "invalid json config") case errors.Is(err, appchannel.ErrInvalidKinds): response.Error(c, http.StatusBadRequest, "invalid kinds") + case errors.Is(err, appchannel.ErrSystemPromptTooLong): + response.Error(c, http.StatusBadRequest, "system prompt too long") case errors.Is(err, appchannel.ErrInvalidPlatformModelName): response.Error(c, http.StatusBadRequest, "invalid platform model name") default: @@ -960,7 +971,7 @@ func (h *Handler) UpdateModel(c *gin.Context) { // ReorderModels godoc // @Summary 管理员调整模型顺序 -// @Description superadmin 调整平台模型在用户侧模型选择器中的展示顺序 +// @Description 管理员调整平台模型在用户侧模型选择器中的展示顺序 // @Tags llm // @Accept json // @Produce json @@ -994,7 +1005,7 @@ func (h *Handler) ReorderModels(c *gin.Context) { // DeleteModel godoc // @Summary 管理员删除模型 -// @Description superadmin 删除平台模型目录项及其关联路由绑定 +// @Description 管理员删除平台模型目录项及其关联路由绑定 // @Tags llm // @Accept json // @Produce json @@ -1025,7 +1036,7 @@ func (h *Handler) DeleteModel(c *gin.Context) { // BatchDeleteModels godoc // @Summary 管理员批量删除模型 -// @Description superadmin 批量删除模型目录及其关联路由绑定,保留上游 +// @Description 管理员批量删除模型目录及其关联路由绑定,保留上游 // @Tags llm // @Accept json // @Produce json @@ -1046,7 +1057,7 @@ func (h *Handler) BatchDeleteModels(c *gin.Context) { // ListModelUpstreamSources godoc // @Summary 管理员查询模型上游来源 -// @Description superadmin 分页查询指定模型在各上游上的路由来源 +// @Description 管理员分页查询指定模型在各上游上的路由来源 // @Tags llm // @Accept json // @Produce json @@ -1085,7 +1096,7 @@ func (h *Handler) ListModelUpstreamSources(c *gin.Context) { // UpdateModelUpstreamSource godoc // @Summary 管理员更新模型上游来源 -// @Description superadmin 快速启停指定模型在某上游上的来源 +// @Description 管理员快速启停指定模型在某上游上的来源 // @Tags llm // @Accept json // @Produce json @@ -1128,6 +1139,14 @@ func (h *Handler) UpdateModelUpstreamSource(c *gin.Context) { response.Error(c, http.StatusNotFound, "model not found") case errors.Is(err, appchannel.ErrUpstreamModelNotFound): response.Error(c, http.StatusNotFound, "upstream model not found") + case errors.Is(err, appchannel.ErrInvalidAdapter): + response.Error(c, http.StatusBadRequest, "invalid adapter") + case errors.Is(err, appchannel.ErrProtocolRequired): + response.Error(c, http.StatusBadRequest, "protocol required") + case errors.Is(err, appchannel.ErrInvalidRouteProtocolCombination): + response.Error(c, http.StatusBadRequest, "invalid route protocol combination") + case errors.Is(err, appchannel.ErrUpstreamModelConflict): + response.Error(c, http.StatusConflict, "target model already bound on this upstream") default: response.Error(c, http.StatusInternalServerError, "update model upstream source failed") } @@ -1142,7 +1161,7 @@ func (h *Handler) UpdateModelUpstreamSource(c *gin.Context) { // ListLLMSettings godoc // @Summary 管理员查询全局设置 -// @Description superadmin 查询 LLM 全局设置列表 +// @Description 管理员查询 LLM 全局设置列表 // @Tags llm // @Accept json // @Produce json @@ -1165,7 +1184,7 @@ func (h *Handler) ListLLMSettings(c *gin.Context) { // UpdateLLMSetting godoc // @Summary 管理员更新全局设置 -// @Description superadmin 更新指定 LLM 全局设置项 +// @Description 管理员更新指定 LLM 全局设置项 // @Tags llm // @Accept json // @Produce json diff --git a/backend/internal/transport/http/conversation/dto_request.go b/backend/internal/transport/http/conversation/dto_request.go index 9dad2fdd..9d6f3d25 100644 --- a/backend/internal/transport/http/conversation/dto_request.go +++ b/backend/internal/transport/http/conversation/dto_request.go @@ -2,8 +2,42 @@ package conversation // CreateConversationRequest 创建会话请求。 type CreateConversationRequest struct { - Title string `json:"title" binding:"max=255"` - Model string `json:"model" binding:"max=128"` + Title string `json:"title" binding:"max=255"` + Model string `json:"model" binding:"max=128"` + ProjectID string `json:"projectID" binding:"omitempty,max=32"` +} + +// CreateConversationProjectRequest 创建会话项目请求。 +type CreateConversationProjectRequest struct { + Name string `json:"name" binding:"required,max=80"` + Description string `json:"description" binding:"max=255"` + Color string `json:"color" binding:"max=32"` + Icon string `json:"icon" binding:"max=32"` +} + +// UpdateConversationProjectRequest 更新会话项目请求。 +type UpdateConversationProjectRequest struct { + Name *string `json:"name" binding:"omitempty,max=80"` + Description *string `json:"description" binding:"omitempty,max=255"` + Color *string `json:"color" binding:"omitempty,max=32"` + Icon *string `json:"icon" binding:"omitempty,max=32"` + Status *string `json:"status" binding:"omitempty,oneof=active archived"` +} + +// ReorderConversationProjectsRequest 更新项目排序请求。 +type ReorderConversationProjectsRequest struct { + ProjectIDs []string `json:"projectIDs" binding:"required,max=200"` +} + +// SetConversationProjectRequest 设置会话项目归属请求。 +type SetConversationProjectRequest struct { + ProjectID string `json:"projectID" binding:"omitempty,max=32"` +} + +// BatchSetConversationProjectRequest 批量设置会话项目归属请求。 +type BatchSetConversationProjectRequest struct { + ConversationPublicIDs []string `json:"conversationPublicIDs" binding:"required,max=1000"` + ProjectID string `json:"projectID" binding:"omitempty,max=32"` } // RenameConversationRequest 重命名会话请求。 @@ -63,6 +97,7 @@ type MediaImageRequest struct { Options map[string]interface{} `json:"options"` ClientRunID string `json:"clientRunID" binding:"omitempty,max=64"` FileIDs []string `json:"fileIDs" binding:"max=20"` + MaskFileID string `json:"maskFileID" binding:"omitempty,max=128"` ParentMessagePublicID string `json:"parentMessagePublicID" binding:"omitempty,max=32"` SourceMessagePublicID string `json:"sourceMessagePublicID" binding:"omitempty,max=32"` BranchReason string `json:"branchReason" binding:"omitempty,oneof=default retry edit"` diff --git a/backend/internal/transport/http/conversation/dto_response.go b/backend/internal/transport/http/conversation/dto_response.go index 1c33c94c..0f7e771a 100644 --- a/backend/internal/transport/http/conversation/dto_response.go +++ b/backend/internal/transport/http/conversation/dto_response.go @@ -17,6 +17,8 @@ import ( type ConversationResponse struct { PublicID string `json:"publicID"` UserID uint `json:"userID"` + ProjectID string `json:"projectID"` + ProjectName string `json:"projectName"` Title string `json:"title"` LabelsJSON string `json:"labelsJSON"` Model string `json:"model"` @@ -49,6 +51,8 @@ func toConversationResponse(item *model.Conversation) ConversationResponse { return ConversationResponse{ PublicID: item.PublicID, UserID: item.UserID, + ProjectID: item.ProjectPublicID, + ProjectName: item.ProjectName, Title: item.Title, LabelsJSON: labelsJSON, Model: item.Model, @@ -70,6 +74,41 @@ func toConversationResponse(item *model.Conversation) ConversationResponse { } } +// ConversationProjectResponse 对外会话项目响应 DTO。 +type ConversationProjectResponse struct { + PublicID string `json:"publicID"` + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` + Icon string `json:"icon"` + SortOrder int `json:"sortOrder"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func toConversationProjectResponse(item *model.ConversationProject) ConversationProjectResponse { + if item == nil { + return ConversationProjectResponse{} + } + return ConversationProjectResponse{ + PublicID: item.PublicID, + Name: item.Name, + Description: item.Description, + Color: item.Color, + Icon: item.Icon, + SortOrder: item.SortOrder, + Status: item.Status, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +// BatchSetConversationProjectResponse 批量设置会话项目归属响应 DTO。 +type BatchSetConversationProjectResponse struct { + Updated int64 `json:"updated"` +} + // ConversationShareResponse 会话分享响应 DTO。 type ConversationShareResponse struct { ShareID string `json:"shareID"` @@ -1087,6 +1126,24 @@ type ConversationListResponseDoc struct { } `json:"data"` } +// ConversationProjectResponseDoc 会话项目响应文档。 +type ConversationProjectResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data ConversationProjectResponse `json:"data"` +} + +// ConversationProjectListResponseDoc 会话项目列表响应文档。 +type ConversationProjectListResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data []ConversationProjectResponse `json:"data"` +} + +// BatchSetConversationProjectResponseDoc 批量设置会话项目响应文档。 +type BatchSetConversationProjectResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data BatchSetConversationProjectResponse `json:"data"` +} + // MessageListResponseDoc 消息分页响应文档。 type MessageListResponseDoc struct { ErrorMsg string `json:"errorMsg"` diff --git a/backend/internal/transport/http/conversation/handler.go b/backend/internal/transport/http/conversation/handler.go index 31fe6d85..f41178c5 100644 --- a/backend/internal/transport/http/conversation/handler.go +++ b/backend/internal/transport/http/conversation/handler.go @@ -129,15 +129,30 @@ func mapStreamError(err error) streamError { case errors.Is(err, appconversation.ErrMessageGenerationCanceled): status = http.StatusBadRequest message = "message generation canceled" + case errors.Is(err, appconversation.ErrMediaImagePromptRequired): + status = http.StatusBadRequest + message = "image prompt is required" + case errors.Is(err, appconversation.ErrMediaImageGenerationRejectsInputs): + status = http.StatusBadRequest + message = "image generation does not accept input images" + case errors.Is(err, appconversation.ErrMediaImageEditInputRequired): + status = http.StatusBadRequest + message = "image edit requires at least one input image" + case errors.Is(err, appconversation.ErrMediaImageEditTooManyInputs): + status = http.StatusBadRequest + message = "too many image edit input images" + case errors.Is(err, appconversation.ErrMediaImageEditInputInvalid): + status = http.StatusBadRequest + message = "image edit input image is invalid" + case errors.Is(err, appconversation.ErrMediaRouteProtocolMismatch): + status = http.StatusServiceUnavailable + message = "media route protocol does not match task" case errors.Is(err, appconversation.ErrInvalidMediaGenerationTask): status = http.StatusBadRequest message = "invalid media generation task" case errors.Is(err, appconversation.ErrDuplicateMessageGenerationRun): status = http.StatusConflict message = "message generation run already exists" - case errors.Is(err, appconversation.ErrMediaImageEditNotImplemented): - status = http.StatusNotImplemented - message = "image edit protocol not implemented" case errors.Is(err, appconversation.ErrUpstreamRequestFailed): status = http.StatusBadGateway message = mapClientErrorMessage(err) @@ -229,6 +244,18 @@ func normalizeConversationShareFilter(value string) string { } } +func normalizeConversationProjectQuery(value string) string { + normalized := strings.TrimSpace(value) + switch normalized { + case "", "all": + return "all" + case "unassigned": + return "unassigned" + default: + return normalized + } +} + func normalizeFileSort(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "created", "recent": diff --git a/backend/internal/transport/http/conversation/handler_conversation.go b/backend/internal/transport/http/conversation/handler_conversation.go index b2b05b23..0478fb00 100644 --- a/backend/internal/transport/http/conversation/handler_conversation.go +++ b/backend/internal/transport/http/conversation/handler_conversation.go @@ -33,8 +33,12 @@ func (h *Handler) CreateConversation(c *gin.Context) { return } - item, err := h.service.CreateConversation(c.Request.Context(), userID, req.Title, req.Model) + item, err := h.service.CreateConversation(c.Request.Context(), userID, req.Title, req.Model, req.ProjectID) if err != nil { + if errors.Is(err, appconversation.ErrConversationProjectNotFound) { + response.Error(c, http.StatusNotFound, "conversation project not found") + return + } response.Error(c, http.StatusInternalServerError, "create conversation failed") return } @@ -60,6 +64,7 @@ func (h *Handler) CreateConversation(c *gin.Context) { // @Param status query string false "状态筛选: active|archived|all" // @Param starred query string false "星标筛选: all|starred|unstarred" // @Param share query string false "分享筛选: all|shared|unshared" +// @Param project query string false "项目筛选: all|unassigned|项目 public_id" // @Success 200 {object} ConversationListResponseDoc // @Failure 500 {object} ErrorDoc // @Router /conversations [get] @@ -70,8 +75,9 @@ func (h *Handler) ListConversations(c *gin.Context) { statusFilter := normalizeConversationStatusFilter(c.Query("status")) starredFilter := normalizeConversationStarredFilter(c.Query("starred")) shareFilter := normalizeConversationShareFilter(c.Query("share")) + projectFilter := normalizeConversationProjectQuery(c.Query("project")) - items, total, err := h.service.ListConversations(c.Request.Context(), userID, page, pageSize, statusFilter, starredFilter, shareFilter) + items, total, err := h.service.ListConversations(c.Request.Context(), userID, page, pageSize, statusFilter, starredFilter, shareFilter, projectFilter) if err != nil { response.Error(c, http.StatusInternalServerError, "list conversations failed") return diff --git a/backend/internal/transport/http/conversation/handler_media.go b/backend/internal/transport/http/conversation/handler_media.go index 740dd348..7bceeecf 100644 --- a/backend/internal/transport/http/conversation/handler_media.go +++ b/backend/internal/transport/http/conversation/handler_media.go @@ -94,6 +94,7 @@ func (h *Handler) streamMediaImage(c *gin.Context, taskType appconversation.Medi Options: req.Options, ClientRunID: req.ClientRunID, FileIDs: req.FileIDs, + MaskFileID: req.MaskFileID, ParentMessagePublicID: req.ParentMessagePublicID, SourceMessagePublicID: req.SourceMessagePublicID, BranchReason: req.BranchReason, diff --git a/backend/internal/transport/http/conversation/handler_project.go b/backend/internal/transport/http/conversation/handler_project.go new file mode 100644 index 00000000..9c9785f1 --- /dev/null +++ b/backend/internal/transport/http/conversation/handler_project.go @@ -0,0 +1,291 @@ +package conversation + +import ( + "errors" + "net/http" + + appconversation "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/conversation" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/response" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/middleware" + "github.com/gin-gonic/gin" +) + +// ListConversationProjects godoc +// @Summary 会话项目列表 +// @Description 查询当前用户的会话项目分组 +// @Tags chat +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param status query string false "状态筛选: active|archived|all" +// @Success 200 {object} ConversationProjectListResponseDoc +// @Failure 500 {object} ErrorDoc +// @Router /conversation-projects [get] +func (h *Handler) ListConversationProjects(c *gin.Context) { + userID := middleware.MustUserID(c) + items, err := h.service.ListConversationProjects(c.Request.Context(), userID, c.Query("status")) + if err != nil { + response.Error(c, http.StatusInternalServerError, "list conversation projects failed") + return + } + results := make([]ConversationProjectResponse, 0, len(items)) + for i := range items { + results = append(results, toConversationProjectResponse(&items[i])) + } + response.Success(c, results) +} + +// CreateConversationProject godoc +// @Summary 创建会话项目 +// @Description 创建当前用户的会话项目分组 +// @Tags chat +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body CreateConversationProjectRequest true "项目参数" +// @Success 200 {object} ConversationProjectResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /conversation-projects [post] +func (h *Handler) CreateConversationProject(c *gin.Context) { + userID := middleware.MustUserID(c) + var req CreateConversationProjectRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + item, err := h.service.CreateConversationProject(c.Request.Context(), userID, appconversation.ConversationProjectInput{ + Name: req.Name, + Description: req.Description, + Color: req.Color, + Icon: req.Icon, + }) + if err != nil { + if errors.Is(err, appconversation.ErrInvalidConversationProject) { + response.Error(c, http.StatusBadRequest, "invalid conversation project") + return + } + response.Error(c, http.StatusInternalServerError, "create conversation project failed") + return + } + h.recordAudit(c, "create_conversation_project", "conversation_project", item.PublicID, map[string]string{"name": item.Name}) + response.Success(c, toConversationProjectResponse(item)) +} + +// UpdateConversationProject godoc +// @Summary 更新会话项目 +// @Description 更新当前用户的会话项目分组 +// @Tags chat +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "项目 public_id" +// @Param body body UpdateConversationProjectRequest true "项目参数" +// @Success 200 {object} ConversationProjectResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /conversation-projects/{id} [patch] +func (h *Handler) UpdateConversationProject(c *gin.Context) { + userID := middleware.MustUserID(c) + publicID, err := stringParam(c, "id") + if err != nil { + response.Error(c, http.StatusBadRequest, "invalid conversation project id") + return + } + var req UpdateConversationProjectRequest + if err = c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + item, err := h.service.UpdateConversationProject(c.Request.Context(), userID, publicID, appconversation.ConversationProjectPatchInput{ + Name: req.Name, + Description: req.Description, + Color: req.Color, + Icon: req.Icon, + Status: req.Status, + }) + if err != nil { + switch { + case errors.Is(err, appconversation.ErrInvalidConversationProject): + response.Error(c, http.StatusBadRequest, "invalid conversation project") + return + case errors.Is(err, appconversation.ErrConversationProjectNotFound): + response.Error(c, http.StatusNotFound, "conversation project not found") + return + default: + response.Error(c, http.StatusInternalServerError, "update conversation project failed") + return + } + } + h.recordAudit(c, "update_conversation_project", "conversation_project", item.PublicID, map[string]string{"name": item.Name}) + response.Success(c, toConversationProjectResponse(item)) +} + +// DeleteConversationProject godoc +// @Summary 删除会话项目 +// @Description 删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话。 +// @Tags chat +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "项目 public_id" +// @Param delete_conversations query bool false "是否同时删除项目内会话" +// @Success 200 {object} ConversationDeleteResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /conversation-projects/{id} [delete] +func (h *Handler) DeleteConversationProject(c *gin.Context) { + userID := middleware.MustUserID(c) + publicID, err := stringParam(c, "id") + if err != nil { + response.Error(c, http.StatusBadRequest, "invalid conversation project id") + return + } + deleteConversations := c.Query("delete_conversations") == "true" + if err = h.service.DeleteConversationProject(c.Request.Context(), userID, publicID, deleteConversations); err != nil { + if errors.Is(err, appconversation.ErrConversationProjectNotFound) { + response.Error(c, http.StatusNotFound, "conversation project not found") + return + } + response.Error(c, http.StatusInternalServerError, "delete conversation project failed") + return + } + h.recordAudit(c, "delete_conversation_project", "conversation_project", publicID, map[string]bool{ + "deleted": true, + "delete_conversations": deleteConversations, + }) + response.Success(c, ConversationDeleteResponse{Deleted: true}) +} + +// ReorderConversationProjects godoc +// @Summary 调整会话项目顺序 +// @Description 更新当前用户项目分组展示顺序 +// @Tags chat +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body ReorderConversationProjectsRequest true "排序参数" +// @Success 200 {object} ConversationProjectListResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /conversation-projects/reorder [post] +func (h *Handler) ReorderConversationProjects(c *gin.Context) { + userID := middleware.MustUserID(c) + var req ReorderConversationProjectsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + if err := h.service.ReorderConversationProjects(c.Request.Context(), userID, req.ProjectIDs); err != nil { + switch { + case errors.Is(err, appconversation.ErrInvalidConversationProject): + response.Error(c, http.StatusBadRequest, "invalid conversation project") + return + case errors.Is(err, appconversation.ErrConversationProjectNotFound): + response.Error(c, http.StatusNotFound, "conversation project not found") + return + default: + response.Error(c, http.StatusInternalServerError, "reorder conversation projects failed") + return + } + } + items, err := h.service.ListConversationProjects(c.Request.Context(), userID, "active") + if err != nil { + response.Error(c, http.StatusInternalServerError, "list conversation projects failed") + return + } + results := make([]ConversationProjectResponse, 0, len(items)) + for i := range items { + results = append(results, toConversationProjectResponse(&items[i])) + } + h.recordAudit(c, "reorder_conversation_projects", "conversation_project", "", map[string]int{"count": len(req.ProjectIDs)}) + response.Success(c, results) +} + +// SetConversationProject godoc +// @Summary 设置会话项目归属 +// @Description 设置当前用户单个会话的项目归属 +// @Tags chat +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "会话 public_id" +// @Param body body SetConversationProjectRequest true "项目归属参数" +// @Success 200 {object} ConversationUpdateResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /conversations/{id}/project [patch] +func (h *Handler) SetConversationProject(c *gin.Context) { + userID := middleware.MustUserID(c) + publicID, err := stringParam(c, "id") + if err != nil { + response.Error(c, http.StatusBadRequest, "invalid conversation id") + return + } + var req SetConversationProjectRequest + if err = c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + item, err := h.service.SetConversationProject(c.Request.Context(), userID, publicID, req.ProjectID) + if err != nil { + switch { + case errors.Is(err, appconversation.ErrConversationProjectNotFound): + response.Error(c, http.StatusNotFound, "conversation project not found") + return + case errors.Is(err, appconversation.ErrConversationNotFound): + response.Error(c, http.StatusNotFound, "conversation not found") + return + default: + response.Error(c, http.StatusInternalServerError, "set conversation project failed") + return + } + } + h.recordAudit(c, "set_conversation_project", "conversation", item.PublicID, map[string]string{"projectID": item.ProjectPublicID}) + response.Success(c, toConversationResponse(item)) +} + +// BatchSetConversationProject godoc +// @Summary 批量设置会话项目归属 +// @Description 批量设置当前用户会话的项目归属 +// @Tags chat +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body BatchSetConversationProjectRequest true "项目归属参数" +// @Success 200 {object} BatchSetConversationProjectResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /conversations/project [post] +func (h *Handler) BatchSetConversationProject(c *gin.Context) { + userID := middleware.MustUserID(c) + var req BatchSetConversationProjectRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + updated, err := h.service.BatchSetConversationProject(c.Request.Context(), userID, req.ConversationPublicIDs, req.ProjectID) + if err != nil { + switch { + case errors.Is(err, appconversation.ErrInvalidConversationProject): + response.Error(c, http.StatusBadRequest, "invalid conversation project") + return + case errors.Is(err, appconversation.ErrConversationProjectNotFound): + response.Error(c, http.StatusNotFound, "conversation project not found") + return + case errors.Is(err, appconversation.ErrConversationNotFound): + response.Error(c, http.StatusNotFound, "conversation not found") + return + default: + response.Error(c, http.StatusInternalServerError, "batch set conversation project failed") + return + } + } + h.recordAudit(c, "batch_set_conversation_project", "conversation", "", map[string]int64{"updated": updated}) + response.Success(c, BatchSetConversationProjectResponse{Updated: updated}) +} diff --git a/backend/internal/transport/http/conversation/router.go b/backend/internal/transport/http/conversation/router.go index dfd0929d..46ef7030 100644 --- a/backend/internal/transport/http/conversation/router.go +++ b/backend/internal/transport/http/conversation/router.go @@ -7,10 +7,17 @@ func (m *Module) RegisterRoutes(authRequired *gin.RouterGroup) { authRequired.POST("/conversations", m.Handler.CreateConversation) authRequired.GET("/conversations", m.Handler.ListConversations) authRequired.POST("/conversations/shares/revoke", m.Handler.RevokeConversationShares) + authRequired.GET("/conversation-projects", m.Handler.ListConversationProjects) + authRequired.POST("/conversation-projects", m.Handler.CreateConversationProject) + authRequired.POST("/conversation-projects/reorder", m.Handler.ReorderConversationProjects) + authRequired.PATCH("/conversation-projects/:id", m.Handler.UpdateConversationProject) + authRequired.DELETE("/conversation-projects/:id", m.Handler.DeleteConversationProject) + authRequired.POST("/conversations/project", m.Handler.BatchSetConversationProject) authRequired.GET("/conversations/:id", m.Handler.GetConversation) authRequired.PATCH("/conversations/:id/title", m.Handler.RenameConversation) authRequired.PATCH("/conversations/:id/star", m.Handler.SetConversationStar) authRequired.PATCH("/conversations/:id/archive", m.Handler.SetConversationArchive) + authRequired.PATCH("/conversations/:id/project", m.Handler.SetConversationProject) authRequired.DELETE("/conversations/:id", m.Handler.DeleteConversation) authRequired.GET("/conversations/:id/share", m.Handler.GetConversationShare) authRequired.POST("/conversations/:id/share", m.Handler.CreateConversationShare) diff --git a/backend/internal/transport/http/middleware/auth.go b/backend/internal/transport/http/middleware/auth.go index 8edc5fdb..cbb97382 100644 --- a/backend/internal/transport/http/middleware/auth.go +++ b/backend/internal/transport/http/middleware/auth.go @@ -84,8 +84,8 @@ func AdminOnly() gin.HandlerFunc { } roleStr, roleOK := role.(string) - if !roleOK || roleStr != domainuser.RoleSuperAdmin { - response.Error(c, http.StatusForbidden, "superadmin permission required") + if !roleOK || !domainuser.IsAdminRole(roleStr) { + response.Error(c, http.StatusForbidden, "admin permission required") c.Abort() return } diff --git a/backend/internal/transport/http/middleware/context_values.go b/backend/internal/transport/http/middleware/context_values.go index 1ec7ad8c..4230d3a0 100644 --- a/backend/internal/transport/http/middleware/context_values.go +++ b/backend/internal/transport/http/middleware/context_values.go @@ -28,6 +28,19 @@ func MustUsername(c *gin.Context) string { return username } +// MustUserRole 获取登录用户角色。 +func MustUserRole(c *gin.Context) string { + value, ok := c.Get(ContextKeyUserRole) + if !ok { + return "" + } + role, ok := value.(string) + if !ok { + return "" + } + return role +} + // MustRequestID 获取请求ID。 func MustRequestID(c *gin.Context) string { value, ok := c.Get(ContextKeyRequestID) diff --git a/backend/internal/transport/http/middleware/ratelimit.go b/backend/internal/transport/http/middleware/ratelimit.go index 5e87b855..01083079 100644 --- a/backend/internal/transport/http/middleware/ratelimit.go +++ b/backend/internal/transport/http/middleware/ratelimit.go @@ -32,7 +32,7 @@ func RateLimit(limiter RateLimiter, runtime *config.Runtime) gin.HandlerFunc { return } role, hasRole := c.Get(ContextKeyUserRole) - if roleStr, ok := role.(string); hasRole && ok && roleStr == domainuser.RoleSuperAdmin { + if roleStr, ok := role.(string); hasRole && ok && domainuser.IsAdminRole(roleStr) { c.Next() return } diff --git a/backend/internal/transport/http/settings/router.go b/backend/internal/transport/http/settings/router.go index a70544cf..08d53dfe 100644 --- a/backend/internal/transport/http/settings/router.go +++ b/backend/internal/transport/http/settings/router.go @@ -10,7 +10,7 @@ func (m *Module) RegisterRoutes(api *gin.RouterGroup) { api.GET("/settings/model-option-policy", m.Handler.GetModelOptionPolicy) } -// RegisterAdminRoutes 注册 settings 管理路由(由 superadmin 中间件保护)。 +// RegisterAdminRoutes 注册 settings 管理路由(由管理员中间件保护)。 func (m *Module) RegisterAdminRoutes(adminGroup *gin.RouterGroup) { g := adminGroup.Group("/settings") g.GET("", m.Handler.ListAll) diff --git a/config.docker.example.yaml b/config.docker.example.yaml index 6ad4f863..55cff5a2 100644 --- a/config.docker.example.yaml +++ b/config.docker.example.yaml @@ -57,8 +57,15 @@ storage: force_path_style: true geoip: - # Optional: use none to disable, or mmdb for a local GeoIP database. + # Optional: use none to disable, ipwhois for the built-in public lookup, + # or mmdb for a local GeoLite2/MMDB database. provider: ipwhois + # GeoLite2/MMDB example. Leave these commented unless provider is set to mmdb. + # database_url: "https://example.com/path/to/GeoLite2-City.mmdb" + # database_path: "/app/data/geoip/GeoLite2-City.mmdb" + # database_max_bytes: 104857600 + # refresh_interval_hours: 168 + # timeout_ms: 2500 observability: tracing: diff --git a/config.example.yaml b/config.example.yaml index b3fe1093..8b49620e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -53,8 +53,15 @@ storage: force_path_style: true geoip: - # Optional: use none to disable, or mmdb for a local GeoIP database. + # Optional: use none to disable, ipwhois for the built-in public lookup, + # or mmdb for a local GeoLite2/MMDB database. provider: ipwhois + # GeoLite2/MMDB example. Leave these commented unless provider is set to mmdb. + # database_url: "https://example.com/path/to/GeoLite2-City.mmdb" + # database_path: "./data/geoip/GeoLite2-City.mmdb" + # database_max_bytes: 104857600 + # refresh_interval_hours: 168 + # timeout_ms: 2500 observability: tracing: diff --git a/frontend/app/(auth)/auth/callback/page.tsx b/frontend/app/(auth)/auth/callback/page.tsx index e019da09..615b634d 100644 --- a/frontend/app/(auth)/auth/callback/page.tsx +++ b/frontend/app/(auth)/auth/callback/page.tsx @@ -28,8 +28,14 @@ export default function Page() { const resolveErrorMessage = useLocalizedErrorMessage(); const router = useRouter(); const [error, setError] = React.useState(""); + const handledRef = React.useRef(false); React.useEffect(() => { + if (handledRef.current) { + return; + } + handledRef.current = true; + const params = new URLSearchParams(window.location.search); const errorMessage = params.get("error"); if (errorMessage) { diff --git a/frontend/components/ui/folder-open.tsx b/frontend/components/ui/folder-open.tsx new file mode 100644 index 00000000..d86d61e4 --- /dev/null +++ b/frontend/components/ui/folder-open.tsx @@ -0,0 +1,101 @@ +"use client"; + +import type { Variants } from "motion/react"; +import { motion, useAnimation } from "motion/react"; +import type { HTMLAttributes, MouseEvent } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; + +import { cn } from "@/lib/utils"; + +export interface FolderOpenIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface FolderOpenIconProps extends HTMLAttributes { + size?: number; + strokeWidth?: number; +} + +const VARIANTS: Variants = { + normal: { rotate: 0 }, + animate: { + rotate: [0, -8, 6, -4, 0], + transition: { + ease: "easeInOut", + rotate: { + duration: 0.6, + }, + }, + }, +}; + +const FolderOpenIcon = forwardRef( + ({ onMouseEnter, onMouseLeave, className, size = 28, strokeWidth = 2, ...props }, ref) => { + const controls = useAnimation(); + const isControlledRef = useRef(false); + + useImperativeHandle(ref, () => { + isControlledRef.current = true; + return { + startAnimation: () => controls.start("animate"), + stopAnimation: () => controls.start("normal"), + }; + }); + + const handleMouseEnter = useCallback( + (e: MouseEvent) => { + if (isControlledRef.current) { + onMouseEnter?.(e); + } else { + controls.start("animate"); + } + }, + [controls, onMouseEnter] + ); + + const handleMouseLeave = useCallback( + (e: MouseEvent) => { + if (isControlledRef.current) { + onMouseLeave?.(e); + } else { + controls.start("normal"); + } + }, + [controls, onMouseLeave] + ); + + return ( +
+ + + +
+ ); + } +); + +FolderOpenIcon.displayName = "FolderOpenIcon"; + +export { FolderOpenIcon }; diff --git a/frontend/features/admin/api/admin.types.ts b/frontend/features/admin/api/admin.types.ts index 22253bdd..9dca31c0 100644 --- a/frontend/features/admin/api/admin.types.ts +++ b/frontend/features/admin/api/admin.types.ts @@ -2,7 +2,7 @@ import type { PagePayload } from "@/shared/api/common.types"; import type { UserDTO } from "@/shared/api/auth.types"; export type AdminUserStatus = "pending_activation" | "active" | "locked" | "suspended" | "deactivated"; -export type AdminUserRole = "user" | "superadmin"; +export type AdminUserRole = "user" | "admin" | "superadmin"; export type CreateAdminUserRequest = { username: string; diff --git a/frontend/features/admin/api/auth.ts b/frontend/features/admin/api/auth.ts index d654d0b4..da45e744 100644 --- a/frontend/features/admin/api/auth.ts +++ b/frontend/features/admin/api/auth.ts @@ -18,9 +18,10 @@ export type IdentityProviderPayload = { userinfoURL?: string; jwksURL?: string; scopes?: string; - defaultRole?: "user" | "superadmin"; + defaultRole?: "user" | "admin" | "superadmin"; subjectField?: string; emailField?: string; + emailVerifiedField?: string; nameField?: string; avatarField?: string; }; diff --git a/frontend/features/admin/api/llm.types.ts b/frontend/features/admin/api/llm.types.ts index cea3a772..d21543da 100644 --- a/frontend/features/admin/api/llm.types.ts +++ b/frontend/features/admin/api/llm.types.ts @@ -11,7 +11,8 @@ export type AdminLLMAdapter = | "google_generate_content" | "google_image_generation" | "xai_responses" - | "xai_image"; + | "xai_image" + | "xai_image_edits"; export type AdminLLMModelVendor = string; export type AdminLLMCompatible = | "openai" @@ -58,6 +59,7 @@ export type AdminLLMModelDTO = { kindsJSON: string; icon: string; capabilitiesJSON: string; + systemPrompt: string; status: AdminLLMStatus; description: string; sortOrder: number; @@ -140,6 +142,7 @@ export type AdminLLMRemoteModelItem = { suggestedPlatformModelName: string; suggestedKindsJSON: string; suggestedProtocol: AdminLLMAdapter | ""; + suggestedProtocols: AdminLLMAdapter[]; bindingCode: string; boundPlatformModels: string[]; upstreamModelStatus: AdminLLMStatus | ""; @@ -202,6 +205,7 @@ export type CreateAdminLLMModelRequest = { kindsJSON?: string; icon?: string; capabilitiesJSON?: string; + systemPrompt?: string; status?: AdminLLMStatus; description?: string; }; @@ -212,6 +216,7 @@ export type UpdateAdminLLMModelRequest = { kindsJSON?: string; icon?: string; capabilitiesJSON?: string; + systemPrompt?: string; status?: AdminLLMStatus; description?: string; }; @@ -248,6 +253,7 @@ export type ImportAdminLLMUpstreamModelsRequest = { platformModelName: string; upstreamModelName: string; protocol?: AdminLLMAdapter; + protocols?: AdminLLMAdapter[]; kindsJSON?: string; status?: AdminLLMStatus; priority?: number; @@ -302,6 +308,9 @@ export type ImportAdminLLMUpstreamModelsData = { bindingCode: string; status: "created" | "existing" | "failed"; createdRoute: boolean; + createdRoutes: number; + existingRoutes: number; + protocols: AdminLLMAdapter[]; createdPlatform: boolean; error?: string; }>; diff --git a/frontend/features/admin/components/admin-access-gate.tsx b/frontend/features/admin/components/admin-access-gate.tsx index fcf8e93d..464c15b1 100644 --- a/frontend/features/admin/components/admin-access-gate.tsx +++ b/frontend/features/admin/components/admin-access-gate.tsx @@ -14,7 +14,7 @@ export function AdminAccessGate({ children }: { children: React.ReactNode }) { return null; } - if (user?.role !== "superadmin") { + if (user?.role !== "admin" && user?.role !== "superadmin") { return (
diff --git a/frontend/features/admin/components/admin-sidebar.tsx b/frontend/features/admin/components/admin-sidebar.tsx index 7162304e..c2470466 100644 --- a/frontend/features/admin/components/admin-sidebar.tsx +++ b/frontend/features/admin/components/admin-sidebar.tsx @@ -3,8 +3,18 @@ import * as React from "react"; import Link from "next/link"; import { useTranslations } from "next-intl"; +import { CircleArrowUp } from "lucide-react"; +import packageMeta from "@/package.json"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { ADMIN_SECTIONS, type AdminSection } from "@/features/admin/model/admin-sections"; +import { AdminUpdateTooltipContent } from "@/features/admin/components/admin-update-tooltip-content"; +import { + getCachedLatestReleaseSnapshot, + getServerLatestReleaseSnapshot, + resolveAvailableRelease, + subscribeLatestReleaseChange, +} from "@/features/admin/model/update-check"; import { cn } from "@/lib/utils"; export function AdminSidebar({ @@ -15,6 +25,13 @@ export function AdminSidebar({ basePath: string; }) { const t = useTranslations("adminUsers"); + const tAbout = useTranslations("adminUsers.aboutPage"); + const cachedLatestRelease = React.useSyncExternalStore( + subscribeLatestReleaseChange, + getCachedLatestReleaseSnapshot, + getServerLatestReleaseSnapshot, + ); + const updateRelease = resolveAvailableRelease(packageMeta.version, cachedLatestRelease); const sectionLabel = React.useCallback( (id: AdminSection, fallback: string) => { const keyByID: Record = { @@ -54,13 +71,25 @@ export function AdminSidebar({ href={`${basePath}${item.href}`} aria-current={active ? "page" : undefined} className={cn( - "relative flex h-8 shrink-0 items-center whitespace-nowrap rounded-md px-3 text-sm font-medium transition-colors xl:h-9 xl:w-full xl:px-3.5", + "relative flex h-8 shrink-0 items-center justify-between gap-2 whitespace-nowrap rounded-md px-3 text-sm font-medium transition-colors xl:h-9 xl:w-full xl:px-3.5", active ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", )} > - {sectionLabel(item.id, item.label)} + {sectionLabel(item.id, item.label)} + {item.id === "about" && updateRelease ? ( + + + + + + + + + + + ) : null} ); })} diff --git a/frontend/features/admin/components/admin-update-tooltip-content.tsx b/frontend/features/admin/components/admin-update-tooltip-content.tsx new file mode 100644 index 00000000..f8ca248a --- /dev/null +++ b/frontend/features/admin/components/admin-update-tooltip-content.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +import packageMeta from "@/package.json"; +import type { ReleaseInfo } from "@/features/admin/model/update-check"; +import { formatReleaseVersion } from "@/features/admin/model/update-check"; + +export function AdminUpdateTooltipContent({ updateRelease }: { updateRelease: ReleaseInfo | null }) { + const t = useTranslations("adminUsers.aboutPage"); + const currentVersion = formatReleaseVersion(packageMeta.version); + + if (!updateRelease) { + return ( +
+

{t("updateTooltip.currentTitle")}

+

{t("updateTooltip.currentVersion", { current: currentVersion })}

+
+ ); + } + + return ( +
+

{t("updateTooltip.title")}

+
+

{t("updateTooltip.currentVersion", { current: currentVersion })}

+

{t("updateTooltip.latestVersion", { latest: formatReleaseVersion(updateRelease.version) })}

+
+

{t("updateTooltip.description")}

+
+ ); +} diff --git a/frontend/features/admin/components/sections/accounts/account-user-editor.tsx b/frontend/features/admin/components/sections/accounts/account-user-editor.tsx index fd33b79f..76eab41d 100644 --- a/frontend/features/admin/components/sections/accounts/account-user-editor.tsx +++ b/frontend/features/admin/components/sections/accounts/account-user-editor.tsx @@ -59,7 +59,6 @@ import type { UserDTO } from "@/shared/api/auth.types"; import type { AdminUserRole, AdminUserStatus } from "@/features/admin/api/admin.types"; import { COMPACT_COMBOBOX_CLASSNAME, - USER_ROLE_OPTIONS, USER_STATUS_OPTIONS, type CreateUserPayload, type EditUserPayload, @@ -317,6 +316,7 @@ type EditUserSheetProps = { editSubscriptionExpiryDate?: Date; statusChanged: boolean; timeZoneOptions: string[]; + roleOptions: AdminUserRole[]; onSaveEdit: () => void; onOpenEditAvatarDialog: () => void; onOpenResetPasswordDialog: () => void; @@ -383,6 +383,7 @@ export function EditUserSheet({ editSubscriptionExpiryDate, statusChanged, timeZoneOptions, + roleOptions, onSaveEdit, onOpenEditAvatarDialog, onOpenResetPasswordDialog, @@ -553,7 +554,7 @@ export function EditUserSheet({
setEditPayload((current) => ({ ...current, role: value as AdminUserRole }))} disabled={pending} diff --git a/frontend/features/admin/components/sections/accounts/accounts-users.tsx b/frontend/features/admin/components/sections/accounts/accounts-users.tsx index 1d8ddb38..382b5dc6 100644 --- a/frontend/features/admin/components/sections/accounts/accounts-users.tsx +++ b/frontend/features/admin/components/sections/accounts/accounts-users.tsx @@ -35,6 +35,7 @@ import { TableSkeletonRows, } from "@/components/ui/table"; import { resolveAvatarImageSrc } from "@/shared/lib/avatar"; +import { useAuthSession } from "@/shared/auth/auth-session-context"; import { TimeZoneSelect } from "@/shared/components/time-zone-select"; import { useProgressiveRows } from "@/hooks/use-progressive-rows"; import type { AdminUserRole, AdminUserStatus } from "@/features/admin/api/admin.types"; @@ -202,6 +203,8 @@ type UserTableRowProps = { inlineStatusPending: boolean; pendingAction: string; actionUserID: number | null; + roleOptions: AdminUserRole[]; + canManage: boolean; onToggleSelectedUser: (userID: number, checked: boolean) => void; onInlinePatch: ( item: UserDTO, @@ -220,6 +223,8 @@ const UserTableRow = React.memo(function UserTableRow({ inlineStatusPending, pendingAction, actionUserID, + roleOptions, + canManage, onToggleSelectedUser, onInlinePatch, onOpenAvatar, @@ -231,7 +236,11 @@ const UserTableRow = React.memo(function UserTableRow({ const resolveSubscriptionStatusLabel = useSubscriptionStatusLabel(); const resolveBillingAccountStatusLabel = useBillingAccountStatusLabel(); const avatarAlt = item.displayName.trim() || item.username.trim() || item.publicID.trim() || t("fallbackUser"); - const disabled = Boolean(pendingAction); + const disabled = Boolean(pendingAction) || !canManage; + const rowRoleOptions = React.useMemo( + () => (roleOptions.includes(item.role as AdminUserRole) ? roleOptions : [item.role as AdminUserRole, ...roleOptions]), + [item.role, roleOptions], + ); return ( @@ -270,16 +279,16 @@ const UserTableRow = React.memo(function UserTableRow({ void onInlinePatch(item, "role", { role: value as UserDTO["role"] })} - disabled={inlineRolePending} + disabled={disabled || inlineRolePending} > {t("table.noMatchingRoles")} @@ -299,13 +308,13 @@ const UserTableRow = React.memo(function UserTableRow({ value={item.status} itemToStringLabel={resolveUserStatusLabel} onValueChange={(value) => void onInlinePatch(item, "status", { status: value as UserDTO["status"] })} - disabled={inlineStatusPending} + disabled={disabled || inlineStatusPending} > {t("table.noMatchingStatus")} @@ -384,7 +393,12 @@ export function AccountsUsers({ onSetTotal, }: AccountsUsersProps) { const t = useTranslations("adminUsers"); + const { user: viewer } = useAuthSession(); const resolveUserStatusLabel = useUserStatusLabel(); + const roleOptions = React.useMemo( + () => (viewer?.role === "superadmin" ? USER_ROLE_OPTIONS : USER_ROLE_OPTIONS.filter((role) => role !== "superadmin")), + [viewer?.role], + ); const createDialogContentRef = React.useRef(null); const { timeZoneOptions, @@ -446,6 +460,7 @@ export function AccountsUsers({ handleOpenAvatarDialog, handleOpenCreateAvatarDialog, handleInlineUserPatch, + canManageUser, onCreateUser, handleSaveAvatarDialog, handleSaveEditDialog, @@ -461,7 +476,7 @@ export function AccountsUsers({ onBulkApplyTimezone, onBulkApplyBalance, handleRandomizeAvatarDialog, - } = useAdminUsersPage({ items, total, page, pageSize, onLoadUsers, onSetUsers, onSetTotal }); + } = useAdminUsersPage({ items, total, page, pageSize, viewerRole: viewer?.role, onLoadUsers, onSetUsers, onSetTotal }); const { visibleRows: renderedItems } = useProgressiveRows(filteredItems, { initialCount: 12, step: 16, @@ -469,6 +484,10 @@ export function AccountsUsers({ }); const [bulkConfirmAction, setBulkConfirmAction] = React.useState(null); const bulkConfirmOpen = bulkConfirmAction !== null; + const hasSelectableFilteredItems = React.useMemo( + () => filteredItems.some(canManageUser), + [canManageUser, filteredItems], + ); function handleConfirmBulkAction() { switch (bulkConfirmAction) { @@ -564,7 +583,7 @@ export function AccountsUsers({ - {USER_ROLE_OPTIONS.map((role) => ( + {roleOptions.map((role) => ( {role} @@ -667,7 +686,7 @@ export function AccountsUsers({ handleSelectAllVisible(checked === true)} - disabled={loading || !filteredItems.length} + disabled={loading || !hasSelectableFilteredItems} />
@@ -697,6 +716,8 @@ export function AccountsUsers({ inlineStatusPending={Boolean(inlinePending[resolveInlineKey(item.id, "status")])} pendingAction={pendingAction} actionUserID={actionUserID} + roleOptions={roleOptions} + canManage={canManageUser(item)} onToggleSelectedUser={handleToggleSelectedUser} onInlinePatch={handleInlineUserPatch} onOpenAvatar={handleOpenAvatarDialog} @@ -809,6 +830,7 @@ export function AccountsUsers({ editSubscriptionExpiryDate={editSubscriptionExpiryDate} statusChanged={editStatusChanged} timeZoneOptions={timeZoneOptions} + roleOptions={roleOptions} onSaveEdit={() => void handleSaveEditDialog()} onOpenEditAvatarDialog={() => { setAvatarDialog({ diff --git a/frontend/features/admin/components/sections/admin-about.tsx b/frontend/features/admin/components/sections/admin-about.tsx index 3d2e8bae..fff346b9 100644 --- a/frontend/features/admin/components/sections/admin-about.tsx +++ b/frontend/features/admin/components/sections/admin-about.tsx @@ -1,17 +1,208 @@ "use client"; +import { useSyncExternalStore, useState } from "react"; import { useTranslations } from "next-intl"; +import { CircleArrowUp, RefreshCw } from "lucide-react"; +import packageMeta from "@/package.json"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { AdminUpdateTooltipContent } from "@/features/admin/components/admin-update-tooltip-content"; +import { + compareReleaseVersions, + formatReleaseVersion, + getCachedLatestReleaseSnapshot, + getServerLatestReleaseSnapshot, + LATEST_RELEASE_ENDPOINT, + resolveAvailableRelease, + subscribeLatestReleaseChange, + type ReleaseInfo, + writeCachedLatestRelease, +} from "@/features/admin/model/update-check"; import { AboutSettingsContent } from "@/shared/components/about-settings-content"; +import { cn } from "@/lib/utils"; + +type GitHubRelease = { + tag_name?: string; + html_url?: string; +}; + +type UpdateDialogState = + | { type: "current" } + | { type: "available"; release: ReleaseInfo } + | { type: "failed" }; + +function AdminUpdateCheck() { + const t = useTranslations("adminUsers.aboutPage"); + const [checking, setChecking] = useState(false); + const [dialogState, setDialogState] = useState(null); + + async function handleCheckUpdate() { + if (checking) return; + + setChecking(true); + try { + const response = await fetch(LATEST_RELEASE_ENDPOINT, { + cache: "no-store", + headers: { Accept: "application/vnd.github+json" }, + }); + + if (!response.ok) { + throw new Error(`Release check failed with HTTP ${response.status}`); + } + + const release = (await response.json()) as GitHubRelease; + const latestVersion = release.tag_name?.trim(); + const releaseURL = release.html_url?.trim(); + + if (!latestVersion || !releaseURL) { + throw new Error("Latest release payload is incomplete"); + } + + const currentVersion = packageMeta.version; + const compareResult = compareReleaseVersions(currentVersion, latestVersion); + + if (compareResult === "available" || compareResult === "unknown") { + const release = { version: latestVersion, url: releaseURL }; + writeCachedLatestRelease(release); + setDialogState({ type: "available", release }); + return; + } + + writeCachedLatestRelease({ version: latestVersion, url: releaseURL }); + setDialogState({ type: "current" }); + } catch { + setDialogState({ type: "failed" }); + } finally { + setChecking(false); + } + } + + return ( + <> + + { + if (!open) setDialogState(null); + }} + onRetry={() => void handleCheckUpdate()} + /> + + ); +} + +function AdminAboutVersionBadge({ updateRelease }: { updateRelease: ReleaseInfo | null }) { + const t = useTranslations("adminUsers.aboutPage"); + const currentVersion = formatReleaseVersion(packageMeta.version); + + return ( + + {currentVersion} + {updateRelease ? ( + + ) : null} + + ); +} + +function UpdateResultDialog({ + state, + onOpenChange, + onRetry, +}: { + state: UpdateDialogState | null; + onOpenChange: (open: boolean) => void; + onRetry: () => void; +}) { + const t = useTranslations("adminUsers.aboutPage"); + const currentVersion = formatReleaseVersion(packageMeta.version); + const latestVersion = state?.type === "available" ? formatReleaseVersion(state.release.version) : ""; + + return ( + + + + + {state?.type === "available" + ? t("updateDialog.availableTitle") + : state?.type === "failed" + ? t("updateDialog.failedTitle") + : t("updateDialog.currentTitle")} + + + {state?.type === "available" + ? t("updateDialog.availableDescription", { current: currentVersion, latest: latestVersion }) + : state?.type === "failed" + ? t("updateDialog.failedDescription") + : t("updateDialog.currentDescription", { current: currentVersion })} + + + {state?.type === "available" ? ( +
+ {t("updateDialog.currentVersion")} + {currentVersion} + {t("updateDialog.latestVersion")} + {latestVersion} +
+ ) : null} + + {state?.type === "failed" ? ( + + ) : null} + + + + {state?.type === "available" ? ( + + ) : null} + +
+
+ ); +} export function AdminAboutPage() { const t = useTranslations("adminUsers.aboutPage"); + const cachedLatestRelease = useSyncExternalStore( + subscribeLatestReleaseChange, + getCachedLatestReleaseSnapshot, + getServerLatestReleaseSnapshot, + ); + const updateRelease = resolveAvailableRelease(packageMeta.version, cachedLatestRelease); return ( } + versionBadgeTooltip={} + versionActions={} labels={{ details: t("details"), official: t("official"), diff --git a/frontend/features/admin/components/sections/models/admin-models.tsx b/frontend/features/admin/components/sections/models/admin-models.tsx index b779b0af..9c38099e 100644 --- a/frontend/features/admin/components/sections/models/admin-models.tsx +++ b/frontend/features/admin/components/sections/models/admin-models.tsx @@ -366,6 +366,7 @@ export function AdminModelsPage() { onToggleStatus={(item, status) => void models.handleToggleStatus(item, status)} onDelete={models.setDeleteTarget} onSourceStatusChange={models.handleSourceStatusChange} + onSourceDeleteChange={models.handleSourceDeleteChange} /> models.setSourcesModel(null)} onRefreshModel={() => void models.loadModels(models.page, models.pageSize)} + onSourceStatusChange={models.handleSourceStatusChange} /> ) : null} diff --git a/frontend/features/admin/components/sections/models/model-sheet.tsx b/frontend/features/admin/components/sections/models/model-sheet.tsx index a5979463..ecbdb7ce 100644 --- a/frontend/features/admin/components/sections/models/model-sheet.tsx +++ b/frontend/features/admin/components/sections/models/model-sheet.tsx @@ -85,6 +85,7 @@ type FormState = { kinds: string[]; icon: string; capabilitiesJSON: string; + systemPrompt: string; status: AdminLLMStatus; description: string; }; @@ -117,6 +118,7 @@ function buildInitialState(target: AdminLLMModelDTO | null): FormState { kinds: [], icon: "", capabilitiesJSON: "", + systemPrompt: "", status: "active", description: "", }; @@ -129,6 +131,7 @@ function buildInitialState(target: AdminLLMModelDTO | null): FormState { kinds, icon: target.icon ?? "", capabilitiesJSON: normalizeCapabilitiesJSON(target.capabilitiesJSON), + systemPrompt: target.systemPrompt ?? "", status: target.status, description: target.description ?? "", }; @@ -285,6 +288,7 @@ export function ModelSheet({ open, mode, target, onClose, onSuccess }: ModelShee kindsJSON: kindsJson, icon: form.icon.trim() || undefined, capabilitiesJSON: normalizeCapabilitiesJSON(form.capabilitiesJSON) || undefined, + systemPrompt: form.systemPrompt.trim() || undefined, status: form.status, description: form.description.trim() || undefined, }); @@ -302,6 +306,7 @@ export function ModelSheet({ open, mode, target, onClose, onSuccess }: ModelShee kindsJSON: kindsJson, icon: form.icon.trim() || undefined, capabilitiesJSON: normalizeCapabilitiesJSON(form.capabilitiesJSON) || undefined, + systemPrompt: form.systemPrompt.trim(), status: form.status, description: form.description.trim() || undefined, }; @@ -507,6 +512,22 @@ export function ModelSheet({ open, mode, target, onClose, onSuccess }: ModelShee /> +
+ +