diff --git a/.github/workflows/cdn.yml b/.github/workflows/cdn.yml new file mode 100644 index 0000000..6223aa6 --- /dev/null +++ b/.github/workflows/cdn.yml @@ -0,0 +1,33 @@ +name: CDN + +on: + push: + branches: + - main + paths: + - "cdn/**" + - ".github/workflows/cdn.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: cdn + cancel-in-progress: false + +jobs: + deploy: + name: Deploy Cloudflare Pages + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Deploy CDN assets + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + workingDirectory: cdn + command: pages deploy public --project-name=claude-code-router-cdn --branch=${{ github.ref_name }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e2f2965..00463e0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,8 +29,8 @@ jobs: - name: Build and upload docs uses: withastro/action@v6 env: - ASTRO_SITE: https://musistudio.github.io - ASTRO_BASE: /claude-code-router + ASTRO_SITE: https://ccrdesk.top + ASTRO_BASE: / with: path: docs node-version: 24 diff --git a/README.md b/README.md index f079394..1377e08 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,9 @@ ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&api Supported query parameters: - `name`: display name for the provider. -- `base_url`: provider API base URL. Aliases: `baseUrl`, `api_base_url`, `url`, `endpoint`. -- `api_key`: optional provider API key. Aliases: `apiKey`, `apikey`, `key`, `token`. -- `models`: comma-separated or newline-separated model list. You can also repeat `model=...`. +- `base_url`: provider API base URL. +- `api_key`: optional provider API key. +- `models`: comma-separated or newline-separated model list. You can also repeat `models=...`. - `protocol`: one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, or `gemini_generate_content`. For larger payloads, pass `payload` as URL-encoded JSON or base64url JSON with the same fields. CCR always opens a confirmation dialog before writing a provider imported from an external link. diff --git a/README_zh.md b/README_zh.md index 4c07395..dc81f80 100644 --- a/README_zh.md +++ b/README_zh.md @@ -89,9 +89,9 @@ ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&api 支持的 query 参数: - `name`:Provider 展示名称。 -- `base_url`:Provider API Base URL。别名:`baseUrl`、`api_base_url`、`url`、`endpoint`。 -- `api_key`:可选 Provider API Key。别名:`apiKey`、`apikey`、`key`、`token`。 -- `models`:逗号或换行分隔的模型列表,也可以重复传入 `model=...`。 +- `base_url`:Provider API Base URL。 +- `api_key`:可选 Provider API Key。 +- `models`:逗号或换行分隔的模型列表,也可以重复传入 `models=...`。 - `protocol`:`openai_chat_completions`、`openai_responses`、`anthropic_messages` 或 `gemini_generate_content`。 更大的 payload 可以通过 URL 编码 JSON 或 base64url JSON 传入 `payload` 字段。CCR 在写入外部链接导入的 Provider 前,总会弹出确认窗口。 diff --git a/cdn/README.md b/cdn/README.md new file mode 100644 index 0000000..772e06a --- /dev/null +++ b/cdn/README.md @@ -0,0 +1,63 @@ +# CCR CDN + +This directory contains the static assets for the embeddable CCR provider import button. + +Default Cloudflare Pages project: + +```text +claude-code-router-cdn +``` + +Default CDN URLs: + +```text +https://cdn.ccrdesk.top/ccr-provider-buttons.js +https://cdn.ccrdesk.top/ccr-icon.png +``` + +Cloudflare Pages custom domain: + +```text +cdn.ccrdesk.top +``` + +The Pages custom domain must have a CNAME record pointing to the Pages project: + +```text +cdn.ccrdesk.top CNAME claude-code-router-cdn.pages.dev +``` + +The documentation site uses `ccrdesk.top` through GitHub Pages. Configure DNS like this: + +```text +ccrdesk.top A 185.199.108.153 +ccrdesk.top A 185.199.109.153 +ccrdesk.top A 185.199.110.153 +ccrdesk.top A 185.199.111.153 +ccrdesk.top AAAA 2606:50c0:8000::153 +ccrdesk.top AAAA 2606:50c0:8001::153 +ccrdesk.top AAAA 2606:50c0:8002::153 +ccrdesk.top AAAA 2606:50c0:8003::153 +cdn.ccrdesk.top CNAME claude-code-router-cdn.pages.dev +``` + +If `ccrdesk.top` is delegated to Cloudflare, use these Cloudflare nameservers at the registrar: + +```text +benedict.ns.cloudflare.com +evangeline.ns.cloudflare.com +``` + +Deploy manually: + +```sh +cd cdn +npx wrangler pages deploy public --project-name=claude-code-router-cdn +``` + +The GitHub Actions workflow requires these repository secrets: + +```text +CLOUDFLARE_API_TOKEN +CLOUDFLARE_ACCOUNT_ID +``` diff --git a/cdn/package.json b/cdn/package.json new file mode 100644 index 0000000..3968876 --- /dev/null +++ b/cdn/package.json @@ -0,0 +1,10 @@ +{ + "name": "claude-code-router-cdn", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "deploy": "wrangler pages deploy public --project-name=claude-code-router-cdn", + "deploy:preview": "wrangler pages deploy public --project-name=claude-code-router-cdn --branch=preview" + } +} diff --git a/cdn/public/_headers b/cdn/public/_headers new file mode 100644 index 0000000..c4b9aa0 --- /dev/null +++ b/cdn/public/_headers @@ -0,0 +1,10 @@ +/* + Access-Control-Allow-Origin: * + X-Content-Type-Options: nosniff + +/ccr-provider-buttons.js + Cache-Control: public, max-age=300, s-maxage=86400 + Content-Type: application/javascript; charset=utf-8 + +/ccr-icon.png + Cache-Control: public, max-age=31536000, immutable diff --git a/cdn/public/ccr-icon.png b/cdn/public/ccr-icon.png new file mode 100644 index 0000000..42cdfc1 Binary files /dev/null and b/cdn/public/ccr-icon.png differ diff --git a/cdn/public/ccr-provider-buttons.js b/cdn/public/ccr-provider-buttons.js new file mode 100644 index 0000000..534070c --- /dev/null +++ b/cdn/public/ccr-provider-buttons.js @@ -0,0 +1,302 @@ +(function () { + "use strict"; + + var VERSION = "0.2.0"; + var STYLE_ID = "ccr-provider-buttons-style"; + var OFFICIAL_CCR_ICON_URL = "https://cdn.ccrdesk.top/ccr-icon.png"; + + var LINK_PARAMS = [ + "name", + "base_url", + "api_key", + "protocol", + "icon", + "source", + "payload", + "usage_url", + "usage_method", + "usage_headers", + "usage_body", + "balance", + "balance_unit", + "subscription", + "subscription_limit", + "subscription_reset", + "subscription_unit", + "subscription_window" + ]; + var ELEMENT_ATTRIBUTES = LINK_PARAMS.concat(["manifest", "models", "fetch_usage", "description", "color", "color_2", "color_3"]); + + var CSS = [ + ".ccr-provider-button{--ccrpb-brand:#17201c;--ccrpb-brand-2:#36624d;--ccrpb-brand-3:#dff8eb;position:relative;isolation:isolate;display:grid;grid-template-columns:132px minmax(0,1fr);align-items:center;min-height:86px;width:100%;max-width:420px;padding:13px 16px 13px 12px;overflow:hidden;border:1px solid color-mix(in srgb,var(--ccrpb-brand-2) 42%,transparent);border-radius:12px;background:radial-gradient(circle at 14% 22%,color-mix(in srgb,var(--ccrpb-brand-3) 34%,transparent) 0,transparent 30%),radial-gradient(circle at 96% 96%,color-mix(in srgb,var(--ccrpb-brand-2) 44%,transparent) 0,transparent 46%),linear-gradient(135deg,var(--ccrpb-brand),color-mix(in srgb,var(--ccrpb-brand-2) 88%,#111 12%));box-shadow:0 10px 26px color-mix(in srgb,var(--ccrpb-brand) 22%,transparent),inset 0 1px 0 rgba(255,255,255,.16);box-sizing:border-box;color:#fff;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;text-decoration:none;transform:translateY(0);transition:border-color 180ms ease,box-shadow 220ms ease,transform 220ms ease;}", + ".ccr-provider-button::before{content:\"\";position:absolute;inset:-40% auto -40% -70%;z-index:-1;width:58%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.26),transparent);transform:skewX(-18deg);transition:transform 620ms ease;}", + ".ccr-provider-button::after{content:\"\";position:absolute;right:-38px;bottom:-52px;z-index:-2;width:132px;height:132px;border-radius:999px;background:radial-gradient(circle,color-mix(in srgb,var(--ccrpb-brand-3) 38%,transparent),transparent 68%);opacity:.82;transform:scale(.96);transition:opacity 220ms ease,transform 220ms ease;}", + ".ccr-provider-button:hover,.ccr-provider-button:focus-visible{border-color:color-mix(in srgb,var(--ccrpb-brand-3) 68%,rgba(255,255,255,.34));box-shadow:0 16px 34px color-mix(in srgb,var(--ccrpb-brand) 30%,transparent),0 0 0 1px color-mix(in srgb,var(--ccrpb-brand-3) 26%,transparent),inset 0 1px 0 rgba(255,255,255,.22);color:#fff;text-decoration:none;transform:translateY(-3px);}", + ".ccr-provider-button:hover::before,.ccr-provider-button:focus-visible::before{transform:translateX(320%) skewX(-18deg);}", + ".ccr-provider-button:hover::after,.ccr-provider-button:focus-visible::after{opacity:1;transform:scale(1.08);}", + ".ccr-provider-button:active{transform:translateY(-1px) scale(.992);}", + ".ccrpb-provider-icon{position:relative;z-index:1;grid-column:1;grid-row:1;justify-self:start;display:grid;place-items:center;width:48px;height:48px;overflow:hidden;border:1px solid rgba(255,255,255,.46);border-radius:12px;background:linear-gradient(135deg,color-mix(in srgb,var(--ccrpb-brand-3) 34%,rgba(255,255,255,.9)),color-mix(in srgb,var(--ccrpb-brand-2) 64%,#111 36%));box-shadow:0 8px 18px rgba(0,0,0,.14),inset 0 1px 0 rgba(255,255,255,.42);color:#fff;font-size:15px;font-weight:820;letter-spacing:0;line-height:1;text-shadow:0 1px 2px rgba(0,0,0,.34);transform:rotate(0) scale(1);transition:border-color 180ms ease,box-shadow 220ms ease,transform 220ms ease;}", + ".ccrpb-provider-icon img{box-sizing:border-box;display:block;width:100%;height:100%;background:color-mix(in srgb,#fff 84%,var(--ccrpb-brand-3));object-fit:contain;filter:drop-shadow(0 1px 1px rgba(0,0,0,.12));}", + ".ccrpb-provider-mark{display:block;max-width:42px;overflow:hidden;text-align:center;text-overflow:ellipsis;white-space:nowrap;}", + ".ccrpb-flow{display:inline-flex;grid-column:1;grid-row:1;align-items:center;justify-self:end;justify-content:flex-end;gap:8px;width:72px;height:48px;padding:0;border:0;background:transparent;color:rgba(255,255,255,.86);font-size:14px;font-weight:760;line-height:1;box-shadow:none;transform:translateX(0);transition:transform 220ms ease;}", + ".ccrpb-ccr-icon{box-sizing:border-box;display:grid;flex:0 0 auto;place-items:center;width:48px;height:48px;overflow:hidden;border:0;border-radius:12px;background:transparent;box-shadow:none;transition:transform 220ms ease;}", + ".ccrpb-ccr-icon img{box-sizing:border-box;display:block;width:48px;height:48px;border-radius:12px;}", + ".ccrpb-arrow{display:inline-block;order:-1;line-height:1;transform:translateX(0);transition:transform 220ms ease;}", + ".ccrpb-copy{display:grid;min-width:0;gap:2px;padding:0 0 0 6px;}", + ".ccrpb-name{display:block;overflow:hidden;color:#fff;font-size:15px;font-weight:780;line-height:1.25;text-overflow:ellipsis;white-space:nowrap;}", + ".ccrpb-description{display:block;overflow:hidden;color:rgba(255,255,255,.74);font-size:11px;font-weight:650;letter-spacing:0;line-height:1.35;text-overflow:ellipsis;white-space:nowrap;}", + ".ccr-provider-button:hover .ccrpb-provider-icon,.ccr-provider-button:focus-visible .ccrpb-provider-icon{border-color:color-mix(in srgb,var(--ccrpb-brand-3) 74%,rgba(255,255,255,.54));box-shadow:0 12px 24px rgba(0,0,0,.18),0 0 0 4px color-mix(in srgb,var(--ccrpb-brand-3) 16%,transparent),inset 0 1px 0 rgba(255,255,255,.52);transform:rotate(-3deg) scale(1.06);}", + ".ccr-provider-button:hover .ccrpb-flow,.ccr-provider-button:focus-visible .ccrpb-flow{transform:translateX(3px);}", + ".ccr-provider-button:hover .ccrpb-ccr-icon,.ccr-provider-button:focus-visible .ccrpb-ccr-icon{transform:scale(1.04);}", + ".ccr-provider-button:hover .ccrpb-arrow,.ccr-provider-button:focus-visible .ccrpb-arrow{transform:translateX(2px);}", + "@media (max-width:520px){.ccr-provider-button{grid-template-columns:118px minmax(0,1fr);min-height:92px;padding:12px}.ccrpb-provider-icon,.ccrpb-provider-icon img,.ccrpb-flow,.ccrpb-ccr-icon,.ccrpb-ccr-icon img{width:44px;height:44px}.ccrpb-provider-icon img,.ccrpb-ccr-icon,.ccrpb-ccr-icon img{border-radius:11px}.ccrpb-flow{width:66px;justify-self:end}}", + "@media (prefers-reduced-motion:reduce){.ccr-provider-button,.ccr-provider-button::before,.ccr-provider-button::after,.ccrpb-provider-icon,.ccrpb-flow,.ccrpb-ccr-icon,.ccrpb-arrow{transition:none}.ccr-provider-button:hover,.ccr-provider-button:focus-visible,.ccr-provider-button:hover .ccrpb-provider-icon,.ccr-provider-button:focus-visible .ccrpb-provider-icon,.ccr-provider-button:hover .ccrpb-flow,.ccr-provider-button:focus-visible .ccrpb-flow,.ccr-provider-button:hover .ccrpb-ccr-icon,.ccr-provider-button:focus-visible .ccrpb-ccr-icon,.ccr-provider-button:hover .ccrpb-arrow,.ccr-provider-button:focus-visible .ccrpb-arrow{transform:none}.ccr-provider-button::before{display:none}}" + ].join(""); + + function injectStyles() { + if (document.getElementById(STYLE_ID)) { + return; + } + var style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = CSS; + document.head.appendChild(style); + } + + function isPresent(value) { + return value !== undefined && value !== null && String(value).trim() !== ""; + } + + function stringifyValue(value) { + if (value === true) { + return "1"; + } + if (value === false) { + return "0"; + } + if (typeof value === "object") { + return JSON.stringify(value); + } + return String(value); + } + + function appendParam(params, key, value) { + if (!isPresent(value)) { + return; + } + if (Array.isArray(value)) { + value.forEach(function (item) { + if (isPresent(item)) { + params.append(key, stringifyValue(item)); + } + }); + return; + } + params.append(key, stringifyValue(value)); + } + + function providerUrl(options) { + options = options || {}; + var params = new URLSearchParams(); + if (isPresent(options.manifest)) { + params.set("manifest", stringifyValue(options.manifest)); + return "ccr://provider?" + params.toString(); + } + LINK_PARAMS.forEach(function (key) { + appendParam(params, key, options[key]); + }); + appendParam(params, "models", options.models); + appendParam(params, "fetch_usage", options.fetch_usage); + return "ccr://provider?" + params.toString(); + } + + function createImage(src, alt) { + var img = document.createElement("img"); + img.src = src; + img.alt = alt || ""; + img.loading = "lazy"; + img.decoding = "async"; + return img; + } + + function providerInitials(name) { + var words = String(name || "") + .replace(/[^A-Za-z0-9\u4e00-\u9fff]+/g, " ") + .trim() + .split(/\s+/) + .filter(Boolean); + if (words.length === 0) { + return "AI"; + } + if (words.length === 1) { + return words[0].slice(0, 2).toUpperCase(); + } + return (words[0].slice(0, 1) + words[1].slice(0, 1)).toUpperCase(); + } + + function protocolDescription(options) { + if (options.description) { + return options.description; + } + if (options.manifest) { + return "Remote provider manifest"; + } + if (options.protocol) { + return options.protocol.replace(/_/g, " "); + } + return "Import provider to CCR"; + } + + function providerName(options) { + return options.name || (options.manifest ? "Provider manifest" : "Provider"); + } + + function providerId(options) { + return providerName(options).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "provider"; + } + + function applyColors(element, options) { + element.style.setProperty("--ccrpb-brand", options.color || "#17201c"); + element.style.setProperty("--ccrpb-brand-2", options.color_2 || "#36624d"); + element.style.setProperty("--ccrpb-brand-3", options.color_3 || "#dff8eb"); + } + + function createProviderIcon(options) { + var shell = document.createElement("span"); + shell.className = "ccrpb-provider-icon"; + if (options.icon) { + shell.appendChild(createImage(options.icon, providerName(options))); + return shell; + } + var mark = document.createElement("span"); + mark.className = "ccrpb-provider-mark"; + mark.textContent = providerInitials(providerName(options)); + shell.appendChild(mark); + return shell; + } + + function createButton(options) { + options = options || {}; + var button = document.createElement("a"); + button.className = "ccr-provider-button ccr-provider-" + providerId(options); + button.href = providerUrl(options); + button.dataset.ccrProviderName = providerName(options); + button.setAttribute("aria-label", "Import " + providerName(options) + " provider to CCR"); + applyColors(button, options); + + var providerIcon = createProviderIcon(options); + + var flow = document.createElement("span"); + flow.className = "ccrpb-flow"; + + var ccrIconShell = document.createElement("span"); + ccrIconShell.className = "ccrpb-ccr-icon"; + ccrIconShell.appendChild(createImage(OFFICIAL_CCR_ICON_URL, "CCR")); + + var arrow = document.createElement("span"); + arrow.className = "ccrpb-arrow"; + arrow.setAttribute("aria-hidden", "true"); + arrow.textContent = "→"; + + flow.appendChild(ccrIconShell); + flow.appendChild(arrow); + + var copy = document.createElement("span"); + copy.className = "ccrpb-copy"; + + var name = document.createElement("span"); + name.className = "ccrpb-name"; + name.textContent = providerName(options); + + var description = document.createElement("span"); + description.className = "ccrpb-description"; + description.textContent = protocolDescription(options); + + copy.appendChild(name); + copy.appendChild(description); + + button.appendChild(providerIcon); + button.appendChild(flow); + button.appendChild(copy); + return button; + } + + function render(target, options) { + options = options || {}; + var root = typeof target === "string" ? document.querySelector(target) : target; + if (!root) { + throw new Error("CCRProviderButtons.render target not found"); + } + injectStyles(); + var button = createButton(options); + if (options.clear !== false) { + root.textContent = ""; + } + root.appendChild(button); + return { + element: button, + href: button.href, + destroy: function () { + if (button.parentNode) { + button.parentNode.removeChild(button); + } + } + }; + } + + function attributeOptions(element) { + var options = {}; + ELEMENT_ATTRIBUTES.forEach(function (name) { + if (!element.hasAttribute(name)) { + return; + } + var value = element.getAttribute(name); + options[name] = name === "fetch_usage" && value === "" ? true : value; + }); + return options; + } + + function defineElement(name) { + if (!("customElements" in window) || customElements.get(name)) { + return; + } + customElements.define(name, class extends HTMLElement { + static get observedAttributes() { + return ELEMENT_ATTRIBUTES; + } + connectedCallback() { + this.render(); + } + attributeChangedCallback() { + if (this.isConnected) { + this.render(); + } + } + render() { + render(this, attributeOptions(this)); + } + }); + } + + function defineElements() { + defineElement("ccr-provider-button"); + defineElement("ccr-provider-buttons"); + } + + window.CCRProviderButtons = { + version: VERSION, + render: render, + renderButton: render, + createButton: function (options) { + injectStyles(); + return createButton(options || {}); + }, + createProviderUrl: providerUrl + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", defineElements, { once: true }); + } else { + defineElements(); + } +}()); diff --git a/cdn/wrangler.toml b/cdn/wrangler.toml new file mode 100644 index 0000000..3baae4b --- /dev/null +++ b/cdn/wrangler.toml @@ -0,0 +1,3 @@ +name = "claude-code-router-cdn" +compatibility_date = "2026-06-28" +pages_build_output_dir = "./public" diff --git a/docs/README.md b/docs/README.md index 62ddfbd..5e7095a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,10 +18,10 @@ The local development server runs from this `docs` directory. Docs are deployed from `.github/workflows/docs.yml` on pushes to `main` that change `docs/**` or the workflow itself. The default public URL is: ```text -https://musistudio.github.io/claude-code-router/ +https://ccrdesk.top/ ``` -The Astro build reads `ASTRO_SITE` and `ASTRO_BASE`, defaulting to `https://musistudio.github.io` and `/claude-code-router`. +The Astro build reads `ASTRO_SITE` and `ASTRO_BASE`, defaulting to `https://ccrdesk.top` and `/`. ## Content diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index becf982..e14bd3b 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -1,7 +1,7 @@ import { defineConfig } from "astro/config"; -const site = process.env.ASTRO_SITE ?? "https://musistudio.github.io"; -const base = process.env.ASTRO_BASE ?? "/claude-code-router"; +const site = process.env.ASTRO_SITE ?? "https://ccrdesk.top"; +const base = process.env.ASTRO_BASE ?? "/"; export default defineConfig({ site, @@ -9,7 +9,11 @@ export default defineConfig({ output: "static", markdown: { shikiConfig: { - theme: "github-light", + themes: { + light: "github-light", + dark: "github-dark", + }, + defaultColor: false, }, }, }); diff --git a/docs/public/CNAME b/docs/public/CNAME new file mode 100644 index 0000000..c47ccbf --- /dev/null +++ b/docs/public/CNAME @@ -0,0 +1 @@ +ccrdesk.top diff --git a/docs/public/ccr-icon.png b/docs/public/ccr-icon.png new file mode 100644 index 0000000..42cdfc1 Binary files /dev/null and b/docs/public/ccr-icon.png differ diff --git a/docs/public/provider-icons/anthropic.png b/docs/public/provider-icons/anthropic.png new file mode 100644 index 0000000..c884fe1 Binary files /dev/null and b/docs/public/provider-icons/anthropic.png differ diff --git a/docs/public/provider-icons/bailian.ico b/docs/public/provider-icons/bailian.ico new file mode 100644 index 0000000..f525bfa Binary files /dev/null and b/docs/public/provider-icons/bailian.ico differ diff --git a/docs/public/provider-icons/deepseek.ico b/docs/public/provider-icons/deepseek.ico new file mode 100644 index 0000000..8e78c0b Binary files /dev/null and b/docs/public/provider-icons/deepseek.ico differ diff --git a/docs/public/provider-icons/gemini.svg b/docs/public/provider-icons/gemini.svg new file mode 100644 index 0000000..38f5625 --- /dev/null +++ b/docs/public/provider-icons/gemini.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/public/provider-icons/mistral.webp b/docs/public/provider-icons/mistral.webp new file mode 100644 index 0000000..74e3817 Binary files /dev/null and b/docs/public/provider-icons/mistral.webp differ diff --git a/docs/public/provider-icons/moonshot.ico b/docs/public/provider-icons/moonshot.ico new file mode 100644 index 0000000..9b4870b Binary files /dev/null and b/docs/public/provider-icons/moonshot.ico differ diff --git a/docs/public/provider-icons/openai.png b/docs/public/provider-icons/openai.png new file mode 100644 index 0000000..16896d2 Binary files /dev/null and b/docs/public/provider-icons/openai.png differ diff --git a/docs/public/provider-icons/openrouter.ico b/docs/public/provider-icons/openrouter.ico new file mode 100644 index 0000000..5d47b23 Binary files /dev/null and b/docs/public/provider-icons/openrouter.ico differ diff --git a/docs/public/provider-icons/siliconflow.png b/docs/public/provider-icons/siliconflow.png new file mode 100644 index 0000000..a4fd985 Binary files /dev/null and b/docs/public/provider-icons/siliconflow.png differ diff --git a/docs/public/provider-icons/zai-global-coding.svg b/docs/public/provider-icons/zai-global-coding.svg new file mode 100644 index 0000000..4f511bd --- /dev/null +++ b/docs/public/provider-icons/zai-global-coding.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/public/provider-icons/zai-global-general.svg b/docs/public/provider-icons/zai-global-general.svg new file mode 100644 index 0000000..4f511bd --- /dev/null +++ b/docs/public/provider-icons/zai-global-general.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/public/provider-icons/zhipu-cn-coding.png b/docs/public/provider-icons/zhipu-cn-coding.png new file mode 100644 index 0000000..0cc7d36 Binary files /dev/null and b/docs/public/provider-icons/zhipu-cn-coding.png differ diff --git a/docs/public/provider-icons/zhipu-cn-general.png b/docs/public/provider-icons/zhipu-cn-general.png new file mode 100644 index 0000000..0cc7d36 Binary files /dev/null and b/docs/public/provider-icons/zhipu-cn-general.png differ diff --git a/docs/src/content/docs/en/configuration.md b/docs/src/content/docs/en/configuration.md index 71b5d09..7f6c4e7 100644 --- a/docs/src/content/docs/en/configuration.md +++ b/docs/src/content/docs/en/configuration.md @@ -12,13 +12,15 @@ Detailed configuration docs are split into standalone pages. Every left-sidebar | Page | Covers | | --- | --- | | Provider Config | Upstream services, protocol, Base URL, model list, and credentials | +| One click import | Provider deeplink protocol, manifest import, one-click import buttons, and security boundaries | | Routing Config | Default routing, conditional rules, fallback, and request rewrites | | Logs & Observability | Request logs, Agent execution traces, tool calls, and tool results | | Fusion Models | Combine a base model with vision, search, or MCP tools into a new selectable model | | Agent Config | Agent launch method, model, scope, multi-instance launching, and Bot binding | +| Extension Mechanism | Wrapper plugins, core gateway plugins, custom extension creation, and debugging | | Bots And IM Agent Relay | Bot forwarding, handoff mode, and platform pages | | Config File Location | Config file location maintained by the desktop app | ## Content Relationships -Providers and routing determine where model requests go. Agent Config covers Claude Code, Codex, and ZCode launch, multi-instance usage, and model selection. Fusion covers vision, web search, and MCP tools. Bots cover IM platform relay. +Provider Config and One click import cover how upstream model services enter CCR. Routing determines where model requests go. Agent Config covers Claude Code, Codex, and ZCode launch, multi-instance usage, and model selection. Fusion covers vision, web search, and MCP tools. Extension Mechanism covers local plugin creation, installation, and debugging. Bots cover IM platform relay. diff --git a/docs/src/content/docs/en/configuration/extensions.md b/docs/src/content/docs/en/configuration/extensions.md new file mode 100644 index 0000000..ceccdea --- /dev/null +++ b/docs/src/content/docs/en/configuration/extensions.md @@ -0,0 +1,281 @@ +--- +title: Extension Mechanism +pageTitle: Extension Mechanism +eyebrow: Detailed Configuration +lead: Learn how CCR extensions are loaded, what they can register, and how to create, install, and debug your own extension. +--- + +## What Extensions Are + +CCR has two extension layers: + +| Type | Config location | Runtime | Good for | +| --- | --- | --- | --- | +| Wrapper plugin | `plugins` | CCR Desktop's Electron wrapper process | Local HTTP routes, local backends, proxy capture routing, built-in browser entries, provider account meters | +| Core gateway plugin | `providerPlugins` or `plugins[].coreGateway.providerPlugins` | core gateway runtime | Provider, auth, or internal core gateway behavior | + +Most custom extensions should start as a Wrapper plugin. It receives CCR config, a private data directory, a logger, and registration helpers through `ctx`. + +## Loading Flow + +When the gateway starts, CCR reads the `plugins` array and processes each plugin whose `enabled !== false`: + +1. It first applies `apps`, `proxy.routes`, `coreGateway.providerPlugins`, `coreGateway.virtualModelProfiles`, and `coreGateway.config` declared in config. +2. It then loads the plugin module. `module` can be an absolute path, a `~/` path, a `./...` path relative to the CCR config directory, or a Node-resolvable package name. +3. If `module` is missing, CCR tries to match the plugin `id` with built-in marketplace plugins such as `claude-design` and `cursor-proxy`. +4. A module can export a function or an object with `setup(ctx)` or `activate(ctx)`. +5. On stop, CCR runs `stop` and `onStop` hooks in reverse order, then closes HTTP backends and SQLite stores registered by the plugin. + +Common module shape: + +```js +"use strict"; + +module.exports = { + async setup(ctx) { + ctx.logger.info("extension loaded"); + }, + async stop() { + // Optional: release resources owned by the extension. + } +}; +``` + +You can also export the setup function directly: + +```js +"use strict"; + +module.exports = async function setup(ctx) { + ctx.logger.info(`loaded ${ctx.pluginId}`); +}; +``` + +`setup(ctx)` or `activate(ctx)` can call `ctx.register...` methods directly, or return a registration object. Returned registrations support `apps`, `gatewayRoutes`, `proxyRoutes`, `providerAccountConnectors`, `coreGateway`, `virtualModelProfiles`, `stop`, and `onStop`. + +## ctx Reference + +`setup(ctx)` receives these common fields and helpers: + +| Field or method | Description | +| --- | --- | +| `ctx.pluginId` | Current plugin ID | +| `ctx.pluginConfig` | Custom value from `plugins[].config` | +| `ctx.config` | Current CCR AppConfig snapshot | +| `ctx.logger` | `debug/info/warn/error` logger prefixed with `[plugin:]` | +| `ctx.paths.configDir` | CCR config directory | +| `ctx.paths.dataDir` | CCR data directory | +| `ctx.paths.pluginDataDir` | Private data directory for this plugin | +| `ctx.registerGatewayRoute(route)` | Register a local HTTP route on the CCR gateway | +| `ctx.registerHttpBackend(backend)` | Start a local HTTP backend and return `{ url, host, port }` | +| `ctx.registerProxyRoute(route)` | Route proxy-captured host/path traffic to a plugin backend or another upstream | +| `ctx.registerApp(app)` | Add an entry to the built-in browser app list | +| `ctx.openSqliteStore(options)` | Open a SQLite store under the plugin data directory | +| `ctx.registerProviderAccountConnector(connector)` | Register a provider balance or quota connector | +| `ctx.registerCoreGatewayProviderPlugin(plugin)` | Inject a provider plugin into the core gateway | +| `ctx.registerCoreGatewayVirtualModelProfile(profile)` | Inject a virtual model profile into the core gateway | + +Gateway route handlers also receive helper functions: + +| Helper | Description | +| --- | --- | +| `helpers.readBody(request)` | Read request body as a `Buffer` | +| `helpers.readJson(request)` | Read and parse JSON request body | +| `helpers.sendJson(response, statusCode, body)` | Send a JSON response | + +`registerGatewayRoute` defaults to `auth: "gateway"`. If CCR has API keys configured, requests must include `Authorization: Bearer ` or `x-api-key: `. Use `auth: "none"` only for debugging or local public status routes. + +## Create Your First Extension + +Create a directory such as `~/ccr-extensions/hello-extension`: + +```text +hello-extension/ + plugin.json + index.cjs +``` + +`plugin.json` lets CCR's local extension picker discover the extension ID, name, and entrypoint: + +```json +{ + "id": "hello-extension", + "name": "Hello Extension", + "module": "index.cjs", + "apps": [ + { + "id": "hello-status", + "name": "Hello Status", + "url": "http://127.0.0.1:3456/plugins/hello" + } + ] +} +``` + +`index.cjs` registers a status route, an echo backend, and one proxy route: + +```js +"use strict"; + +module.exports = { + async setup(ctx) { + ctx.registerGatewayRoute({ + auth: "none", + id: "hello-status", + method: "GET", + path: "/plugins/hello", + handler(_request, response, helpers) { + helpers.sendJson(response, 200, { + ok: true, + plugin: ctx.pluginId, + message: ctx.pluginConfig?.message || "hello from CCR" + }); + } + }); + + const backend = await ctx.registerHttpBackend({ + id: "hello-echo", + async handler(request, response, helpers) { + const body = request.method === "POST" + ? (await helpers.readBody(request)).toString("utf8") + : ""; + + helpers.sendJson(response, 200, { + method: request.method, + path: request.url, + body + }); + } + }); + + ctx.registerProxyRoute({ + host: "api.example.local", + id: "hello-example-api", + paths: ["/v1"], + preserveHost: true, + upstream: backend.url + }); + + ctx.logger.info(`hello backend listening at ${backend.url}`); + } +}; +``` + +This exposes: + +- `GET /plugins/hello`: a route mounted directly on the CCR gateway to verify that the plugin loaded. +- A local echo backend: CCR assigns a free port automatically. +- A proxy rule: proxy-captured `api.example.local/v1...` traffic is forwarded to the echo backend. + +## Install The Extension + +The recommended flow is through the desktop UI: + +1. Open **Extensions**. +2. Add an extension and choose a local extension directory. +3. Select the `hello-extension` directory. +4. Save the config. +5. Open **Server** and restart the gateway. + +You can also edit the config file directly: + +```json +{ + "plugins": [ + { + "id": "hello-extension", + "enabled": true, + "module": "/Users/you/ccr-extensions/hello-extension/index.cjs", + "config": { + "message": "hello from my config" + } + } + ] +} +``` + +Restart CCR after editing the config file directly. See [Config File Location](/en/configuration/configuration-file/). + +The local directory picker recognizes entry metadata from: + +- `plugin.json` +- `ccr-plugin.json` +- `.ccr-plugin/plugin.json` +- `.codex-plugin/plugin.json` +- `main`, `ccr.module`, or `ccrPlugin.module` in `package.json` + +If no entrypoint is declared, CCR tries `index.cjs`, `index.mjs`, `index.js`, `plugin.cjs`, `plugin.mjs`, or `plugin.js` in the selected directory. + +## Debug Extensions + +### 1. Check syntax first + +For CommonJS extensions: + +```bash +node --check ~/ccr-extensions/hello-extension/index.cjs +``` + +If the extension depends on npm packages, install them in the extension directory and make sure Node can resolve the entrypoint. + +### 2. Start CCR from source + +From the CCR repository root: + +```bash +npm install +npm run dev +``` + +`ctx.logger.info/warn/error` output appears in the terminal that started CCR, with a prefix such as `[plugin:hello-extension]`. + +### 3. Verify the Gateway route + +After the gateway starts, request the status route: + +```bash +curl http://127.0.0.1:3456/plugins/hello +``` + +If the route uses the default `auth: "gateway"` and CCR has API keys configured: + +```bash +curl -H "Authorization: Bearer " http://127.0.0.1:3456/plugins/hello +``` + +You can also use: + +```bash +curl -H "x-api-key: " http://127.0.0.1:3456/plugins/hello +``` + +### 4. Verify the HTTP backend and proxy route + +`registerHttpBackend` returns `backend.url`, which the example writes to logs. Request that URL directly first, then enable proxy mode and verify whether the target host/path hits `registerProxyRoute`. + +Proxy route matching rules: + +- `host` must match the target hostname. Exact host, `.example.com` suffix, and `*.example.com` wildcard patterns are supported. +- Empty `paths` matches all paths for that host. +- When multiple paths match, CCR chooses the longest path prefix. +- `stripPathPrefix` removes the matched prefix from the forwarded path. +- `rewritePathPrefix` replaces the matched prefix with a configured prefix. + +### 5. Common Issues + +| Symptom | What to check | +| --- | --- | +| Extension does not load | Check `plugins[].enabled`, `plugins[].module`, and terminal errors prefixed with `[plugin:]` | +| `GET /plugins/hello` returns 404 | Restart the gateway and confirm `path` or `pathPrefix` starts with `/` | +| Response is 401 | Routes require gateway API key by default; set `auth: "none"` for debug routes | +| Code changes do not apply | Wrapper plugins are not hot reloaded; restart the gateway or CCR | +| Port is already in use | Omit `port` in `registerHttpBackend` so CCR can allocate one automatically | +| Proxy route is not hit | Confirm proxy mode is enabled, the certificate is installed, and host matches the real request hostname | + +## Security Notes + +- Use `auth: "none"` only for status pages, health checks, or local debugging routes. +- Do not log API keys, OAuth tokens, cookies, or complete request headers. +- Prefer `ctx.paths.pluginDataDir` for files written by your extension. +- Validate all external input returned by `readJson`. +- When proxying to external upstreams, handle headers explicitly so local auth material is not forwarded to untrusted services. diff --git a/docs/src/content/docs/en/configuration/provider-deeplink.md b/docs/src/content/docs/en/configuration/provider-deeplink.md new file mode 100644 index 0000000..449133c --- /dev/null +++ b/docs/src/content/docs/en/configuration/provider-deeplink.md @@ -0,0 +1,271 @@ +--- +title: One click import +pageTitle: One click import +eyebrow: Detailed Configuration +lead: Use ccr://provider links to hand provider configuration to CCR, or open the import confirmation page with preset provider buttons. +--- + +## One-Click Import + +The buttons below open the CCR desktop app's provider import confirmation dialog. Preset buttons do not include API keys; custom provider links may include one. Always confirm the provider name, Base URL, protocol, and models before importing a key. + + + +## Embeddable Button Component + +CCR also ships a framework-free button script that providers can embed on their own webpages so users can import that provider into CCR with one click. The script registers Web Components automatically. + +### HTML + +```html + + + +``` + +For larger configs, pass a manifest: + +```html + + + +``` + +### JavaScript + +```html +
+ + +``` + +### Render Parameters + +`CCRProviderButtons.render(target, options)` and `` support the same parameter set. Parameter names match the `ccr://provider` protocol: + +| Parameter | Description | +| --- | --- | +| `name` | Provider display name | +| `base_url` | Provider API Base URL, required for direct imports | +| `api_key` | Optional provider API key | +| `protocol` | Protocol, one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, `gemini_generate_content` | +| `models` | Model list. Use comma/newline-separated text in HTML, or a string/array in JavaScript | +| `icon` | Provider icon URL | +| `source` | Provider website or config source | +| `manifest` | Remote manifest URL. When present, the button creates a manifest import link | +| `payload` | JSON or base64url JSON config. JavaScript may pass an object | +| `usage_url` | Optional account usage endpoint | +| `fetch_usage` | Whether account usage fetching is enabled | +| `usage_method` | Usage request method, `GET` or `POST` | +| `usage_headers` | Usage request headers. JavaScript may pass an object; HTML must pass a JSON string | +| `usage_body` | Usage request body. JavaScript may pass an object; HTML must pass a JSON string | +| `balance` | Balance field path | +| `balance_unit` | Balance unit | +| `subscription` | Subscription remaining field path | +| `subscription_limit` | Subscription limit field path | +| `subscription_reset` | Subscription reset time field path | +| `subscription_unit` | Subscription unit | +| `subscription_window` | Subscription window, such as `monthly` | + +## URL Format + +CCR supports two URL shapes. The host form is recommended: + +```text +ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&protocol=openai_chat_completions&models=example-chat%2Cexample-coder +``` + +The path form is also recognized: + +```text +ccr:///provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1 +``` + +For larger configs, put JSON in `payload`. The value can be URL-encoded JSON or base64url JSON: + +```text +ccr://provider?payload=%7B%22name%22%3A%22Example%20AI%22%2C%22base_url%22%3A%22https%3A%2F%2Fapi.example.com%2Fv1%22%2C%22models%22%3A%5B%22example-chat%22%5D%7D +``` + +## Manifest Import + +Providers can also pass a manifest URL: + +```text +ccr://provider?manifest=https%3A%2F%2Fexample.com%2Fccr-provider.json +``` + +The manifest must use HTTPS, return JSON, avoid local or private network hosts, and stay under 128 KB. CCR fetches the manifest inside the app, shows a confirmation dialog, and writes config only after user approval. + +The manifest can put provider information in a top-level `provider` object: + +| Field | Description | +| --- | --- | +| `provider.name` | Provider display name | +| `provider.base_url` | Provider API Base URL, required | +| `provider.protocol` | Protocol type | +| `provider.models` | Model list as a string array | +| `provider.icon` | Provider icon URL | +| `provider.source` | Provider website or config source | +| `provider.account.enabled` | Whether account usage fetching is enabled | +| `provider.account.refreshIntervalMs` | Usage refresh interval in milliseconds | +| `provider.account.connectors` | Usage connector list | +| `provider.account.connectors[].type` | Connector type, commonly `http-json` | +| `provider.account.connectors[].auth` | Auth mode, commonly `provider-api-key` | +| `provider.account.connectors[].endpoint` | Usage endpoint URL | +| `provider.account.connectors[].method` | Request method, `GET` or `POST` | +| `provider.account.connectors[].headers` | Request headers, without sensitive auth headers | +| `provider.account.connectors[].body` | Optional request body | +| `provider.account.connectors[].mapping.meters` | Usage meter mappings | + +Complete manifest example: + +```json +{ + "provider": { + "name": "Example AI", + "base_url": "https://api.example.com/v1", + "protocol": "openai_chat_completions", + "models": ["example-chat", "example-coder"], + "icon": "https://example.com/icon.png", + "source": "https://example.com", + "account": { + "enabled": true, + "refreshIntervalMs": 300000, + "connectors": [ + { + "type": "http-json", + "auth": "provider-api-key", + "endpoint": "https://api.example.com/v1/account/usage", + "method": "GET", + "headers": { + "accept": "application/json" + }, + "mapping": { + "meters": [ + { + "id": "balance", + "kind": "balance", + "label": "Balance", + "remaining": "data.balance.remaining", + "unit": "USD" + }, + { + "id": "subscription", + "kind": "subscription", + "label": "Monthly quota", + "remaining": "data.quota.remaining", + "limit": "data.quota.limit", + "resetAt": "data.quota.reset_at", + "unit": "tokens", + "window": "monthly" + } + ] + } + } + ] + } + } +} +``` + +## Supported Parameters + +| Parameter | Description | +| --- | --- | +| `name` | Provider display name | +| `base_url` | Provider API Base URL, required | +| `api_key` | Optional provider API key | +| `protocol` | Protocol, one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, `gemini_generate_content` | +| `models` | Model list, comma-separated, newline-separated, or repeated | +| `icon` | Provider icon URL | +| `source` | Provider website or config source | +| `manifest` | Remote manifest URL | +| `payload` | JSON or base64url JSON config | +| `usage_url` | Optional account usage endpoint | +| `fetch_usage` | Whether account usage fetching is enabled | +| `usage_method` | Usage request method, `GET` or `POST` | +| `usage_headers` | Usage request headers as a JSON string | +| `usage_body` | Usage request body as a JSON string | +| `balance` | Balance field path | +| `balance_unit` | Balance unit | +| `subscription` | Subscription remaining field path | +| `subscription_limit` | Subscription limit field path | +| `subscription_reset` | Subscription reset time field path | +| `subscription_unit` | Subscription unit | +| `subscription_window` | Subscription window, such as `monthly` | + +Parameter names and protocol values must use the exact names above. Aliases such as `baseUrl`, `apiKey`, `model`, `type`, or `openai` are not accepted. diff --git a/docs/src/content/docs/zh/configuration.md b/docs/src/content/docs/zh/configuration.md index e566930..26b0a78 100644 --- a/docs/src/content/docs/zh/configuration.md +++ b/docs/src/content/docs/zh/configuration.md @@ -12,12 +12,15 @@ lead: 深入配置供应商、路由、Agent配置、Fusion、Bot 和配置文 | 页面 | 内容 | | --- | --- | | 供应商配置 | 上游服务、协议、基础 URL、模型列表和凭据 | +| 一键导入供应商 | Provider deeplink 协议、Manifest 导入、一键导入按钮和安全边界 | | 路由配置 | 默认路由、条件规则、fallback 和请求改写 | +| 日志&观测 | 请求日志、Agent 执行追踪、工具调用和工具结果 | | Fusion 组合模型 | 把基础模型与视觉、搜索、MCP 工具组合成新的可选模型 | | Agent配置 | Agent 启动方式、模型、作用范围、多开和 Bot 绑定 | +| 扩展机制 | Wrapper plugin、Core gateway plugin、自定义扩展创建和调试 | | Bot 与 IM 接力 Agent | Bot 转发、接力模式和平台页面 | | 配置文件位置 | 桌面 App 维护的配置文件位置 | ## 内容关系 -供应商和路由决定模型请求的上游去向;Agent配置页面覆盖 Claude Code、Codex 和 ZCode 的启动、多开与模型选择;Fusion 页面覆盖图像、搜索和 MCP 工具;Bot 页面覆盖 IM 平台接力。 +供应商配置和一键导入供应商页面覆盖上游模型服务如何进入 CCR;路由决定模型请求的上游去向;Agent配置页面覆盖 Claude Code、Codex 和 ZCode 的启动、多开与模型选择;Fusion 页面覆盖图像、搜索和 MCP 工具;扩展机制页面覆盖本地插件的创建、安装和调试;Bot 页面覆盖 IM 平台接力。 diff --git a/docs/src/content/docs/zh/configuration/extensions.md b/docs/src/content/docs/zh/configuration/extensions.md new file mode 100644 index 0000000..5fdf852 --- /dev/null +++ b/docs/src/content/docs/zh/configuration/extensions.md @@ -0,0 +1,281 @@ +--- +title: 扩展机制 +pageTitle: 扩展机制 +eyebrow: 详细配置 +lead: 了解 CCR 扩展如何加载、能注册哪些能力,并从零创建、安装和调试自己的扩展。 +--- + +## 扩展是什么 + +CCR 的扩展分为两层: + +| 类型 | 配置位置 | 运行位置 | 适合做什么 | +| --- | --- | --- | --- | +| Wrapper plugin | `plugins` | CCR Desktop 的 Electron wrapper 进程 | 注册本地 HTTP 路由、启动本地后端、拦截代理流量、添加内置浏览器入口、连接 Provider 账号用量 | +| Core gateway plugin | `providerPlugins` 或 `plugins[].coreGateway.providerPlugins` | core gateway runtime | 扩展上游 Provider、认证方式或 core gateway 内部能力 | + +多数用户自定义扩展应从 Wrapper plugin 开始。它能拿到 CCR 配置、私有数据目录和日志对象,并通过 `ctx` 注册能力。 + +## 加载机制 + +启动网关时,CCR 会读取配置里的 `plugins` 数组,并按顺序处理每个 `enabled !== false` 的扩展: + +1. 先应用配置中声明的 `apps`、`proxy.routes`、`coreGateway.providerPlugins`、`coreGateway.virtualModelProfiles` 和 `coreGateway.config`。 +2. 再加载扩展模块。`module` 可以是绝对路径、`~/` 开头路径、相对配置目录的 `./...` 路径,或 Node 可以解析到的包名。 +3. 如果没有配置 `module`,CCR 会尝试用扩展 `id` 匹配内置市场扩展,例如 `claude-design` 和 `cursor-proxy`。 +4. 模块可以导出函数,也可以导出包含 `setup(ctx)` 或 `activate(ctx)` 的对象。 +5. 扩展停止时,CCR 会反向执行 `stop`、`onStop` 钩子,并关闭该扩展注册的 HTTP 后端和 SQLite store。 + +扩展模块常见导出形式: + +```js +"use strict"; + +module.exports = { + async setup(ctx) { + ctx.logger.info("extension loaded"); + }, + async stop() { + // 可选:释放扩展自己持有的资源。 + } +}; +``` + +也可以直接导出函数: + +```js +"use strict"; + +module.exports = async function setup(ctx) { + ctx.logger.info(`loaded ${ctx.pluginId}`); +}; +``` + +`setup(ctx)` 或 `activate(ctx)` 可以直接调用 `ctx.register...` 方法,也可以返回注册对象。返回对象支持 `apps`、`gatewayRoutes`、`proxyRoutes`、`providerAccountConnectors`、`coreGateway`、`virtualModelProfiles`、`stop` 和 `onStop`。 + +## ctx 能力参考 + +`setup(ctx)` 的 `ctx` 包含这些常用字段和方法: + +| 字段或方法 | 说明 | +| --- | --- | +| `ctx.pluginId` | 当前扩展 ID | +| `ctx.pluginConfig` | `plugins[].config` 中的自定义配置 | +| `ctx.config` | 当前 CCR AppConfig 快照 | +| `ctx.logger` | 带 `[plugin:]` 前缀的 `debug/info/warn/error` 日志 | +| `ctx.paths.configDir` | CCR 配置目录 | +| `ctx.paths.dataDir` | CCR 数据目录 | +| `ctx.paths.pluginDataDir` | 当前扩展专属数据目录 | +| `ctx.registerGatewayRoute(route)` | 在 CCR 网关上注册本地 HTTP 路由 | +| `ctx.registerHttpBackend(backend)` | 启动一个本地 HTTP 后端,返回 `{ url, host, port }` | +| `ctx.registerProxyRoute(route)` | 把代理模式捕获到的某个 host/path 转发到扩展后端或其他 upstream | +| `ctx.registerApp(app)` | 在内置浏览器应用列表里添加入口 | +| `ctx.openSqliteStore(options)` | 在扩展数据目录打开 SQLite store | +| `ctx.registerProviderAccountConnector(connector)` | 注册 Provider 账号余额或额度读取器 | +| `ctx.registerCoreGatewayProviderPlugin(plugin)` | 向 core gateway 注入 provider plugin | +| `ctx.registerCoreGatewayVirtualModelProfile(profile)` | 向 core gateway 注入虚拟模型配置 | + +Gateway route handler 会额外收到 helper: + +| Helper | 说明 | +| --- | --- | +| `helpers.readBody(request)` | 读取请求 body,返回 `Buffer` | +| `helpers.readJson(request)` | 读取并解析 JSON body | +| `helpers.sendJson(response, statusCode, body)` | 返回 JSON 响应 | + +`registerGatewayRoute` 默认使用 `auth: "gateway"`。如果 CCR 配置了 API Key,请求必须带 `Authorization: Bearer ` 或 `x-api-key: `。仅调试或本地公开状态页建议使用 `auth: "none"`。 + +## 创建第一个扩展 + +创建一个目录,例如 `~/ccr-extensions/hello-extension`: + +```text +hello-extension/ + plugin.json + index.cjs +``` + +`plugin.json` 用于让 CCR 的本地扩展选择器识别扩展 ID、名称和入口文件: + +```json +{ + "id": "hello-extension", + "name": "Hello Extension", + "module": "index.cjs", + "apps": [ + { + "id": "hello-status", + "name": "Hello Status", + "url": "http://127.0.0.1:3456/plugins/hello" + } + ] +} +``` + +`index.cjs` 注册一个状态路由、一个 echo 后端,以及一个代理转发规则: + +```js +"use strict"; + +module.exports = { + async setup(ctx) { + ctx.registerGatewayRoute({ + auth: "none", + id: "hello-status", + method: "GET", + path: "/plugins/hello", + handler(_request, response, helpers) { + helpers.sendJson(response, 200, { + ok: true, + plugin: ctx.pluginId, + message: ctx.pluginConfig?.message || "hello from CCR" + }); + } + }); + + const backend = await ctx.registerHttpBackend({ + id: "hello-echo", + async handler(request, response, helpers) { + const body = request.method === "POST" + ? (await helpers.readBody(request)).toString("utf8") + : ""; + + helpers.sendJson(response, 200, { + method: request.method, + path: request.url, + body + }); + } + }); + + ctx.registerProxyRoute({ + host: "api.example.local", + id: "hello-example-api", + paths: ["/v1"], + preserveHost: true, + upstream: backend.url + }); + + ctx.logger.info(`hello backend listening at ${backend.url}`); + } +}; +``` + +这个扩展会暴露: + +- `GET /plugins/hello`:直接挂在 CCR 网关上,用来验证扩展是否加载。 +- 一个本地 echo 后端:由 CCR 自动分配端口。 +- 一个代理规则:当代理模式捕获到 `api.example.local/v1...` 时转发到 echo 后端。 + +## 安装扩展 + +推荐使用桌面 UI 安装本地扩展: + +1. 打开 **Extensions** 页面。 +2. 点击添加扩展,选择本地扩展目录。 +3. 选择刚创建的 `hello-extension` 目录。 +4. 保存配置。 +5. 打开 **Server** 页面,重启网关。 + +也可以直接编辑配置文件: + +```json +{ + "plugins": [ + { + "id": "hello-extension", + "enabled": true, + "module": "/Users/you/ccr-extensions/hello-extension/index.cjs", + "config": { + "message": "hello from my config" + } + } + ] +} +``` + +直接编辑配置文件后需要重启 CCR。配置文件位置见 [配置文件位置](/configuration/config-file/)。 + +本地目录选择器会按顺序识别这些入口信息: + +- `plugin.json` +- `ccr-plugin.json` +- `.ccr-plugin/plugin.json` +- `.codex-plugin/plugin.json` +- `package.json` 里的 `main`、`ccr.module` 或 `ccrPlugin.module` + +如果没有显式入口文件,CCR 会尝试目录里的 `index.cjs`、`index.mjs`、`index.js`、`plugin.cjs`、`plugin.mjs` 或 `plugin.js`。 + +## 调试扩展 + +### 1. 先做语法检查 + +CommonJS 扩展可以运行: + +```bash +node --check ~/ccr-extensions/hello-extension/index.cjs +``` + +如果扩展依赖 npm 包,先在扩展目录安装依赖,并确保入口文件能被 Node 解析。 + +### 2. 用源码模式启动 CCR + +在 CCR 仓库根目录运行: + +```bash +npm install +npm run dev +``` + +扩展里的 `ctx.logger.info/warn/error` 会出现在启动 CCR 的终端中,前缀类似 `[plugin:hello-extension]`。 + +### 3. 验证 Gateway route + +启动网关后,请求状态路由: + +```bash +curl http://127.0.0.1:3456/plugins/hello +``` + +如果路由使用默认的 `auth: "gateway"`,并且 CCR 已配置 API Key: + +```bash +curl -H "Authorization: Bearer " http://127.0.0.1:3456/plugins/hello +``` + +也可以使用: + +```bash +curl -H "x-api-key: " http://127.0.0.1:3456/plugins/hello +``` + +### 4. 验证 HTTP 后端和代理规则 + +`registerHttpBackend` 返回的 `backend.url` 会写入日志。先直接请求这个地址,确认后端工作正常;再开启代理模式,验证目标 host/path 是否被 `registerProxyRoute` 命中。 + +代理规则匹配逻辑: + +- `host` 必须匹配目标 hostname,支持精确 host、`.example.com` 后缀和 `*.example.com` 通配。 +- `paths` 为空时匹配该 host 的所有路径。 +- 多个路径匹配时,CCR 会选最长的 path prefix。 +- `stripPathPrefix` 会从转发路径中移除匹配前缀。 +- `rewritePathPrefix` 会把匹配前缀替换成指定前缀。 + +### 5. 常见问题 + +| 现象 | 排查方向 | +| --- | --- | +| 扩展没有加载 | 检查 `plugins[].enabled`、`plugins[].module` 路径和终端里的 `[plugin:]` 报错 | +| `GET /plugins/hello` 返回 404 | 确认网关已重启,路由 `path` 或 `pathPrefix` 是否以 `/` 开头 | +| 返回 401 | 路由默认需要 gateway API Key;调试路由可显式设置 `auth: "none"` | +| 修改代码不生效 | Wrapper plugin 不会热重载,修改后需要重启网关或重启 CCR | +| 端口被占用 | `registerHttpBackend` 不传 `port` 会自动分配端口;固定端口冲突时改回自动分配 | +| 代理规则不命中 | 检查代理模式是否开启、证书是否安装、host 是否匹配真实请求的 hostname | + +## 安全建议 + +- 只有状态页、健康检查或本机调试路由才使用 `auth: "none"`。 +- 不要在日志里打印 API Key、OAuth token、Cookie 或完整请求头。 +- 扩展写入文件时优先使用 `ctx.paths.pluginDataDir`。 +- 对 `readJson` 得到的外部输入做类型校验。 +- 代理转发到外部 upstream 时,明确处理 header 白名单,避免把本地鉴权信息转发到不可信服务。 diff --git a/docs/src/content/docs/zh/configuration/provider-deeplink.md b/docs/src/content/docs/zh/configuration/provider-deeplink.md new file mode 100644 index 0000000..b0e2f37 --- /dev/null +++ b/docs/src/content/docs/zh/configuration/provider-deeplink.md @@ -0,0 +1,271 @@ +--- +title: 一键导入供应商 +pageTitle: 一键导入供应商 +eyebrow: 详细配置 +lead: 使用 ccr://provider 链接把供应商配置带入 CCR,并通过现有 preset 供应商按钮快速打开导入确认页。 +--- + +## 一键导入 + +下面的按钮会打开 CCR 桌面 App 的供应商导入确认页。预设按钮不会携带 API Key;自定义供应商链接可以携带 Key。导入前始终确认供应商名称、Base URL、协议和模型。 + + + +## 嵌入式按钮组件 + +CCR 也提供了一个无框架的按钮脚本,供应商可以嵌入到自己的网页,让用户一键把该供应商导入 CCR。脚本会自动注册 Web Components。 + +### HTML 写法 + +```html + + + +``` + +如果配置较大,可以只传 manifest: + +```html + + + +``` + +### JS 写法 + +```html +
+ + +``` + +### render 参数 + +`CCRProviderButtons.render(target, options)` 和 `` 支持同一组参数,参数名与 `ccr://provider` 协议保持一致: + +| 参数 | 说明 | +| --- | --- | +| `name` | Provider 展示名称 | +| `base_url` | Provider API Base URL,直链导入时必填 | +| `api_key` | 可选 Provider API Key | +| `protocol` | 协议类型,支持 `openai_chat_completions`、`openai_responses`、`anthropic_messages`、`gemini_generate_content` | +| `models` | 模型列表。HTML 中用逗号或换行分隔,JS 中可传字符串或数组 | +| `icon` | Provider 图标 URL | +| `source` | Provider 官网或配置来源 | +| `manifest` | 远程 manifest URL。传入后按钮会生成 manifest 导入链接 | +| `payload` | JSON 或 base64url JSON 配置。JS 中也可以传对象 | +| `usage_url` | 可选账号用量接口 | +| `fetch_usage` | 是否启用账号用量读取 | +| `usage_method` | 用量接口请求方法,`GET` 或 `POST` | +| `usage_headers` | 用量接口请求头。JS 中可传对象,HTML 中传 JSON 字符串 | +| `usage_body` | 用量接口请求体。JS 中可传对象,HTML 中传 JSON 字符串 | +| `balance` | 余额字段路径 | +| `balance_unit` | 余额单位 | +| `subscription` | 订阅剩余额度字段路径 | +| `subscription_limit` | 订阅总额度字段路径 | +| `subscription_reset` | 订阅重置时间字段路径 | +| `subscription_unit` | 订阅额度单位 | +| `subscription_window` | 订阅窗口,例如 `monthly` | + +## 协议格式 + +CCR 支持两种写法,推荐使用 host 写法: + +```text +ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&protocol=openai_chat_completions&models=example-chat%2Cexample-coder +``` + +路径写法也可以被识别: + +```text +ccr:///provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1 +``` + +如果配置较大,可以把 JSON 放入 `payload` 参数。值可以是 URL 编码 JSON,也可以是 base64url JSON: + +```text +ccr://provider?payload=%7B%22name%22%3A%22Example%20AI%22%2C%22base_url%22%3A%22https%3A%2F%2Fapi.example.com%2Fv1%22%2C%22models%22%3A%5B%22example-chat%22%5D%7D +``` + +## Manifest 导入 + +供应商也可以只传一个 manifest URL: + +```text +ccr://provider?manifest=https%3A%2F%2Fexample.com%2Fccr-provider.json +``` + +Manifest 必须使用 HTTPS,返回 JSON,不能指向本地或内网地址,体积不能超过 128 KB。CCR 会在 App 内拉取 manifest,展示确认页,然后再写入配置。 + +Manifest 可以把供应商信息放在顶层 `provider` 对象中: + +| 字段 | 说明 | +| --- | --- | +| `provider.name` | Provider 展示名称 | +| `provider.base_url` | Provider API Base URL,必填 | +| `provider.protocol` | 协议类型 | +| `provider.models` | 模型列表,字符串数组 | +| `provider.icon` | Provider 图标 URL | +| `provider.source` | Provider 官网或配置来源 | +| `provider.account.enabled` | 是否启用账号用量读取 | +| `provider.account.refreshIntervalMs` | 用量刷新间隔,单位毫秒 | +| `provider.account.connectors` | 用量读取 connector 列表 | +| `provider.account.connectors[].type` | Connector 类型,常用 `http-json` | +| `provider.account.connectors[].auth` | 认证方式,常用 `provider-api-key` | +| `provider.account.connectors[].endpoint` | 用量接口 URL | +| `provider.account.connectors[].method` | 请求方法,`GET` 或 `POST` | +| `provider.account.connectors[].headers` | 请求头,不能包含敏感认证头 | +| `provider.account.connectors[].body` | 可选请求体 | +| `provider.account.connectors[].mapping.meters` | 用量指标映射列表 | + +完整 manifest 示例: + +```json +{ + "provider": { + "name": "Example AI", + "base_url": "https://api.example.com/v1", + "protocol": "openai_chat_completions", + "models": ["example-chat", "example-coder"], + "icon": "https://example.com/icon.png", + "source": "https://example.com", + "account": { + "enabled": true, + "refreshIntervalMs": 300000, + "connectors": [ + { + "type": "http-json", + "auth": "provider-api-key", + "endpoint": "https://api.example.com/v1/account/usage", + "method": "GET", + "headers": { + "accept": "application/json" + }, + "mapping": { + "meters": [ + { + "id": "balance", + "kind": "balance", + "label": "Balance", + "remaining": "data.balance.remaining", + "unit": "USD" + }, + { + "id": "subscription", + "kind": "subscription", + "label": "Monthly quota", + "remaining": "data.quota.remaining", + "limit": "data.quota.limit", + "resetAt": "data.quota.reset_at", + "unit": "tokens", + "window": "monthly" + } + ] + } + } + ] + } + } +} +``` + +## 支持的参数 + +| 参数 | 说明 | +| --- | --- | +| `name` | Provider 展示名称 | +| `base_url` | Provider API Base URL,必填 | +| `api_key` | 可选 Provider API Key | +| `protocol` | 协议类型,支持 `openai_chat_completions`、`openai_responses`、`anthropic_messages`、`gemini_generate_content` | +| `models` | 模型列表,支持逗号或换行分隔,也可以重复传入 | +| `icon` | Provider 图标 URL | +| `source` | Provider 官网或配置来源 | +| `manifest` | 远程 manifest URL | +| `payload` | JSON 或 base64url JSON 配置 | +| `usage_url` | 可选账号用量接口 | +| `fetch_usage` | 是否启用账号用量读取 | +| `usage_method` | 用量接口请求方法,`GET` 或 `POST` | +| `usage_headers` | 用量接口请求头,JSON 字符串 | +| `usage_body` | 用量接口请求体,JSON 字符串 | +| `balance` | 余额字段路径 | +| `balance_unit` | 余额单位 | +| `subscription` | 订阅剩余额度字段路径 | +| `subscription_limit` | 订阅总额度字段路径 | +| `subscription_reset` | 订阅重置时间字段路径 | +| `subscription_unit` | 订阅额度单位 | +| `subscription_window` | 订阅窗口,例如 `monthly` | + +参数名和协议值必须使用上表中的完整规范名。不再接受 `baseUrl`、`apiKey`、`model`、`type` 或 `openai` 等别名。 diff --git a/docs/src/i18n/content.ts b/docs/src/i18n/content.ts index 8e1e279..5148abf 100644 --- a/docs/src/i18n/content.ts +++ b/docs/src/i18n/content.ts @@ -64,10 +64,12 @@ export const docsContent = { icon: "wand", items: [ "供应商配置", + "一键导入供应商", "路由配置", "日志&观测", "Fusion 组合模型", "Agent配置", + "扩展机制", "Bot 与 IM 接力 Agent", "配置文件位置", ], @@ -91,6 +93,7 @@ export const docsContent = { }, sidebarLinks: { 供应商配置: "/configuration/provider/", + "一键导入供应商": "/configuration/provider-deeplink/", 路由配置: "/configuration/routing/", "日志&观测": "/configuration/observability/", "Fusion 组合模型": "/configuration/fusion/", @@ -98,6 +101,7 @@ export const docsContent = { 内置联网搜索: "/configuration/fusion-web-search/", "自定义 MCP 工具": "/configuration/fusion-mcp-tool/", Agent配置: "/configuration/profile/", + 扩展机制: "/configuration/extensions/", "Bot 与 IM 接力 Agent": "/configuration/bot-relay/", 配置步骤: "/configuration/bot-setup/", Slack: "/bot-与-im-接力-agent/slack/", @@ -200,10 +204,12 @@ export const docsContent = { icon: "wand", items: [ "Provider Config", + "One click import", "Routing Config", "Logs & Observability", "Fusion Models", "Agent Config", + "Extension Mechanism", "Bots And IM Agent Relay", "Config File Location", ], @@ -217,6 +223,7 @@ export const docsContent = { }, sidebarLinks: { "Provider Config": "/en/configuration/providers/", + "One click import": "/en/configuration/provider-deeplink/", "Routing Config": "/en/configuration/routing/", "Logs & Observability": "/en/configuration/observability/", "Fusion Models": "/en/configuration/fusion-models/", @@ -224,6 +231,7 @@ export const docsContent = { "Built-In Web Search": "/en/configuration/fusion-web-search/", "Custom MCP Tool": "/en/configuration/fusion-mcp-tool/", "Agent Config": "/en/configuration/profiles/", + "Extension Mechanism": "/en/configuration/extensions/", "Bots And IM Agent Relay": "/en/configuration/bots/", Setup: "/en/configuration/bot-setup/", Slack: "/en/relay-agents-in-im-with-bots/slack/", diff --git a/docs/src/pages/configuration/[slug].astro b/docs/src/pages/configuration/[slug].astro index 99b34a7..5e056e9 100644 --- a/docs/src/pages/configuration/[slug].astro +++ b/docs/src/pages/configuration/[slug].astro @@ -5,6 +5,7 @@ import { configurationSlugFromPath, zhConfigurationDocs } from "../../configurat export function getStaticPaths() { const activeLabels: Record = { provider: "供应商配置", + "provider-deeplink": "一键导入供应商", routing: "路由配置", profile: "Agent配置", observability: "日志&观测", @@ -12,6 +13,7 @@ export function getStaticPaths() { "fusion-vision": "内置图像能力", "fusion-web-search": "内置联网搜索", "fusion-mcp-tool": "自定义 MCP 工具", + extensions: "扩展机制", "bot-relay": "Bot 与 IM 接力 Agent", "bot-setup": "配置步骤", "config-file": "配置文件位置", diff --git a/docs/src/pages/en/configuration/[slug].astro b/docs/src/pages/en/configuration/[slug].astro index 974c6ab..3c7bd97 100644 --- a/docs/src/pages/en/configuration/[slug].astro +++ b/docs/src/pages/en/configuration/[slug].astro @@ -5,6 +5,7 @@ import { configurationSlugFromPath, enConfigurationDocs } from "../../../configu export function getStaticPaths() { const activeLabels: Record = { providers: "Provider Config", + "provider-deeplink": "One click import", routing: "Routing Config", profiles: "Agent Config", observability: "Logs & Observability", @@ -12,6 +13,7 @@ export function getStaticPaths() { "fusion-vision": "Built-In Vision", "fusion-web-search": "Built-In Web Search", "fusion-mcp-tool": "Custom MCP Tool", + extensions: "Extension Mechanism", bots: "Bots And IM Agent Relay", "bot-setup": "Setup", "configuration-file": "Config File Location", diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css index 10ae1cb..ab21f4e 100644 --- a/docs/src/styles/global.css +++ b/docs/src/styles/global.css @@ -913,8 +913,8 @@ h1 { text-underline-offset: 3px; } -.doc-article code, -.doc-markdown code { +.doc-article :not(pre) > code, +.doc-markdown :not(pre) > code { padding: 2px 6px; border: 1px solid var(--code-border); border-radius: 6px; @@ -1048,6 +1048,292 @@ h1 { color: var(--quote-text); } +.provider-import-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 14px; + margin: 24px 0 34px; +} + +.doc-markdown a.provider-import-button { + --provider-brand: #17201c; + --provider-brand-2: #36624d; + --provider-brand-3: #dff8eb; + --provider-accent: rgba(255, 255, 255, 0.72); + position: relative; + isolation: isolate; + display: grid; + grid-template-columns: 48px minmax(0, 1fr); + column-gap: 14px; + align-items: center; + min-height: 86px; + padding: 13px 16px 13px 12px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--provider-brand-2) 42%, transparent); + border-radius: 12px; + background: + radial-gradient(circle at 14% 22%, color-mix(in srgb, var(--provider-brand-3) 34%, transparent) 0, transparent 30%), + radial-gradient(circle at 96% 96%, color-mix(in srgb, var(--provider-brand-2) 44%, transparent) 0, transparent 46%), + linear-gradient(135deg, var(--provider-brand), color-mix(in srgb, var(--provider-brand-2) 88%, #111 12%)); + box-shadow: + 0 10px 26px color-mix(in srgb, var(--provider-brand) 22%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.16); + color: #fff; + text-decoration: none; + text-decoration-thickness: 0; + transform: translateY(0); + transition: + border-color 180ms ease, + box-shadow 220ms ease, + transform 220ms ease; +} + +.doc-markdown a.provider-import-button::before { + content: ""; + position: absolute; + inset: -40% auto -40% -70%; + z-index: -1; + width: 58%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.26), transparent); + transform: skewX(-18deg); + transition: transform 620ms ease; +} + +.doc-markdown a.provider-import-button::after { + content: ""; + position: absolute; + right: -38px; + bottom: -52px; + z-index: -2; + width: 132px; + height: 132px; + border-radius: 999px; + background: radial-gradient(circle, color-mix(in srgb, var(--provider-brand-3) 38%, transparent), transparent 68%); + opacity: 0.82; + transform: scale(0.96); + transition: + opacity 220ms ease, + transform 220ms ease; +} + +.doc-markdown a.provider-import-button:hover, +.doc-markdown a.provider-import-button:focus-visible { + border-color: color-mix(in srgb, var(--provider-brand-3) 68%, rgba(255, 255, 255, 0.34)); + box-shadow: + 0 16px 34px color-mix(in srgb, var(--provider-brand) 30%, transparent), + 0 0 0 1px color-mix(in srgb, var(--provider-brand-3) 26%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.22); + color: #fff; + transform: translateY(-3px); + text-decoration: none; +} + +.doc-markdown a.provider-import-button:hover::before, +.doc-markdown a.provider-import-button:focus-visible::before { + transform: translateX(320%) skewX(-18deg); +} + +.doc-markdown a.provider-import-button:hover::after, +.doc-markdown a.provider-import-button:focus-visible::after { + opacity: 1; + transform: scale(1.08); +} + +.doc-markdown a.provider-import-button:active { + transform: translateY(-1px) scale(0.992); +} + +.provider-import-icon-shell { + position: relative; + z-index: 1; + grid-column: 1; + grid-row: 1; + justify-self: start; + display: grid; + place-items: center; + width: 48px; + height: 48px; + border: 0; + background: transparent; + box-shadow: none; + transform: rotate(0) scale(1); + transition: transform 220ms ease; +} + +.provider-import-icon-shell::before { + content: none; +} + +.provider-import-icon-shell img { + box-sizing: border-box; + display: block; + width: 48px; + height: 48px; + border: 1px solid rgba(255, 255, 255, 0.46); + border-radius: 12px; + background: color-mix(in srgb, #ffffff 84%, var(--provider-brand-3)); + box-shadow: + 0 8px 18px rgba(0, 0, 0, 0.14), + inset 0 1px 0 rgba(255, 255, 255, 0.6); + object-fit: contain; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.12)); + transition: + border-color 180ms ease, + box-shadow 220ms ease; +} + +.provider-import-mark { + box-sizing: border-box; + display: grid; + place-items: center; + width: 48px; + height: 48px; + border: 1px solid rgba(255, 255, 255, 0.46); + border-radius: 12px; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--provider-brand-3) 36%, rgba(255, 255, 255, 0.88)), + color-mix(in srgb, var(--provider-brand-2) 72%, #111111 28%) + ); + box-shadow: + 0 8px 18px rgba(0, 0, 0, 0.14), + inset 0 1px 0 rgba(255, 255, 255, 0.52); + color: #ffffff; + font-size: 14px; + font-weight: 820; + letter-spacing: 0; + line-height: 1; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.34); + transition: + border-color 180ms ease, + box-shadow 220ms ease; +} + +.doc-markdown a.provider-import-button:hover .provider-import-icon-shell, +.doc-markdown a.provider-import-button:focus-visible .provider-import-icon-shell { + transform: rotate(-3deg) scale(1.06); +} + +.doc-markdown a.provider-import-button:hover .provider-import-icon-shell img, +.doc-markdown a.provider-import-button:focus-visible .provider-import-icon-shell img, +.doc-markdown a.provider-import-button:hover .provider-import-mark, +.doc-markdown a.provider-import-button:focus-visible .provider-import-mark { + border-color: color-mix(in srgb, var(--provider-brand-3) 74%, rgba(255, 255, 255, 0.54)); + box-shadow: + 0 12px 24px rgba(0, 0, 0, 0.18), + 0 0 0 4px color-mix(in srgb, var(--provider-brand-3) 16%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.72); +} + +.provider-import-copy { + display: grid; + min-width: 0; + gap: 2px; + padding: 0; +} + +.provider-import-name { + display: block; + overflow: hidden; + color: #fff; + font-size: 15px; + font-weight: 780; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.provider-import-meta { + display: block; + overflow: hidden; + color: rgba(255, 255, 255, 0.74); + font-size: 11px; + font-weight: 650; + letter-spacing: 0; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.doc-markdown a.provider-import-button.provider-openai { + --provider-brand: #0b0f0e; + --provider-brand-2: #39413e; + --provider-brand-3: #f2f5f1; +} + +.doc-markdown a.provider-import-button.provider-anthropic { + --provider-brand: #3f2118; + --provider-brand-2: #c56a4b; + --provider-brand-3: #ffd1bf; +} + +.doc-markdown a.provider-import-button.provider-gemini { + --provider-brand: #1a73e8; + --provider-brand-2: #a142f4; + --provider-brand-3: #fbbc04; +} + +.doc-markdown a.provider-import-button.provider-openrouter { + --provider-brand: #111722; + --provider-brand-2: #66758c; + --provider-brand-3: #d9e3f1; +} + +.doc-markdown a.provider-import-button.provider-deepseek { + --provider-brand: #173aa8; + --provider-brand-2: #4e69ff; + --provider-brand-3: #d6deff; +} + +.doc-markdown a.provider-import-button.provider-zhipu-coding { + --provider-brand: #0d4fd7; + --provider-brand-2: #22b8f0; + --provider-brand-3: #d7f2ff; +} + +.doc-markdown a.provider-import-button.provider-zhipu-general { + --provider-brand: #1728a6; + --provider-brand-2: #5f6dff; + --provider-brand-3: #e0e4ff; +} + +.doc-markdown a.provider-import-button.provider-zai-coding { + --provider-brand: #111111; + --provider-brand-2: #49515d; + --provider-brand-3: #f2f5f8; +} + +.doc-markdown a.provider-import-button.provider-zai-general { + --provider-brand: #0d1424; + --provider-brand-2: #3267ac; + --provider-brand-3: #dce8ff; +} + +.doc-markdown a.provider-import-button.provider-mistral { + --provider-brand: #581900; + --provider-brand-2: #ff8a00; + --provider-brand-3: #ffd84f; +} + +.doc-markdown a.provider-import-button.provider-moonshot { + --provider-brand: #10133a; + --provider-brand-2: #7465ff; + --provider-brand-3: #e4ddff; +} + +.doc-markdown a.provider-import-button.provider-bailian { + --provider-brand: #5a2300; + --provider-brand-2: #ff6a00; + --provider-brand-3: #ffd1a3; +} + +.doc-markdown a.provider-import-button.provider-siliconflow { + --provider-brand: #041f1b; + --provider-brand-2: #00c2a8; + --provider-brand-3: #b4fff4; +} + .doc-markdown > pre, .doc-markdown > .code-panel { margin: 22px 0 32px; @@ -1145,23 +1431,44 @@ pre, line-height: 1.7; } +pre.astro-code, +pre.astro-code span { + color: var(--shiki-light, var(--code-text)); +} + +:root[data-theme="dark"] pre.astro-code, :root[data-theme="dark"] pre.astro-code span { - color: var(--code-text) !important; + color: var(--shiki-dark, var(--code-text)); } @media (prefers-color-scheme: dark) { + :root:not([data-theme]) pre.astro-code, :root:not([data-theme]) pre.astro-code span { - color: var(--code-text) !important; + color: var(--shiki-dark, var(--code-text)); } } .code-content code, pre code { + display: block; padding: 0; + border: 0; border-radius: 0; background: transparent; + box-shadow: none; color: inherit; font-size: inherit; + line-height: inherit; + white-space: pre; +} + +.code-content code span, +pre code span { + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; } .steps { @@ -1431,3 +1738,46 @@ pre code { } } + +@media (max-width: 520px) { + .provider-import-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .doc-markdown a.provider-import-button { + grid-template-columns: 44px minmax(0, 1fr); + column-gap: 12px; + min-height: 92px; + padding: 12px; + } + + .provider-import-icon-shell { + width: 44px; + height: 44px; + } + + .provider-import-icon-shell img, + .provider-import-mark { + width: 44px; + height: 44px; + border-radius: 11px; + } + +} + +@media (prefers-reduced-motion: reduce) { + .doc-markdown a.provider-import-button, + .doc-markdown a.provider-import-button::before, + .doc-markdown a.provider-import-button::after, + .provider-import-icon-shell { + transition: none; + } + + .doc-markdown a.provider-import-button:hover, + .doc-markdown a.provider-import-button:focus-visible, + .doc-markdown a.provider-import-button:hover .provider-import-icon-shell, + .doc-markdown a.provider-import-button:focus-visible .provider-import-icon-shell { + transform: none; + } +} diff --git a/src/main/app-menu.ts b/src/main/app-menu.ts index be1c5d7..de2f219 100644 --- a/src/main/app-menu.ts +++ b/src/main/app-menu.ts @@ -1,8 +1,6 @@ import { app, dialog, Menu, type BrowserWindow, type MenuItemConstructorOptions } from "electron"; import { APP_NAME, IPC_CHANNELS } from "./constants"; -import { appUpdateService } from "./update-service"; import windowsManager from "./windows"; -import type { AppUpdateStatus } from "../shared/app"; export function setupApplicationMenu(): void { Menu.setApplicationMenu(Menu.buildFromTemplate(createMenuTemplate())); @@ -133,121 +131,7 @@ function showAboutPanel(): void { function checkForUpdatesFromMenu(): void { const window = windowsManager.showMainWindow(); - void checkForUpdatesWithDialog(window); -} - -async function checkForUpdatesWithDialog(window: BrowserWindow): Promise { - try { - await presentUpdateStatus(window, await appUpdateService.checkForUpdates()); - } catch (error) { - await showUpdateDialog(window, { - detail: formatError(error), - message: "Update check failed.", - type: "error" - }); - } -} - -async function presentUpdateStatus(window: BrowserWindow, status: AppUpdateStatus): Promise { - if (status.state === "not-available") { - await showUpdateDialog(window, { - detail: `Version ${status.currentVersion} is currently installed.`, - message: `${APP_NAME} is up to date.`, - type: "info" - }); - return; - } - - if (status.state === "available") { - const result = await showUpdateDialog(window, { - buttons: ["Download", "Later"], - cancelId: 1, - detail: status.availableVersion - ? `Version ${status.availableVersion} is available.` - : "An update is available.", - message: "Update available.", - type: "info" - }); - if (result.response === 0) { - await downloadUpdateFromMenu(window); - } - return; - } - - if (status.state === "downloaded") { - await promptInstallUpdate(window); - return; - } - - if (status.state === "error") { - await showUpdateDialog(window, { - detail: status.lastError || "Unable to check for updates.", - message: "Update check failed.", - type: "error" - }); - } -} - -async function downloadUpdateFromMenu(window: BrowserWindow): Promise { - try { - const status = await appUpdateService.downloadUpdate(); - if (status.state === "downloaded" || status.canInstall) { - await promptInstallUpdate(window); - return; - } - if (status.state === "error") { - await showUpdateDialog(window, { - detail: status.lastError || "Unable to download the update.", - message: "Update download failed.", - type: "error" - }); - } - } catch (error) { - await showUpdateDialog(window, { - detail: formatError(error), - message: "Update download failed.", - type: "error" - }); - } -} - -async function promptInstallUpdate(window: BrowserWindow): Promise { - const result = await showUpdateDialog(window, { - buttons: ["Install and Restart", "Later"], - cancelId: 1, - detail: "The update has been downloaded and is ready to install.", - message: "Update ready to install.", - type: "info" - }); - - if (result.response !== 0) { - return; - } - - try { - await appUpdateService.installUpdate(); - } catch (error) { - await showUpdateDialog(window, { - detail: formatError(error), - message: "Update install failed.", - type: "error" - }); - } -} - -function showUpdateDialog( - window: BrowserWindow, - options: { buttons?: string[]; cancelId?: number; detail: string; message: string; type: "error" | "info" } -): Promise { - return dialog.showMessageBox(window, { - buttons: options.buttons ?? ["OK"], - cancelId: options.cancelId, - defaultId: 0, - detail: options.detail, - message: options.message, - title: "Software Update", - type: options.type - }); + sendWhenReady(window, IPC_CHANNELS.appOpenUpdate); } function sendWhenReady(window: BrowserWindow, channel: string): void { @@ -268,7 +152,3 @@ function sendWhenReady(window: BrowserWindow, channel: string): void { send(); } - -function formatError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/src/main/preload.ts b/src/main/preload.ts index 6e6599b..c7d117d 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -157,6 +157,11 @@ contextBridge.exposeInMainWorld("ccr", { ipcRenderer.on(IPC_CHANNELS.appOpenSettings, handler); return () => ipcRenderer.removeListener(IPC_CHANNELS.appOpenSettings, handler); }, + onOpenUpdateRequest: (callback: () => void) => { + const handler = () => callback(); + ipcRenderer.on(IPC_CHANNELS.appOpenUpdate, handler); + return () => ipcRenderer.removeListener(IPC_CHANNELS.appOpenUpdate, handler); + }, onUpdateStatusChanged: (callback: (status: AppUpdateStatus) => void) => { const handler = (_event: Electron.IpcRendererEvent, status: AppUpdateStatus) => callback(status); ipcRenderer.on(IPC_CHANNELS.appUpdateStatusChanged, handler); diff --git a/src/renderer/pages/home/App.tsx b/src/renderer/pages/home/App.tsx index e58dfd1..de581e4 100644 --- a/src/renderer/pages/home/App.tsx +++ b/src/renderer/pages/home/App.tsx @@ -1,6 +1,6 @@ import { AddApiKeyDraft, AddProfileDraft, AddProviderDraft, AddRoutingRuleDraft, AgentAnalysisSessionSelection, AgentAnalysisSnapshot, AgentFilterValue, - ApiKeyConfig, AppConfig, appCopy, AppI18nContext, AppInfo, AppSaveConfigOptions, + ApiKeyConfig, AppConfig, appCopy, AppI18nContext, AppInfo, AppSaveConfigOptions, AppUpdateStatus, AppLanguagePreference, applyProviderProbeResult, AppToast, BotGatewaySavedConfig, buildExtensionList, claudeDesignRoutingConfigFromDraft, buildRouterConditionPath, ClaudeDesignRoutingDraft, ClaudeDesignRoutingRuleDraft, cloneConfig, createApiKeyDraft, createApiKeyEditDraft, @@ -11,7 +11,7 @@ import { enforceSingleEnabledGlobalProfilePerAgent, ExtensionConfigTarget, ExtensionDeleteTarget, ExtensionInstallDraft, ExtensionSource, fallbackAgentAnalysis, fallbackConfig, fallbackGatewayStatus, fallbackInfo, fallbackProxyCertificateStatus, fallbackProxyNetworkSnapshot, fallbackProxyStatus, fallbackRequestLogPage, - fallbackUsageStats, formatAppError, formatJson, formatProxyCertificateInstallMessage, GatewayProviderConfig, + fallbackUpdateStatus, fallbackUsageStats, formatAppError, formatJson, formatProxyCertificateInstallMessage, GatewayProviderConfig, fusionCustomMcpServerFromDraft, fusionCustomToolConfigFromProfile, GatewayProviderProbeResult, gatewayServiceMessage, GatewayStatus, getDefaultOnboardingStep, isClaudeDesignPluginConfig, isClaudeDesignRoutingDraftValid, isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady, @@ -168,6 +168,10 @@ function App() { const [proxyNetworkSnapshot, setProxyNetworkSnapshot] = useState(fallbackProxyNetworkSnapshot); const [proxyStatus, setProxyStatus] = useState(fallbackProxyStatus); const [actionBusy, setActionBusy] = useState(""); + const [updateActionBusy, setUpdateActionBusy] = useState<"" | "check" | "download" | "install">(""); + const [updateActionError, setUpdateActionError] = useState(""); + const [updateDialogOpen, setUpdateDialogOpen] = useState(false); + const [updateDialogStatus, setUpdateDialogStatus] = useState(fallbackUpdateStatus); const [gatewayActionBusy, setGatewayActionBusy] = useState(false); const [actionMessage, setActionMessage] = useState(""); const [actionError, setActionError] = useState(""); @@ -249,6 +253,7 @@ function App() { const [usageRange, setUsageRange] = useState("7d"); const [usageStats, setUsageStats] = useState(fallbackUsageStats); const [providerAccountSnapshots, setProviderAccountSnapshots] = useState([]); + const updateActionBusyRef = useRef(false); const resolvedLanguage = languagePreference === "system" ? systemLanguage : languagePreference; const copy = appCopy[resolvedLanguage]; const t = useMemo(() => (value: string) => translateText(copy, value), [copy]); @@ -318,6 +323,7 @@ function App() { void window.ccr.getPluginMarketplace().then(setPluginMarketplace).catch(() => setPluginMarketplace([])); void window.ccr.getProxyCertificateStatus().then(setProxyCertificateStatus); const unsubscribeOpenSettings = window.ccr.onOpenSettingsRequest(openSettingsDialog); + const unsubscribeOpenUpdate = window.ccr.onOpenUpdateRequest(openUpdateDialog); const refreshRuntimeStatus = () => { void window.ccr?.getGatewayStatus().then(setGatewayStatus); void window.ccr?.getProxyStatus().then(setProxyStatus); @@ -328,9 +334,40 @@ function App() { return () => { window.clearInterval(timer); unsubscribeOpenSettings(); + unsubscribeOpenUpdate(); }; }, []); + useEffect(() => { + if (!updateDialogOpen || !window.ccr) { + return; + } + + let disposed = false; + void window.ccr.getUpdateStatus() + .then((status) => { + if (!disposed) { + setUpdateDialogStatus(status); + } + }) + .catch(() => { + if (!disposed) { + setUpdateDialogStatus(fallbackUpdateStatus); + } + }); + + const unsubscribe = window.ccr.onUpdateStatusChanged((status) => { + if (!disposed) { + setUpdateDialogStatus(status); + } + }); + + return () => { + disposed = true; + unsubscribe(); + }; + }, [updateDialogOpen]); + useEffect(() => { if (!window.ccr) { return; @@ -844,6 +881,74 @@ function App() { }, 1800); } + function openUpdateDialog() { + setUpdateDialogOpen(true); + setUpdateActionError(""); + void checkForAppUpdate(); + } + + async function checkForAppUpdate() { + if (updateActionBusyRef.current) { + return; + } + if (!window.ccr?.updateCheck) { + setUpdateDialogStatus({ + ...fallbackUpdateStatus, + currentVersion: appInfo.version, + lastError: t("Updates are only available in packaged builds."), + state: "error" + }); + return; + } + + updateActionBusyRef.current = true; + setUpdateActionBusy("check"); + setUpdateActionError(""); + try { + setUpdateDialogStatus(await window.ccr.updateCheck()); + } catch (error) { + setUpdateActionError(formatError(error)); + } finally { + updateActionBusyRef.current = false; + setUpdateActionBusy(""); + } + } + + async function downloadAppUpdate() { + if (updateActionBusyRef.current || !window.ccr?.updateDownload) { + return; + } + + updateActionBusyRef.current = true; + setUpdateActionBusy("download"); + setUpdateActionError(""); + try { + setUpdateDialogStatus(await window.ccr.updateDownload()); + } catch (error) { + setUpdateActionError(formatError(error)); + } finally { + updateActionBusyRef.current = false; + setUpdateActionBusy(""); + } + } + + async function installAppUpdate() { + if (updateActionBusyRef.current || !window.ccr?.updateInstall) { + return; + } + + updateActionBusyRef.current = true; + setUpdateActionBusy("install"); + setUpdateActionError(""); + try { + await window.ccr.updateInstall(); + } catch (error) { + setUpdateActionError(formatError(error)); + updateActionBusyRef.current = false; + setUpdateActionBusy(""); + } + } + function updateConfig(mutator: (config: AppConfig) => AppConfig) { setDraftConfig((current) => { const next = normalizeConfig(mutator(cloneConfig(current))); @@ -2697,6 +2802,7 @@ function App() { isMac={isMac} needsTrafficLightSafeArea={needsTrafficLightSafeArea} networkCaptureEnabled={networkCaptureEnabled} + onCheckUpdate={openUpdateDialog} onOpenSettings={openSettingsDialog} onSelectNavigationItem={selectNavigationItem} onToggleSidebar={() => setSidebarOpen((current) => !current)} @@ -2704,6 +2810,7 @@ function App() { shouldReduceMotion={shouldReduceMotion} sidebarOpen={sidebarOpen} toggleGatewayService={toggleGatewayService} + updateActionBusy={Boolean(updateActionBusy)} visibleNavigation={visibleNavigation} viewProps={{ apiKeys: { @@ -3017,6 +3124,21 @@ function App() { trayIconPreference: draftConfig.trayIcon || "random", trayWidgets: normalizeTrayWidgets(draftConfig.trayWidgets ?? DEFAULT_TRAY_WIDGETS, draftConfig.trayWindowModules, draftConfig.trayComponentVariants) } : undefined} + update={updateDialogOpen ? { + actionBusy: updateActionBusy, + actionError: updateActionError, + copy, + onCheck: checkForAppUpdate, + onClose: () => { + if (updateActionBusy !== "install") { + setUpdateDialogOpen(false); + setUpdateActionError(""); + } + }, + onDownload: downloadAppUpdate, + onInstall: installAppUpdate, + status: updateDialogStatus + } : undefined} virtualModelUpsert={virtualModelDialogOpen ? { canSubmit: canSubmitVirtualModel, draft: virtualModelDraft, diff --git a/src/renderer/pages/home/components/dialog-stack.tsx b/src/renderer/pages/home/components/dialog-stack.tsx index 4424509..b254698 100644 --- a/src/renderer/pages/home/components/dialog-stack.tsx +++ b/src/renderer/pages/home/components/dialog-stack.tsx @@ -6,6 +6,7 @@ import { AddProfileDialog, ProfileOpenDialog } from "./profiles"; import { AddProviderDialog, DeleteProviderDialog, ProviderDeepLinkDialog } from "./providers"; import { AddRoutingRuleDialog, DeleteRoutingRuleDialog } from "./routing"; import { AppSettingsDialog } from "./settings"; +import { UpdateDialog } from "./update"; import { InstallExtensionDialog, VirtualModelDialog } from "./virtual-models"; export function AppDialogStack({ @@ -26,6 +27,7 @@ export function AppDialogStack({ routingDelete, routingUpsert, settings, + update, virtualModelUpsert }: { apiKeyAdd?: ComponentProps; @@ -45,6 +47,7 @@ export function AppDialogStack({ routingDelete?: ComponentProps; routingUpsert?: ComponentProps; settings?: ComponentProps; + update?: ComponentProps; virtualModelUpsert?: ComponentProps; }) { const dialogs = [ @@ -65,7 +68,8 @@ export function AppDialogStack({ extensionSettings ? { key: "extension-settings", node: } : null, claudeDesignConfig ? { key: "extension-config", node: } : null, cursorProxyConfig ? { key: "cursor-proxy-config", node: } : null, - settings ? { key: "settings", node: } : null + settings ? { key: "settings", node: } : null, + update ? { key: "update", node: } : null ].filter((dialog): dialog is { key: string; node: ReactElement } => Boolean(dialog)); return ( diff --git a/src/renderer/pages/home/components/index.ts b/src/renderer/pages/home/components/index.ts index f7816f4..edd7faa 100644 --- a/src/renderer/pages/home/components/index.ts +++ b/src/renderer/pages/home/components/index.ts @@ -3,6 +3,7 @@ export { LightToast } from "./feedback"; export { AppDialogStack } from "./dialog-stack"; export { MainLayout, OnboardingLayout } from "./layout"; export { AppSettingsDialog } from "./settings"; +export { UpdateDialog } from "./update"; export { OverviewView, AgentAnalysisView } from "./dashboard"; export { ApiKeysView, AddApiKeyDialog, EditApiKeyDialog } from "./api-keys"; export { ServerView } from "./server"; diff --git a/src/renderer/pages/home/components/layout.tsx b/src/renderer/pages/home/components/layout.tsx index 319777c..bbc3fea 100644 --- a/src/renderer/pages/home/components/layout.tsx +++ b/src/renderer/pages/home/components/layout.tsx @@ -2,8 +2,8 @@ import type { ComponentProps } from "react"; import { AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, Button, cn, EndpointTitleBar, GatewayStatus, listSpringTransition, LucideIcon, motion, motionEase, - NavigationId, PanelLeftClose, PanelLeftOpen, - reducedMotionTransition, ServiceControlButton, Settings, ViewId, + LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen, + reducedMotionTransition, RefreshCw, ServiceControlButton, Settings, ViewId, ViewMotionShell, viewUsesInternalScroll } from "../shared"; import { ApiKeysView } from "./api-keys"; @@ -63,12 +63,14 @@ export function MainLayout({ needsTrafficLightSafeArea, agentAnalysisEnabled, networkCaptureEnabled, + onCheckUpdate, onOpenSettings, onSelectNavigationItem, onToggleSidebar, shouldReduceMotion, sidebarOpen, toggleGatewayService, + updateActionBusy, viewProps, requestLogsEnabled, visibleNavigation @@ -83,17 +85,21 @@ export function MainLayout({ isMac: boolean; needsTrafficLightSafeArea: boolean; networkCaptureEnabled: boolean; + onCheckUpdate: () => void; onOpenSettings: () => void; onSelectNavigationItem: (id: NavigationId) => void; onToggleSidebar: () => void; shouldReduceMotion: boolean | null; sidebarOpen: boolean; toggleGatewayService: () => void; + updateActionBusy: boolean; viewProps: MainViewProps; requestLogsEnabled: boolean; visibleNavigation: MainNavigationItem[]; }) { const windowControlSafeAreaWidth = isMac ? 152 : 88; + const checkForUpdatesLabel = copy.text["Check for updates"] ?? "Check for updates"; + const checkingForUpdatesLabel = copy.text["Checking for updates"] ?? "Checking for updates"; return ( <> @@ -175,7 +181,20 @@ export function MainLayout({ ))} -
+
+ + + + +
+
+
{updateStateLabel(status, t)}
+
{updateStateDescription(status, t)}
+
+ +
+ +
+ + + + +
+ + {status.state === "downloading" ? ( +
+
+ {t("Downloading update")} + {progressPercent !== undefined ? `${progressPercent.toFixed(0)}%` : ""} +
+
+
+
+
{formatDownloadProgress(status.progress)}
+
+ ) : null} + + {canInstall ? ( +
+ {t("Update ready to install")} +
+ ) : null} + + {!status.supported ? ( +
+ {t("Updates are only available in packaged builds.")} +
+ ) : null} + + {error ? ( +
+ + {error} +
+ ) : null} + + {status.releaseNotes ? ( +
+
{t("Release notes")}
+
{status.releaseNotes}
+
+ ) : null} + + + + + + + + + + + ); +} + +function UpdateInfoRow({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function UpdateStateBadge({ label, status }: { label: string; status: AppUpdateStatus }) { + return ( + + {label} + + ); +} + +function updateStateLabel(status: AppUpdateStatus, t: (value: string) => string): string { + if (!status.supported) return t("Not configured"); + if (status.state === "checking") return t("Checking for updates"); + if (status.state === "available") return t("Update available"); + if (status.state === "not-available") return t("No updates available"); + if (status.state === "downloading") return t("Downloading update"); + if (status.state === "downloaded") return t("Update ready to install"); + if (status.state === "installing") return t("Install and restart"); + if (status.state === "error") return t("Update failed"); + return t("Check for updates"); +} + +function updateStateDescription(status: AppUpdateStatus, t: (value: string) => string): string { + if (!status.supported) return t("Updates are only available in packaged builds."); + if (status.state === "available" && status.availableVersion) return `${t("Available version")}: ${status.availableVersion}`; + if (status.state === "downloaded") return t("Update downloaded"); + if (status.state === "not-available") return t("No updates available"); + if (status.state === "downloading") return t("Downloading update"); + return t("Online updates"); +} + +function clampPercent(value: number | undefined): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + return Math.max(0, Math.min(100, value)); +} + +function formatDownloadProgress(progress: AppUpdateStatus["progress"]): string { + if (!progress) { + return ""; + } + const transferred = formatBytes(progress.transferred); + const total = formatBytes(progress.total); + const speed = formatBytes(progress.bytesPerSecond); + return [ + transferred && total ? `${transferred} / ${total}` : transferred || total, + speed ? `${speed}/s` : "" + ].filter(Boolean).join(" | "); +} + +function formatBytes(value: number | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return ""; + } + const units = ["B", "KB", "MB", "GB"]; + let current = value; + let unitIndex = 0; + while (current >= 1024 && unitIndex < units.length - 1) { + current /= 1024; + unitIndex += 1; + } + return `${current >= 10 || unitIndex === 0 ? current.toFixed(0) : current.toFixed(1)} ${units[unitIndex]}`; +} + +function formatUpdateDate(value: string | undefined): string { + if (!value) { + return ""; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ""; + } + return date.toLocaleString(); +} diff --git a/src/renderer/pages/home/shared/i18n.tsx b/src/renderer/pages/home/shared/i18n.tsx index 494a410..5012f3a 100644 --- a/src/renderer/pages/home/shared/i18n.tsx +++ b/src/renderer/pages/home/shared/i18n.tsx @@ -872,7 +872,6 @@ export const appCopy: Record = { "Provider credential weight must be a positive number.": "供应商凭据权重必须是正数。", "Provider ID": "供应商 ID", "Provider link failed": "供应商链接失败", - "Provider links cannot include API keys. Add the key manually after verifying the endpoint.": "供应商链接不能包含 API 密钥。请核验端点后手动添加密钥。", "Provider middleware": "供应商中间件", "Provider name": "供应商名称", "Provider name and Base URL are required.": "供应商名称和基础 URL 不能为空。", diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index b7f33e4..4935b74 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -131,6 +131,7 @@ declare global { waitBotGatewayQrLogin: (request: BotGatewayQrLoginWaitRequest) => Promise; onBeforeQuit: (callback: () => void) => () => void; onOpenSettingsRequest: (callback: () => void) => () => void; + onOpenUpdateRequest: (callback: () => void) => () => void; onProviderDeepLink: (callback: (request: ProviderDeepLinkRequest) => void) => () => void; onUpdateStatusChanged: (callback: (status: AppUpdateStatus) => void) => () => void; }; diff --git a/src/shared/deep-link.ts b/src/shared/deep-link.ts index 2fb8cba..df2cbef 100644 --- a/src/shared/deep-link.ts +++ b/src/shared/deep-link.ts @@ -21,23 +21,13 @@ const maxManifestUrlLength = 2_048; const maxSourceLength = 2_048; const maxModelLength = 256; const maxModels = 300; -const providerLinkApiKeyError = "Provider links cannot include API keys. Add the key manually after verifying the endpoint."; -const protocolAliases: Record = { - anthropic: "anthropic_messages", - anthropic_messages: "anthropic_messages", - claude: "anthropic_messages", - gemini: "gemini_generate_content", - gemini_generate: "gemini_generate_content", - gemini_generate_content: "gemini_generate_content", - google: "gemini_generate_content", - openai: "openai_chat_completions", - openai_chat: "openai_chat_completions", - openai_chat_completions: "openai_chat_completions", - openai_response: "openai_responses", - openai_responses: "openai_responses", - responses: "openai_responses" -}; +const providerProtocols = new Set([ + "anthropic_messages", + "gemini_generate_content", + "openai_chat_completions", + "openai_responses" +]); export function isAppDeepLinkUrl(value: string): boolean { return value.trim().toLowerCase().startsWith(`${appDeepLinkProtocol}://`); @@ -91,8 +81,8 @@ export function parseProviderManifestDeepLinkPayload(rawUrl: string): ProviderMa const payload = readPayloadRecord(url.searchParams); const manifestUrl = boundedString( - firstStringParam(url.searchParams, ["manifest_url", "manifestUrl", "manifest"]) ?? - firstPayloadString(payload, ["manifest_url", "manifestUrl", "manifest"]), + firstStringParam(url.searchParams, ["manifest"]) ?? + firstPayloadString(payload, ["manifest"]), maxManifestUrlLength, "Manifest URL" ); @@ -125,14 +115,14 @@ export function parseProviderDeepLinkPayload(rawUrl: string): ProviderDeepLinkPa const params = url.searchParams; const payload = readPayloadRecord(params); const name = boundedString( - firstStringParam(params, ["name", "provider_name", "providerName", "title"]) ?? - firstPayloadString(payload, ["name", "provider_name", "providerName", "title"]), + firstStringParam(params, ["name"]) ?? + firstPayloadString(payload, ["name"]), maxNameLength, "Provider name" ); const baseUrl = boundedString( - firstStringParam(params, ["base_url", "baseUrl", "api_base_url", "apiBaseUrl", "url", "endpoint"]) ?? - firstPayloadString(payload, ["base_url", "baseUrl", "api_base_url", "apiBaseUrl", "url", "endpoint"]), + firstStringParam(params, ["base_url"]) ?? + firstPayloadString(payload, ["base_url"]), maxBaseUrlLength, "Base URL" ); @@ -142,33 +132,31 @@ export function parseProviderDeepLinkPayload(rawUrl: string): ProviderDeepLinkPa validateProviderBaseUrl(baseUrl); const apiKey = boundedString( - firstStringParam(params, ["api_key", "apiKey", "apikey", "key", "token"]) ?? - firstPayloadString(payload, ["api_key", "apiKey", "apikey", "key", "token"]), + firstStringParam(params, ["api_key"]) ?? + firstPayloadString(payload, ["api_key"]), maxApiKeyLength, "API key" ); - if (apiKey) { - throw new Error(providerLinkApiKeyError); - } const icon = boundedString( - firstStringParam(params, ["icon", "icon_url", "iconUrl"]) ?? - firstPayloadString(payload, ["icon", "icon_url", "iconUrl"]), + firstStringParam(params, ["icon"]) ?? + firstPayloadString(payload, ["icon"]), maxIconLength, "Provider icon" ); const protocol = normalizeProviderProtocol( - firstStringParam(params, ["protocol", "type"]) ?? firstPayloadString(payload, ["protocol", "type"]) + firstStringParam(params, ["protocol"]) ?? firstPayloadString(payload, ["protocol"]) ); const models = readDeepLinkModels(params, payload); const account = readDeepLinkAccount(params, payload); const source = boundedString( - firstStringParam(params, ["source", "source_url", "sourceUrl"]) ?? - firstPayloadString(payload, ["source", "source_url", "sourceUrl"]), + firstStringParam(params, ["source"]) ?? + firstPayloadString(payload, ["source"]), maxSourceLength, "Source URL" ); return { ...(account ? { account } : {}), + ...(apiKey ? { apiKey } : {}), baseUrl, ...(icon ? { icon } : {}), models, @@ -196,14 +184,14 @@ function parseProviderPayloadFields( sourceFallback?: string ): ProviderDeepLinkPayload { const name = boundedString( - firstStringParam(params, ["name", "provider_name", "providerName", "title"]) ?? - firstPayloadString(payload, ["name", "provider_name", "providerName", "title"]), + firstStringParam(params, ["name"]) ?? + firstPayloadString(payload, ["name"]), maxNameLength, "Provider name" ); const baseUrl = boundedString( - firstStringParam(params, ["base_url", "baseUrl", "api_base_url", "apiBaseUrl", "url", "endpoint"]) ?? - firstPayloadString(payload, ["base_url", "baseUrl", "api_base_url", "apiBaseUrl", "url", "endpoint"]), + firstStringParam(params, ["base_url"]) ?? + firstPayloadString(payload, ["base_url"]), maxBaseUrlLength, "Base URL" ); @@ -213,25 +201,25 @@ function parseProviderPayloadFields( validateProviderBaseUrl(baseUrl); const apiKey = boundedString( - firstStringParam(params, ["api_key", "apiKey", "apikey", "key", "token"]) ?? - firstPayloadString(payload, ["api_key", "apiKey", "apikey", "key", "token"]), + firstStringParam(params, ["api_key"]) ?? + firstPayloadString(payload, ["api_key"]), maxApiKeyLength, "API key" ); const icon = boundedString( - firstStringParam(params, ["icon", "icon_url", "iconUrl"]) ?? - firstPayloadString(payload, ["icon", "icon_url", "iconUrl"]), + firstStringParam(params, ["icon"]) ?? + firstPayloadString(payload, ["icon"]), maxIconLength, "Provider icon" ); const protocol = normalizeProviderProtocol( - firstStringParam(params, ["protocol", "type"]) ?? firstPayloadString(payload, ["protocol", "type"]) + firstStringParam(params, ["protocol"]) ?? firstPayloadString(payload, ["protocol"]) ); const models = readDeepLinkModels(params, payload); const account = readDeepLinkAccount(params, payload); const source = boundedString( - firstStringParam(params, ["source", "source_url", "sourceUrl"]) ?? - firstPayloadString(payload, ["source", "source_url", "sourceUrl"]) ?? + firstStringParam(params, ["source"]) ?? + firstPayloadString(payload, ["source"]) ?? sourceFallback, maxSourceLength, "Source URL" @@ -251,25 +239,20 @@ function parseProviderPayloadFields( function readDeepLinkAccount(params: URLSearchParams, payload: Record | undefined): ProviderAccountConfig | undefined { const fetchUsage = readDeepLinkBoolean(params, payload, [ - "fetch_usage", - "fetchUsage", - "usage_enabled", - "usageEnabled", - "account_enabled", - "accountEnabled" + "fetch_usage" ]); if (fetchUsage === false) { return { enabled: false }; } - const payloadAccount = normalizeProviderAccountConfig(payload?.account ?? payload?.usage); + const payloadAccount = normalizeProviderAccountConfig(payload?.account); if (payloadAccount) { return payloadAccount; } const endpoint = boundedString( - firstStringParam(params, ["usage_url", "usageUrl", "account_url", "accountUrl"]) ?? - firstPayloadString(payload, ["usage_url", "usageUrl", "account_url", "accountUrl"]), + firstStringParam(params, ["usage_url"]) ?? + firstPayloadString(payload, ["usage_url"]), maxBaseUrlLength, "Usage URL" ); @@ -279,23 +262,23 @@ function readDeepLinkAccount(params: URLSearchParams, payload: Record | undefined { - const value = firstStringParam(params, ["payload", "config", "data"]); + const value = firstStringParam(params, ["payload"]); if (!value) { return undefined; } @@ -492,19 +475,16 @@ function normalizeProviderProtocol(value: string | undefined): GatewayProviderPr if (!value) { return undefined; } - const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_"); - const protocol = protocolAliases[normalized]; - if (!protocol) { + const protocol = value.trim(); + if (!providerProtocols.has(protocol as GatewayProviderProtocol)) { throw new Error(`Unsupported provider protocol: ${value}`); } - return protocol; + return protocol as GatewayProviderProtocol; } function readDeepLinkModels(params: URLSearchParams, payload: Record | undefined): string[] { const values = [ - ...params.getAll("model"), ...params.getAll("models"), - ...params.getAll("models[]"), ...payloadModels(payload) ]; const seen = new Set(); @@ -533,7 +513,7 @@ function payloadModels(payload: Record | undefined): string[] { if (!payload) { return []; } - const value = payload.models ?? payload.model; + const value = payload.models; if (Array.isArray(value)) { return value.filter((item): item is string => typeof item === "string"); } diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index e380e8e..f88003a 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -29,6 +29,7 @@ export const IPC_CHANNELS = { appOpenBuiltInBrowser: "ccr:app:open-built-in-browser", appOpenExternal: "ccr:app:open-external", appOpenSettings: "ccr:app:open-settings", + appOpenUpdate: "ccr:app:open-update", appOpenProfile: "ccr:app:open-profile", appApplyClaudeAppGateway: "ccr:app:apply-claude-app-gateway", appApplyProfile: "ccr:app:apply-profile",