From 59008b8aef9b3d8409c39d5cdbf9b3cd2680a20f Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:46:48 +0800 Subject: [PATCH] fix: enhance branding configuration and public API endpoints for branding details --- Dockerfile | 20 -- README.md | 4 +- README.zh-CN.md | 6 +- backend/docs/docs.go | 148 ++++++++++++++ backend/docs/swagger.json | 148 ++++++++++++++ backend/docs/swagger.yaml | 96 +++++++++ backend/internal/infra/config/config.go | 47 ++++- backend/internal/infra/config/config_test.go | 37 ++++ backend/internal/transport/http/server.go | 3 + .../internal/transport/http/settings/dto.go | 40 ++++ .../transport/http/settings/handler.go | 74 +++++++ .../http/settings/handler_branding_test.go | 87 +++++++++ .../transport/http/settings/router.go | 7 + config.example.yaml | 12 ++ config.full.example.yaml | 12 ++ config.sqlite.example.yaml | 12 ++ docs/BRANDING.md | 182 +++++++----------- frontend/.env.example | 11 -- frontend/app/globals.css | 4 + frontend/app/layout.tsx | 42 ++-- frontend/app/manifest.ts | 42 ---- .../features/admin/components/admin-shell.tsx | 9 +- .../features/auth/components/login-page.tsx | 9 +- .../chat/components/message/message-bot.tsx | 8 +- .../chat/components/sections/chat-area.tsx | 8 +- .../chat/hooks/use-chat-message-submit.ts | 3 +- .../features/chat/model/chat-artifacts.ts | 5 +- .../components/navigation/app-sidebar.tsx | 5 +- .../components/app-settings-panel.tsx | 9 +- .../share/components/public-share-page.tsx | 17 +- frontend/i18n/app-i18n-provider.tsx | 12 +- frontend/i18n/messages.ts | 21 +- frontend/shared/api/branding.ts | 17 ++ frontend/shared/components/app-logo.tsx | 9 +- .../shared/components/powered-by-deeix.tsx | 15 ++ frontend/shared/config/branding-provider.tsx | 104 ++++++++++ frontend/shared/config/branding.ts | 31 +++ frontend/shared/lib/branding.ts | 43 ----- frontend/shared/lib/browser-notifications.ts | 7 +- 39 files changed, 1042 insertions(+), 324 deletions(-) create mode 100644 backend/internal/transport/http/settings/handler_branding_test.go delete mode 100644 frontend/app/manifest.ts create mode 100644 frontend/shared/api/branding.ts create mode 100644 frontend/shared/config/branding-provider.tsx create mode 100644 frontend/shared/config/branding.ts delete mode 100644 frontend/shared/lib/branding.ts diff --git a/Dockerfile b/Dockerfile index 4d1f035c..0cdb3837 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,26 +10,6 @@ ENV PATH=$PNPM_HOME:$PATH ARG NEXT_PUBLIC_API_BASE_URL="" ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} -ARG NEXT_PUBLIC_LOGO_URL="" -ARG NEXT_PUBLIC_FAVICON_URL="" -ARG NEXT_PUBLIC_PWA_ICON_192_URL="" -ARG NEXT_PUBLIC_PWA_ICON_512_URL="" -ARG NEXT_PUBLIC_PWA_MASKABLE_ICON_512_URL="" -ARG NEXT_PUBLIC_APPLE_TOUCH_ICON_180_URL="" -ARG NEXT_PUBLIC_BRAND_TITLE="" -ARG NEXT_PUBLIC_BRAND_SHORT_NAME="" -ARG NEXT_PUBLIC_BRAND_DESCRIPTION="" - -ENV NEXT_PUBLIC_LOGO_URL=${NEXT_PUBLIC_LOGO_URL} -ENV NEXT_PUBLIC_FAVICON_URL=${NEXT_PUBLIC_FAVICON_URL} -ENV NEXT_PUBLIC_PWA_ICON_192_URL=${NEXT_PUBLIC_PWA_ICON_192_URL} -ENV NEXT_PUBLIC_PWA_ICON_512_URL=${NEXT_PUBLIC_PWA_ICON_512_URL} -ENV NEXT_PUBLIC_PWA_MASKABLE_ICON_512_URL=${NEXT_PUBLIC_PWA_MASKABLE_ICON_512_URL} -ENV NEXT_PUBLIC_APPLE_TOUCH_ICON_180_URL=${NEXT_PUBLIC_APPLE_TOUCH_ICON_180_URL} -ENV NEXT_PUBLIC_BRAND_TITLE=${NEXT_PUBLIC_BRAND_TITLE} -ENV NEXT_PUBLIC_BRAND_SHORT_NAME=${NEXT_PUBLIC_BRAND_SHORT_NAME} -ENV NEXT_PUBLIC_BRAND_DESCRIPTION=${NEXT_PUBLIC_BRAND_DESCRIPTION} - COPY VERSION /src/VERSION COPY scripts /src/scripts COPY frontend/package.json frontend/pnpm-lock.yaml ./ diff --git a/README.md b/README.md index b393ae4d..c5631659 100644 --- a/README.md +++ b/README.md @@ -311,10 +311,12 @@ If a superadmin already exists, the service does not regenerate or print the ini > Full configuration guide: [Configuration](https://deeix.com/docs/deeix-chat/configuration). -Backend configuration is split into static runtime configuration and runtime business settings. Static runtime configuration describes the infrastructure, security, and storage parameters required to start the service, and is provided through `config.yaml` and environment variables. Runtime business settings cover product capabilities such as authentication, conversations, models, files, and billing; they are stored in `system_settings` and maintained from the admin console. Environment variables override matching config-file values, which is useful for containerized deployments, separated deployments, and secret injection. +Backend configuration is split into static runtime configuration and runtime business settings. Static runtime configuration describes branding and the infrastructure, security, and storage parameters required to start the service, and is provided through `config.yaml` and environment variables. Runtime business settings cover product capabilities such as authentication, conversations, models, files, and billing; they are stored in `system_settings` and maintained from the admin console. Environment variables override matching config-file values, which is useful for containerized deployments, separated deployments, and secret injection. At startup, the backend resolves the default config file from the working directory: starting from the repository root reads `config.yaml`, while starting from `backend/` reads `../config.yaml`. Docker deployments usually mount host `./config.yaml` as read-only `/app/config.yaml` inside the container. If the config file is stored elsewhere, set `CONFIG_FILE` to a path accessible from the running process or container. +Frontend branding is also runtime configuration. Set the `branding` section in `config.yaml`, then restart the application; rebuilding the frontend or Docker image is not required. See [Custom branding](docs/BRANDING.md). + Static configuration environment variables: | Area | Environment variable | Purpose | diff --git a/README.zh-CN.md b/README.zh-CN.md index 7bbae623..8a13562f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -208,7 +208,7 @@ docker compose -f docker-compose.full.yml up -d #### 配置、持久化和镜像 -配置优先级是:`环境变量 > config.yaml > 代码内置默认值`。`config.yaml` 只负责静态基础设施和安全配置,例如服务地址、数据库、缓存、存储、GeoIP、Trace、JWT 和加密密钥。运行时业务配置存储在数据库中,并通过后台管理修改。 +配置优先级是:`环境变量 > config.yaml > 代码内置默认值`。`config.yaml` 负责品牌和静态基础设施、安全配置,例如品牌资源、服务地址、数据库、缓存、存储、GeoIP、Trace、JWT 和加密密钥。运行时业务配置存储在数据库中,并通过后台管理修改。 默认 compose 会持久化应用数据: @@ -311,10 +311,12 @@ docker compose logs app > 完整配置说明:[配置说明](https://deeix.com/zh/docs/deeix-chat/configuration)。 -后端配置分为静态运行配置和运行时业务配置。静态运行配置用于描述服务启动所需的基础设施、安全和存储参数,由 `config.yaml` 与环境变量提供;运行时业务配置用于认证、会话、模型、文件、计费等产品能力,写入 `system_settings` 并通过后台管理维护。环境变量会覆盖配置文件中的同名项,适合容器化、分离部署和密钥注入场景。 +后端配置分为静态运行配置和运行时业务配置。静态运行配置用于描述品牌以及服务启动所需的基础设施、安全和存储参数,由 `config.yaml` 与环境变量提供;运行时业务配置用于认证、会话、模型、文件、计费等产品能力,写入 `system_settings` 并通过后台管理维护。环境变量会覆盖配置文件中的同名项,适合容器化、分离部署和密钥注入场景。 后端启动时会按运行目录解析默认配置文件:从仓库根目录启动读取 `config.yaml`,从 `backend/` 目录启动读取 `../config.yaml`。Docker 部署通常将宿主机 `./config.yaml` 只读挂载到容器内 `/app/config.yaml`;如果配置文件放在其他位置,请使用 `CONFIG_FILE` 指向实际运行环境可访问的路径。 +前端品牌同样属于运行时配置。在 `config.yaml` 中设置 `branding` 后重启应用即可生效,无需重新构建前端或 Docker 镜像。详见[自定义品牌资源](docs/BRANDING.md)。 + 静态配置环境变量: | 所属域 | 环境变量 | 说明 | diff --git a/backend/docs/docs.go b/backend/docs/docs.go index a97003e7..7a4fb371 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -6625,6 +6625,44 @@ const docTemplate = `{ } } }, + "/branding": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "查询公开品牌配置", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_settings.BrandingResponseDoc" + } + } + } + } + }, + "/branding/manifest.webmanifest": { + "get": { + "produces": [ + "application/manifest+json" + ], + "tags": [ + "settings" + ], + "summary": "查询品牌 Web App Manifest", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_settings.BrandingManifestResponse" + } + } + } + } + }, "/context-artifacts/{id}": { "get": { "security": [ @@ -17681,6 +17719,116 @@ const docTemplate = `{ } } }, + "internal_transport_http_settings.BrandingManifestIcon": { + "type": "object", + "properties": { + "purpose": { + "type": "string" + }, + "sizes": { + "type": "string" + }, + "src": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "internal_transport_http_settings.BrandingManifestResponse": { + "type": "object", + "properties": { + "background_color": { + "type": "string" + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "display": { + "type": "string" + }, + "icons": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_transport_http_settings.BrandingManifestIcon" + } + }, + "id": { + "type": "string" + }, + "lang": { + "type": "string" + }, + "name": { + "type": "string" + }, + "orientation": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "short_name": { + "type": "string" + }, + "start_url": { + "type": "string" + }, + "theme_color": { + "type": "string" + } + } + }, + "internal_transport_http_settings.BrandingResponse": { + "type": "object", + "properties": { + "appleTouchIcon180URL": { + "type": "string" + }, + "description": { + "type": "string" + }, + "faviconURL": { + "type": "string" + }, + "logoURL": { + "type": "string" + }, + "pwaIcon192URL": { + "type": "string" + }, + "pwaIcon512URL": { + "type": "string" + }, + "pwaMaskableIcon512URL": { + "type": "string" + }, + "shortName": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "internal_transport_http_settings.BrandingResponseDoc": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/internal_transport_http_settings.BrandingResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, "internal_transport_http_settings.PatchItem": { "type": "object", "required": [ diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index ddc283c5..f430483f 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -6618,6 +6618,44 @@ } } }, + "/branding": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "查询公开品牌配置", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_settings.BrandingResponseDoc" + } + } + } + } + }, + "/branding/manifest.webmanifest": { + "get": { + "produces": [ + "application/manifest+json" + ], + "tags": [ + "settings" + ], + "summary": "查询品牌 Web App Manifest", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_settings.BrandingManifestResponse" + } + } + } + } + }, "/context-artifacts/{id}": { "get": { "security": [ @@ -17674,6 +17712,116 @@ } } }, + "internal_transport_http_settings.BrandingManifestIcon": { + "type": "object", + "properties": { + "purpose": { + "type": "string" + }, + "sizes": { + "type": "string" + }, + "src": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "internal_transport_http_settings.BrandingManifestResponse": { + "type": "object", + "properties": { + "background_color": { + "type": "string" + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "display": { + "type": "string" + }, + "icons": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_transport_http_settings.BrandingManifestIcon" + } + }, + "id": { + "type": "string" + }, + "lang": { + "type": "string" + }, + "name": { + "type": "string" + }, + "orientation": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "short_name": { + "type": "string" + }, + "start_url": { + "type": "string" + }, + "theme_color": { + "type": "string" + } + } + }, + "internal_transport_http_settings.BrandingResponse": { + "type": "object", + "properties": { + "appleTouchIcon180URL": { + "type": "string" + }, + "description": { + "type": "string" + }, + "faviconURL": { + "type": "string" + }, + "logoURL": { + "type": "string" + }, + "pwaIcon192URL": { + "type": "string" + }, + "pwaIcon512URL": { + "type": "string" + }, + "pwaMaskableIcon512URL": { + "type": "string" + }, + "shortName": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "internal_transport_http_settings.BrandingResponseDoc": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/internal_transport_http_settings.BrandingResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, "internal_transport_http_settings.PatchItem": { "type": "object", "required": [ diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 3ced5365..67ce60f8 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -5000,6 +5000,78 @@ definitions: - title - trigger type: object + internal_transport_http_settings.BrandingManifestIcon: + properties: + purpose: + type: string + sizes: + type: string + src: + type: string + type: + type: string + type: object + internal_transport_http_settings.BrandingManifestResponse: + properties: + background_color: + type: string + categories: + items: + type: string + type: array + description: + type: string + display: + type: string + icons: + items: + $ref: '#/definitions/internal_transport_http_settings.BrandingManifestIcon' + type: array + id: + type: string + lang: + type: string + name: + type: string + orientation: + type: string + scope: + type: string + short_name: + type: string + start_url: + type: string + theme_color: + type: string + type: object + internal_transport_http_settings.BrandingResponse: + properties: + appleTouchIcon180URL: + type: string + description: + type: string + faviconURL: + type: string + logoURL: + type: string + pwaIcon192URL: + type: string + pwaIcon512URL: + type: string + pwaMaskableIcon512URL: + type: string + shortName: + type: string + title: + type: string + type: object + internal_transport_http_settings.BrandingResponseDoc: + properties: + data: + $ref: '#/definitions/internal_transport_http_settings.BrandingResponse' + errorMsg: + type: string + type: object internal_transport_http_settings.PatchItem: properties: clear: @@ -9397,6 +9469,30 @@ paths: summary: 查询月度用量 tags: - billing + /branding: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_transport_http_settings.BrandingResponseDoc' + summary: 查询公开品牌配置 + tags: + - settings + /branding/manifest.webmanifest: + get: + produces: + - application/manifest+json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_transport_http_settings.BrandingManifestResponse' + summary: 查询品牌 Web App Manifest + tags: + - settings /context-artifacts/{id}: get: consumes: diff --git a/backend/internal/infra/config/config.go b/backend/internal/infra/config/config.go index b38c2406..cfd9b90a 100644 --- a/backend/internal/infra/config/config.go +++ b/backend/internal/infra/config/config.go @@ -14,6 +14,15 @@ import ( ) const ( + defaultAppName = "DEEIX Chat" + defaultBrandTitle = "DEEIX Chat" + defaultBrandShortName = "DEEIX" + defaultBrandDescription = "DEEIX Chat is a multi-model AI conversation system." + defaultBrandFaviconURL = "/favicon.ico" + defaultBrandPWAIcon192URL = "/pwa/icon-192.png" + defaultBrandPWAIcon512URL = "/pwa/icon-512.png" + defaultBrandPWAMaskableIcon512URL = "/pwa/icon-maskable-512.png" + defaultBrandAppleTouchIcon180URL = "/pwa/apple-touch-icon.png" defaultJWTSecret = "deeix-chat-dev-secret" defaultDataEncryptionKey = "deeix-chat-dev-data-encryption-key" defaultAdminUsername = "admin" @@ -182,6 +191,17 @@ type yamlConfig struct { Name string `yaml:"name"` Env string `yaml:"env"` } `yaml:"app"` + Branding struct { + Title string `yaml:"title"` + ShortName string `yaml:"short_name"` + Description string `yaml:"description"` + LogoURL string `yaml:"logo_url"` + FaviconURL string `yaml:"favicon_url"` + PWAIcon192URL string `yaml:"pwa_icon_192_url"` + PWAIcon512URL string `yaml:"pwa_icon_512_url"` + PWAMaskableIcon512URL string `yaml:"pwa_maskable_icon_512_url"` + AppleTouchIcon180URL string `yaml:"apple_touch_icon_180_url"` + } `yaml:"branding"` Server struct { HTTPPort string `yaml:"http_port"` CORSAllowOrigin string `yaml:"cors_allow_origin"` @@ -274,6 +294,15 @@ type Config struct { // ── 静态配置(YAML/ENV) ── AppName string Env string + BrandTitle string + BrandShortName string + BrandDescription string + BrandLogoURL string + BrandFaviconURL string + BrandPWAIcon192URL string + BrandPWAIcon512URL string + BrandPWAMaskableIcon512URL string + BrandAppleTouchIcon180URL string HTTPPort string CORSAllowOrigin string TrustedProxies string @@ -496,8 +525,17 @@ func Load() Config { yc := loadYAML() return Config{ // 静态基础设施 - AppName: envOr("APP_NAME", yc.App.Name, "DEEIX Chat"), + AppName: envOr("APP_NAME", yc.App.Name, defaultAppName), Env: normalizeEnv(envOrNonEmpty("APP_ENV", yc.App.Env, "prod")), + BrandTitle: valueOrDefault(yc.Branding.Title, defaultBrandTitle), + BrandShortName: valueOrDefault(yc.Branding.ShortName, defaultBrandShortName), + BrandDescription: valueOrDefault(yc.Branding.Description, defaultBrandDescription), + BrandLogoURL: strings.TrimSpace(yc.Branding.LogoURL), + BrandFaviconURL: valueOrDefault(yc.Branding.FaviconURL, defaultBrandFaviconURL), + BrandPWAIcon192URL: valueOrDefault(yc.Branding.PWAIcon192URL, defaultBrandPWAIcon192URL), + BrandPWAIcon512URL: valueOrDefault(yc.Branding.PWAIcon512URL, defaultBrandPWAIcon512URL), + BrandPWAMaskableIcon512URL: valueOrDefault(yc.Branding.PWAMaskableIcon512URL, defaultBrandPWAMaskableIcon512URL), + BrandAppleTouchIcon180URL: valueOrDefault(yc.Branding.AppleTouchIcon180URL, defaultBrandAppleTouchIcon180URL), HTTPPort: envOr("HTTP_PORT", yc.Server.HTTPPort, "8080"), CORSAllowOrigin: envOr("CORS_ALLOW_ORIGIN", yc.Server.CORSAllowOrigin, "http://127.0.0.1:8080,http://localhost:8080"), TrustedProxies: envOr("TRUSTED_PROXIES", yc.Server.TrustedProxies, ""), @@ -843,6 +881,13 @@ func envOr(envKey string, yamlVal string, defaultVal string) string { return defaultVal } +func valueOrDefault(value string, defaultValue string) string { + if normalized := strings.TrimSpace(value); normalized != "" { + return normalized + } + return defaultValue +} + func envOrNonEmpty(envKey string, yamlVal string, defaultVal string) string { if v, ok := os.LookupEnv(envKey); ok && strings.TrimSpace(v) != "" { return v diff --git a/backend/internal/infra/config/config_test.go b/backend/internal/infra/config/config_test.go index 881507e7..a3a4116c 100644 --- a/backend/internal/infra/config/config_test.go +++ b/backend/internal/infra/config/config_test.go @@ -170,6 +170,43 @@ security: } } +func TestLoadReadsBrandingFromConfig(t *testing.T) { + cleanupConfigEnv(t) + + configPath := filepath.Join(t.TempDir(), "config.yaml") + configBody := []byte(` +app: + name: Example Backend +branding: + title: Example Chat + short_name: Example + description: Example description + logo_url: https://cdn.example.com/logo.svg + favicon_url: https://cdn.example.com/favicon.ico + pwa_icon_192_url: https://cdn.example.com/icon-192.png + pwa_icon_512_url: https://cdn.example.com/icon-512.png + pwa_maskable_icon_512_url: https://cdn.example.com/icon-maskable.png + apple_touch_icon_180_url: https://cdn.example.com/apple-touch-icon.png +`) + if err := os.WriteFile(configPath, configBody, 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + t.Setenv("CONFIG_FILE", configPath) + + cfg := Load() + if cfg.AppName != "Example Backend" || cfg.BrandTitle != "Example Chat" || cfg.BrandShortName != "Example" || cfg.BrandDescription != "Example description" { + t.Fatalf("unexpected branding text: %+v", cfg) + } + if cfg.BrandLogoURL != "https://cdn.example.com/logo.svg" || + cfg.BrandFaviconURL != "https://cdn.example.com/favicon.ico" || + cfg.BrandPWAIcon192URL != "https://cdn.example.com/icon-192.png" || + cfg.BrandPWAIcon512URL != "https://cdn.example.com/icon-512.png" || + cfg.BrandPWAMaskableIcon512URL != "https://cdn.example.com/icon-maskable.png" || + cfg.BrandAppleTouchIcon180URL != "https://cdn.example.com/apple-touch-icon.png" { + t.Fatalf("unexpected branding assets: %+v", cfg) + } +} + func TestValidateAllowsOnlyDevAndProdEnvironment(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/transport/http/server.go b/backend/internal/transport/http/server.go index fd135744..b9de73b7 100644 --- a/backend/internal/transport/http/server.go +++ b/backend/internal/transport/http/server.go @@ -208,6 +208,9 @@ func NewEngine(cfg *config.Runtime, log *zap.Logger, modules Modules, hc HealthC if modules.StartupLog != nil { modules.StartupLog(log) } + if modules.Settings != nil { + modules.Settings.RegisterFrontendRoutes(engine) + } registerFrontendStatic(engine, snapshot.FrontendDistDir, log) return engine, nil diff --git a/backend/internal/transport/http/settings/dto.go b/backend/internal/transport/http/settings/dto.go index 4abcb0ca..7114655a 100644 --- a/backend/internal/transport/http/settings/dto.go +++ b/backend/internal/transport/http/settings/dto.go @@ -38,6 +38,46 @@ type LoginPageSettingsResponse struct { DefaultNextPath string `json:"defaultNextPath"` } +type BrandingResponse struct { + Title string `json:"title"` + ShortName string `json:"shortName"` + Description string `json:"description"` + LogoURL string `json:"logoURL"` + FaviconURL string `json:"faviconURL"` + PWAIcon192URL string `json:"pwaIcon192URL"` + PWAIcon512URL string `json:"pwaIcon512URL"` + PWAMaskableIcon512URL string `json:"pwaMaskableIcon512URL"` + AppleTouchIcon180URL string `json:"appleTouchIcon180URL"` +} + +type BrandingResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data BrandingResponse `json:"data"` +} + +type BrandingManifestResponse struct { + Name string `json:"name"` + ShortName string `json:"short_name"` + Description string `json:"description"` + ID string `json:"id"` + StartURL string `json:"start_url"` + Scope string `json:"scope"` + Display string `json:"display"` + BackgroundColor string `json:"background_color"` + ThemeColor string `json:"theme_color"` + Orientation string `json:"orientation"` + Categories []string `json:"categories"` + Lang string `json:"lang"` + Icons []BrandingManifestIcon `json:"icons"` +} + +type BrandingManifestIcon struct { + Src string `json:"src"` + Sizes string `json:"sizes"` + Type string `json:"type"` + Purpose string `json:"purpose"` +} + type ModelOptionPolicyResponse struct { Mode string `json:"mode"` AllowedPathsJSON string `json:"allowedPathsJSON"` diff --git a/backend/internal/transport/http/settings/handler.go b/backend/internal/transport/http/settings/handler.go index 465aade9..6217657a 100644 --- a/backend/internal/transport/http/settings/handler.go +++ b/backend/internal/transport/http/settings/handler.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "net/url" "strings" "time" @@ -124,6 +125,79 @@ func (h *Handler) GetLoginPageSettings(c *gin.Context) { }) } +// GetBranding godoc +// @Summary 查询公开品牌配置 +// @Tags settings +// @Produce json +// @Success 200 {object} BrandingResponseDoc +// @Router /branding [get] +func (h *Handler) GetBranding(c *gin.Context) { + c.Header("Cache-Control", "no-cache") + response.Success(c, brandingResponse(h.runtime.Snapshot())) +} + +// GetBrandingManifest godoc +// @Summary 查询品牌 Web App Manifest +// @Tags settings +// @Produce application/manifest+json +// @Success 200 {object} BrandingManifestResponse +// @Router /branding/manifest.webmanifest [get] +func (h *Handler) GetBrandingManifest(c *gin.Context) { + cfg := h.runtime.Snapshot() + branding := brandingResponse(cfg) + c.Header("Cache-Control", "no-cache") + c.Header("Content-Type", "application/manifest+json; charset=utf-8") + c.JSON(http.StatusOK, BrandingManifestResponse{ + Name: branding.Title, + ShortName: branding.ShortName, + Description: branding.Description, + ID: publicBrandURL(cfg.PublicWebBaseURL, "/"), + StartURL: publicBrandURL(cfg.PublicWebBaseURL, "/chat"), + Scope: publicBrandURL(cfg.PublicWebBaseURL, "/"), + Display: "standalone", + BackgroundColor: "#ffffff", + ThemeColor: "#0f172a", + Orientation: "any", + Categories: []string{"productivity", "business", "utilities"}, + Lang: "en", + Icons: []BrandingManifestIcon{ + {Src: publicBrandURL(cfg.PublicWebBaseURL, branding.PWAIcon192URL), Sizes: "192x192", Type: "image/png", Purpose: "any"}, + {Src: publicBrandURL(cfg.PublicWebBaseURL, branding.PWAIcon512URL), Sizes: "512x512", Type: "image/png", Purpose: "any"}, + {Src: publicBrandURL(cfg.PublicWebBaseURL, branding.PWAMaskableIcon512URL), Sizes: "512x512", Type: "image/png", Purpose: "maskable"}, + }, + }) +} + +func publicBrandURL(baseURL string, value string) string { + normalized := strings.TrimSpace(value) + if normalized == "" { + return normalized + } + reference, err := url.Parse(normalized) + if err != nil || reference.IsAbs() { + return normalized + } + base, err := url.Parse(strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/") + if err != nil || base.Host == "" { + return normalized + } + return base.ResolveReference(reference).String() +} + +func brandingResponse(cfg config.Config) BrandingResponse { + return BrandingResponse{ + Title: cfg.BrandTitle, + ShortName: cfg.BrandShortName, + Description: cfg.BrandDescription, + LogoURL: cfg.BrandLogoURL, + FaviconURL: cfg.BrandFaviconURL, + PWAIcon192URL: cfg.BrandPWAIcon192URL, + PWAIcon512URL: cfg.BrandPWAIcon512URL, + PWAMaskableIcon512URL: cfg.BrandPWAMaskableIcon512URL, + AppleTouchIcon180URL: cfg.BrandAppleTouchIcon180URL, + } +} + // GetModelOptionPolicy godoc // @Summary 查询模型 options 透传策略 // @Tags settings diff --git a/backend/internal/transport/http/settings/handler_branding_test.go b/backend/internal/transport/http/settings/handler_branding_test.go new file mode 100644 index 00000000..2c772aee --- /dev/null +++ b/backend/internal/transport/http/settings/handler_branding_test.go @@ -0,0 +1,87 @@ +package settings + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" + "github.com/gin-gonic/gin" +) + +func TestGetBrandingReturnsRuntimeConfig(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := &Handler{runtime: config.NewRuntime(config.Config{ + AppName: "Example Backend", + BrandTitle: "Example Chat", + BrandShortName: "Example", + BrandDescription: "Example description", + BrandLogoURL: "https://cdn.example.com/logo.svg", + BrandFaviconURL: "https://cdn.example.com/favicon.ico", + BrandPWAIcon192URL: "https://cdn.example.com/icon-192.png", + BrandPWAIcon512URL: "https://cdn.example.com/icon-512.png", + BrandPWAMaskableIcon512URL: "https://cdn.example.com/icon-maskable.png", + BrandAppleTouchIcon180URL: "https://cdn.example.com/apple-touch-icon.png", + })} + router := gin.New() + router.GET("/branding", handler.GetBranding) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/branding", nil)) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", recorder.Code) + } + if got := recorder.Header().Get("Cache-Control"); got != "no-cache" { + t.Fatalf("unexpected cache policy: %q", got) + } + var body struct { + Data BrandingResponse `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Data.Title != "Example Chat" || body.Data.LogoURL != "https://cdn.example.com/logo.svg" { + t.Fatalf("unexpected branding response: %+v", body.Data) + } +} + +func TestGetBrandingManifestUsesBrandingIcons(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := &Handler{runtime: config.NewRuntime(config.Config{ + AppName: "Example Backend", + BrandTitle: "Example Chat", + BrandShortName: "Example", + BrandDescription: "Example description", + PublicWebBaseURL: "https://chat.example.com/deeix", + BrandPWAIcon192URL: "/pwa/icon-192.png", + BrandPWAIcon512URL: "https://cdn.example.com/icon-512.png", + BrandPWAMaskableIcon512URL: "https://cdn.example.com/icon-maskable.png", + })} + router := gin.New() + NewModule(handler).RegisterFrontendRoutes(router) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manifest.webmanifest", nil)) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", recorder.Code) + } + if got := recorder.Header().Get("Content-Type"); got != "application/manifest+json; charset=utf-8" { + t.Fatalf("unexpected content type: %q", got) + } + var manifest BrandingManifestResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &manifest); err != nil { + t.Fatalf("decode manifest: %v", err) + } + if manifest.Name != "Example Chat" || len(manifest.Icons) != 3 { + t.Fatalf("unexpected manifest: %+v", manifest) + } + if manifest.StartURL != "https://chat.example.com/chat" || manifest.Icons[0].Src != "https://chat.example.com/pwa/icon-192.png" { + t.Fatalf("unexpected public web URLs: %+v", manifest) + } + if manifest.Icons[2].Purpose != "maskable" || manifest.Icons[2].Src != "https://cdn.example.com/icon-maskable.png" { + t.Fatalf("unexpected maskable icon: %+v", manifest.Icons[2]) + } +} diff --git a/backend/internal/transport/http/settings/router.go b/backend/internal/transport/http/settings/router.go index 50ed5df7..4c7a2b32 100644 --- a/backend/internal/transport/http/settings/router.go +++ b/backend/internal/transport/http/settings/router.go @@ -3,9 +3,16 @@ package settings import "github.com/gin-gonic/gin" func (m *Module) RegisterPublicRoutes(api *gin.RouterGroup) { + api.GET("/branding", m.Handler.GetBranding) + api.GET("/branding/manifest.webmanifest", m.Handler.GetBrandingManifest) api.GET("/settings/login-page", m.Handler.GetLoginPageSettings) } +// RegisterFrontendRoutes 注册由前端直接发现的公开资源路由。 +func (m *Module) RegisterFrontendRoutes(engine *gin.Engine) { + engine.GET("/manifest.webmanifest", m.Handler.GetBrandingManifest) +} + func (m *Module) RegisterRoutes(api *gin.RouterGroup) { api.GET("/settings/model-option-policy", m.Handler.GetModelOptionPolicy) api.GET("/settings/mcp-policy", m.Handler.GetMCPPolicy) diff --git a/config.example.yaml b/config.example.yaml index 0f6ed50f..a1159eb4 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -26,6 +26,18 @@ security: # Optional. Override only when using a compatible Turnstile verification proxy. turnstile_siteverify_url: "https://challenges.cloudflare.com/turnstile/v0/siteverify" +branding: + # Optional. Frontend product branding; changes take effect after restarting the app. + title: DEEIX Chat + short_name: DEEIX + description: DEEIX Chat is a multi-model AI conversation system. + logo_url: "" + favicon_url: /favicon.ico + pwa_icon_192_url: /pwa/icon-192.png + pwa_icon_512_url: /pwa/icon-512.png + pwa_maskable_icon_512_url: /pwa/icon-maskable-512.png + apple_touch_icon_180_url: /pwa/apple-touch-icon.png + database: # Required. PostgreSQL is used for this profile. driver: postgres diff --git a/config.full.example.yaml b/config.full.example.yaml index fab2f7c7..e92218dd 100644 --- a/config.full.example.yaml +++ b/config.full.example.yaml @@ -31,6 +31,18 @@ security: # Optional. Override only when using a compatible Turnstile verification proxy. turnstile_siteverify_url: "https://challenges.cloudflare.com/turnstile/v0/siteverify" +branding: + # Optional. Frontend product branding; changes take effect after restarting the app. + title: DEEIX Chat + short_name: DEEIX + description: DEEIX Chat is a multi-model AI conversation system. + logo_url: "" + favicon_url: /favicon.ico + pwa_icon_192_url: /pwa/icon-192.png + pwa_icon_512_url: /pwa/icon-512.png + pwa_maskable_icon_512_url: /pwa/icon-maskable-512.png + apple_touch_icon_180_url: /pwa/apple-touch-icon.png + database: # Required. PostgreSQL is used for this profile. driver: postgres diff --git a/config.sqlite.example.yaml b/config.sqlite.example.yaml index c3102b08..0c49d0be 100644 --- a/config.sqlite.example.yaml +++ b/config.sqlite.example.yaml @@ -25,6 +25,18 @@ security: # Optional. Override only when using a compatible Turnstile verification proxy. turnstile_siteverify_url: "https://challenges.cloudflare.com/turnstile/v0/siteverify" +branding: + # Optional. Frontend product branding; changes take effect after restarting the app. + title: DEEIX Chat + short_name: DEEIX + description: DEEIX Chat is a multi-model AI conversation system. + logo_url: "" + favicon_url: /favicon.ico + pwa_icon_192_url: /pwa/icon-192.png + pwa_icon_512_url: /pwa/icon-512.png + pwa_maskable_icon_512_url: /pwa/icon-maskable-512.png + apple_touch_icon_180_url: /pwa/apple-touch-icon.png + database: # Required. SQLite is intended for single-node deployments. driver: sqlite diff --git a/docs/BRANDING.md b/docs/BRANDING.md index 8c4ee140..c90283f2 100644 --- a/docs/BRANDING.md +++ b/docs/BRANDING.md @@ -2,150 +2,104 @@ > **许可与商标说明** > -> DEEIX Chat Copyright 2026 DEEIX。源代码依据 [Apache License 2.0](../LICENSE) 授权,品牌配置不会移除或替换仓库及分发产物中的 [NOTICE](../NOTICE)、许可证副本或版权声明。重新分发本项目或其衍生作品时,须遵守 Apache License 2.0 并保留适用的归因信息。Apache License 2.0 不授予 DEEIX 名称、Logo 或其他商标的使用权,但合理描述作品来源及复制 NOTICE 内容的情形除外。 +> DEEIX Chat Copyright 2026 DEEIX。源代码依据 [Apache License 2.0](../LICENSE) 授权。品牌配置不会移除或替换仓库及分发产物中的 [NOTICE](../NOTICE)、许可证副本或版权声明。Apache License 2.0 不授予 DEEIX 名称、Logo 或其他商标的使用权。 -DEEIX Chat 的标题、HTML Meta Description、LOGO、浏览器图标和 PWA 图标通过前端构建环境变量配置。配置值会在 `pnpm build` 或 Docker 镜像构建时写入静态产物;修改运行中容器的环境变量不会更新已经构建好的页面。 +DEEIX Chat 的产品标题、HTML Meta Description、Logo、浏览器图标和 PWA 图标由根目录 `config.yaml` 统一配置。后端启动时读取配置并通过公开品牌接口提供给静态前端,因此修改品牌不需要重新构建前端或 Docker 镜像,只需更新配置并重启应用。 -所有变量都可独立配置。未配置或只包含空白字符时,对应位置继续使用仓库内置资源。 +未配置或配置为空时,对应位置使用仓库内置品牌资源。品牌接口不可用时,前端也会保留内置品牌,不阻塞页面加载。 -品牌配置只影响明确允许定制的产品展示区域。用户端和管理端的“关于”页面始终展示 DEEIX 官方 Logo、产品介绍、版权、官网、仓库和许可证信息,不读取任何品牌环境变量。批量文案替换不会处理管理端专用文案。公开分享页和聊天截图可以展示自定义品牌;配置自定义 Logo 时,同时会显示固定的 `Powered by DEEIX` 来源标识。 +品牌配置只影响允许定制的产品展示区域。用户端和管理端的“关于”页面始终展示 DEEIX 官方 Logo、产品介绍、版权、官网、仓库和许可证信息。配置自定义 Logo 时,公开分享页和聊天截图仍会显示固定的 `Powered by DEEIX` 来源标识。 -## 资源与环境变量 +## 配置项 -| 环境变量 | 使用位置 | 推荐格式与尺寸 | 内置资源 | +前端产品品牌统一使用 `branding`。`app.name` 仍是后端服务名称,不会因前端品牌定制而改变: + +```yaml +branding: + title: Example Chat + short_name: Example + description: Example Chat is a multi-model AI conversation system. + logo_url: https://example.com/logo.svg + favicon_url: https://example.com/favicon.ico + pwa_icon_192_url: https://example.com/icon-192.png + pwa_icon_512_url: https://example.com/icon-512.png + pwa_maskable_icon_512_url: https://example.com/icon-maskable-512.png + apple_touch_icon_180_url: https://example.com/apple-touch-icon-180.png +``` + +| YAML 配置 | 使用位置 | 推荐格式与尺寸 | 内置资源 | | --- | --- | --- | --- | -| `NEXT_PUBLIC_BRAND_TITLE` | HTML 标题、PWA 名称及前端默认产品名称 | 简短的纯文本产品名称 | `DEEIX Chat` | -| `NEXT_PUBLIC_BRAND_SHORT_NAME` | PWA 短名称、生成占位动画和 Artifact 标识 | 推荐不超过 12 个字符 | `DEEIX` | -| `NEXT_PUBLIC_BRAND_DESCRIPTION` | 仅 HTML `` 中的 `` | 简洁的纯文本页面摘要 | `DEEIX Chat is a multi-model AI conversation system.` | -| `NEXT_PUBLIC_LOGO_URL` | 登录页、侧边栏、移动端标题、公开分享页和聊天截图的产品品牌区域 | SVG 优先;PNG/WebP 至少 `1200 × 369`,推荐约 `3.25:1` | 浅色主题使用 `frontend/public/logo.svg`,深色主题使用 `frontend/public/logo-white.svg` | -| `NEXT_PUBLIC_FAVICON_URL` | 浏览器标签页、书签 | SVG 或包含 `16/32/48/64/128/256 px` 多尺寸的 ICO;也可使用至少 `64 × 64` 的 PNG | `frontend/public/favicon.ico` | -| `NEXT_PUBLIC_PWA_ICON_192_URL` | PWA manifest、浏览器通知 | `192 × 192` PNG | `frontend/public/pwa/icon-192.png` | -| `NEXT_PUBLIC_PWA_ICON_512_URL` | PWA manifest、高分辨率安装图标 | `512 × 512` PNG | `frontend/public/pwa/icon-512.png` | -| `NEXT_PUBLIC_PWA_MASKABLE_ICON_512_URL` | Android 等平台的自适应 PWA 图标 | `512 × 512` PNG,必须包含铺满画布的不透明背景 | `frontend/public/pwa/icon-maskable-512.png` | -| `NEXT_PUBLIC_APPLE_TOUCH_ICON_180_URL` | iOS/iPadOS 主屏幕图标 | `180 × 180` PNG,推荐使用铺满画布的不透明背景 | `frontend/public/pwa/apple-touch-icon.png` | +| `branding.title` | HTML 标题、PWA 名称及前端默认产品名称 | 简短的纯文本产品名称 | `DEEIX Chat` | +| `branding.short_name` | PWA 短名称、生成占位动画和 Artifact 标识 | 推荐不超过 12 个字符 | `DEEIX` | +| `branding.description` | HTML Meta Description 和 PWA description | 简洁的纯文本页面摘要 | `DEEIX Chat is a multi-model AI conversation system.` | +| `branding.logo_url` | 登录页、侧边栏、移动端标题、公开分享页和聊天截图 | SVG 优先;PNG/WebP 至少 `1200 x 369`,推荐约 `3.25:1` | 浅色使用 `frontend/public/logo.svg`,深色使用 `frontend/public/logo-white.svg` | +| `branding.favicon_url` | 浏览器标签页、书签 | SVG、ICO,或至少 `64 x 64` 的 PNG | `frontend/public/favicon.ico` | +| `branding.pwa_icon_192_url` | PWA manifest、浏览器通知 | `192 x 192` PNG | `frontend/public/pwa/icon-192.png` | +| `branding.pwa_icon_512_url` | PWA manifest、高分辨率安装图标 | `512 x 512` PNG | `frontend/public/pwa/icon-512.png` | +| `branding.pwa_maskable_icon_512_url` | Android 等平台的自适应 PWA 图标 | `512 x 512` PNG,背景铺满画布 | `frontend/public/pwa/icon-maskable-512.png` | +| `branding.apple_touch_icon_180_url` | iOS/iPadOS 主屏幕图标 | `180 x 180` PNG,背景铺满画布 | `frontend/public/pwa/apple-touch-icon.png` | -`NEXT_PUBLIC_LOGO_URL` 配置后,浅色与深色主题会使用同一张自定义图片。请确保它在白色背景和深色背景(可用 `#0f172a` 检查)上都有足够对比度。若使用单色字标,建议增加有对比度的底板、描边或选择同时适配两种背景的品牌色。 +品牌资源可以使用浏览器可访问的绝对 HTTPS URL,也可以使用当前前端站点下的根相对路径。前后端分离部署时,推荐使用绝对 HTTPS URL,并正确设置 `server.public_web_base_url`,确保 Web App Manifest 的启动地址、作用域和内置图标指向前端站点。 -`NEXT_PUBLIC_BRAND_DESCRIPTION` 只控制搜索引擎等读取的 HTML Meta Description。PWA manifest 的 description、用户端和管理端“关于”页描述不会被它覆盖。 +`branding.logo_url` 配置后,浅色与深色主题使用同一张自定义图片。请确保图片在白色和深色背景上都有足够对比度。 + +承载 Logo 的服务需要允许当前站点读取图片,否则聊天截图可能无法嵌入该图片。图标 URL 应长期公开可访问;更新资源时推荐使用带版本号的 URL,避免浏览器或已安装 PWA 继续使用旧缓存。 ## 不参与定制的 DEEIX 信息 -以下内容不受品牌环境变量影响: +以下内容不受品牌配置影响: - 用户端和管理端“关于”页面中的 DEEIX Logo、产品介绍、版权、官网、仓库、社交账号和许可证信息。 -- 配置自定义 Logo 时,公开分享页右下角和聊天截图顶部右侧的 `Powered by DEEIX` 来源标识,以及底部固定的 DEEIX Logo。 +- 自定义 Logo 场景下的 `Powered by DEEIX` 来源标识和固定 DEEIX Logo。 - 仓库及 Docker 镜像中的 `LICENSE` 和 `NOTICE` 文件。 -这些限制只描述仓库内置品牌配置的行为,不改变前述许可证授权与商标边界。 - -## LOGO 尺寸与留白 - -仓库当前字标可以作为横向 LOGO 的比例参考: +这些限制只描述仓库内置品牌配置的行为,不改变许可证授权与商标边界。 -- `frontend/public/logo.svg` 的画布约为 `2400 × 737`,宽高比约 `3.25:1`。 -- SVG 的可见图形约占画布宽度的 `98%`、高度的 `94%`,左右透明边约各 `1%`,上下透明边约各 `3%`。这是偏紧凑的下限,不建议裁切得更紧。 -- 页面实际显示高度主要在 `20–56 px` 之间,因此细线、小字号标语和复杂纹理在界面中容易丢失。 +## Logo 尺寸与留白 -自定义横向 LOGO 推荐遵循以下规则: +仓库字标画布约为 `2400 x 737`,宽高比约 `3.25:1`。自定义横向 Logo 推荐: -1. 优先提供带正确 `viewBox` 的 SVG;如果使用位图,推荐 `1200 × 369` 或更高的同等比例图片。 -2. 画布宽高比保持在 `3:1–3.5:1`,推荐 `3.25:1`。不要使用正方形应用图标代替横向字标。 -3. 可见图形距离左右边界至少保留画布宽度的 `2%`,距离上下边界至少保留画布高度的 `4%`;带阴影、外发光或描边时,推荐分别提高到 `4%` 和 `6%`。 -4. 所有留白应位于图片画布内部并保持透明,不要通过写死白色背景制造留白。 -5. 在 `20 px` 高度下检查辨识度,在 `56 px` 高度下检查边缘、透明区和视觉居中。 - -以 `1200 × 369` 画布为例,最低透明留白约为: - -- 左右各 `24 px`;带外部效果时推荐各 `48 px`。 -- 上下各 `15 px`;带外部效果时推荐各 `22 px`。 +1. 优先提供带正确 `viewBox` 的 SVG;位图推荐使用 `1200 x 369` 或更高的同等比例图片。 +2. 画布宽高比保持在 `3:1-3.5:1`,不要使用正方形应用图标替代横向字标。 +3. 可见图形距离左右边界至少保留画布宽度的 `2%`,距离上下边界至少保留画布高度的 `4%`。 +4. 留白应位于图片画布内部并保持透明,不要通过写死白色背景制造留白。 +5. 在 `20 px` 和 `56 px` 显示高度下分别检查辨识度、边缘和视觉居中。 ## 方形图标与安全区 -普通 PWA 图标、favicon 和 Apple Touch 图标应使用同一个方形品牌符号,不要直接缩小完整横向字标。 - -### 普通图标 - -仓库当前普通图标的可见符号占比可作为示例: - -- `icon-192.png` 的可见区域约为 `148 × 150`,四周留白约 `11%`。 -- `icon-512.png` 的可见区域约为 `392 × 400`,四周留白约 `11%`。 -- `apple-touch-icon.png` 的可见区域约为 `138 × 142`,四周留白约 `11%`。 -- `favicon.ico` 内含 `16/32/48/64/128/256 px` 六档图片;其中 `256 px` 图片的可见区域约为 `196 × 200`,四周同样保留约 `11%`。 +普通 PWA 图标、favicon 和 Apple Touch 图标应使用方形品牌符号,不要直接缩小完整横向字标。 -推荐让核心符号占画布宽、高的 `76%–80%`,即四边各保留约 `10%–12%` 的视觉留白: +- 普通图标建议让核心符号占画布宽高的 `76%-80%`,四边保留约 `10%-12%` 的视觉留白。 +- Maskable 图标必须使用 `512 x 512` PNG,背景延伸到画布四边。 +- Maskable 图标的关键内容必须位于画布中央、直径为画布 `80%` 的圆形安全区内。 +- 为兼容方形符号的四角,核心符号建议控制在约 `56% x 56%` 以内。 -| 画布 | 推荐核心符号宽、高 | 推荐单边留白 | -| --- | --- | --- | -| `192 × 192` | 各 `146–154 px` | `19–23 px` | -| `512 × 512` | 各 `389–410 px` | `51–62 px` | -| `180 × 180` | 各 `137–144 px` | `18–22 px` | +## 生效与检查 -视觉重心不对称时,可以在上述范围内做少量光学校正,但不要让任何关键笔画进入最外侧 `8%` 的区域。 +更新 `config.yaml` 后重启应用。静态页面水合时由根级品牌 Provider 请求一次 `/api/v1/branding`,品牌应用完成前页面不会显示,因此首个可见画面直接使用运行时品牌;接口异常时最多等待 3 秒便回退到内置品牌。Provider 在后续客户端路由切换中持续持有同一份标题、图标和文案,不会逐页请求或重新读取配置文件。 -### Maskable 图标 +### 同源部署 -Maskable 图标会被系统裁成圆形、圆角矩形或其他形状,必须为它单独导出资源: +Docker 镜像内的前端仍是 Next.js 静态导出文件,由同一个 Go 服务提供。品牌接口和 manifest 使用相对地址,浏览器不会产生跨域请求。后端同时在 `/manifest.webmanifest` 返回动态品牌 manifest,使使用旧根路径安装的 PWA 仍有机会按浏览器更新周期获取新配置。 -1. 使用 `512 × 512` PNG,背景色或背景图形必须延伸到画布四边,不允许透明边缘。 -2. 所有不可被裁掉的内容必须位于画布中央、直径为画布 `80%` 的圆形安全区内。 -3. 为兼容方形符号的四角,推荐把核心符号控制在约 `56% × 56%` 以内,即最大约 `287 × 287 px`。 -4. 仓库当前 `icon-maskable-512.png` 的核心符号约为 `274 × 280`,距离四边约 `23%`,可作为安全留白示例。 -5. 背景可以超出安全区并铺满画布,但文字、徽标主体和关键边缘不能依赖安全区之外的内容。 +### 分离部署 -## 配置示例 +前端可以继续部署为纯静态文件,后端独立部署。构建前端时通过 `NEXT_PUBLIC_API_BASE_URL` 指定后端公开地址;该变量只用于定位 API,不包含任何品牌值。品牌仍在部署时由后端 `config.yaml` 提供,修改品牌不需要重新构建前端。 -### 使用 HTTPS 资源 +分离部署还需要: -在 `frontend/.env.local` 中配置浏览器可访问的绝对 HTTPS URL,而不是文件系统路径: +- 将前端来源加入后端 `server.cors_allow_origin`。 +- 将 `server.public_web_base_url` 设置为前端公开地址,使 manifest 的 `id`、`start_url` 和 `scope` 指向前端站点。 +- 确保浏览器可访问 `${NEXT_PUBLIC_API_BASE_URL}/api/v1/branding` 和 `${NEXT_PUBLIC_API_BASE_URL}/api/v1/branding/manifest.webmanifest`。 +- 若要让旧 PWA 沿用前端域名下的 `/manifest.webmanifest` 原地更新,可在前端网关将该路径代理到后端同名路由;无法配置代理时,旧安装可能需要卸载后重新安装。 -```env -NEXT_PUBLIC_BRAND_TITLE="Example Chat" -NEXT_PUBLIC_BRAND_SHORT_NAME="Example" -NEXT_PUBLIC_BRAND_DESCRIPTION="Example Chat is a multi-model AI conversation system." -NEXT_PUBLIC_LOGO_URL=https://example.com/logo.png -NEXT_PUBLIC_FAVICON_URL=https://example.com/favicon.ico -NEXT_PUBLIC_PWA_ICON_192_URL=https://example.com/icon-192.png -NEXT_PUBLIC_PWA_ICON_512_URL=https://example.com/icon-512.png -NEXT_PUBLIC_PWA_MASKABLE_ICON_512_URL=https://example.com/icon-maskable-512.png -NEXT_PUBLIC_APPLE_TOUCH_ICON_180_URL=https://example.com/apple-touch-icon-180.png -``` - -承载 LOGO 的服务应返回允许当前站点读取图片的 CORS 响应头,否则聊天截图可能无法嵌入该图片。PWA 图标 URL 也应长期保持可公开访问。 - -### Docker 构建 - -Docker 镜像中的前端也是构建期配置,可直接传入绝对 HTTPS URL: - -```bash -docker build \ - --build-arg NEXT_PUBLIC_BRAND_TITLE="Example Chat" \ - --build-arg NEXT_PUBLIC_BRAND_SHORT_NAME="Example" \ - --build-arg NEXT_PUBLIC_BRAND_DESCRIPTION="Example Chat is a multi-model AI conversation system." \ - --build-arg NEXT_PUBLIC_LOGO_URL=https://example.com/logo.png \ - --build-arg NEXT_PUBLIC_FAVICON_URL=https://example.com/favicon.ico \ - --build-arg NEXT_PUBLIC_PWA_ICON_192_URL=https://example.com/icon-192.png \ - --build-arg NEXT_PUBLIC_PWA_ICON_512_URL=https://example.com/icon-512.png \ - --build-arg NEXT_PUBLIC_PWA_MASKABLE_ICON_512_URL=https://example.com/icon-maskable-512.png \ - --build-arg NEXT_PUBLIC_APPLE_TOUCH_ICON_180_URL=https://example.com/apple-touch-icon-180.png \ - -t deeix-chat:branded . -``` - -生产环境推荐使用带版本号的 URL,例如 `https://example.com/v2/logo.png`。如果在同一 URL 下直接替换图片,浏览器、CDN 或已安装 PWA 可能继续显示旧缓存。 - -构建出的 Docker 镜像会在 `/app/licenses/DEEIX-Chat/` 中携带 `LICENSE` 和 `NOTICE`。重新打包或分发镜像时必须保留这两份文件。 - -## 构建后检查 - -完成配置后重新构建前端或 Docker 镜像,并至少检查: +至少检查以下位置: - 浅色和深色主题中的登录页、侧边栏、移动端标题和公开分享页。 -- 用户端和管理端“关于”页面仍完整展示 DEEIX 官方信息,没有被自定义品牌覆盖。 -- 聊天截图顶部左侧显示产品品牌;配置自定义 Logo 时,右侧显示固定的 `Powered by DEEIX`。底部在配置自定义 Logo 时居中显示“自定义 Logo|DEEIX Logo”,未配置时只显示 DEEIX Logo。 -- 公开分享页顶部左侧显示产品品牌;配置自定义 Logo 时,视口右下角显示固定的 `Powered by DEEIX`。底部在配置自定义 Logo 时居中显示“自定义 Logo|DEEIX Logo”,未配置时只显示 DEEIX Logo。 -- 浏览器标签页和书签图标。 -- HTML `` 和 `<meta name="description">`,以及搜索引擎重新抓取后的标题与摘要。 -- `manifest.webmanifest` 中的品牌名称、短名称、原始 description,以及 `192 × 192`、`512 × 512` 和 maskable 图标 URL。 -- Chrome/Edge 安装后的 PWA 图标,以及 iOS/iPadOS“添加到主屏幕”图标。 -- 后台回复完成通知所使用的图标。 - -如果只有 PWA 图标未更新,先确认新构建已经发布,再清理站点数据或卸载旧 PWA 后重新安装。生产环境应优先通过版本化 URL 更新品牌资源,避免依赖用户手动清理缓存。 +- 用户端和管理端“关于”页面仍完整展示 DEEIX 官方信息。 +- 聊天截图和公开分享页中的自定义品牌及 DEEIX 来源标识。 +- 浏览器标题、Meta Description、favicon 和 Apple Touch Icon。 +- `/api/v1/branding`、`/api/v1/branding/manifest.webmanifest` 和同源兼容地址 `/manifest.webmanifest` 中的品牌名称、启动地址和图标。 +- 后台回复完成通知使用的品牌名称和图标。 + +普通页面刷新后会立即使用新品牌。已经安装的 PWA 名称和应用图标由浏览器独立缓存并按浏览器自己的周期检查 manifest,不保证在应用重启后立刻更新。生产环境应使用版本化图标 URL;需要立即验证时,请卸载旧 PWA、清理该站点的应用数据后重新安装。 diff --git a/frontend/.env.example b/frontend/.env.example index ed2de28d..d149c518 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,17 +1,6 @@ # Browser API base URL. Omit for same-origin deployments. NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080 -# Optional build-time branding. See ../docs/BRANDING.md. -NEXT_PUBLIC_BRAND_TITLE= -NEXT_PUBLIC_BRAND_SHORT_NAME= -NEXT_PUBLIC_BRAND_DESCRIPTION= -NEXT_PUBLIC_LOGO_URL= -NEXT_PUBLIC_FAVICON_URL= -NEXT_PUBLIC_PWA_ICON_192_URL= -NEXT_PUBLIC_PWA_ICON_512_URL= -NEXT_PUBLIC_PWA_MASKABLE_ICON_512_URL= -NEXT_PUBLIC_APPLE_TOUCH_ICON_180_URL= - # Development only. NEXT_PUBLIC_DEV_AUTO_LOGIN=false NEXT_PUBLIC_DEV_USERNAME=deeix diff --git a/frontend/app/globals.css b/frontend/app/globals.css index e6d1aaae..c7ce3f00 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -9,6 +9,10 @@ @custom-variant dark (&:is(.dark *)); +html[data-branding-pending] body { + visibility: hidden; +} + :root { --background: oklch(97.86% 0.0027 106.45); --foreground: oklch(0.3438 0.0269 95.7226); diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index bffdbcf4..0e578f14 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -4,9 +4,9 @@ import { Geist, Geist_Mono, JetBrains_Mono } from "next/font/google"; import { AppVersionGuard } from "@/features/layouts"; import { AppearancePreferencesProvider } from "@/features/settings"; import { AppI18nProvider } from "@/i18n/app-i18n-provider"; +import { BrandingProvider } from "@/shared/config/branding-provider"; import { DevtoolsBrandBanner } from "@/shared/components/devtools-brand-banner"; import { ThemeProvider } from "@/shared/components/theme-provider"; -import { brandAssets, brandText } from "@/shared/lib/branding"; import { LegacyPWAServiceWorkerMigration } from "@/shared/pwa/migrations/legacy-service-worker-migration"; import { Toaster } from "@/components/ui/sonner"; @@ -30,28 +30,13 @@ const jetBrainsMono = JetBrains_Mono({ }); export const metadata: Metadata = { - applicationName: brandText.title, - title: brandText.title, - description: brandText.description, - manifest: "/manifest.webmanifest", appleWebApp: { capable: true, statusBarStyle: "default", - title: brandText.title, }, formatDetection: { telephone: false, }, - icons: { - icon: [ - { url: brandAssets.favicon }, - { url: brandAssets.pwaIcon192, sizes: "192x192", type: "image/png" }, - { url: brandAssets.pwaIcon512, sizes: "512x512", type: "image/png" }, - ], - apple: [ - { url: brandAssets.appleTouchIcon180, sizes: "180x180", type: "image/png" }, - ], - }, }; export const viewport: Viewport = { @@ -71,22 +56,25 @@ export default function RootLayout({ <html lang="en" className={`${geistSans.variable} ${geistMono.variable} ${jetBrainsMono.variable} h-full`} + data-branding-pending="true" suppressHydrationWarning > <body className="h-full min-h-svh overflow-hidden antialiased" > - <AppI18nProvider> - <ThemeProvider> - <AppearancePreferencesProvider> - {children} - <AppVersionGuard /> - <LegacyPWAServiceWorkerMigration /> - <Toaster /> - <DevtoolsBrandBanner /> - </AppearancePreferencesProvider> - </ThemeProvider> - </AppI18nProvider> + <BrandingProvider> + <AppI18nProvider> + <ThemeProvider> + <AppearancePreferencesProvider> + {children} + <AppVersionGuard /> + <LegacyPWAServiceWorkerMigration /> + <Toaster /> + <DevtoolsBrandBanner /> + </AppearancePreferencesProvider> + </ThemeProvider> + </AppI18nProvider> + </BrandingProvider> </body> </html> ); diff --git a/frontend/app/manifest.ts b/frontend/app/manifest.ts deleted file mode 100644 index 40c84b0f..00000000 --- a/frontend/app/manifest.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { MetadataRoute } from "next"; - -import { brandAssets, brandText } from "@/shared/lib/branding"; - -export const dynamic = "force-static"; - -export default function manifest(): MetadataRoute.Manifest { - return { - name: brandText.title, - short_name: brandText.shortName, - description: "DEEIX Chat is a multi-model AI conversation workspace.", - id: "/", - start_url: "/chat", - scope: "/", - display: "standalone", - background_color: "#ffffff", - theme_color: "#0f172a", - orientation: "any", - categories: ["productivity", "business", "utilities"], - lang: "en", - icons: [ - { - src: brandAssets.pwaIcon192, - sizes: "192x192", - type: "image/png", - purpose: "any", - }, - { - src: brandAssets.pwaIcon512, - sizes: "512x512", - type: "image/png", - purpose: "any", - }, - { - src: brandAssets.pwaMaskableIcon512, - sizes: "512x512", - type: "image/png", - purpose: "maskable", - }, - ], - }; -} diff --git a/frontend/features/admin/components/admin-shell.tsx b/frontend/features/admin/components/admin-shell.tsx index 054a80e5..fba53059 100644 --- a/frontend/features/admin/components/admin-shell.tsx +++ b/frontend/features/admin/components/admin-shell.tsx @@ -1,8 +1,7 @@ import type { ReactNode } from "react"; import { AdminSidebar } from "@/features/admin/components/admin-sidebar"; -import { PoweredByDeeix } from "@/shared/components/powered-by-deeix"; -import { brandAssets } from "@/shared/lib/branding"; +import { CustomBrandAttribution } from "@/shared/components/powered-by-deeix"; export function AdminShell({ children, @@ -22,11 +21,7 @@ export function AdminShell({ </main> </div> - {brandAssets.logo ? ( - <div className="fixed bottom-4 right-4"> - <PoweredByDeeix /> - </div> - ) : null} + <CustomBrandAttribution className="fixed bottom-4 right-4" /> </div> ); } diff --git a/frontend/features/auth/components/login-page.tsx b/frontend/features/auth/components/login-page.tsx index 84b99be9..df541934 100644 --- a/frontend/features/auth/components/login-page.tsx +++ b/frontend/features/auth/components/login-page.tsx @@ -9,8 +9,7 @@ import { PASSWORD_MIN_LENGTH } from "@/shared/auth/account-policy"; import { useLoginPage } from "@/features/auth/hooks/use-auth-login-page"; import { AppLogo } from "@/shared/components/app-logo"; import { IdentityProviderIcon } from "@/shared/components/identity-provider-icon"; -import { PoweredByDeeix } from "@/shared/components/powered-by-deeix"; -import { brandAssets } from "@/shared/lib/branding"; +import { CustomBrandAttribution } from "@/shared/components/powered-by-deeix"; import { TurnstileWidget } from "@/features/auth/components/turnstile-widget"; import { cn } from "@/lib/utils"; @@ -449,11 +448,7 @@ export function LoginPage({ nextPath }: LoginPageProps) { </div> - {brandAssets.logo ? ( - <div className="fixed bottom-4 right-4"> - <PoweredByDeeix /> - </div> - ) : null} + <CustomBrandAttribution className="fixed bottom-4 right-4" /> </main> ); } diff --git a/frontend/features/chat/components/message/message-bot.tsx b/frontend/features/chat/components/message/message-bot.tsx index 7b85c394..4dad356e 100644 --- a/frontend/features/chat/components/message/message-bot.tsx +++ b/frontend/features/chat/components/message/message-bot.tsx @@ -43,7 +43,7 @@ import { useLiveUpstreamThinkTrace, } from "@/features/chat/model/upstream-think-store"; import type { BillingDisplayCurrency } from "@/shared/lib/billing-display"; -import { brandText } from "@/shared/lib/branding"; +import { useBranding } from "@/shared/config/branding-provider"; const EMPTY_TRACE_EVENTS: NonNullable<ChatAreaMessage["processTrace"]>["events"] = []; @@ -601,6 +601,7 @@ export function AssistantImageGenerationSkeleton({ aspectRatio?: ChatAreaMessage["imageAspectRatio"]; }) { const t = useTranslations("chat.messages"); + const branding = useBranding(); const frameClassName = aspectRatio === "portrait" ? "max-w-[18rem]" : aspectRatio === "square" ? "max-w-[24rem]" : "max-w-[32rem]"; const aspectClassName = @@ -625,7 +626,7 @@ export function AssistantImageGenerationSkeleton({ /> <div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <span className="select-none text-[clamp(1.75rem,7vw,4rem)] font-semibold tracking-[0.18em] text-white/30 mix-blend-overlay drop-shadow-sm"> - {brandText.shortName} + {branding.shortName} </span> </div> </div> @@ -635,6 +636,7 @@ export function AssistantImageGenerationSkeleton({ export function AssistantVideoGenerationSkeleton({ label }: { label?: string }) { const t = useTranslations("chat.messages"); + const branding = useBranding(); return ( <div className="my-4 w-full max-w-[32rem] space-y-2.5"> <div className="flex items-center gap-2 pt-1 text-[13px] text-muted-foreground"> @@ -657,7 +659,7 @@ export function AssistantVideoGenerationSkeleton({ label }: { label?: string }) <div className="flex flex-col items-center gap-5 text-white/30 mix-blend-overlay drop-shadow-sm"> <Film className="size-14" strokeWidth={1.4} /> <span className="select-none text-[clamp(1.75rem,7vw,4rem)] font-semibold tracking-[0.18em]"> - {brandText.shortName} + {branding.shortName} </span> </div> </div> diff --git a/frontend/features/chat/components/sections/chat-area.tsx b/frontend/features/chat/components/sections/chat-area.tsx index 7804617a..04d8c9db 100644 --- a/frontend/features/chat/components/sections/chat-area.tsx +++ b/frontend/features/chat/components/sections/chat-area.tsx @@ -40,8 +40,8 @@ import { } from "@/features/chat/components/sections/chat-message-position-rail"; import { cn } from "@/lib/utils"; import { AppLogo, DeeixLogo } from "@/shared/components/app-logo"; +import { useBranding } from "@/shared/config/branding-provider"; import { PoweredByDeeix } from "@/shared/components/powered-by-deeix"; -import { brandAssets } from "@/shared/lib/branding"; function CompactDivider({ summaryPreview }: { summaryPreview: string }) { const t = useTranslations("chat.messages"); @@ -194,6 +194,8 @@ function ChatScreenshotMessageMeta({ } function ChatScreenshotBrandMark({ placement }: { placement: "top" | "bottom" }) { + const branding = useBranding(); + return ( <div className={cn( @@ -207,9 +209,9 @@ function ChatScreenshotBrandMark({ placement }: { placement: "top" | "bottom" }) {placement === "top" ? ( <> <AppLogo width={65} height={20} className="h-5 w-auto opacity-75" /> - {brandAssets.logo ? <PoweredByDeeix className="text-[10px]" /> : null} + {branding.logoURL ? <PoweredByDeeix className="text-[10px]" /> : null} </> - ) : brandAssets.logo ? ( + ) : branding.logoURL ? ( <> <AppLogo width={65} height={20} className="h-5 w-auto opacity-75" /> <span aria-hidden="true" className="h-4 w-px bg-border" /> diff --git a/frontend/features/chat/hooks/use-chat-message-submit.ts b/frontend/features/chat/hooks/use-chat-message-submit.ts index 16baf2ab..dfffce94 100644 --- a/frontend/features/chat/hooks/use-chat-message-submit.ts +++ b/frontend/features/chat/hooks/use-chat-message-submit.ts @@ -34,7 +34,6 @@ import { sanitizeConversationOptions } from "@/features/chat/model/conversation- import { buildMediaImagePreviewMarkdown } from "@/features/chat/model/media-image-preview"; import { resolveAccessToken } from "@/shared/auth/resolve-access-token"; import { notifyResponseCompletion } from "@/shared/lib/browser-notifications"; -import { brandText } from "@/shared/lib/branding"; import { cancelMessageGeneration, getConversation, @@ -994,7 +993,7 @@ export function useChatMessageSubmit({ notifyResponseCompletion({ content: completed.assistantMessage.content, conversationPublicID: targetConversationID, - conversationTitle: targetConversation?.title || brandText.title, + conversationTitle: targetConversation?.title, }); } reload(); diff --git a/frontend/features/chat/model/chat-artifacts.ts b/frontend/features/chat/model/chat-artifacts.ts index 8667db8e..0903bbe5 100644 --- a/frontend/features/chat/model/chat-artifacts.ts +++ b/frontend/features/chat/model/chat-artifacts.ts @@ -3,7 +3,7 @@ import { resolveArtifactPreviewKind, type ArtifactPreviewKind, } from "@/shared/lib/artifact-preview"; -import { brandText } from "@/shared/lib/branding"; +import { getBrandingSnapshot } from "@/shared/config/branding"; export type { ArtifactPreviewKind } from "@/shared/lib/artifact-preview"; @@ -158,6 +158,7 @@ function htmlPreviewDocument(code: string): string { } function cssPreviewDocument(code: string): string { + const branding = getBrandingSnapshot(); return `<!doctype html> <html> <head> @@ -167,7 +168,7 @@ ${previewHead("CSS Preview")} <body> <main class="artifact-preview"> <section class="preview-panel"> - <p class="eyebrow">${escapeHTML(brandText.shortName)} Artifact</p> + <p class="eyebrow">${escapeHTML(branding.shortName)} Artifact</p> <h1>Preview Surface</h1> <p>Generated CSS is applied to this isolated document.</p> <div class="preview-row"> diff --git a/frontend/features/layouts/components/navigation/app-sidebar.tsx b/frontend/features/layouts/components/navigation/app-sidebar.tsx index 9eb392f8..1527e15d 100644 --- a/frontend/features/layouts/components/navigation/app-sidebar.tsx +++ b/frontend/features/layouts/components/navigation/app-sidebar.tsx @@ -17,8 +17,8 @@ import { NavRecents } from "@/features/layouts/components/navigation/nav-recents import { NavStarred } from "@/features/layouts/components/navigation/nav-starred"; import { NavUser } from "@/features/layouts/components/navigation/nav-user"; import { useOptionalAuthSession } from "@/shared/auth/auth-session-context"; +import { useBranding } from "@/shared/config/branding-provider"; import { resolveAvatarImageSrc } from "@/shared/lib/avatar"; -import { brandText } from "@/shared/lib/branding"; export function AppSidebar({ onCreateConversation, @@ -27,6 +27,7 @@ export function AppSidebar({ onCreateConversation: () => void; }) { const t = useTranslations("common.navigation"); + const branding = useBranding(); const sessionUser = useOptionalAuthSession()?.user; const username = sessionUser?.username.trim() ?? ""; const user = sessionUser @@ -37,7 +38,7 @@ export function AppSidebar({ role: sessionUser.role, } : { - name: brandText.title, + name: branding.title, email: "deeix.com", avatar: "", }; diff --git a/frontend/features/settings/components/app-settings-panel.tsx b/frontend/features/settings/components/app-settings-panel.tsx index f535e0e3..9b17f0f1 100644 --- a/frontend/features/settings/components/app-settings-panel.tsx +++ b/frontend/features/settings/components/app-settings-panel.tsx @@ -1,8 +1,7 @@ import type { ReactNode } from "react"; import { SettingsSidebar } from "@/features/settings/components/settings-sidebar"; -import { PoweredByDeeix } from "@/shared/components/powered-by-deeix"; -import { brandAssets } from "@/shared/lib/branding"; +import { CustomBrandAttribution } from "@/shared/components/powered-by-deeix"; export function AppSettingsPanel({ children, @@ -22,11 +21,7 @@ export function AppSettingsPanel({ </main> </div> - {brandAssets.logo ? ( - <div className="fixed bottom-4 right-4"> - <PoweredByDeeix /> - </div> - ) : null} + <CustomBrandAttribution className="fixed bottom-4 right-4" /> </div> ); } diff --git a/frontend/features/share/components/public-share-page.tsx b/frontend/features/share/components/public-share-page.tsx index eff691df..1f5f33b9 100644 --- a/frontend/features/share/components/public-share-page.tsx +++ b/frontend/features/share/components/public-share-page.tsx @@ -29,12 +29,12 @@ import { CenteredEmptyState } from "@/components/ui/empty-state"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { AppLogo, DeeixLogo } from "@/shared/components/app-logo"; -import { PoweredByDeeix } from "@/shared/components/powered-by-deeix"; +import { useBranding } from "@/shared/config/branding-provider"; +import { CustomBrandAttribution } from "@/shared/components/powered-by-deeix"; import { useOptionalAuthSession } from "@/shared/auth/auth-session-context"; import { resolveAccessToken } from "@/shared/auth/resolve-access-token"; import { useAppLocale } from "@/i18n/app-i18n-provider"; import { useLocalizedErrorMessage } from "@/i18n/use-localized-error"; -import { brandAssets, brandText } from "@/shared/lib/branding"; function formatSharedAt(value: string, locale: string): string { const date = new Date(value); @@ -207,6 +207,7 @@ function PublicSharedMessage({ export function PublicSharePage() { const t = useTranslations("share"); + const branding = useBranding(); const { locale } = useAppLocale(); const resolveErrorMessage = useLocalizedErrorMessage(); const router = useRouter(); @@ -377,12 +378,12 @@ export function PublicSharePage() { <main className="h-full min-h-0 w-full overflow-y-auto bg-background text-foreground"> <div className="mx-auto min-h-full w-full max-w-[820px] px-4 pb-24 pt-5 md:pt-6"> <header className="flex items-center border-b border-border/50 pb-3"> - {brandAssets.logo ? ( + {branding.logoURL ? ( <span className="inline-flex h-8 shrink-0 items-center"> <AppLogo width={78} height={24} priority className="h-6 w-auto" /> </span> ) : ( - <Link href="/" aria-label={brandText.title} className="inline-flex h-8 shrink-0 items-center"> + <Link href="/" aria-label={branding.title} className="inline-flex h-8 shrink-0 items-center"> <AppLogo width={78} height={24} priority className="h-6 w-auto" /> </Link> )} @@ -412,7 +413,7 @@ export function PublicSharePage() { <div className="mt-12 flex items-center justify-center border-t border-border/50 pt-3"> <div className="inline-flex items-center gap-3"> - {brandAssets.logo ? ( + {branding.logoURL ? ( <> <span className="inline-flex h-8 shrink-0 items-center"> <AppLogo width={78} height={24} className="h-6 w-auto opacity-75" /> @@ -443,11 +444,7 @@ export function PublicSharePage() { </Button> </div> - {brandAssets.logo ? ( - <div className="fixed bottom-4 right-4"> - <PoweredByDeeix /> - </div> - ) : null} + <CustomBrandAttribution className="fixed bottom-4 right-4" /> </div> </main> ); diff --git a/frontend/i18n/app-i18n-provider.tsx b/frontend/i18n/app-i18n-provider.tsx index 4f45df0a..17fabca3 100644 --- a/frontend/i18n/app-i18n-provider.tsx +++ b/frontend/i18n/app-i18n-provider.tsx @@ -4,7 +4,8 @@ import * as React from "react"; import { NextIntlClientProvider } from "next-intl"; import { DEFAULT_LOCALE, LOCALE_COOKIE_NAME, normalizeAppLocale, resolveBrowserLocale, type AppLocale } from "@/i18n/config"; -import { DEFAULT_MESSAGES, loadLocaleMessages, type AppMessages } from "@/i18n/messages"; +import { applyBrandingToMessages, DEFAULT_MESSAGES, loadLocaleMessages, type AppMessages } from "@/i18n/messages"; +import { useBranding } from "@/shared/config/branding-provider"; type AppI18nContextValue = { locale: AppLocale; @@ -49,8 +50,9 @@ function applyDocumentLocale(locale: AppLocale): void { } export function AppI18nProvider({ children }: { children: React.ReactNode }) { + const branding = useBranding(); const [locale, setLocaleState] = React.useState<AppLocale>(DEFAULT_LOCALE); - const [messages, setMessages] = React.useState<AppMessages>(DEFAULT_MESSAGES); + const [localeMessages, setLocaleMessages] = React.useState<AppMessages>(DEFAULT_MESSAGES); const localeRef = React.useRef<AppLocale>(DEFAULT_LOCALE); const applyLocale = React.useCallback(async (nextLocale: AppLocale, persist: boolean) => { @@ -66,7 +68,7 @@ export function AppI18nProvider({ children }: { children: React.ReactNode }) { const nextMessages = await loadLocaleMessages(normalized); localeRef.current = normalized; setLocaleState(normalized); - setMessages(nextMessages); + setLocaleMessages(nextMessages); if (persist) { writeLocaleCookie(normalized); } @@ -89,6 +91,10 @@ export function AppI18nProvider({ children }: { children: React.ReactNode }) { }), [locale, setLocale], ); + const messages = React.useMemo( + () => applyBrandingToMessages(localeMessages, branding.title), + [branding.title, localeMessages], + ); return ( <AppI18nContext.Provider value={value}> diff --git a/frontend/i18n/messages.ts b/frontend/i18n/messages.ts index 4aefbaec..a5fe10d3 100644 --- a/frontend/i18n/messages.ts +++ b/frontend/i18n/messages.ts @@ -23,7 +23,7 @@ import enRecent from "@/i18n/messages/en-US/recent.json"; import enSettings from "@/i18n/messages/en-US/settings.json"; import enShare from "@/i18n/messages/en-US/share.json"; import type { AppLocale } from "@/i18n/config"; -import { replaceDefaultBrandTitle } from "@/shared/lib/branding"; +import { replaceDefaultBrandTitle } from "@/shared/config/branding"; const ENGLISH_MESSAGES = { common: enCommon, @@ -54,28 +54,28 @@ const ENGLISH_MESSAGES = { export type AppMessages = typeof ENGLISH_MESSAGES; -function prepareMessages(messages: AppMessages): AppMessages { +export function applyBrandingToMessages(messages: AppMessages, brandTitle: string): AppMessages { return { ...messages, guide: { ...messages.guide, - userWelcomeTitle: replaceDefaultBrandTitle(messages.guide.userWelcomeTitle), + userWelcomeTitle: replaceDefaultBrandTitle(messages.guide.userWelcomeTitle, brandTitle), }, recent: { ...messages.recent, - allConversationsDescription: replaceDefaultBrandTitle(messages.recent.allConversationsDescription), + allConversationsDescription: replaceDefaultBrandTitle(messages.recent.allConversationsDescription, brandTitle), }, login: { ...messages.login, - title: replaceDefaultBrandTitle(messages.login.title), + title: replaceDefaultBrandTitle(messages.login.title, brandTitle), }, share: { ...messages.share, - signInToContinue: replaceDefaultBrandTitle(messages.share.signInToContinue), + signInToContinue: replaceDefaultBrandTitle(messages.share.signInToContinue, brandTitle), }, chat: { ...messages.chat, - placeholder: replaceDefaultBrandTitle(messages.chat.placeholder), + placeholder: replaceDefaultBrandTitle(messages.chat.placeholder, brandTitle), }, settings: { ...messages.settings, @@ -89,6 +89,7 @@ function prepareMessages(messages: AppMessages): AppMessages { ...messages.settings.accountPage.securityDialog.email.description, change: replaceDefaultBrandTitle( messages.settings.accountPage.securityDialog.email.description.change, + brandTitle, ), }, }, @@ -98,7 +99,7 @@ function prepareMessages(messages: AppMessages): AppMessages { }; } -export const DEFAULT_MESSAGES: AppMessages = prepareMessages(ENGLISH_MESSAGES); +export const DEFAULT_MESSAGES: AppMessages = ENGLISH_MESSAGES; export async function loadLocaleMessages(locale: AppLocale): Promise<AppMessages> { if (locale === "en-US") { @@ -157,7 +158,7 @@ export async function loadLocaleMessages(locale: AppLocale): Promise<AppMessages import("@/i18n/messages/zh-CN/admin-users.json"), ]); - return prepareMessages({ + return { common: common.default, conversation: conversation.default, errors: errors.default, @@ -182,5 +183,5 @@ export async function loadLocaleMessages(locale: AppLocale): Promise<AppMessages adminTools: adminTools.default, adminUpstreams: adminUpstreams.default, adminUsers: adminUsers.default, - }); + }; } diff --git a/frontend/shared/api/branding.ts b/frontend/shared/api/branding.ts new file mode 100644 index 00000000..285f4c1f --- /dev/null +++ b/frontend/shared/api/branding.ts @@ -0,0 +1,17 @@ +import { apiRequest } from "@/shared/api/http-client"; + +export type BrandingDTO = { + title: string; + shortName: string; + description: string; + logoURL: string; + faviconURL: string; + pwaIcon192URL: string; + pwaIcon512URL: string; + pwaMaskableIcon512URL: string; + appleTouchIcon180URL: string; +}; + +export function getPublicBranding(): Promise<BrandingDTO> { + return apiRequest<BrandingDTO>("/api/v1/branding"); +} diff --git a/frontend/shared/components/app-logo.tsx b/frontend/shared/components/app-logo.tsx index 3714a6a1..97ee87be 100644 --- a/frontend/shared/components/app-logo.tsx +++ b/frontend/shared/components/app-logo.tsx @@ -2,8 +2,8 @@ import Image from "next/image"; +import { useBranding } from "@/shared/config/branding-provider"; import { useTheme } from "@/shared/components/theme-provider"; -import { brandAssets, brandText } from "@/shared/lib/branding"; type AppLogoProps = { alt?: string; @@ -14,18 +14,19 @@ type AppLogoProps = { }; export function AppLogo({ - alt = brandText.title, + alt, width, height, priority, className, }: AppLogoProps) { + const branding = useBranding(); const { resolvedTheme } = useTheme(); return ( <Image - src={brandAssets.logo ?? (resolvedTheme === "dark" ? "/logo-white.svg" : "/logo.svg")} - alt={alt} + src={branding.logoURL || (resolvedTheme === "dark" ? "/logo-white.svg" : "/logo.svg")} + alt={alt ?? branding.title} width={width} height={height} priority={priority} diff --git a/frontend/shared/components/powered-by-deeix.tsx b/frontend/shared/components/powered-by-deeix.tsx index de9ced4b..e846b20c 100644 --- a/frontend/shared/components/powered-by-deeix.tsx +++ b/frontend/shared/components/powered-by-deeix.tsx @@ -1,6 +1,9 @@ +"use client"; + import Image from "next/image"; import { cn } from "@/lib/utils"; +import { useBranding } from "@/shared/config/branding-provider"; export function PoweredByDeeix({ className }: { className?: string }) { return ( @@ -38,3 +41,15 @@ export function PoweredByDeeix({ className }: { className?: string }) { </span> ); } + +export function CustomBrandAttribution({ className }: { className?: string }) { + const branding = useBranding(); + if (!branding.logoURL) { + return null; + } + return ( + <div className={className}> + <PoweredByDeeix /> + </div> + ); +} diff --git a/frontend/shared/config/branding-provider.tsx b/frontend/shared/config/branding-provider.tsx new file mode 100644 index 00000000..0aa53394 --- /dev/null +++ b/frontend/shared/config/branding-provider.tsx @@ -0,0 +1,104 @@ +"use client"; + +import * as React from "react"; + +import { getPublicBranding, type BrandingDTO } from "@/shared/api/branding"; +import { resolveApiBaseURL } from "@/shared/api/http-client"; +import { + DEFAULT_BRANDING, + setBrandingSnapshot, +} from "@/shared/config/branding"; + +const BRANDING_FALLBACK_DELAY_MS = 3_000; +const BRANDING_RETRY_DELAY_MS = 1_000; +const BrandingContext = React.createContext<BrandingDTO>(DEFAULT_BRANDING); + +let brandingRequest: Promise<BrandingDTO> | null = null; + +function requestBranding(): Promise<BrandingDTO> { + brandingRequest ??= getPublicBranding().catch((error) => { + brandingRequest = null; + throw error; + }); + return brandingRequest; +} + +function BrandingMetadata({ branding }: { branding: BrandingDTO }) { + return ( + <> + <title>{branding.title} + + + + + + + + + + ); +} + +export function BrandingProvider({ children }: { children: React.ReactNode }) { + const [branding, setBranding] = React.useState(DEFAULT_BRANDING); + const [ready, setReady] = React.useState(false); + + React.useLayoutEffect(() => { + let active = true; + const fallbackTimer = window.setTimeout(() => { + if (active) { + setReady(true); + } + }, BRANDING_FALLBACK_DELAY_MS); + let retryTimer: number | undefined; + const applyBranding = (nextBranding: BrandingDTO) => { + setBrandingSnapshot(nextBranding); + if (!active) { + return; + } + window.clearTimeout(fallbackTimer); + setBranding(nextBranding); + setReady(true); + }; + const handleLoadFailure = () => { + if (!active) { + return; + } + window.clearTimeout(fallbackTimer); + setReady(true); + retryTimer = window.setTimeout(() => { + void requestBranding().then(applyBranding).catch(() => undefined); + }, BRANDING_RETRY_DELAY_MS); + }; + + void requestBranding().then(applyBranding).catch(handleLoadFailure); + + return () => { + active = false; + window.clearTimeout(fallbackTimer); + if (retryTimer !== undefined) { + window.clearTimeout(retryTimer); + } + }; + }, []); + + React.useLayoutEffect(() => { + if (ready) { + document.documentElement.removeAttribute("data-branding-pending"); + } + }, [ready]); + + return ( + + {ready ? : null} + {children} + + ); +} + +export function useBranding(): BrandingDTO { + return React.useContext(BrandingContext); +} diff --git a/frontend/shared/config/branding.ts b/frontend/shared/config/branding.ts new file mode 100644 index 00000000..8e8dfac0 --- /dev/null +++ b/frontend/shared/config/branding.ts @@ -0,0 +1,31 @@ +import type { BrandingDTO } from "@/shared/api/branding"; +import { pwaAsset } from "@/shared/pwa/assets"; + +export const DEFAULT_BRANDING: BrandingDTO = { + title: "DEEIX Chat", + shortName: "DEEIX", + description: "DEEIX Chat is a multi-model AI conversation system.", + logoURL: "", + faviconURL: "/favicon.ico", + pwaIcon192URL: pwaAsset("/pwa/icon-192.png"), + pwaIcon512URL: pwaAsset("/pwa/icon-512.png"), + pwaMaskableIcon512URL: pwaAsset("/pwa/icon-maskable-512.png"), + appleTouchIcon180URL: pwaAsset("/pwa/apple-touch-icon.png"), +}; + +let brandingSnapshot = DEFAULT_BRANDING; + +export function getBrandingSnapshot(): BrandingDTO { + return brandingSnapshot; +} + +export function setBrandingSnapshot(branding: BrandingDTO): void { + brandingSnapshot = branding; +} + +export function replaceDefaultBrandTitle(value: string, brandTitle: string): string { + if (brandTitle === DEFAULT_BRANDING.title) { + return value; + } + return value.replaceAll(DEFAULT_BRANDING.title, () => brandTitle); +} diff --git a/frontend/shared/lib/branding.ts b/frontend/shared/lib/branding.ts deleted file mode 100644 index 62fb6185..00000000 --- a/frontend/shared/lib/branding.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { pwaAsset } from "@/shared/pwa/assets"; - -const DEFAULT_BRAND_TITLE = "DEEIX Chat"; - -function assetURL(configured: string | undefined, fallback: string): string { - return configured?.trim() || fallback; -} - -export const brandText = { - title: process.env.NEXT_PUBLIC_BRAND_TITLE?.trim() || DEFAULT_BRAND_TITLE, - shortName: process.env.NEXT_PUBLIC_BRAND_SHORT_NAME?.trim() || "DEEIX", - description: - process.env.NEXT_PUBLIC_BRAND_DESCRIPTION?.trim() || - "DEEIX Chat is a multi-model AI conversation system.", -} as const; - -export function replaceDefaultBrandTitle(value: string): string { - if (brandText.title === DEFAULT_BRAND_TITLE) { - return value; - } - return value.replaceAll(DEFAULT_BRAND_TITLE, () => brandText.title); -} - -export const brandAssets = { - logo: process.env.NEXT_PUBLIC_LOGO_URL?.trim() || undefined, - favicon: assetURL(process.env.NEXT_PUBLIC_FAVICON_URL, "/favicon.ico"), - pwaIcon192: assetURL( - process.env.NEXT_PUBLIC_PWA_ICON_192_URL, - pwaAsset("/pwa/icon-192.png"), - ), - pwaIcon512: assetURL( - process.env.NEXT_PUBLIC_PWA_ICON_512_URL, - pwaAsset("/pwa/icon-512.png"), - ), - pwaMaskableIcon512: assetURL( - process.env.NEXT_PUBLIC_PWA_MASKABLE_ICON_512_URL, - pwaAsset("/pwa/icon-maskable-512.png"), - ), - appleTouchIcon180: assetURL( - process.env.NEXT_PUBLIC_APPLE_TOUCH_ICON_180_URL, - pwaAsset("/pwa/apple-touch-icon.png"), - ), -} as const; diff --git a/frontend/shared/lib/browser-notifications.ts b/frontend/shared/lib/browser-notifications.ts index 8d1c7966..f7b55b7f 100644 --- a/frontend/shared/lib/browser-notifications.ts +++ b/frontend/shared/lib/browser-notifications.ts @@ -1,6 +1,6 @@ "use client"; -import { brandAssets, brandText } from "@/shared/lib/branding"; +import { getBrandingSnapshot } from "@/shared/config/branding"; const RESPONSE_COMPLETION_NOTIFICATIONS_STORAGE_KEY = "deeix-chat:response-completion-notifications"; const NOTIFICATION_BODY_MAX_LENGTH = 140; @@ -95,10 +95,11 @@ export function notifyResponseCompletion(input: ResponseCompletionNotificationIn } const conversationTitle = normalizeString(input.conversationTitle); - const notification = new Notification(conversationTitle || brandText.title, { + const branding = getBrandingSnapshot(); + const notification = new Notification(conversationTitle || branding.title, { body: normalizeNotificationBody(normalizeString(input.content)), tag: normalizeString(input.conversationPublicID, `response-completion:${Date.now()}`), - icon: brandAssets.pwaIcon192, + icon: branding.pwaIcon192URL, }); notification.onclick = () => {