diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index d0c4abc6..66c01044 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.130 +0.1.131 diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 0ea664d8..0bf437b0 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -1134,6 +1134,14 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'") return } + switch item.Placement { + case "", "sidebar": + items[i].Placement = "sidebar" + case "home_header", "both": + default: + response.BadRequest(c, "Custom menu item placement must be 'sidebar', 'home_header' or 'both'") + return + } if len(item.IconSVG) > maxMenuItemIconSVGLen { response.BadRequest(c, "Custom menu item icon SVG is too large (max 10KB)") return diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 551cf0dc..2ac9eadc 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -13,6 +13,7 @@ type CustomMenuItem struct { URL string `json:"url"` PageSlug string `json:"page_slug,omitempty"` Visibility string `json:"visibility"` // "user" or "admin" + Placement string `json:"placement,omitempty"` SortOrder int `json:"sort_order"` } @@ -367,9 +368,25 @@ func ParseCustomMenuItems(raw string) []CustomMenuItem { if err := json.Unmarshal([]byte(raw), &items); err != nil { return []CustomMenuItem{} } + for i := range items { + items[i].Placement = normalizeCustomMenuPlacement(items[i].Placement) + } return items } +func normalizeCustomMenuPlacement(raw string) string { + switch strings.TrimSpace(raw) { + case "", "sidebar": + return "sidebar" + case "home_header": + return "home_header" + case "both": + return "both" + default: + return "sidebar" + } +} + // ParseUserVisibleMenuItems parses custom menu items and filters out admin-only entries. func ParseUserVisibleMenuItems(raw string) []CustomMenuItem { items := ParseCustomMenuItems(raw) diff --git a/backend/internal/server/middleware/security_headers.go b/backend/internal/server/middleware/security_headers.go index e71f6318..0e8980c8 100644 --- a/backend/internal/server/middleware/security_headers.go +++ b/backend/internal/server/middleware/security_headers.go @@ -91,8 +91,17 @@ func SecurityHeaders(cfg config.CSPConfig, getFrameSrcOrigins func() []string) g } } + embeddedMode := isEmbeddedUIRequest(c) + if embeddedMode { + finalPolicy = setDirective(finalPolicy, "frame-ancestors", "'self'") + } + c.Header("X-Content-Type-Options", "nosniff") - c.Header("X-Frame-Options", "DENY") + if embeddedMode { + c.Header("X-Frame-Options", "SAMEORIGIN") + } else { + c.Header("X-Frame-Options", "DENY") + } c.Header("Referrer-Policy", "strict-origin-when-cross-origin") if isAPIRoutePath(c) { c.Next() @@ -127,6 +136,13 @@ func isAPIRoutePath(c *gin.Context) bool { strings.HasPrefix(path, "/images") } +func isEmbeddedUIRequest(c *gin.Context) bool { + if c == nil || c.Request == nil || c.Request.URL == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(c.Query("ui_mode")), "embedded") +} + // enhanceCSPPolicy 确保 CSP 策略包含 nonce 支持和支付 SDK 必需域名。 // 这样旧配置文件没有及时补域名时,前端支付组件仍能正常加载。 func enhanceCSPPolicy(policy string) string { @@ -195,3 +211,31 @@ func addToDirective(policy, directive, value string) string { insertPos := idx + endIdx return policy[:insertPos] + " " + value + policy[insertPos:] } + +func setDirective(policy, directive, value string) string { + directives := strings.Split(policy, ";") + replaced := false + for i, rawDirective := range directives { + fields := strings.Fields(strings.TrimSpace(rawDirective)) + if len(fields) == 0 { + continue + } + if fields[0] == directive { + directives[i] = directive + " " + value + replaced = true + } + } + if !replaced { + directives = append(directives, directive+" "+value) + } + + result := make([]string, 0, len(directives)) + for _, rawDirective := range directives { + trimmed := strings.TrimSpace(rawDirective) + if trimmed == "" { + continue + } + result = append(result, trimmed) + } + return strings.Join(result, "; ") +} diff --git a/backend/internal/server/middleware/security_headers_test.go b/backend/internal/server/middleware/security_headers_test.go index c980f7e6..d8a30bba 100644 --- a/backend/internal/server/middleware/security_headers_test.go +++ b/backend/internal/server/middleware/security_headers_test.go @@ -151,6 +151,24 @@ func TestSecurityHeaders(t *testing.T) { assert.Empty(t, GetNonceFromContext(c)) }) + t.Run("embedded_ui_allows_same_origin_frame", func(t *testing.T) { + cfg := config.CSPConfig{ + Enabled: true, + Policy: "default-src 'self'; frame-ancestors 'none'", + } + middleware := SecurityHeaders(cfg, nil) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/docs/api.html?ui_mode=embedded", nil) + + middleware(c) + + assert.Equal(t, "SAMEORIGIN", w.Header().Get("X-Frame-Options")) + assert.Contains(t, w.Header().Get("Content-Security-Policy"), "frame-ancestors 'self'") + assert.NotContains(t, w.Header().Get("Content-Security-Policy"), "frame-ancestors 'none'") + }) + t.Run("csp_enabled_with_nonce_placeholder", func(t *testing.T) { cfg := config.CSPConfig{ Enabled: true, @@ -410,6 +428,23 @@ func TestAddToDirective(t *testing.T) { }) } +func TestSetDirective(t *testing.T) { + t.Run("replaces_existing_directive", func(t *testing.T) { + policy := "default-src 'self'; frame-ancestors 'none'; script-src 'self'" + result := setDirective(policy, "frame-ancestors", "'self'") + + assert.Contains(t, result, "frame-ancestors 'self'") + assert.NotContains(t, result, "frame-ancestors 'none'") + }) + + t.Run("adds_directive_when_missing", func(t *testing.T) { + policy := "default-src 'self'; script-src 'self'" + result := setDirective(policy, "frame-ancestors", "'self'") + + assert.Contains(t, result, "frame-ancestors 'self'") + }) +} + // Benchmark tests func BenchmarkGenerateNonce(b *testing.B) { for i := 0; i < b.N; i++ { diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 86978eec..2f49045a 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -720,7 +720,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), TableDefaultPageSize: tableDefaultPageSize, TablePageSizeOptions: tablePageSizeOptions, - CustomMenuItems: settings[SettingKeyCustomMenuItems], + CustomMenuItems: string(normalizeCustomMenuItemsRaw(settings[SettingKeyCustomMenuItems])), CustomEndpoints: settings[SettingKeyCustomEndpoints], LinuxDoOAuthEnabled: linuxDoEnabled, WeChatOAuthEnabled: weChatEnabled, @@ -987,7 +987,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, TableDefaultPageSize: settings.TableDefaultPageSize, TablePageSizeOptions: settings.TablePageSizeOptions, - CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), + CustomMenuItems: filterUserVisibleMenuItems(string(normalizeCustomMenuItemsRaw(settings.CustomMenuItems))), CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, WeChatOAuthEnabled: settings.WeChatOAuthEnabled, @@ -1200,6 +1200,32 @@ func filterUserVisibleMenuItems(raw string) json.RawMessage { return result } +func normalizeCustomMenuItemsRaw(raw string) json.RawMessage { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "[]" { + return json.RawMessage("[]") + } + var items []map[string]any + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return json.RawMessage("[]") + } + for _, item := range items { + placement, _ := item["placement"].(string) + switch strings.TrimSpace(placement) { + case "", "sidebar": + item["placement"] = "sidebar" + case "home_header", "both": + default: + item["placement"] = "sidebar" + } + } + normalized, err := json.Marshal(items) + if err != nil { + return json.RawMessage("[]") + } + return normalized +} + // safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]". func safeRawJSONArray(raw string) json.RawMessage { raw = strings.TrimSpace(raw) diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index 12b60767..a9e5a223 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -24,7 +24,7 @@ services: # Sub2API Application # =========================================================================== sub2api: - image: weishaw/sub2api:latest + image: ghcr.io/man209111-cpu/sub2api:latest container_name: sub2api restart: unless-stopped ulimits: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 405c0da0..fdaeb8fa 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -16,7 +16,7 @@ services: # Sub2API Application # =========================================================================== sub2api: - image: weishaw/sub2api:latest + image: ghcr.io/man209111-cpu/sub2api:latest container_name: sub2api restart: unless-stopped ulimits: diff --git a/frontend/public/docs/api.html b/frontend/public/docs/api.html index 85bd9526..c81a32f7 100644 --- a/frontend/public/docs/api.html +++ b/frontend/public/docs/api.html @@ -3,26 +3,69 @@ - API 使用文档 + 大模型 API 接入文档 +
-
API Gateway Docs
-

大模型 API 使用文档

-

- 这是面向当前站点用户的接入说明页。页面内容按 OpenAI 兼容网关的常见用法组织,适合挂在“系统设置 -> 通用设置 -> 文档链接”或“自定义菜单”中直接访问。 +

+ CC-Switch 优先 + 默认 Host:https://re.94xy.cn + 文档路径:/docs/api.html +
+

大模型 API 接入文档

+

+ 推荐流程:先进入 /keys 创建或复制 API Key,再优先点击 + 导入到 CCS 一键导入到 CC-Switch。其余客户端按对应协议接入: + OpenAI SDK / HTTP 使用 /v1,Claude Code 使用根 Host,Gemini / OpenClaw / Hermes 按各自配置格式接入。

+
-
推荐访问路径
-
/docs/api.html
+
默认主 Host
+
https://re.94xy.cn
-
默认协议
-
Bearer API Key
+
站内取 Key 路径
+
/keys -> 创建密钥 / 使用密钥 / 导入到 CCS
-
兼容方向
-
OpenAI / Claude Code / Gemini CLI / Codex CLI
+
文档放置方式
+
静态页,适合挂到文档链接或自定义菜单
+
+
+
兼容协议
+
OpenAI / Claude / Gemini / Responses / OpenClaw / Hermes
@@ -390,284 +898,531 @@
+
+

快速开始

+

+ 默认推荐走 CC-Switch。它和站内 API 密钥 页面已经对齐,拿到 Key 后可以直接一键导入。 +

+
+
+
01
+

获取 API Key

+

登录站点后进入 /keys。创建密钥后,列表行内可以直接看到 使用密钥导入到 CCS 两个动作。

+
+
+
02
+

优先一键导入

+

点击 导入到 CCS。OpenAI 分组会导入为 Codex,Anthropic 分组导入为 Claude Code,Gemini 分组导入为 Gemini CLI,Antigravity 会先让你选 Claude 或 Gemini。

+
+
+
03
+

按客户端使用

+

如果不使用 CC-Switch,按下方目录选择对应格式:HTTP、OpenAI SDK、Claude Code、Gemini CLI、Codex CLI、OpenClaw、Hermes。

+
+
+
+ 后台管理员如果隐藏了 导入到 CCS 按钮,用户仍然可以在本页使用“手动导入 CC-Switch”工具,或按下方 CLI / SDK 示例手动配置。 +
+
+ +
+

CC-Switch

+

+ 站内默认推荐使用 CC-Switch。先在站内 /keys 获取 API Key,再导入到对应客户端配置。 +

+
+
+

下载安装索引

+
    +
  • 官方 Releases:GitHub Releases
  • +
  • Windows:推荐 CC-Switch-*-Windows.msi;便携版可用 Windows-Portable.zip
  • +
  • macOS:推荐 macOS.zip;Homebrew 可用 brew tap farion1231/ccswitch + brew install --cask cc-switch
  • +
  • Linux:Debian/Ubuntu 用 .deb,Fedora/RHEL 用 .rpm,通用可用 .AppImage
  • +
+ +
+
+

站内一键导入位置

+
    +
  • 路径:/keys
  • +
  • 拿到 Key 后,点击同一行的 导入到 CCS
  • +
  • Antigravity 分组会先选择导入到 Claude CodeGemini CLI
  • +
  • 导入完成后,在 CC-Switch 对应应用的 Provider 列表里查看
  • +
+
+ 如果点击导入后没有拉起客户端,通常是 CC-Switch 未安装,或系统还没有注册 ccswitch:// 协议。 +
+
+
+ +
+
+

导入映射

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
站内分组导入应用CC-Switch Endpoint
OpenAICodexHOST
Anthropic / ClaudeClaude CodeHOST
GeminiGemini CLIHOST
AntigravityClaude Code / Gemini CLIHOST/antigravity
+
+
+
+

手动导入 CC-Switch

+
+
+ + + 默认优先使用 https://re.94xy.cn +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+
-

接入概览

+

支持平台概览

- 用户先在平台生成 API Key,再通过 OpenAI 兼容地址访问上游模型。站点实际域名、默认 Base URL、自定义端点由管理员在“系统设置 -> 通用设置”中维护,这个页面只负责给出固定的使用范式。 + 不同客户端读取 Host 的方式不一样。HTTP / OpenAI SDK 通常需要显式写 /v1;站内 CLI 配置模板大多使用根 Host。

-
-
-

认证方式

-

所有请求统一使用 Authorization: Bearer YOUR_API_KEY

-
-
-

常用入口

-

推荐默认 OpenAI 兼容路径:/v1/chat/completions

-
-
-
- 页面不直接写死你的正式域名。部署后建议把“文档链接”设置为完整地址,例如 https://your-domain.com/docs/api.html,这样头部文档按钮和自定义菜单都会跳转到这个静态页。 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
平台 / 客户端协议推荐写法说明
HTTP / OpenAI SDKOpenAI Chat / Responseshttps://re.94xy.cn/v1标准服务端与脚本最稳妥
Claude CodeAnthropic 兼容https://re.94xy.cn使用 ANTHROPIC_BASE_URL
Gemini CLIGemini CLI 兼容https://re.94xy.cn使用 GOOGLE_GEMINI_BASE_URL
Codex CLIOpenAI Responseshttps://re.94xy.cn按站内生成模板写入 ~/.codex
OpenCodeOpenAI / Anthropic / Google Providerhttps://re.94xy.cn/v1OpenAI 分组按 baseURL 配置
OpenClawCustom Providerhttps://re.94xy.cn/v1~/.openclaw/openclaw.jsonmodels.providers
HermesCustom Endpointhttps://re.94xy.cn/v1当前版本使用 ~/.hermes/config.yaml
-
-

接口路径

+
+

API 接口地址 Host 列表

- 下表给出常见兼容路径。默认情况下,站点会把这些路径代理到对应上游模型服务。 + 默认主 Host 为 re.94xy.cn。如果后台配置了公开的 API Base URL 或自定义端点,下方会自动带出来。

-
-
- OpenAI 兼容 - https://your-domain.com/v1/chat/completions -
-
- Gemini 兼容 - https://your-domain.com/v1beta -
-
- Claude Code / Anthropic - https://your-domain.com -
-
- Antigravity 专用 - https://your-domain.com/antigravity -
+
+
+ HTTP / OpenAI SDK 通常写 HOST/v1;Gemini / OpenClaw / Hermes 的详细格式看各自章节。站内 API 密钥 页面也会展示管理员配置的可用 Host。
-
-

HTTP 调用示例

+
+

HTTP 示例

- 业务系统、脚本和第三方客户端优先使用 OpenAI 兼容格式,兼容性最好。 + 直接脚本、后端服务、第三方网关优先用 OpenAI 兼容格式。下方示例默认使用 https://re.94xy.cn/v1

- cURL: Chat Completions - /v1/chat/completions + cURL /v1/chat/completions +
-
curl https://your-domain.com/v1/chat/completions \
+              
curl https://re.94xy.cn/v1/chat/completions \
   -H "Authorization: Bearer YOUR_API_KEY" \
   -H "Content-Type: application/json" \
   -d '{
     "model": "gpt-5.4",
     "messages": [
       {"role": "system", "content": "You are a helpful assistant."},
-      {"role": "user", "content": "请简要介绍这个 API 网关"}
+      {"role": "user", "content": "请给我一份三点接入建议"}
     ]
   }'
- cURL: 流式输出 - "stream": true + cURL /v1/models +
-
curl https://your-domain.com/v1/chat/completions \
-  -H "Authorization: Bearer YOUR_API_KEY" \
-  -H "Content-Type: application/json" \
-  -d '{
-    "model": "gpt-5.4",
-    "stream": true,
-    "messages": [
-      {"role": "user", "content": "生成一个三点行动计划"}
-    ]
-  }'
+
curl https://re.94xy.cn/v1/models \
+  -H "Authorization: Bearer YOUR_API_KEY"
-
-

SDK 示例

+
+

JavaScript SDK

- 只要 SDK 支持自定义 base_urlbaseURL,通常都能平滑接入。 + 只要 SDK 支持自定义 baseURL,大多数都能直接接入。

-
-
-
- JavaScript - openai npm -
-
import OpenAI from "openai";
+            
+
+ openai npm + +
+
import OpenAI from "openai";
 
 const client = new OpenAI({
   apiKey: process.env.API_KEY,
-  baseURL: "https://your-domain.com/v1",
+  baseURL: "https://re.94xy.cn/v1",
 });
 
-const res = await client.chat.completions.create({
+const response = await client.chat.completions.create({
   model: "gpt-5.4",
   messages: [{ role: "user", content: "Hello" }],
 });
 
-console.log(res.choices[0]?.message?.content);
+console.log(response.choices[0]?.message?.content);
+
+
+ +
+

Python SDK

+

+ Python 接入方式和 JavaScript 一样,关键是把 base_url 指向网关的 /v1。 +

+
+
+ openai python +
-
-
- Python - openai python -
-
from openai import OpenAI
+              
from openai import OpenAI
 
 client = OpenAI(
     api_key="YOUR_API_KEY",
-    base_url="https://your-domain.com/v1",
+    base_url="https://re.94xy.cn/v1",
 )
 
-res = client.chat.completions.create(
+response = client.chat.completions.create(
     model="gpt-5.4",
     messages=[{"role": "user", "content": "Hello"}],
 )
 
-print(res.choices[0].message.content)
-
+print(response.choices[0].message.content)
-
-

CLI 配置

+
+

Claude Code

- 如果你的用户主要通过 Claude Code、Gemini CLI、Codex CLI 或 OpenCode 接入,可以直接参考下面的环境变量和配置示例。 + Claude Code 按站内现有模板使用根 Host,不额外拼 /v1。 +

+
+
+ macOS / Linux + +
+
export ANTHROPIC_BASE_URL="https://re.94xy.cn"
+export ANTHROPIC_AUTH_TOKEN="YOUR_API_KEY"
+export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
+
+
+ +
+

Gemini CLI

+

+ Gemini CLI 也使用根 Host。模型名按你当前可用权限调整。 +

+
+
+ macOS / Linux + +
+
export GOOGLE_GEMINI_BASE_URL="https://re.94xy.cn"
+export GEMINI_API_KEY="YOUR_API_KEY"
+export GEMINI_MODEL="gemini-2.0-flash"
+
+
+ +
+

Codex CLI

+

+ 站内 使用密钥 页面给 Codex CLI 生成的是 ~/.codex/config.tomlauth.json 两个文件。以下格式与站内模板一致。

- Claude Code - Anthropic 风格 + ~/.codex/config.toml +
-
export ANTHROPIC_BASE_URL="https://your-domain.com"
-export ANTHROPIC_AUTH_TOKEN="YOUR_API_KEY"
-export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
-
-
-
- Gemini CLI - Gemini 风格 -
-
export GOOGLE_GEMINI_BASE_URL="https://your-domain.com/v1beta"
-export GEMINI_API_KEY="YOUR_API_KEY"
-export GEMINI_MODEL="gemini-2.0-flash"
-
-
-
- Codex CLI - ~/.codex/config.toml -
-
model_provider = "OpenAI"
+                
model_provider = "OpenAI"
 model = "gpt-5.4"
+review_model = "gpt-5.4"
+model_reasoning_effort = "xhigh"
 disable_response_storage = true
 network_access = "enabled"
+windows_wsl_setup_acknowledged = true
+model_context_window = 1000000
+model_auto_compact_token_limit = 900000
 
 [model_providers.OpenAI]
 name = "OpenAI"
-base_url = "https://your-domain.com/v1"
+base_url = "https://re.94xy.cn"
 wire_api = "responses"
 requires_openai_auth = true
- OpenCode - opencode.json + ~/.codex/auth.json +
-
{
-  "provider": {
-    "openai": {
-      "options": {
-        "baseURL": "https://your-domain.com/v1",
-        "apiKey": "YOUR_API_KEY"
-      }
-    }
-  },
-  "model": "openai/gpt-5.4"
+                
{
+  "OPENAI_API_KEY": "YOUR_API_KEY"
 }
-
-

兼容格式

+
+

OpenCode

- 这个站点本质上是统一网关,所以文档应重点说明“按什么协议接入”,而不是只强调某一家模型。 + OpenCode 建议写入 opencode.json。OpenAI 分组的核心是 provider.openai.options.baseURLapiKey

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
类型适用场景关键路径 / 变量
OpenAI Chat Completions网页、服务端、脚本、第三方 SDK/v1/chat/completions
Responses / CodexCodex CLI、支持 responses 的客户端base_url=/v1
Anthropic / ClaudeClaude CodeANTHROPIC_BASE_URL
GeminiGemini CLI、Gemini 兼容调用GOOGLE_GEMINI_BASE_URL
+
+
+ opencode.json + +
+
{
+  "provider": {
+    "openai": {
+      "options": {
+        "baseURL": "https://re.94xy.cn/v1",
+        "apiKey": "YOUR_API_KEY"
+      }
+    }
+  },
+  "agent": {
+    "build": {
+      "options": {
+        "store": false
+      }
+    },
+    "plan": {
+      "options": {
+        "store": false
+      }
+    }
+  },
+  "$schema": "https://opencode.ai/config.json"
+}
-
+
+

OpenClaw

+

+ OpenClaw 当前推荐按 ~/.openclaw/openclaw.jsonmodels.providers 写自定义 Provider。这里采用站内实际生成格式:OpenAI 分组使用 api: "openai-responses"。 +

+
+
+ ~/.openclaw/openclaw.json + +
+
{
+  "env": {
+    "SUB2API_API_KEY": "YOUR_API_KEY"
+  },
+  "agents": {
+    "defaults": {
+      "model": {
+        "primary": "sub2api-openai/gpt-5.4"
+      }
+    }
+  },
+  "models": {
+    "mode": "merge",
+    "providers": {
+      "sub2api-openai": {
+        "baseUrl": "https://re.94xy.cn/v1",
+        "apiKey": "${SUB2API_API_KEY}",
+        "api": "openai-responses",
+        "models": [
+          { "id": "gpt-5.4", "name": "GPT-5.4" },
+          { "id": "gpt-5.4-mini", "name": "GPT-5.4 Mini" },
+          { "id": "gpt-5.3-codex", "name": "GPT-5.3 Codex" }
+        ]
+      }
+    }
+  }
+}
+
+
+ 参考 OpenClaw 官方模型提供者文档:自定义 Provider 建议放在 models.providers 下,并按实际后端选择 openai-responses 或其他 API 类型。 +
+
+ +
+

Hermes

+

+ Hermes 当前版本以 ~/.hermes/config.yaml 为准。官方文档已经明确:旧的 OPENAI_BASE_URL / LLM_MODEL 环境变量不再作为主配置来源。 +

+
+
+
+ ~/.hermes/config.yaml + +
+
model:
+  default: gpt-5.4
+  provider: custom
+  base_url: https://re.94xy.cn/v1
+  api_key: YOUR_API_KEY
+
+
+
+ 交互式配置 + +
+
hermes model
+# 选择 "Custom endpoint (self-hosted / VLLM / etc.)"
+# API base URL: https://re.94xy.cn/v1
+# API key: YOUR_API_KEY
+# Model name: gpt-5.4
+
+
+
+ 当前 Hermes 的自定义端点以 config.yaml 为单一事实来源。不要再依赖旧版 .env 里的 OPENAI_BASE_URL。 +
+
+ +

常见问题

- - + + - - + + + + + + - + - + - - - - - - + +
状态码说明问题排查方向
401API Key 缺失、错误或已失效。先检查 Bearer Token。CC-Switch 一键导入没有拉起确认已安装 CC-Switch,且系统已注册 ccswitch:// 协议;否则先走安装。
401 / invalid_api_key检查 Key 是否从 /keys 复制完整,确认没有多空格、换行或旧 Key。
403密钥被禁用、IP 限制未通过,或当前分组无权限。密钥可能被禁用、触发 IP 限制,或当前分组不可用。
404请求路径错误,常见于把 /v1/v1beta 写错。大多数是 Host 或路径写错。HTTP / SDK 常用 /v1;Claude / Gemini / Codex CLI 多数写根 Host。
429触发速率限制或额度限制,可去 API Key 页面看用量窗口。
5xx网关或上游临时异常,建议稍后重试并检查后台账号状态。Hermes / OpenClaw 不工作先确认配置文件路径正确,再确认 Host 是否按当前客户端格式填写。OpenClaw 看 openclaw.json,Hermes 看 ~/.hermes/config.yaml
-
- 如果你希望这个静态页显示你自己的域名、默认模型、品牌名,可以后续再把它改成读取运行时配置的版本。但按你当前要求,静态 HTML 更简单,也最适合挂到“文档链接”里。 -
@@ -676,5 +1431,8 @@ requires_openai_auth = true 静态文档地址:/docs/api.html
+ + + diff --git a/frontend/public/docs/api.i18n.js b/frontend/public/docs/api.i18n.js new file mode 100644 index 00000000..8eb78e32 --- /dev/null +++ b/frontend/public/docs/api.i18n.js @@ -0,0 +1,667 @@ +(function () { + window.DOCS_I18N = { + zh: { + lang: "zh-CN", + localeLabel: "语言", + viewDocs: "查看文档", + goLogin: "登录", + dashboard: "控制台", + themeDark: "切换到深色模式", + themeLight: "切换到浅色模式", + titleSuffix: "大模型 API 接入文档", + messages: { + copy: "复制", + copied: "已复制", + copyFailed: "复制失败,请手动复制。", + pasteApiKeyFirst: "请先粘贴 API Key。", + tryingOpen: "正在尝试拉起 CC-Switch...", + clientNotOpened: + "没有拉起客户端。请先安装 CC-Switch,或检查协议处理程序是否已注册。", + deepLinkCopied: "CC-Switch 深链已复制。", + importFailed: "导入失败。", + copyRootHost: "复制根 Host", + copyV1: "复制 /v1", + }, + hero: { + priority: "CC-Switch 优先", + defaultHostLabel: "默认 Host:", + docPathLabel: "文档路径:", + title: "大模型 API 接入文档", + copyHtml: + '推荐流程:先进入 /keys 创建或复制 API Key,再优先点击 导入到 CCS 一键导入到 CC-Switch。其余客户端按对应协议接入:OpenAI SDK / HTTP 使用 /v1,Claude Code 使用根 Host,Gemini / OpenClaw / Hermes 按各自配置格式接入。', + actions: ["进入 API 密钥", "下载 CC-Switch", "查看 Host 列表"], + statLabels: ["默认主 Host", "站内取 Key 路径", "文档放置方式", "兼容协议"], + keyPathHtml: + "/keys -> 创建密钥 / 使用密钥 / 导入到 CCS", + placement: + "静态页,适合挂到文档链接或自定义菜单", + protocols: + "OpenAI / Claude / Gemini / Responses / OpenClaw / Hermes", + }, + sidebar: { + title: "目录", + links: [ + "快速开始", + "CC-Switch", + "支持平台概览", + "Host 列表", + "HTTP 示例", + "JavaScript SDK", + "Python SDK", + "Claude Code", + "Gemini CLI", + "Codex CLI", + "OpenCode", + "OpenClaw", + "Hermes", + "常见问题", + ], + }, + quickstart: { + title: "快速开始", + descHtml: + '默认推荐走 CC-Switch。它和站内 API 密钥 页面已经对齐,拿到 Key 后可以直接一键导入。', + cards: [ + { + title: "获取 API Key", + bodyHtml: + '登录站点后进入 /keys。创建密钥后,列表行内可以直接看到 使用密钥导入到 CCS 两个动作。', + }, + { + title: "优先一键导入", + bodyHtml: + '点击 导入到 CCS。OpenAI 分组会导入为 Codex,Anthropic 分组导入为 Claude Code,Gemini 分组导入为 Gemini CLI,Antigravity 会先让你选 Claude 或 Gemini。', + }, + { + title: "按客户端使用", + bodyHtml: + "如果不使用 CC-Switch,按下方目录选择对应格式:HTTP、OpenAI SDK、Claude Code、Gemini CLI、Codex CLI、OpenClaw、Hermes。", + }, + ], + noteHtml: + '后台管理员如果隐藏了 导入到 CCS 按钮,用户仍然可以在本页使用“手动导入 CC-Switch”工具,或按下方 CLI / SDK 示例手动配置。', + }, + ccswitch: { + title: "CC-Switch", + descHtml: + '站内默认推荐使用 CC-Switch。先在站内 /keys 获取 API Key,再导入到对应客户端配置。', + cards: { + download: { + title: "下载安装索引", + items: [ + '官方 Releases:GitHub Releases', + 'Windows:推荐 CC-Switch-*-Windows.msi;便携版可用 Windows-Portable.zip', + 'macOS:推荐 macOS.zip;Homebrew 可用 brew tap farion1231/ccswitch + brew install --cask cc-switch', + 'Linux:Debian/Ubuntu 用 .deb,Fedora/RHEL 用 .rpm,通用可用 .AppImage', + ], + actions: ["打开下载页", "去 API 密钥"], + }, + oneClick: { + title: "站内一键导入位置", + items: [ + "路径:/keys", + '拿到 Key 后,点击同一行的 导入到 CCS', + 'Antigravity 分组会先选择导入到 Claude CodeGemini CLI', + "导入完成后,在 CC-Switch 对应应用的 Provider 列表里查看", + ], + noteHtml: + '如果点击导入后没有拉起客户端,通常是 CC-Switch 未安装,或系统还没有注册 ccswitch:// 协议。', + }, + importMap: { + title: "导入映射", + headers: ["站内分组", "导入应用", "CC-Switch Endpoint"], + rows: [ + ["OpenAI", "Codex", "HOST"], + ["Anthropic / Claude", "Claude Code", "HOST"], + ["Gemini", "Gemini CLI", "HOST"], + [ + "Antigravity", + "Claude Code / Gemini CLI", + "HOST/antigravity", + ], + ], + }, + manual: { + title: "手动导入 CC-Switch", + labels: ["Host", "导入类型", "Provider 名称", "API Key"], + hostHelpHtml: + '默认优先使用 https://re.94xy.cn。', + apiKeyPlaceholder: "粘贴从 /keys 复制出来的 API Key", + buttons: ["导入到 CC-Switch", "复制深链"], + options: [ + "OpenAI / Codex", + "Claude Code", + "Gemini CLI", + "Antigravity -> Claude", + "Antigravity -> Gemini", + ], + }, + }, + }, + overview: { + title: "支持平台概览", + descHtml: + '不同客户端读取 Host 的方式不一样。HTTP / OpenAI SDK 通常需要显式写 /v1;站内 CLI 配置模板大多使用根 Host。', + headers: ["平台 / 客户端", "协议", "推荐写法", "说明"], + rows: [ + [ + "HTTP / OpenAI SDK", + "OpenAI Chat / Responses", + 'https://re.94xy.cn/v1', + "标准服务端与脚本最稳妥", + ], + [ + "Claude Code", + "Anthropic 兼容", + 'https://re.94xy.cn', + "使用 ANTHROPIC_BASE_URL", + ], + [ + "Gemini CLI", + "Gemini CLI 兼容", + 'https://re.94xy.cn', + "使用 GOOGLE_GEMINI_BASE_URL", + ], + [ + "Codex CLI", + "OpenAI Responses", + 'https://re.94xy.cn', + "按站内生成模板写入 ~/.codex", + ], + [ + "OpenCode", + "OpenAI / Anthropic / Google Provider", + 'https://re.94xy.cn/v1', + "OpenAI 分组按 baseURL 配置", + ], + [ + "OpenClaw", + "Custom Provider", + 'https://re.94xy.cn/v1', + '按 ~/.openclaw/openclaw.jsonmodels.providers', + ], + [ + "Hermes", + "Custom Endpoint", + 'https://re.94xy.cn/v1', + "当前版本使用 ~/.hermes/config.yaml", + ], + ], + }, + hosts: { + title: "API 接口地址 Host 列表", + descHtml: + '默认主 Host 为 re.94xy.cn。如果后台配置了公开的 API Base URL 或自定义端点,下方会自动带出来。', + noteHtml: + 'HTTP / OpenAI SDK 通常写 HOST/v1;Gemini / OpenClaw / Hermes 的详细格式看各自章节。站内 API 密钥 页面也会展示管理员配置的可用 Host。', + }, + http: { + title: "HTTP 示例", + descHtml: + '直接脚本、后端服务、第三方网关优先用 OpenAI 兼容格式。下方示例默认使用 https://re.94xy.cn/v1。', + }, + sdkJs: { + title: "JavaScript SDK", + descHtml: + "只要 SDK 支持自定义 baseURL,大多数都能直接接入。", + }, + sdkPython: { + title: "Python SDK", + descHtml: + 'Python 接入方式和 JavaScript 一样,关键是把 base_url 指向网关的 /v1。', + }, + claude: { + title: "Claude Code", + descHtml: + 'Claude Code 按站内现有模板使用根 Host,不额外拼 /v1。', + }, + gemini: { + title: "Gemini CLI", + descHtml: + "Gemini CLI 也使用根 Host。模型名按你当前可用权限调整。", + }, + codex: { + title: "Codex CLI", + descHtml: + '站内 使用密钥 页面给 Codex CLI 生成的是 ~/.codex/config.tomlauth.json 两个文件。以下格式与站内模板一致。', + }, + opencode: { + title: "OpenCode", + descHtml: + 'OpenCode 建议写入 opencode.json。OpenAI 分组的核心是 provider.openai.options.baseURLapiKey。', + }, + openclaw: { + title: "OpenClaw", + descHtml: + 'OpenClaw 当前推荐按 ~/.openclaw/openclaw.jsonmodels.providers 写自定义 Provider。这里采用站内实际生成格式:OpenAI 分组使用 api: "openai-responses"。', + noteHtml: + '参考 OpenClaw 官方模型提供者文档:自定义 Provider 建议放在 models.providers 下,并按实际后端选择 openai-responses 或其他 API 类型。', + }, + hermes: { + title: "Hermes", + descHtml: + 'Hermes 当前版本以 ~/.hermes/config.yaml 为准。官方文档已经明确:旧的 OPENAI_BASE_URL / LLM_MODEL 环境变量不再作为主配置来源。', + interactiveTitle: "交互式配置", + warningHtml: + '当前 Hermes 的自定义端点以 config.yaml 为单一事实来源。不要再依赖旧版 .env 里的 OPENAI_BASE_URL。', + }, + faq: { + title: "常见问题", + headers: ["问题", "排查方向"], + rows: [ + [ + "CC-Switch 一键导入没有拉起", + '确认已安装 CC-Switch,且系统已注册 ccswitch:// 协议;否则先走安装。', + ], + [ + "401 / invalid_api_key", + '检查 Key 是否从 /keys 复制完整,确认没有多空格、换行或旧 Key。', + ], + [ + "403", + "密钥可能被禁用、触发 IP 限制,或当前分组不可用。", + ], + [ + "404", + '大多数是 Host 或路径写错。HTTP / SDK 常用 /v1;Claude / Gemini / Codex CLI 多数写根 Host。', + ], + [ + "Hermes / OpenClaw 不工作", + '先确认配置文件路径正确,再确认 Host 是否按当前客户端格式填写。OpenClaw 看 openclaw.json,Hermes 看 ~/.hermes/config.yaml。', + ], + ], + }, + footerHtml: '静态文档地址:/docs/api.html', + codeTitles: { + httpChat: "cURL /v1/chat/completions", + httpModels: "cURL /v1/models", + sdkJs: "openai npm", + sdkPython: "openai python", + claude: "macOS / Linux", + gemini: "macOS / Linux", + codexConfig: "~/.codex/config.toml", + codexAuth: "~/.codex/auth.json", + opencode: "opencode.json", + openclaw: "~/.openclaw/openclaw.json", + hermes: "~/.hermes/config.yaml", + hermesInteractive: "交互式配置", + }, + codeSamples: { + httpChat: `curl https://re.94xy.cn/v1/chat/completions \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "gpt-5.4", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "请给我一份三点接入建议"} + ] + }'`, + hermesInteractive: `hermes model +# 选择 "Custom endpoint (self-hosted / VLLM / etc.)" +# API base URL: https://re.94xy.cn/v1 +# API key: YOUR_API_KEY +# Model name: gpt-5.4`, + }, + hostCard: { + fallbackDesc: "公开可访问的网关入口", + labels: { + root: "根 Host", + openai: "OpenAI SDK / HTTP", + gemini: "Gemini / OpenClaw / Hermes", + antigravity: "Antigravity", + }, + builtin: { + default: { + name: "默认主 Host", + description: "文档默认推荐值", + }, + current: { + name: "当前访问地址", + description: "当前浏览器访问的站点地址", + }, + apiBase: { + name: "后台 API Base URL", + description: "系统设置 -> 通用设置 中公开的 API Base URL", + }, + customPrefix: "自定义端点", + customDescription: "后台公开的自定义 Host", + }, + }, + }, + en: { + lang: "en", + localeLabel: "Language", + viewDocs: "Docs", + goLogin: "Login", + dashboard: "Dashboard", + themeDark: "Switch to dark mode", + themeLight: "Switch to light mode", + titleSuffix: "API Integration Guide", + messages: { + copy: "Copy", + copied: "Copied", + copyFailed: "Copy failed. Please copy it manually.", + pasteApiKeyFirst: "Paste an API key first.", + tryingOpen: "Trying to open CC-Switch...", + clientNotOpened: + "The client was not opened. Install CC-Switch or check whether the protocol handler is registered.", + deepLinkCopied: "CC-Switch deep link copied.", + importFailed: "Import failed.", + copyRootHost: "Copy root host", + copyV1: "Copy /v1", + }, + hero: { + priority: "CC-Switch First", + defaultHostLabel: "Default Host:", + docPathLabel: "Docs path:", + title: "API Integration Guide", + copyHtml: + 'Recommended flow: go to /keys to create or copy an API key, then click Import to CCS to import it into CC-Switch in one step. For other clients, use the matching protocol: OpenAI SDK / HTTP uses /v1; Claude Code uses the root host; Gemini / OpenClaw / Hermes use their own config formats.', + actions: ["Open API Keys", "Download CC-Switch", "View Host List"], + statLabels: [ + "Default host", + "Where to get keys", + "Recommended placement", + "Supported protocols", + ], + keyPathHtml: + "/keys -> create key / use key / import to CCS", + placement: + "Static page, suitable for the Docs link or a custom menu entry", + protocols: + "OpenAI / Claude / Gemini / Responses / OpenClaw / Hermes", + }, + sidebar: { + title: "Contents", + links: [ + "Quick Start", + "CC-Switch", + "Platform Overview", + "Host List", + "HTTP Examples", + "JavaScript SDK", + "Python SDK", + "Claude Code", + "Gemini CLI", + "Codex CLI", + "OpenCode", + "OpenClaw", + "Hermes", + "FAQ", + ], + }, + quickstart: { + title: "Quick Start", + descHtml: + 'CC-Switch is the default recommendation. It already matches the API Keys page on this site, so once you have a key you can import it directly.', + cards: [ + { + title: "Get an API key", + bodyHtml: + 'After logging in, open /keys. Once a key is created, each row shows both Use Key and Import to CCS.', + }, + { + title: "Prefer one-click import", + bodyHtml: + 'Click Import to CCS. OpenAI groups are imported as Codex, Anthropic groups as Claude Code, Gemini groups as Gemini CLI, and Antigravity lets you choose Claude or Gemini first.', + }, + { + title: "Choose by client", + bodyHtml: + "If you do not use CC-Switch, pick the matching format below: HTTP, OpenAI SDK, Claude Code, Gemini CLI, Codex CLI, OpenClaw, or Hermes.", + }, + ], + noteHtml: + 'If the admin hides the Import to CCS button, users can still use the manual CC-Switch import tool on this page or configure the client manually with the CLI / SDK examples below.', + }, + ccswitch: { + title: "CC-Switch", + descHtml: + 'CC-Switch is the default recommendation on this site. First get an API key from /keys, then import it into the target client.', + cards: { + download: { + title: "Download Index", + items: [ + 'Official releases: GitHub Releases', + 'Windows: use CC-Switch-*-Windows.msi; a portable build is available as Windows-Portable.zip', + 'macOS: use macOS.zip; Homebrew is also available with brew tap farion1231/ccswitch + brew install --cask cc-switch', + 'Linux: use .deb for Debian/Ubuntu, .rpm for Fedora/RHEL, or .AppImage for a generic build', + ], + actions: ["Open download page", "Open API Keys"], + }, + oneClick: { + title: "Where one-click import lives", + items: [ + "Path: /keys", + 'After you get the key, click Import to CCS on the same row', + 'For Antigravity groups, choose either Claude Code or Gemini CLI first', + "After import, check the provider list for that app inside CC-Switch", + ], + noteHtml: + 'If clicking import does not open the client, CC-Switch is usually not installed yet, or the ccswitch:// protocol has not been registered on the system.', + }, + importMap: { + title: "Import Mapping", + headers: ["Site group", "Imported app", "CC-Switch endpoint"], + rows: [ + ["OpenAI", "Codex", "HOST"], + ["Anthropic / Claude", "Claude Code", "HOST"], + ["Gemini", "Gemini CLI", "HOST"], + [ + "Antigravity", + "Claude Code / Gemini CLI", + "HOST/antigravity", + ], + ], + }, + manual: { + title: "Manual CC-Switch Import", + labels: ["Host", "Import type", "Provider name", "API key"], + hostHelpHtml: + 'Default recommendation: https://re.94xy.cn.', + apiKeyPlaceholder: "Paste the API key copied from /keys", + buttons: ["Import to CC-Switch", "Copy deep link"], + options: [ + "OpenAI / Codex", + "Claude Code", + "Gemini CLI", + "Antigravity -> Claude", + "Antigravity -> Gemini", + ], + }, + }, + }, + overview: { + title: "Platform Overview", + descHtml: + 'Different clients read the host in different ways. HTTP / OpenAI SDK usually needs an explicit /v1; most CLI templates on this site use the root host.', + headers: ["Platform / Client", "Protocol", "Recommended format", "Notes"], + rows: [ + [ + "HTTP / OpenAI SDK", + "OpenAI Chat / Responses", + 'https://re.94xy.cn/v1', + "Best default for servers and scripts", + ], + [ + "Claude Code", + "Anthropic compatible", + 'https://re.94xy.cn', + "Use ANTHROPIC_BASE_URL", + ], + [ + "Gemini CLI", + "Gemini CLI compatible", + 'https://re.94xy.cn', + "Use GOOGLE_GEMINI_BASE_URL", + ], + [ + "Codex CLI", + "OpenAI Responses", + 'https://re.94xy.cn', + "Write the template generated by this site into ~/.codex", + ], + [ + "OpenCode", + "OpenAI / Anthropic / Google Provider", + 'https://re.94xy.cn/v1', + "OpenAI groups use baseURL", + ], + [ + "OpenClaw", + "Custom Provider", + 'https://re.94xy.cn/v1', + 'Write models.providers in ~/.openclaw/openclaw.json', + ], + [ + "Hermes", + "Custom Endpoint", + 'https://re.94xy.cn/v1', + "Current releases use ~/.hermes/config.yaml", + ], + ], + }, + hosts: { + title: "API Host List", + descHtml: + 'The default primary host is re.94xy.cn. If the admin configured a public API Base URL or custom endpoints, they will appear below automatically.', + noteHtml: + 'HTTP / OpenAI SDK usually uses HOST/v1; for Gemini / OpenClaw / Hermes, see each section for the exact format. The API Keys page on this site also shows the available hosts configured by the admin.', + }, + http: { + title: "HTTP Examples", + descHtml: + 'For direct scripts, backend services, or third-party gateways, prefer the OpenAI-compatible format. The examples below use https://re.94xy.cn/v1.', + }, + sdkJs: { + title: "JavaScript SDK", + descHtml: + "As long as the SDK supports a custom baseURL, most clients work directly.", + }, + sdkPython: { + title: "Python SDK", + descHtml: + 'Python works the same way as JavaScript: point base_url at the gateway /v1.', + }, + claude: { + title: "Claude Code", + descHtml: + 'Claude Code uses the root host from the current site templates and does not need an extra /v1.', + }, + gemini: { + title: "Gemini CLI", + descHtml: + "Gemini CLI also uses the root host. Adjust the model name based on the access you currently have.", + }, + codex: { + title: "Codex CLI", + descHtml: + 'The Use Key page on this site generates two files for Codex CLI: ~/.codex/config.toml and auth.json. The format below matches that template.', + }, + opencode: { + title: "OpenCode", + descHtml: + 'OpenCode is best configured through opencode.json. For OpenAI groups, the key fields are provider.openai.options.baseURL and apiKey.', + }, + openclaw: { + title: "OpenClaw", + descHtml: + 'For OpenClaw, the current recommendation is to define a custom provider in models.providers inside ~/.openclaw/openclaw.json. This page follows the format generated by the site: OpenAI groups use api: "openai-responses".', + noteHtml: + 'See the official OpenClaw provider docs as well: custom providers should live under models.providers, and the API type should match the backend, such as openai-responses.', + }, + hermes: { + title: "Hermes", + descHtml: + 'Current Hermes releases use ~/.hermes/config.yaml. The official docs already make it clear that the older OPENAI_BASE_URL / LLM_MODEL environment variables are no longer the primary source of truth.', + interactiveTitle: "Interactive setup", + warningHtml: + 'For Hermes, the custom endpoint should now be configured from config.yaml. Do not rely on the legacy OPENAI_BASE_URL value from older .env setups anymore.', + }, + faq: { + title: "FAQ", + headers: ["Issue", "What to check"], + rows: [ + [ + "CC-Switch one-click import does not open", + 'Confirm that CC-Switch is installed and the ccswitch:// protocol is registered on the system first.', + ], + [ + "401 / invalid_api_key", + 'Check whether the key was copied completely from /keys, without extra spaces, line breaks, or an older key value.', + ], + [ + "403", + "The key may have been disabled, failed an IP restriction, or the current group may not be available.", + ], + [ + "404", + 'Most of the time the host or path is wrong. HTTP / SDK usually uses /v1; Claude / Gemini / Codex CLI usually use the root host.', + ], + [ + "Hermes / OpenClaw does not work", + 'Check the config file path first, then verify the host format for the current client. OpenClaw uses openclaw.json; Hermes uses ~/.hermes/config.yaml.', + ], + ], + }, + footerHtml: 'Static docs path: /docs/api.html', + codeTitles: { + httpChat: "cURL /v1/chat/completions", + httpModels: "cURL /v1/models", + sdkJs: "openai npm", + sdkPython: "openai python", + claude: "macOS / Linux", + gemini: "macOS / Linux", + codexConfig: "~/.codex/config.toml", + codexAuth: "~/.codex/auth.json", + opencode: "opencode.json", + openclaw: "~/.openclaw/openclaw.json", + hermes: "~/.hermes/config.yaml", + hermesInteractive: "Interactive setup", + }, + codeSamples: { + httpChat: `curl https://re.94xy.cn/v1/chat/completions \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "gpt-5.4", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Please give me three integration tips"} + ] + }'`, + hermesInteractive: `hermes model +# Choose "Custom endpoint (self-hosted / VLLM / etc.)" +# API base URL: https://re.94xy.cn/v1 +# API key: YOUR_API_KEY +# Model name: gpt-5.4`, + }, + hostCard: { + fallbackDesc: "Publicly accessible gateway entry", + labels: { + root: "Root host", + openai: "OpenAI SDK / HTTP", + gemini: "Gemini / OpenClaw / Hermes", + antigravity: "Antigravity", + }, + builtin: { + default: { + name: "Default primary host", + description: "Recommended default in this document", + }, + current: { + name: "Current site origin", + description: "The address currently opened in the browser", + }, + apiBase: { + name: "Public API Base URL", + description: "The public API Base URL exposed in Settings -> General", + }, + customPrefix: "Custom endpoint", + customDescription: "Custom host exposed by the admin", + }, + }, + }, + }; +})(); diff --git a/frontend/public/docs/api.js b/frontend/public/docs/api.js new file mode 100644 index 00000000..e276893f --- /dev/null +++ b/frontend/public/docs/api.js @@ -0,0 +1,792 @@ +(function () { + const DEFAULT_HOST = "https://re.94xy.cn"; + const DEFAULT_SITE_NAME = "Sub2API"; + const DEFAULT_SITE_SUBTITLE = "AI API Gateway Platform"; + const THEME_KEY = "theme"; + const LOCALE_KEY = "sub2api_locale"; + const AUTH_TOKEN_KEY = "auth_token"; + const AUTH_USER_KEY = "auth_user"; + const DOCS_I18N = window.DOCS_I18N || {}; + + const defaultHostEls = document.querySelectorAll("[data-default-host-text]"); + const hostListEl = document.getElementById("host-list"); + const ccsHostSelect = document.getElementById("ccs-host"); + const providerNameInput = document.getElementById("ccs-provider-name"); + const apiKeyInput = document.getElementById("ccs-api-key"); + const platformSelect = document.getElementById("ccs-platform"); + const importBtn = document.getElementById("ccs-import-btn"); + const copyLinkBtn = document.getElementById("ccs-copy-btn"); + const resultEl = document.getElementById("ccs-result"); + const siteLogoEl = document.getElementById("site-logo"); + const siteNameEl = document.getElementById("site-name"); + const siteSubtitleEl = document.getElementById("site-subtitle"); + const siteHeaderNavEl = document.getElementById("site-header-nav"); + const headerLoginLinkEl = document.getElementById("header-login-link"); + const localeSwitcherEl = document.getElementById("locale-switcher"); + const themeToggleEl = document.getElementById("theme-toggle"); + + let currentLocale = resolveLocale(); + let currentTheme = resolveTheme(); + let publicSettings = null; + + applyTheme(currentTheme); + document.documentElement.setAttribute("lang", getText().lang || currentLocale); + if (localeSwitcherEl) { + localeSwitcherEl.value = currentLocale; + } + + function getText() { + return DOCS_I18N[currentLocale] || DOCS_I18N.zh || {}; + } + + function getSearchParams() { + return new URLSearchParams(window.location.search); + } + + function normalizeLocale(value) { + if (typeof value !== "string") return ""; + const normalized = value.trim().toLowerCase(); + if (!normalized) return ""; + if (normalized === "zh" || normalized.startsWith("zh-")) return "zh"; + if (normalized === "en" || normalized.startsWith("en-")) return "en"; + return ""; + } + + function normalizeTheme(value) { + return value === "dark" || value === "light" ? value : ""; + } + + function resolveLocale() { + const queryLocale = normalizeLocale( + getSearchParams().get("lang") || getSearchParams().get("locale"), + ); + if (queryLocale) { + return queryLocale; + } + + const saved = normalizeLocale(localStorage.getItem(LOCALE_KEY)); + if (saved) { + return saved; + } + + const documentLang = normalizeLocale( + document.documentElement.getAttribute("lang"), + ); + if (documentLang) { + return documentLang; + } + + return navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en"; + } + + function resolveTheme() { + const queryTheme = normalizeTheme(getSearchParams().get("theme")); + if (queryTheme) { + return queryTheme; + } + + const saved = normalizeTheme(localStorage.getItem(THEME_KEY)); + if (saved) { + return saved; + } + + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + + function applyTheme(theme) { + currentTheme = theme === "dark" ? "dark" : "light"; + document.documentElement.classList.toggle("dark", currentTheme === "dark"); + localStorage.setItem(THEME_KEY, currentTheme); + const text = getText(); + if (themeToggleEl) { + themeToggleEl.textContent = currentTheme === "dark" ? "☀" : "☾"; + themeToggleEl.title = + currentTheme === "dark" ? text.themeLight : text.themeDark; + themeToggleEl.setAttribute("aria-label", themeToggleEl.title || "Theme"); + } + } + + function readAuthUser() { + const rawUser = localStorage.getItem(AUTH_USER_KEY); + const token = localStorage.getItem(AUTH_TOKEN_KEY); + if (!rawUser || !token) { + return null; + } + try { + const user = JSON.parse(rawUser); + return { + token, + email: typeof user?.email === "string" ? user.email : "", + role: typeof user?.role === "string" ? user.role : "", + }; + } catch (error) { + return null; + } + } + + function uniqueBy(items, keyFn) { + const seen = new Set(); + return items.filter((item) => { + const key = keyFn(item); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } + + function stripSuffix(value) { + return value + .replace(/\/antigravity\/v1beta\/?$/i, "") + .replace(/\/antigravity\/v1\/?$/i, "") + .replace(/\/antigravity\/?$/i, "") + .replace(/\/v1beta\/?$/i, "") + .replace(/\/v1\/?$/i, "") + .replace(/\/+$/g, ""); + } + + function resolveUrl(value) { + if (!value) return ""; + const trimmed = String(value).trim(); + if (!trimmed) return ""; + try { + if (/^https?:\/\//i.test(trimmed)) { + return stripSuffix(new URL(trimmed).toString()); + } + if (trimmed.startsWith("/")) { + return stripSuffix(new URL(trimmed, window.location.origin).toString()); + } + return stripSuffix(new URL(`https://${trimmed}`).toString()); + } catch (error) { + return ""; + } + } + + function joinUrl(root, suffix) { + return `${root.replace(/\/+$/g, "")}${suffix}`; + } + + function buildUsageScript() { + return `({ + request: { + url: "{{baseUrl}}/v1/usage", + method: "GET", + headers: { "Authorization": "Bearer {{apiKey}}" } + }, + extractor: function(response) { + const remaining = response?.remaining ?? response?.quota?.remaining ?? response?.balance; + const unit = response?.unit ?? response?.quota?.unit ?? "USD"; + return { + isValid: response?.is_active ?? response?.isValid ?? true, + remaining, + unit + }; + } +})`; + } + + function resolveCcSwitchConfig(platform, baseUrl) { + const root = baseUrl.replace(/\/+$/g, ""); + switch (platform) { + case "openai": + return { app: "codex", endpoint: root, model: "gpt-5.4" }; + case "gemini": + return { app: "gemini", endpoint: root }; + case "antigravity-claude": + return { app: "claude", endpoint: `${root}/antigravity` }; + case "antigravity-gemini": + return { app: "gemini", endpoint: `${root}/antigravity` }; + default: + return { app: "claude", endpoint: root }; + } + } + + function buildCcSwitchLink() { + const text = getText(); + const baseUrl = ccsHostSelect.value || DEFAULT_HOST; + const apiKey = apiKeyInput.value.trim(); + const providerName = providerNameInput.value.trim() || DEFAULT_SITE_NAME; + const platform = platformSelect.value; + + if (!apiKey) { + throw new Error(text.messages?.pasteApiKeyFirst || "Paste API key first."); + } + + const config = resolveCcSwitchConfig(platform, baseUrl); + const params = new URLSearchParams(); + params.set("resource", "provider"); + params.set("app", config.app); + if (config.model) { + params.set("model", config.model); + } + params.set("name", providerName); + params.set("homepage", baseUrl); + params.set("endpoint", config.endpoint); + params.set("apiKey", apiKey); + params.set("configFormat", "json"); + params.set("usageEnabled", "true"); + params.set("usageScript", btoa(buildUsageScript())); + params.set("usageAutoInterval", "30"); + return `ccswitch://v1/import?${params.toString()}`; + } + + function normalizePlacement(value) { + return value === "home_header" || value === "both" ? value : "sidebar"; + } + + function buildCustomMenuHref(item) { + return `/custom/${encodeURIComponent(item.id)}`; + } + + function hostCardTemplate(item) { + const text = getText(); + const labels = text.hostCard?.labels || {}; + const root = item.root; + const openaiBase = joinUrl(root, "/v1"); + const geminiBase = joinUrl(root, "/v1beta"); + const antigravityBase = joinUrl(root, "/antigravity"); + const antigravityGeminiBase = joinUrl(root, "/antigravity/v1beta"); + + return ` +
+

${item.name}

+

${item.description || text.hostCard?.fallbackDesc || ""}

+
+
+ ${labels.root || "Root host"} + ${root} +
+
+ ${labels.openai || "OpenAI SDK / HTTP"} + ${openaiBase} +
+
+ ${labels.gemini || "Gemini / OpenClaw / Hermes"} + ${geminiBase} +
+
+ ${labels.antigravity || "Antigravity"} + ${antigravityBase}
+ ${antigravityGeminiBase} +
+
+
+ + +
+
+ `; + } + + function renderHosts(items) { + hostListEl.innerHTML = items.map(hostCardTemplate).join(""); + ccsHostSelect.innerHTML = items + .map((item) => ``) + .join(""); + } + + async function copyText(text, trigger) { + const i18n = getText(); + try { + await navigator.clipboard.writeText(text); + if (trigger) { + const prev = trigger.textContent; + trigger.textContent = i18n.messages?.copied || "Copied"; + setTimeout(() => { + trigger.textContent = prev; + }, 1200); + } + } catch (error) { + if (resultEl) { + resultEl.textContent = + i18n.messages?.copyFailed || "Copy failed. Please copy it manually."; + } + } + } + + function renderHeaderMenus(settings) { + if (!siteHeaderNavEl) return; + const items = Array.isArray(settings?.custom_menu_items) + ? settings.custom_menu_items + : []; + const visible = items + .filter((item) => item && item.visibility === "user") + .filter((item) => { + const placement = normalizePlacement(item.placement); + return placement === "home_header" || placement === "both"; + }) + .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); + + siteHeaderNavEl.innerHTML = visible + .map((item) => { + const icon = item.icon_svg + ? `${item.icon_svg}` + : ""; + return `${icon}${item.label || ""}`; + }) + .join(""); + } + + function updateHeader(settings) { + const text = getText(); + const auth = readAuthUser(); + const siteName = + typeof settings?.site_name === "string" && settings.site_name.trim() + ? settings.site_name.trim() + : DEFAULT_SITE_NAME; + const siteSubtitle = + typeof settings?.site_subtitle === "string" && settings.site_subtitle.trim() + ? settings.site_subtitle.trim() + : DEFAULT_SITE_SUBTITLE; + const siteLogo = + typeof settings?.site_logo === "string" && settings.site_logo.trim() + ? settings.site_logo.trim() + : "/logo.png"; + + if (siteLogoEl) { + siteLogoEl.src = siteLogo; + } + if (siteNameEl) { + siteNameEl.textContent = siteName; + } + if (siteSubtitleEl) { + siteSubtitleEl.textContent = siteSubtitle; + } + document.title = `${siteName} - ${text.titleSuffix || ""}`; + + if (headerLoginLinkEl) { + if (auth) { + headerLoginLinkEl.href = + auth.role === "admin" || auth.role === "useradmin" + ? "/admin/dashboard" + : "/dashboard"; + headerLoginLinkEl.querySelector("[data-i18n]").textContent = + text.dashboard || "Dashboard"; + } else { + headerLoginLinkEl.href = "/login"; + headerLoginLinkEl.querySelector("[data-i18n]").textContent = + text.goLogin || "Login"; + } + headerLoginLinkEl.title = text.goLogin || "Login"; + } + renderHeaderMenus(settings); + } + + function replaceNodeContent(selector, content, mode) { + const el = document.querySelector(selector); + if (!el) return; + if (mode === "html") { + el.innerHTML = content; + return; + } + el.textContent = content; + } + + function replaceAllText(selector, values) { + const nodes = document.querySelectorAll(selector); + nodes.forEach((node, index) => { + if (values[index] != null) { + node.textContent = values[index]; + } + }); + } + + function replaceAllHtml(selector, values) { + const nodes = document.querySelectorAll(selector); + nodes.forEach((node, index) => { + if (values[index] != null) { + node.innerHTML = values[index]; + } + }); + } + + function renderStaticContent() { + const text = getText(); + document.documentElement.setAttribute("lang", text.lang || currentLocale); + if (localeSwitcherEl) { + localeSwitcherEl.setAttribute("aria-label", text.localeLabel || "Language"); + } + + replaceNodeContent(".eyebrow-row .pill.brand", text.hero?.priority || "", "text"); + replaceNodeContent( + ".eyebrow-row .pill:nth-child(2)", + `${text.hero?.defaultHostLabel || ""}${DEFAULT_HOST}`, + "html", + ); + replaceNodeContent( + ".eyebrow-row .pill:nth-child(3)", + `${text.hero?.docPathLabel || ""}/docs/api.html`, + "html", + ); + replaceNodeContent(".hero h1", text.hero?.title || "", "text"); + replaceNodeContent(".hero-copy", text.hero?.copyHtml || "", "html"); + replaceAllText(".hero-actions a", text.hero?.actions || []); + replaceAllText(".hero-stat .label", text.hero?.statLabels || []); + replaceNodeContent( + ".hero-stat:nth-child(2) .value", + text.hero?.keyPathHtml || "", + "html", + ); + replaceNodeContent( + ".hero-stat:nth-child(3) .value", + text.hero?.placement || "", + "text", + ); + replaceNodeContent( + ".hero-stat:nth-child(4) .value", + text.hero?.protocols || "", + "text", + ); + + replaceNodeContent(".sidebar h2", text.sidebar?.title || "", "text"); + replaceAllText(".sidebar a", text.sidebar?.links || []); + + replaceNodeContent("#quickstart h2", text.quickstart?.title || "", "text"); + replaceNodeContent("#quickstart .section-desc", text.quickstart?.descHtml || "", "html"); + replaceAllText("#quickstart .card h3", (text.quickstart?.cards || []).map((item) => item.title)); + replaceAllHtml("#quickstart .card p", (text.quickstart?.cards || []).map((item) => item.bodyHtml)); + replaceNodeContent("#quickstart .note", text.quickstart?.noteHtml || "", "html"); + + replaceNodeContent("#ccswitch h2", text.ccswitch?.title || "", "text"); + replaceNodeContent("#ccswitch > .section-desc", text.ccswitch?.descHtml || "", "html"); + replaceAllText("#ccswitch .grid-2 .card h3", [ + text.ccswitch?.cards?.download?.title || "", + text.ccswitch?.cards?.oneClick?.title || "", + text.ccswitch?.cards?.importMap?.title || "", + text.ccswitch?.cards?.manual?.title || "", + ]); + replaceAllHtml( + "#ccswitch .grid-2:nth-of-type(1) .card:nth-child(1) .plain-list li", + text.ccswitch?.cards?.download?.items || [], + ); + replaceAllText( + "#ccswitch .grid-2:nth-of-type(1) .card:nth-child(1) .meta-row a", + text.ccswitch?.cards?.download?.actions || [], + ); + replaceAllHtml( + "#ccswitch .grid-2:nth-of-type(1) .card:nth-child(2) .plain-list li", + text.ccswitch?.cards?.oneClick?.items || [], + ); + replaceNodeContent( + "#ccswitch .grid-2:nth-of-type(1) .card:nth-child(2) .note", + text.ccswitch?.cards?.oneClick?.noteHtml || "", + "html", + ); + replaceAllText( + "#ccswitch .grid-2:nth-of-type(2) .card:nth-child(1) th", + text.ccswitch?.cards?.importMap?.headers || [], + ); + const importRows = document.querySelectorAll( + "#ccswitch .grid-2:nth-of-type(2) .card:nth-child(1) tbody tr", + ); + importRows.forEach((row, rowIndex) => { + const cells = row.querySelectorAll("td"); + const values = text.ccswitch?.cards?.importMap?.rows?.[rowIndex] || []; + cells.forEach((cell, colIndex) => { + if (values[colIndex] != null) { + cell.innerHTML = values[colIndex]; + } + }); + }); + replaceAllText( + "#ccswitch .grid-2:nth-of-type(2) .card:nth-child(2) label", + text.ccswitch?.cards?.manual?.labels || [], + ); + replaceNodeContent( + "#ccswitch .grid-2:nth-of-type(2) .card:nth-child(2) small", + text.ccswitch?.cards?.manual?.hostHelpHtml || "", + "html", + ); + replaceAllText( + "#ccswitch .grid-2:nth-of-type(2) .card:nth-child(2) .host-actions button", + text.ccswitch?.cards?.manual?.buttons || [], + ); + if (apiKeyInput) { + apiKeyInput.placeholder = text.ccswitch?.cards?.manual?.apiKeyPlaceholder || ""; + } + if (platformSelect) { + Array.from(platformSelect.options).forEach((option, index) => { + if (text.ccswitch?.cards?.manual?.options?.[index]) { + option.textContent = text.ccswitch.cards.manual.options[index]; + } + }); + } + + replaceNodeContent("#overview h2", text.overview?.title || "", "text"); + replaceNodeContent("#overview .section-desc", text.overview?.descHtml || "", "html"); + replaceAllText("#overview th", text.overview?.headers || []); + const overviewRows = document.querySelectorAll("#overview tbody tr"); + overviewRows.forEach((row, rowIndex) => { + const cells = row.querySelectorAll("td"); + const values = text.overview?.rows?.[rowIndex] || []; + cells.forEach((cell, colIndex) => { + if (values[colIndex] != null) { + cell.innerHTML = values[colIndex]; + } + }); + }); + + replaceNodeContent("#hosts h2", text.hosts?.title || "", "text"); + replaceNodeContent("#hosts .section-desc", text.hosts?.descHtml || "", "html"); + replaceNodeContent("#hosts .note", text.hosts?.noteHtml || "", "html"); + + replaceNodeContent("#http h2", text.http?.title || "", "text"); + replaceNodeContent("#http .section-desc", text.http?.descHtml || "", "html"); + replaceNodeContent("#sdk-js h2", text.sdkJs?.title || "", "text"); + replaceNodeContent("#sdk-js .section-desc", text.sdkJs?.descHtml || "", "html"); + replaceNodeContent("#sdk-python h2", text.sdkPython?.title || "", "text"); + replaceNodeContent("#sdk-python .section-desc", text.sdkPython?.descHtml || "", "html"); + replaceNodeContent("#claude-code h2", text.claude?.title || "", "text"); + replaceNodeContent("#claude-code .section-desc", text.claude?.descHtml || "", "html"); + replaceNodeContent("#gemini-cli h2", text.gemini?.title || "", "text"); + replaceNodeContent("#gemini-cli .section-desc", text.gemini?.descHtml || "", "html"); + replaceNodeContent("#codex-cli h2", text.codex?.title || "", "text"); + replaceNodeContent("#codex-cli .section-desc", text.codex?.descHtml || "", "html"); + replaceNodeContent("#opencode h2", text.opencode?.title || "", "text"); + replaceNodeContent("#opencode .section-desc", text.opencode?.descHtml || "", "html"); + replaceNodeContent("#openclaw h2", text.openclaw?.title || "", "text"); + replaceNodeContent("#openclaw .section-desc", text.openclaw?.descHtml || "", "html"); + replaceNodeContent("#openclaw .note", text.openclaw?.noteHtml || "", "html"); + replaceNodeContent("#hermes h2", text.hermes?.title || "", "text"); + replaceNodeContent("#hermes .section-desc", text.hermes?.descHtml || "", "html"); + replaceNodeContent( + "#hermes .grid-2 .code-block:nth-child(2) .code-title", + text.hermes?.interactiveTitle || "", + "text", + ); + replaceNodeContent("#hermes .note", text.hermes?.warningHtml || "", "html"); + replaceNodeContent("#faq h2", text.faq?.title || "", "text"); + replaceAllText("#faq th", text.faq?.headers || []); + const faqRows = document.querySelectorAll("#faq tbody tr"); + faqRows.forEach((row, rowIndex) => { + const cells = row.querySelectorAll("td"); + const values = text.faq?.rows?.[rowIndex] || []; + cells.forEach((cell, colIndex) => { + if (values[colIndex] != null) { + cell.innerHTML = values[colIndex]; + } + }); + }); + + replaceNodeContent(".footer", text.footerHtml || "", "html"); + + const copyButtons = document.querySelectorAll(".copy-btn"); + copyButtons.forEach((button) => { + button.textContent = text.messages?.copy || "Copy"; + }); + replaceAllText(".code-title", [ + text.codeTitles?.httpChat || "", + text.codeTitles?.httpModels || "", + text.codeTitles?.sdkJs || "", + text.codeTitles?.sdkPython || "", + text.codeTitles?.claude || "", + text.codeTitles?.gemini || "", + text.codeTitles?.codexConfig || "", + text.codeTitles?.codexAuth || "", + text.codeTitles?.opencode || "", + text.codeTitles?.openclaw || "", + text.codeTitles?.hermes || "", + text.codeTitles?.hermesInteractive || "", + ]); + + const codeHttpChat = document.getElementById("code-http-chat"); + const codeHermesInteractive = document.getElementById("code-hermes-interactive"); + if (codeHttpChat && text.codeSamples?.httpChat) { + codeHttpChat.textContent = text.codeSamples.httpChat; + } + if (codeHermesInteractive && text.codeSamples?.hermesInteractive) { + codeHermesInteractive.textContent = text.codeSamples.hermesInteractive; + } + + defaultHostEls.forEach((el) => { + el.textContent = DEFAULT_HOST; + }); + } + + function syncState(options) { + const previousLocale = currentLocale; + currentLocale = resolveLocale(); + currentTheme = resolveTheme(); + + if (localeSwitcherEl) { + localeSwitcherEl.value = currentLocale; + } + + applyTheme(currentTheme); + renderStaticContent(); + updateHeader(publicSettings); + + if (options?.reloadHosts || previousLocale !== currentLocale) { + loadPublicSettings(); + } + } + + async function loadPublicSettings() { + const text = getText(); + const builtins = text.hostCard?.builtin || {}; + const seeds = [ + { + name: builtins.default?.name || "Default primary host", + root: resolveUrl(DEFAULT_HOST), + description: builtins.default?.description || "", + }, + ]; + + const currentRoot = resolveUrl(window.location.origin); + if (currentRoot && currentRoot !== resolveUrl(DEFAULT_HOST)) { + seeds.push({ + name: builtins.current?.name || "Current site origin", + root: currentRoot, + description: builtins.current?.description || "", + }); + } + + try { + const response = await fetch("/api/v1/settings/public", { + credentials: "same-origin", + }); + const payload = await response.json(); + const settings = payload && payload.data ? payload.data : payload; + publicSettings = settings; + updateHeader(settings); + + if ( + settings && + typeof settings.site_name === "string" && + settings.site_name.trim() + ) { + providerNameInput.value = settings.site_name.trim(); + } + + if (settings && settings.api_base_url) { + const apiRoot = resolveUrl(settings.api_base_url); + if (apiRoot) { + seeds.push({ + name: builtins.apiBase?.name || "Public API Base URL", + root: apiRoot, + description: builtins.apiBase?.description || "", + }); + } + } + + if (settings && Array.isArray(settings.custom_endpoints)) { + settings.custom_endpoints.forEach((item, index) => { + const root = resolveUrl(item.endpoint); + if (!root) return; + seeds.push({ + name: + item.name || + `${builtins.customPrefix || "Custom endpoint"} ${index + 1}`, + root: root, + description: + item.description || builtins.customDescription || "", + }); + }); + } + } catch (error) { + updateHeader(publicSettings); + } + + const deduped = uniqueBy( + seeds.filter((item) => item.root), + (item) => item.root, + ); + + renderHosts(deduped); + } + + function initHeaderEvents() { + if (themeToggleEl) { + themeToggleEl.addEventListener("click", () => { + applyTheme(currentTheme === "dark" ? "light" : "dark"); + }); + } + if (localeSwitcherEl) { + localeSwitcherEl.addEventListener("change", (event) => { + const next = event.target.value === "zh" ? "zh" : "en"; + currentLocale = next; + localStorage.setItem(LOCALE_KEY, next); + syncState({ reloadHosts: true }); + }); + } + } + + function initCrossContextSync() { + window.addEventListener("storage", (event) => { + if ( + event.key && + event.key !== LOCALE_KEY && + event.key !== THEME_KEY && + event.key !== AUTH_TOKEN_KEY && + event.key !== AUTH_USER_KEY + ) { + return; + } + + syncState({ + reloadHosts: + event.key === null || + event.key === LOCALE_KEY || + event.key === AUTH_TOKEN_KEY || + event.key === AUTH_USER_KEY, + }); + }); + } + + document.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + + const copyTargetId = target.getAttribute("data-copy-target"); + if (copyTargetId) { + const codeEl = document.getElementById(copyTargetId); + if (codeEl) { + copyText(codeEl.textContent || "", target); + } + return; + } + + const copyTextValue = target.getAttribute("data-copy-text"); + if (copyTextValue) { + copyText(copyTextValue, target); + } + }); + + if (importBtn) { + importBtn.addEventListener("click", () => { + const text = getText(); + try { + const link = buildCcSwitchLink(); + resultEl.textContent = text.messages?.tryingOpen || ""; + window.open(link, "_self"); + setTimeout(() => { + if (document.hasFocus()) { + resultEl.textContent = text.messages?.clientNotOpened || ""; + } + }, 120); + } catch (error) { + resultEl.textContent = error.message || text.messages?.importFailed || ""; + } + }); + } + + if (copyLinkBtn) { + copyLinkBtn.addEventListener("click", async () => { + const text = getText(); + try { + const link = buildCcSwitchLink(); + await copyText(link, copyLinkBtn); + resultEl.textContent = text.messages?.deepLinkCopied || ""; + } catch (error) { + resultEl.textContent = error.message || text.messages?.copyFailed || ""; + } + }); + } + + renderStaticContent(); + updateHeader(publicSettings); + initHeaderEvents(); + initCrossContextSync(); + loadPublicSettings(); +})(); diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 475775f5..1e9ab4bb 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -187,6 +187,7 @@ import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } import VersionBadge from '@/components/common/VersionBadge.vue' import { sanitizeSvg } from '@/utils/sanitize' import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags' +import { getCustomMenuRoute, isSidebarMenuPlacement, normalizeCustomMenuItems } from '@/utils/custom-menu' interface NavItem { path: string @@ -693,7 +694,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] { { path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true, featureFlag: flagAffiliate }, { path: '/profile', label: t('nav.profile'), icon: UserIcon }, ...customMenuItemsForUser.value.map((item): NavItem => ({ - path: `/custom/${item.id}`, + path: getCustomMenuRoute(item.id), label: item.label, icon: null, iconSvg: item.icon_svg, @@ -718,15 +719,15 @@ const personalNavItems = computed((): NavItem[] => finalizeNav(buildSelfNavItems // Custom menu items filtered by visibility const customMenuItemsForUser = computed(() => { - const items = appStore.cachedPublicSettings?.custom_menu_items ?? [] + const items = normalizeCustomMenuItems(appStore.cachedPublicSettings?.custom_menu_items) return items - .filter((item) => item.visibility === 'user') + .filter((item) => item.visibility === 'user' && isSidebarMenuPlacement(item)) .sort((a, b) => a.sort_order - b.sort_order) }) const customMenuItemsForAdmin = computed(() => { - return adminSettingsStore.customMenuItems - .filter((item) => item.visibility === 'admin') + return normalizeCustomMenuItems(adminSettingsStore.customMenuItems) + .filter((item) => item.visibility === 'admin' && isSidebarMenuPlacement(item)) .sort((a, b) => a.sort_order - b.sort_order) }) @@ -795,7 +796,7 @@ const adminNavItems = computed((): NavItem[] => { filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) } for (const cm of customMenuItemsForAdmin.value) { - filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg }) + filtered.push({ path: getCustomMenuRoute(cm.id), label: cm.label, icon: null, iconSvg: cm.icon_svg }) } return filtered } @@ -804,7 +805,7 @@ const adminNavItems = computed((): NavItem[] => { visible.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) } for (const cm of customMenuItemsForAdmin.value) { - visible.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg }) + visible.push({ path: getCustomMenuRoute(cm.id), label: cm.label, icon: null, iconSvg: cm.icon_svg }) } return visible }) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 722ea197..d52cf2da 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5457,7 +5457,7 @@ export default { }, customMenu: { title: 'Custom Menu Pages', - description: 'Add custom iframe pages to the sidebar navigation. Each page can be visible to regular users or administrators.', + description: 'Add custom pages to site navigation. Each page can be visible to regular users or administrators, and can be shown in the sidebar, the home header, or both.', itemLabel: 'Menu Item #{n}', name: 'Menu Name', namePlaceholder: 'e.g. Help Center', @@ -5471,6 +5471,10 @@ export default { visibility: 'Visible To', visibilityUser: 'Regular Users', visibilityAdmin: 'Administrators', + placement: 'Placement', + placementSidebar: 'Sidebar only', + placementHomeHeader: 'Home header only', + placementBoth: 'Sidebar + Home header', add: 'Add Menu Item', remove: 'Remove', moveUp: 'Move Up', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 9917899b..366f76cb 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5618,7 +5618,7 @@ export default { }, customMenu: { title: '自定义菜单页面', - description: '添加自定义 iframe 页面到侧边栏导航。每个页面可以设置为普通用户或管理员可见。', + description: '添加自定义页面到站点导航。每个页面可以设置为普通用户或管理员可见,并指定显示在侧边栏、首页头部,或同时显示。', itemLabel: '菜单项 #{n}', name: '菜单名称', namePlaceholder: '如:帮助中心', @@ -5632,6 +5632,10 @@ export default { visibility: '可见角色', visibilityUser: '普通用户', visibilityAdmin: '管理员', + placement: '显示位置', + placementSidebar: '仅侧边栏', + placementHomeHeader: '仅首页头部', + placementBoth: '侧边栏 + 首页头部', add: '添加菜单项', remove: '删除', moveUp: '上移', diff --git a/frontend/src/stores/adminSettings.ts b/frontend/src/stores/adminSettings.ts index ca34cc8e..1219d0c4 100644 --- a/frontend/src/stores/adminSettings.ts +++ b/frontend/src/stores/adminSettings.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { adminAPI } from '@/api' import type { CustomMenuItem } from '@/types' +import { normalizeCustomMenuItems } from '@/utils/custom-menu' export const useAdminSettingsStore = defineStore('adminSettings', () => { const loaded = ref(false) @@ -70,7 +71,7 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => { opsQueryModeDefault.value = settings.ops_query_mode_default || 'auto' writeCachedString('ops_query_mode_default_cached', opsQueryModeDefault.value) - customMenuItems.value = Array.isArray(settings.custom_menu_items) ? settings.custom_menu_items : [] + customMenuItems.value = normalizeCustomMenuItems(settings.custom_menu_items) paymentEnabled.value = paymentConfigResp.data?.enabled ?? false writeCachedBool('payment_enabled_cached', paymentEnabled.value) diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 4d701b2e..1901c115 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -7,6 +7,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { Toast, ToastType, PublicSettings } from '@/types' import { i18n } from '@/i18n' +import { normalizeCustomMenuItems } from '@/utils/custom-menu' import { checkUpdates as checkUpdatesAPI, type VersionInfo, @@ -288,16 +289,20 @@ export const useAppStore = defineStore('app', () => { * Apply settings to store state (internal helper to avoid code duplication) */ function applySettings(config: PublicSettings): void { - if (typeof window !== 'undefined') { - window.__APP_CONFIG__ = { ...config } + const normalizedConfig: PublicSettings = { + ...config, + custom_menu_items: normalizeCustomMenuItems(config.custom_menu_items) } - cachedPublicSettings.value = config - siteName.value = config.site_name || 'Sub2API' - siteLogo.value = config.site_logo || '' - siteVersion.value = config.version || '' - contactInfo.value = config.contact_info || '' - apiBaseUrl.value = config.api_base_url || '' - docUrl.value = config.doc_url || '' + if (typeof window !== 'undefined') { + window.__APP_CONFIG__ = { ...normalizedConfig } + } + cachedPublicSettings.value = normalizedConfig + siteName.value = normalizedConfig.site_name || 'Sub2API' + siteLogo.value = normalizedConfig.site_logo || '' + siteVersion.value = normalizedConfig.version || '' + contactInfo.value = normalizedConfig.contact_info || '' + apiBaseUrl.value = normalizedConfig.api_base_url || '' + docUrl.value = normalizedConfig.doc_url || '' publicSettingsLoaded.value = true } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 45ef9945..16c169ef 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -163,6 +163,8 @@ export interface SendVerifyCodeResponse { countdown: number } +export type CustomMenuPlacement = 'sidebar' | 'home_header' | 'both' + export interface CustomMenuItem { id: string label: string @@ -170,6 +172,7 @@ export interface CustomMenuItem { url: string page_slug?: string visibility: 'user' | 'admin' + placement?: CustomMenuPlacement sort_order: number } diff --git a/frontend/src/utils/custom-menu.ts b/frontend/src/utils/custom-menu.ts new file mode 100644 index 00000000..0d58f851 --- /dev/null +++ b/frontend/src/utils/custom-menu.ts @@ -0,0 +1,46 @@ +import type { CustomMenuItem, CustomMenuPlacement } from '@/types' + +export const DEFAULT_CUSTOM_MENU_PLACEMENT: CustomMenuPlacement = 'sidebar' + +export function normalizeCustomMenuPlacement( + value: unknown, +): CustomMenuPlacement { + if (value === 'home_header' || value === 'both') { + return value + } + return DEFAULT_CUSTOM_MENU_PLACEMENT +} + +export function normalizeCustomMenuItem(item: CustomMenuItem): CustomMenuItem { + return { + ...item, + placement: normalizeCustomMenuPlacement(item.placement), + } +} + +export function normalizeCustomMenuItems( + items: CustomMenuItem[] | null | undefined, +): CustomMenuItem[] { + if (!Array.isArray(items)) { + return [] + } + return items.map((item) => normalizeCustomMenuItem(item)) +} + +export function isSidebarMenuPlacement( + item: Pick, +): boolean { + const placement = normalizeCustomMenuPlacement(item.placement) + return placement === 'sidebar' || placement === 'both' +} + +export function isHomeHeaderMenuPlacement( + item: Pick, +): boolean { + const placement = normalizeCustomMenuPlacement(item.placement) + return placement === 'home_header' || placement === 'both' +} + +export function getCustomMenuRoute(id: string): string { + return `/custom/${encodeURIComponent(id)}` +} diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 6a3753f1..073d3ae5 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -47,7 +47,26 @@ -
+
+ + @@ -410,6 +429,12 @@ import { useI18n } from 'vue-i18n' import { useAuthStore, useAppStore } from '@/stores' import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue' import Icon from '@/components/icons/Icon.vue' +import { sanitizeSvg } from '@/utils/sanitize' +import { + getCustomMenuRoute, + isHomeHeaderMenuPlacement, + normalizeCustomMenuItems, +} from '@/utils/custom-menu' const { t } = useI18n() @@ -422,6 +447,11 @@ const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appS const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'AI API Gateway Platform') const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '') const homeContent = computed(() => appStore.cachedPublicSettings?.home_content || '') +const homeHeaderMenuItems = computed(() => + normalizeCustomMenuItems(appStore.cachedPublicSettings?.custom_menu_items) + .filter((item) => item.visibility === 'user' && isHomeHeaderMenuPlacement(item)) + .sort((a, b) => a.sort_order - b.sort_order) +) // Check if homeContent is a URL (for iframe display) const isHomeContentUrl = computed(() => { @@ -448,6 +478,10 @@ const userInitial = computed(() => { // Current year for footer const currentYear = computed(() => new Date().getFullYear()) +function customMenuRoute(id: string) { + return getCustomMenuRoute(id) +} + // Toggle theme function toggleTheme() { isDark.value = !isDark.value diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 78c89fa6..152f0276 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -4408,6 +4408,26 @@
+ +
+ + +
+