Merge branch 'dev/3.1'

This commit is contained in:
musistudio 2026-06-28 21:21:22 +08:00
commit 673373d958
48 changed files with 2790 additions and 222 deletions

33
.github/workflows/cdn.yml vendored Normal file
View file

@ -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 }}

View file

@ -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

View file

@ -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.

View file

@ -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 前,总会弹出确认窗口。

63
cdn/README.md Normal file
View file

@ -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
```

10
cdn/package.json Normal file
View file

@ -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"
}
}

10
cdn/public/_headers Normal file
View file

@ -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

BIN
cdn/public/ccr-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 KiB

View file

@ -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();
}
}());

3
cdn/wrangler.toml Normal file
View file

@ -0,0 +1,3 @@
name = "claude-code-router-cdn"
compatibility_date = "2026-06-28"
pages_build_output_dir = "./public"

View file

@ -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

View file

@ -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,
},
},
});

1
docs/public/CNAME Normal file
View file

@ -0,0 +1 @@
ccrdesk.top

BIN
docs/public/ccr-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 B

View file

@ -0,0 +1,219 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<style type="text/css">
.st0{opacity:0.3;fill:#E2E4E7;}
.st1{opacity:0.8;fill:#E2E4E7;stroke:#FFFFFF;stroke-width:5;stroke-miterlimit:10;}
.st2{fill:url(#SVGID_1_);}
.st3{fill:none;stroke:#E0E4E9;stroke-width:0.25;stroke-miterlimit:10;}
.st4{fill:none;}
.st5{fill:#9DA1A5;}
.st6{fill-rule:evenodd;clip-rule:evenodd;fill:none;}
.st7{fill-rule:evenodd;clip-rule:evenodd;fill:#DFE2E7;}
.st8{fill-rule:evenodd;clip-rule:evenodd;fill:#CDD4DA;}
.st9{fill-rule:evenodd;clip-rule:evenodd;fill:#B3BCC7;}
.st10{fill-rule:evenodd;clip-rule:evenodd;fill:#9DAAB7;}
.st11{fill-rule:evenodd;clip-rule:evenodd;fill:#8698A8;}
.st12{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_2_);}
.st13{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_3_);}
.st14{fill:#1F63EC;}
.st15{fill:#2D2D2D;}
.st16{fill:none;stroke:#E0E4E9;stroke-width:0.5;stroke-miterlimit:10;}
.st17{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_4_);}
.st18{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_5_);}
.st19{fill:none;stroke:#677380;stroke-width:0.5;stroke-miterlimit:10;}
.st20{fill:none;stroke:url(#SVGID_6_);stroke-width:2;stroke-miterlimit:10;}
.st21{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_7_);}
.st22{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_8_);}
.st23{fill:#FFFFFF;}
.st24{fill-rule:evenodd;clip-rule:evenodd;fill:#2D2D2D;}
.st25{clip-path:url(#SVGID_10_);}
.st26{clip-path:url(#SVGID_12_);}
.st27{fill:url(#SVGID_13_);}
.st28{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_14_);}
.st29{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_15_);}
.st30{clip-path:url(#SVGID_17_);}
.st31{clip-path:url(#SVGID_19_);}
.st32{fill:url(#SVGID_20_);}
.st33{fill:none;stroke:url(#SVGID_21_);stroke-width:2;stroke-miterlimit:10;}
.st34{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_22_);}
.st35{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_23_);}
.st36{clip-path:url(#SVGID_25_);}
.st37{clip-path:url(#SVGID_27_);}
.st38{fill:url(#SVGID_28_);}
.st39{clip-path:url(#SVGID_30_);}
.st40{clip-path:url(#SVGID_32_);}
.st41{fill:url(#SVGID_33_);}
.st42{fill-rule:evenodd;clip-rule:evenodd;fill:#126EF6;}
.st43{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
.st44{clip-path:url(#SVGID_35_);}
.st45{clip-path:url(#SVGID_37_);}
.st46{fill:url(#SVGID_38_);}
.st47{fill-rule:evenodd;clip-rule:evenodd;fill:#9DA1A5;}
.st48{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_39_);}
.st49{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_40_);}
.st50{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_41_);}
.st51{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_42_);}
.st52{fill:none;stroke:url(#SVGID_43_);stroke-width:2;stroke-miterlimit:10;}
.st53{fill-rule:evenodd;clip-rule:evenodd;fill:none;stroke:#E0E4E9;stroke-width:0.5;stroke-miterlimit:10;}
.st54{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_44_);}
.st55{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_45_);}
.st56{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_46_);}
.st57{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_47_);}
.st58{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_48_);}
.st59{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_49_);}
.st60{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_50_);}
.st61{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_51_);}
.st62{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_52_);}
.st63{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_53_);}
.st64{clip-path:url(#SVGID_55_);}
.st65{clip-path:url(#SVGID_57_);}
.st66{fill:url(#SVGID_58_);}
.st67{clip-path:url(#SVGID_60_);}
.st68{clip-path:url(#SVGID_62_);}
.st69{fill:url(#SVGID_63_);}
.st70{fill:none;stroke:url(#SVGID_64_);stroke-width:2;stroke-miterlimit:10;}
.st71{clip-path:url(#SVGID_66_);}
.st72{clip-path:url(#SVGID_68_);}
.st73{fill:url(#SVGID_69_);}
.st74{clip-path:url(#SVGID_71_);}
.st75{clip-path:url(#SVGID_73_);}
.st76{fill:url(#SVGID_74_);}
.st77{clip-path:url(#SVGID_76_);}
.st78{clip-path:url(#SVGID_78_);}
.st79{fill:url(#SVGID_79_);}
.st80{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_80_);}
.st81{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_81_);}
.st82{clip-path:url(#SVGID_83_);}
.st83{clip-path:url(#SVGID_85_);}
.st84{fill:url(#SVGID_86_);}
.st85{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_87_);}
.st86{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_88_);}
.st87{clip-path:url(#SVGID_90_);}
.st88{clip-path:url(#SVGID_92_);}
.st89{fill:url(#SVGID_93_);}
.st90{fill:none;stroke:url(#SVGID_94_);stroke-width:2;stroke-miterlimit:10;}
.st91{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_95_);}
.st92{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_96_);}
.st93{clip-path:url(#SVGID_98_);}
.st94{clip-path:url(#SVGID_100_);}
.st95{fill:url(#SVGID_101_);}
.st96{clip-path:url(#SVGID_103_);}
.st97{clip-path:url(#SVGID_105_);}
.st98{fill:url(#SVGID_106_);}
.st99{clip-path:url(#SVGID_108_);}
.st100{clip-path:url(#SVGID_110_);}
.st101{fill:url(#SVGID_111_);}
.st102{fill:#FFFFFF;stroke:#B3BCC7;stroke-width:0.275;stroke-miterlimit:10;}
.st103{clip-path:url(#SVGID_113_);}
.st104{fill:#FDD138;}
.st105{fill:#FCA62F;}
.st106{fill:#FB7927;}
.st107{fill:#F44B22;}
.st108{fill:#D81915;}
.st109{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.3354;stroke-miterlimit:10;}
.st110{fill:none;stroke:#65727F;stroke-width:2;stroke-miterlimit:10;}
.st111{fill:none;stroke:#65727F;stroke-width:0.75;stroke-miterlimit:10;}
.st112{fill:url(#SVGID_114_);}
.st113{fill:#D06C50;}
.st114{fill:#2D2D2D;stroke:#B3BCC7;stroke-width:0.275;stroke-miterlimit:10;}
.st115{opacity:0.2;}
.st116{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;}
.st117{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0212,1.0212;}
.st118{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0205,1.0205;}
.st119{opacity:0.2;fill:none;}
.st120{fill:none;stroke:#677380;stroke-width:0.3689;stroke-miterlimit:10;}
.st121{fill:none;stroke:#677380;stroke-width:0.3689;stroke-miterlimit:10;stroke-dasharray:1.0509,1.0509;}
.st122{opacity:0.3;fill:#1F63EC;}
.st123{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.3162;stroke-miterlimit:10;}
.st124{fill:#FFFFFF;stroke:#B3BCC7;stroke-width:0.3162;stroke-miterlimit:10;}
.st125{clip-path:url(#SVGID_118_);}
.st126{fill:url(#SVGID_119_);}
.st127{fill:none;stroke:#DFE2E7;stroke-width:0.75;stroke-miterlimit:10;}
.st128{fill:#9DA1A5;stroke:#FFFFFF;stroke-miterlimit:10;}
.st129{fill:url(#SVGID_120_);}
.st130{fill:none;stroke:#677380;stroke-width:0.75;stroke-miterlimit:10;}
.st131{opacity:0.4;}
.st132{clip-path:url(#SVGID_122_);}
.st133{clip-path:url(#SVGID_124_);}
.st134{fill:url(#SVGID_125_);}
.st135{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;}
.st136{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:0.9951,0.9951;}
.st137{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:1.004,1.004;}
.st138{fill:none;stroke:url(#SVGID_126_);stroke-width:1.5;stroke-miterlimit:10;}
.st139{fill:url(#SVGID_127_);}
.st140{fill:none;stroke:#DDE0E4;stroke-width:0.35;stroke-miterlimit:10;}
.st141{fill:#2D2D2D;stroke:#A9B3BE;stroke-width:0.275;stroke-miterlimit:10;}
.st142{fill-rule:evenodd;clip-rule:evenodd;fill:#126EF4;}
.st143{fill:#FFFFFF;stroke:#B1BAC4;stroke-width:0.275;stroke-miterlimit:10;}
.st144{fill:#CE6C50;}
.st145{fill:#5B5B5B;}
.st146{fill:#8392A3;}
.st147{fill:none;stroke:url(#SVGID_128_);stroke-width:1.5;stroke-miterlimit:10;}
.st148{fill:url(#SVGID_129_);}
.st149{fill:none;stroke:#B5BDC4;stroke-width:0.7;stroke-miterlimit:10;}
.st150{opacity:0.6;fill:none;stroke:#78838E;stroke-width:0.35;stroke-miterlimit:10;}
.st151{opacity:0.2;fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:1,1;}
.st152{fill:none;stroke:#DDE0E4;stroke-width:0.75;stroke-miterlimit:10;}
.st153{fill:none;stroke:#8392A3;stroke-width:0.5;stroke-miterlimit:10;}
.st154{opacity:0.2;fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0182,1.0182;}
.st155{fill:none;stroke:#DDE0E4;stroke-width:0.765;stroke-miterlimit:10;}
.st156{fill:url(#SVGID_130_);}
.st157{fill:url(#SVGID_131_);}
.st158{fill:#B1BAC4;}
.st159{fill:#CBD1D8;}
.st160{fill:#0B1B2B;}
.st161{fill:#91D119;}
.st162{opacity:0.7;}
.st163{fill:#FFFFFF;stroke:#000000;stroke-width:0.4418;stroke-miterlimit:10;}
.st164{fill:none;stroke:#939CAA;stroke-width:0.2209;stroke-miterlimit:10;}
.st165{fill:none;stroke:#FFFFFF;stroke-width:3.0924;stroke-miterlimit:10;}
.st166{fill:url(#SVGID_132_);}
.st167{fill:none;stroke:url(#SVGID_133_);stroke-width:1.714;stroke-miterlimit:10;}
.st168{fill:url(#SVGID_134_);}
.st169{fill:url(#SVGID_135_);}
.st170{fill:url(#SVGID_136_);}
.st171{fill:url(#SVGID_137_);}
.st172{fill:url(#SVGID_138_);}
.st173{fill:url(#SVGID_139_);}
.st174{fill:url(#SVGID_140_);}
.st175{fill:url(#SVGID_141_);}
.st176{fill:url(#SVGID_142_);}
.st177{fill:url(#SVGID_143_);}
.st178{fill:url(#SVGID_144_);}
.st179{fill:none;stroke:#1F63EC;stroke-width:4;stroke-miterlimit:10;}
.st180{fill:none;stroke:#0B1B2B;stroke-width:4;stroke-miterlimit:10;}
.st181{fill:none;stroke:#677380;stroke-width:0.3989;stroke-miterlimit:10;}
.st182{fill:none;stroke:#677380;stroke-width:0.3989;stroke-miterlimit:10;stroke-dasharray:1.14,1.14;}
.st183{fill:#257AF1;}
.st184{opacity:0.3;fill:#FFFFFF;}
.st185{fill:none;stroke:#98A5B2;stroke-width:4;stroke-miterlimit:10;}
.st186{fill:none;stroke:#65727F;stroke-width:0.3989;stroke-miterlimit:10;}
.st187{fill:none;stroke:#65727F;stroke-width:0.3989;stroke-miterlimit:10;stroke-dasharray:1.14,1.14;}
.st188{fill:none;stroke:#DDDFE4;stroke-width:0.75;stroke-miterlimit:10;}
.st189{fill:#9A9EA2;}
.st190{fill-rule:evenodd;clip-rule:evenodd;fill:#3267AC;}
.st191{fill:#FFFFFF;stroke:#AFB8C3;stroke-width:0.275;stroke-miterlimit:10;}
.st192{fill:#C5694E;}
.st193{fill:#8192A2;}
.st194{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.6317;stroke-miterlimit:10;}
</style>
<g id="图层_2">
</g>
<g id="图层_1">
<path class="st194" d="M24.51,28.51H5.49c-2.21,0-4-1.79-4-4V5.49c0-2.21,1.79-4,4-4h19.03c2.21,0,4,1.79,4,4v19.03
C28.51,26.72,26.72,28.51,24.51,28.51z"/>
<g>
<g>
<g>
<g>
<path class="st23" d="M15.47,7.1l-1.3,1.85c-0.2,0.29-0.54,0.47-0.9,0.47h-7.1V7.09C6.16,7.1,15.47,7.1,15.47,7.1z"/>
<polygon class="st23" points="24.3,7.1 13.14,22.91 5.7,22.91 16.86,7.1 "/>
<path class="st23" d="M14.53,22.91l1.31-1.86c0.2-0.29,0.54-0.47,0.9-0.47h7.09v2.33H14.53z"/>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,219 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<style type="text/css">
.st0{opacity:0.3;fill:#E2E4E7;}
.st1{opacity:0.8;fill:#E2E4E7;stroke:#FFFFFF;stroke-width:5;stroke-miterlimit:10;}
.st2{fill:url(#SVGID_1_);}
.st3{fill:none;stroke:#E0E4E9;stroke-width:0.25;stroke-miterlimit:10;}
.st4{fill:none;}
.st5{fill:#9DA1A5;}
.st6{fill-rule:evenodd;clip-rule:evenodd;fill:none;}
.st7{fill-rule:evenodd;clip-rule:evenodd;fill:#DFE2E7;}
.st8{fill-rule:evenodd;clip-rule:evenodd;fill:#CDD4DA;}
.st9{fill-rule:evenodd;clip-rule:evenodd;fill:#B3BCC7;}
.st10{fill-rule:evenodd;clip-rule:evenodd;fill:#9DAAB7;}
.st11{fill-rule:evenodd;clip-rule:evenodd;fill:#8698A8;}
.st12{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_2_);}
.st13{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_3_);}
.st14{fill:#1F63EC;}
.st15{fill:#2D2D2D;}
.st16{fill:none;stroke:#E0E4E9;stroke-width:0.5;stroke-miterlimit:10;}
.st17{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_4_);}
.st18{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_5_);}
.st19{fill:none;stroke:#677380;stroke-width:0.5;stroke-miterlimit:10;}
.st20{fill:none;stroke:url(#SVGID_6_);stroke-width:2;stroke-miterlimit:10;}
.st21{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_7_);}
.st22{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_8_);}
.st23{fill:#FFFFFF;}
.st24{fill-rule:evenodd;clip-rule:evenodd;fill:#2D2D2D;}
.st25{clip-path:url(#SVGID_10_);}
.st26{clip-path:url(#SVGID_12_);}
.st27{fill:url(#SVGID_13_);}
.st28{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_14_);}
.st29{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_15_);}
.st30{clip-path:url(#SVGID_17_);}
.st31{clip-path:url(#SVGID_19_);}
.st32{fill:url(#SVGID_20_);}
.st33{fill:none;stroke:url(#SVGID_21_);stroke-width:2;stroke-miterlimit:10;}
.st34{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_22_);}
.st35{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_23_);}
.st36{clip-path:url(#SVGID_25_);}
.st37{clip-path:url(#SVGID_27_);}
.st38{fill:url(#SVGID_28_);}
.st39{clip-path:url(#SVGID_30_);}
.st40{clip-path:url(#SVGID_32_);}
.st41{fill:url(#SVGID_33_);}
.st42{fill-rule:evenodd;clip-rule:evenodd;fill:#126EF6;}
.st43{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
.st44{clip-path:url(#SVGID_35_);}
.st45{clip-path:url(#SVGID_37_);}
.st46{fill:url(#SVGID_38_);}
.st47{fill-rule:evenodd;clip-rule:evenodd;fill:#9DA1A5;}
.st48{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_39_);}
.st49{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_40_);}
.st50{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_41_);}
.st51{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_42_);}
.st52{fill:none;stroke:url(#SVGID_43_);stroke-width:2;stroke-miterlimit:10;}
.st53{fill-rule:evenodd;clip-rule:evenodd;fill:none;stroke:#E0E4E9;stroke-width:0.5;stroke-miterlimit:10;}
.st54{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_44_);}
.st55{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_45_);}
.st56{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_46_);}
.st57{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_47_);}
.st58{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_48_);}
.st59{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_49_);}
.st60{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_50_);}
.st61{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_51_);}
.st62{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_52_);}
.st63{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_53_);}
.st64{clip-path:url(#SVGID_55_);}
.st65{clip-path:url(#SVGID_57_);}
.st66{fill:url(#SVGID_58_);}
.st67{clip-path:url(#SVGID_60_);}
.st68{clip-path:url(#SVGID_62_);}
.st69{fill:url(#SVGID_63_);}
.st70{fill:none;stroke:url(#SVGID_64_);stroke-width:2;stroke-miterlimit:10;}
.st71{clip-path:url(#SVGID_66_);}
.st72{clip-path:url(#SVGID_68_);}
.st73{fill:url(#SVGID_69_);}
.st74{clip-path:url(#SVGID_71_);}
.st75{clip-path:url(#SVGID_73_);}
.st76{fill:url(#SVGID_74_);}
.st77{clip-path:url(#SVGID_76_);}
.st78{clip-path:url(#SVGID_78_);}
.st79{fill:url(#SVGID_79_);}
.st80{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_80_);}
.st81{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_81_);}
.st82{clip-path:url(#SVGID_83_);}
.st83{clip-path:url(#SVGID_85_);}
.st84{fill:url(#SVGID_86_);}
.st85{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_87_);}
.st86{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_88_);}
.st87{clip-path:url(#SVGID_90_);}
.st88{clip-path:url(#SVGID_92_);}
.st89{fill:url(#SVGID_93_);}
.st90{fill:none;stroke:url(#SVGID_94_);stroke-width:2;stroke-miterlimit:10;}
.st91{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_95_);}
.st92{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_96_);}
.st93{clip-path:url(#SVGID_98_);}
.st94{clip-path:url(#SVGID_100_);}
.st95{fill:url(#SVGID_101_);}
.st96{clip-path:url(#SVGID_103_);}
.st97{clip-path:url(#SVGID_105_);}
.st98{fill:url(#SVGID_106_);}
.st99{clip-path:url(#SVGID_108_);}
.st100{clip-path:url(#SVGID_110_);}
.st101{fill:url(#SVGID_111_);}
.st102{fill:#FFFFFF;stroke:#B3BCC7;stroke-width:0.275;stroke-miterlimit:10;}
.st103{clip-path:url(#SVGID_113_);}
.st104{fill:#FDD138;}
.st105{fill:#FCA62F;}
.st106{fill:#FB7927;}
.st107{fill:#F44B22;}
.st108{fill:#D81915;}
.st109{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.3354;stroke-miterlimit:10;}
.st110{fill:none;stroke:#65727F;stroke-width:2;stroke-miterlimit:10;}
.st111{fill:none;stroke:#65727F;stroke-width:0.75;stroke-miterlimit:10;}
.st112{fill:url(#SVGID_114_);}
.st113{fill:#D06C50;}
.st114{fill:#2D2D2D;stroke:#B3BCC7;stroke-width:0.275;stroke-miterlimit:10;}
.st115{opacity:0.2;}
.st116{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;}
.st117{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0212,1.0212;}
.st118{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0205,1.0205;}
.st119{opacity:0.2;fill:none;}
.st120{fill:none;stroke:#677380;stroke-width:0.3689;stroke-miterlimit:10;}
.st121{fill:none;stroke:#677380;stroke-width:0.3689;stroke-miterlimit:10;stroke-dasharray:1.0509,1.0509;}
.st122{opacity:0.3;fill:#1F63EC;}
.st123{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.3162;stroke-miterlimit:10;}
.st124{fill:#FFFFFF;stroke:#B3BCC7;stroke-width:0.3162;stroke-miterlimit:10;}
.st125{clip-path:url(#SVGID_118_);}
.st126{fill:url(#SVGID_119_);}
.st127{fill:none;stroke:#DFE2E7;stroke-width:0.75;stroke-miterlimit:10;}
.st128{fill:#9DA1A5;stroke:#FFFFFF;stroke-miterlimit:10;}
.st129{fill:url(#SVGID_120_);}
.st130{fill:none;stroke:#677380;stroke-width:0.75;stroke-miterlimit:10;}
.st131{opacity:0.4;}
.st132{clip-path:url(#SVGID_122_);}
.st133{clip-path:url(#SVGID_124_);}
.st134{fill:url(#SVGID_125_);}
.st135{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;}
.st136{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:0.9951,0.9951;}
.st137{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:1.004,1.004;}
.st138{fill:none;stroke:url(#SVGID_126_);stroke-width:1.5;stroke-miterlimit:10;}
.st139{fill:url(#SVGID_127_);}
.st140{fill:none;stroke:#DDE0E4;stroke-width:0.35;stroke-miterlimit:10;}
.st141{fill:#2D2D2D;stroke:#A9B3BE;stroke-width:0.275;stroke-miterlimit:10;}
.st142{fill-rule:evenodd;clip-rule:evenodd;fill:#126EF4;}
.st143{fill:#FFFFFF;stroke:#B1BAC4;stroke-width:0.275;stroke-miterlimit:10;}
.st144{fill:#CE6C50;}
.st145{fill:#5B5B5B;}
.st146{fill:#8392A3;}
.st147{fill:none;stroke:url(#SVGID_128_);stroke-width:1.5;stroke-miterlimit:10;}
.st148{fill:url(#SVGID_129_);}
.st149{fill:none;stroke:#B5BDC4;stroke-width:0.7;stroke-miterlimit:10;}
.st150{opacity:0.6;fill:none;stroke:#78838E;stroke-width:0.35;stroke-miterlimit:10;}
.st151{opacity:0.2;fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:1,1;}
.st152{fill:none;stroke:#DDE0E4;stroke-width:0.75;stroke-miterlimit:10;}
.st153{fill:none;stroke:#8392A3;stroke-width:0.5;stroke-miterlimit:10;}
.st154{opacity:0.2;fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0182,1.0182;}
.st155{fill:none;stroke:#DDE0E4;stroke-width:0.765;stroke-miterlimit:10;}
.st156{fill:url(#SVGID_130_);}
.st157{fill:url(#SVGID_131_);}
.st158{fill:#B1BAC4;}
.st159{fill:#CBD1D8;}
.st160{fill:#0B1B2B;}
.st161{fill:#91D119;}
.st162{opacity:0.7;}
.st163{fill:#FFFFFF;stroke:#000000;stroke-width:0.4418;stroke-miterlimit:10;}
.st164{fill:none;stroke:#939CAA;stroke-width:0.2209;stroke-miterlimit:10;}
.st165{fill:none;stroke:#FFFFFF;stroke-width:3.0924;stroke-miterlimit:10;}
.st166{fill:url(#SVGID_132_);}
.st167{fill:none;stroke:url(#SVGID_133_);stroke-width:1.714;stroke-miterlimit:10;}
.st168{fill:url(#SVGID_134_);}
.st169{fill:url(#SVGID_135_);}
.st170{fill:url(#SVGID_136_);}
.st171{fill:url(#SVGID_137_);}
.st172{fill:url(#SVGID_138_);}
.st173{fill:url(#SVGID_139_);}
.st174{fill:url(#SVGID_140_);}
.st175{fill:url(#SVGID_141_);}
.st176{fill:url(#SVGID_142_);}
.st177{fill:url(#SVGID_143_);}
.st178{fill:url(#SVGID_144_);}
.st179{fill:none;stroke:#1F63EC;stroke-width:4;stroke-miterlimit:10;}
.st180{fill:none;stroke:#0B1B2B;stroke-width:4;stroke-miterlimit:10;}
.st181{fill:none;stroke:#677380;stroke-width:0.3989;stroke-miterlimit:10;}
.st182{fill:none;stroke:#677380;stroke-width:0.3989;stroke-miterlimit:10;stroke-dasharray:1.14,1.14;}
.st183{fill:#257AF1;}
.st184{opacity:0.3;fill:#FFFFFF;}
.st185{fill:none;stroke:#98A5B2;stroke-width:4;stroke-miterlimit:10;}
.st186{fill:none;stroke:#65727F;stroke-width:0.3989;stroke-miterlimit:10;}
.st187{fill:none;stroke:#65727F;stroke-width:0.3989;stroke-miterlimit:10;stroke-dasharray:1.14,1.14;}
.st188{fill:none;stroke:#DDDFE4;stroke-width:0.75;stroke-miterlimit:10;}
.st189{fill:#9A9EA2;}
.st190{fill-rule:evenodd;clip-rule:evenodd;fill:#3267AC;}
.st191{fill:#FFFFFF;stroke:#AFB8C3;stroke-width:0.275;stroke-miterlimit:10;}
.st192{fill:#C5694E;}
.st193{fill:#8192A2;}
.st194{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.6317;stroke-miterlimit:10;}
</style>
<g id="图层_2">
</g>
<g id="图层_1">
<path class="st194" d="M24.51,28.51H5.49c-2.21,0-4-1.79-4-4V5.49c0-2.21,1.79-4,4-4h19.03c2.21,0,4,1.79,4,4v19.03
C28.51,26.72,26.72,28.51,24.51,28.51z"/>
<g>
<g>
<g>
<g>
<path class="st23" d="M15.47,7.1l-1.3,1.85c-0.2,0.29-0.54,0.47-0.9,0.47h-7.1V7.09C6.16,7.1,15.47,7.1,15.47,7.1z"/>
<polygon class="st23" points="24.3,7.1 13.14,22.91 5.7,22.91 16.86,7.1 "/>
<path class="st23" d="M14.53,22.91l1.31-1.86c0.2-0.29,0.54-0.47,0.9-0.47h7.09v2.33H14.53z"/>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -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.

View file

@ -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:<id>]` |
| `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 <key>` or `x-api-key: <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 <CCR_API_KEY>" http://127.0.0.1:3456/plugins/hello
```
You can also use:
```bash
curl -H "x-api-key: <CCR_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:<id>]` |
| `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.

View file

@ -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.
<div class="provider-import-grid" aria-label="Preset provider import buttons">
<a class="provider-import-button provider-openai" href="ccr://provider?name=OpenAI&amp;base_url=https%3A%2F%2Fapi.openai.com%2Fv1&amp;protocol=openai_responses&amp;models=gpt-4o" aria-label="Import OpenAI provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/openai.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">OpenAI</span><span class="provider-import-meta">Responses / Chat Completions</span></span>
</a>
<a class="provider-import-button provider-anthropic" href="ccr://provider?name=Anthropic&amp;base_url=https%3A%2F%2Fapi.anthropic.com&amp;protocol=anthropic_messages&amp;models=claude-sonnet-4-20250514" aria-label="Import Anthropic provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/anthropic.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Anthropic</span><span class="provider-import-meta">Anthropic Messages</span></span>
</a>
<a class="provider-import-button provider-gemini" href="ccr://provider?name=Google+Gemini&amp;base_url=https%3A%2F%2Fgenerativelanguage.googleapis.com&amp;protocol=gemini_generate_content" aria-label="Import Google Gemini provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/gemini.svg" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Google Gemini</span><span class="provider-import-meta">Gemini Generate Content</span></span>
</a>
<a class="provider-import-button provider-openrouter" href="ccr://provider?name=OpenRouter&amp;base_url=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1&amp;protocol=openai_chat_completions" aria-label="Import OpenRouter provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/openrouter.ico" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">OpenRouter</span><span class="provider-import-meta">OpenAI compatible gateway</span></span>
</a>
<a class="provider-import-button provider-deepseek" href="ccr://provider?name=DeepSeek&amp;base_url=https%3A%2F%2Fapi.deepseek.com&amp;protocol=openai_chat_completions" aria-label="Import DeepSeek provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/deepseek.ico" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">DeepSeek</span><span class="provider-import-meta">Chat Completions</span></span>
</a>
<a class="provider-import-button provider-zhipu-coding" href="ccr://provider?name=Zhipu+AI+%28China%29+-+Coding+Plan&amp;base_url=https%3A%2F%2Fopen.bigmodel.cn%2Fapi%2Fcoding%2Fpaas%2Fv4&amp;protocol=openai_chat_completions&amp;models=glm-5.2%2Cglm-5.1%2Cglm-4.7%2Cglm-4.5-air" aria-label="Import Zhipu AI China Coding Plan provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/zhipu-cn-coding.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Zhipu Coding</span><span class="provider-import-meta">China Coding Plan</span></span>
</a>
<a class="provider-import-button provider-zhipu-general" href="ccr://provider?name=Zhipu+AI+%28China%29+-+General+Endpoint&amp;base_url=https%3A%2F%2Fopen.bigmodel.cn%2Fapi%2Fpaas%2Fv4&amp;protocol=openai_chat_completions&amp;models=glm-5.2%2Cglm-5.1%2Cglm-4.7%2Cglm-4.5-air" aria-label="Import Zhipu AI China General Endpoint provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/zhipu-cn-general.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Zhipu General</span><span class="provider-import-meta">China General Endpoint</span></span>
</a>
<a class="provider-import-button provider-zai-coding" href="ccr://provider?name=Z.ai+%28Global%29+-+Coding+Plan&amp;base_url=https%3A%2F%2Fapi.z.ai%2Fapi%2Fcoding%2Fpaas%2Fv4&amp;protocol=openai_chat_completions&amp;models=glm-5.2%2Cglm-5.1%2Cglm-4.7%2Cglm-4.5-air" aria-label="Import Z.ai Global Coding Plan provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/zai-global-coding.svg" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Z.ai Coding</span><span class="provider-import-meta">Global Coding Plan</span></span>
</a>
<a class="provider-import-button provider-zai-general" href="ccr://provider?name=Z.ai+%28Global%29+-+General+Endpoint&amp;base_url=https%3A%2F%2Fapi.z.ai%2Fapi%2Fpaas%2Fv4&amp;protocol=openai_chat_completions&amp;models=glm-5.2%2Cglm-5.1%2Cglm-4.7%2Cglm-4.5-air" aria-label="Import Z.ai Global General Endpoint provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/zai-global-general.svg" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Z.ai General</span><span class="provider-import-meta">Global General Endpoint</span></span>
</a>
<a class="provider-import-button provider-mistral" href="ccr://provider?name=Mistral&amp;base_url=https%3A%2F%2Fapi.mistral.ai%2Fv1&amp;protocol=openai_chat_completions" aria-label="Import Mistral provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/mistral.webp" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Mistral</span><span class="provider-import-meta">Chat Completions</span></span>
</a>
<a class="provider-import-button provider-moonshot" href="ccr://provider?name=Moonshot+Kimi&amp;base_url=https%3A%2F%2Fapi.moonshot.cn%2Fv1&amp;protocol=openai_chat_completions&amp;models=moonshot-v1-8k" aria-label="Import Moonshot Kimi provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Moonshot Kimi</span><span class="provider-import-meta">Chat Completions</span></span>
</a>
<a class="provider-import-button provider-bailian" href="ccr://provider?name=Alibaba+Bailian&amp;base_url=https%3A%2F%2Fdashscope.aliyuncs.com%2Fcompatible-mode%2Fv1&amp;protocol=openai_chat_completions" aria-label="Import Alibaba Bailian provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/bailian.ico" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Alibaba Bailian</span><span class="provider-import-meta">DashScope compatible</span></span>
</a>
<a class="provider-import-button provider-siliconflow" href="ccr://provider?name=SiliconFlow&amp;base_url=https%3A%2F%2Fapi.siliconflow.cn%2Fv1&amp;protocol=openai_chat_completions" aria-label="Import SiliconFlow provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/siliconflow.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">SiliconFlow</span><span class="provider-import-meta">Chat Completions</span></span>
</a>
</div>
## 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
<script src="https://cdn.ccrdesk.top/ccr-provider-buttons.js" defer></script>
<ccr-provider-button
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"
></ccr-provider-button>
```
For larger configs, pass a manifest:
```html
<script src="https://cdn.ccrdesk.top/ccr-provider-buttons.js" defer></script>
<ccr-provider-button
name="Example AI"
manifest="https://example.com/.well-known/ccr-provider.json"
></ccr-provider-button>
```
### JavaScript
```html
<div id="ccr-buttons"></div>
<script src="https://cdn.ccrdesk.top/ccr-provider-buttons.js"></script>
<script>
CCRProviderButtons.render("#ccr-buttons", {
name: "Example AI",
base_url: "https://api.example.com/v1",
api_key: "sk-user-key",
protocol: "openai_chat_completions",
models: ["example-chat", "example-coder"],
icon: "https://example.com/icon.png",
source: "https://example.com"
});
</script>
```
### Render Parameters
`CCRProviderButtons.render(target, options)` and `<ccr-provider-button>` 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.

View file

@ -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 平台接力。

View file

@ -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:<id>]` 前缀的 `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 <key>``x-api-key: <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 <CCR_API_KEY>" http://127.0.0.1:3456/plugins/hello
```
也可以使用:
```bash
curl -H "x-api-key: <CCR_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:<id>]` 报错 |
| `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 白名单,避免把本地鉴权信息转发到不可信服务。

View file

@ -0,0 +1,271 @@
---
title: 一键导入供应商
pageTitle: 一键导入供应商
eyebrow: 详细配置
lead: 使用 ccr://provider 链接把供应商配置带入 CCR并通过现有 preset 供应商按钮快速打开导入确认页。
---
## 一键导入
下面的按钮会打开 CCR 桌面 App 的供应商导入确认页。预设按钮不会携带 API Key自定义供应商链接可以携带 Key。导入前始终确认供应商名称、Base URL、协议和模型。
<div class="provider-import-grid" aria-label="Preset provider import buttons">
<a class="provider-import-button provider-openai" href="ccr://provider?name=OpenAI&amp;base_url=https%3A%2F%2Fapi.openai.com%2Fv1&amp;protocol=openai_responses&amp;models=gpt-4o" aria-label="导入 OpenAI 官方供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/openai.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">OpenAI 官方</span><span class="provider-import-meta">Responses / Chat Completions</span></span>
</a>
<a class="provider-import-button provider-anthropic" href="ccr://provider?name=Anthropic&amp;base_url=https%3A%2F%2Fapi.anthropic.com&amp;protocol=anthropic_messages&amp;models=claude-sonnet-4-20250514" aria-label="导入 Anthropic 官方供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/anthropic.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Anthropic 官方</span><span class="provider-import-meta">Anthropic Messages</span></span>
</a>
<a class="provider-import-button provider-gemini" href="ccr://provider?name=Google+Gemini&amp;base_url=https%3A%2F%2Fgenerativelanguage.googleapis.com&amp;protocol=gemini_generate_content" aria-label="导入谷歌 Gemini 供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/gemini.svg" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">谷歌 Gemini</span><span class="provider-import-meta">Gemini Generate Content</span></span>
</a>
<a class="provider-import-button provider-openrouter" href="ccr://provider?name=OpenRouter&amp;base_url=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1&amp;protocol=openai_chat_completions" aria-label="导入 OpenRouter 路由供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/openrouter.ico" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">OpenRouter 路由</span><span class="provider-import-meta">OpenAI compatible gateway</span></span>
</a>
<a class="provider-import-button provider-deepseek" href="ccr://provider?name=DeepSeek&amp;base_url=https%3A%2F%2Fapi.deepseek.com&amp;protocol=openai_chat_completions" aria-label="导入 DeepSeek 深度求索供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/deepseek.ico" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">DeepSeek 深度求索</span><span class="provider-import-meta">Chat Completions</span></span>
</a>
<a class="provider-import-button provider-zhipu-coding" href="ccr://provider?name=Zhipu+AI+%28China%29+-+Coding+Plan&amp;base_url=https%3A%2F%2Fopen.bigmodel.cn%2Fapi%2Fcoding%2Fpaas%2Fv4&amp;protocol=openai_chat_completions&amp;models=glm-5.2%2Cglm-5.1%2Cglm-4.7%2Cglm-4.5-air" aria-label="导入智谱 Coding 供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/zhipu-cn-coding.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">智谱 Coding</span><span class="provider-import-meta">中国 Coding 计划</span></span>
</a>
<a class="provider-import-button provider-zhipu-general" href="ccr://provider?name=Zhipu+AI+%28China%29+-+General+Endpoint&amp;base_url=https%3A%2F%2Fopen.bigmodel.cn%2Fapi%2Fpaas%2Fv4&amp;protocol=openai_chat_completions&amp;models=glm-5.2%2Cglm-5.1%2Cglm-4.7%2Cglm-4.5-air" aria-label="导入智谱通用供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/zhipu-cn-general.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">智谱通用</span><span class="provider-import-meta">中国通用端点</span></span>
</a>
<a class="provider-import-button provider-zai-coding" href="ccr://provider?name=Z.ai+%28Global%29+-+Coding+Plan&amp;base_url=https%3A%2F%2Fapi.z.ai%2Fapi%2Fcoding%2Fpaas%2Fv4&amp;protocol=openai_chat_completions&amp;models=glm-5.2%2Cglm-5.1%2Cglm-4.7%2Cglm-4.5-air" aria-label="导入智谱国际 Coding 供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/zai-global-coding.svg" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">智谱国际 Coding</span><span class="provider-import-meta">全球 Coding 计划</span></span>
</a>
<a class="provider-import-button provider-zai-general" href="ccr://provider?name=Z.ai+%28Global%29+-+General+Endpoint&amp;base_url=https%3A%2F%2Fapi.z.ai%2Fapi%2Fpaas%2Fv4&amp;protocol=openai_chat_completions&amp;models=glm-5.2%2Cglm-5.1%2Cglm-4.7%2Cglm-4.5-air" aria-label="导入智谱国际通用供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/zai-global-general.svg" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">智谱国际通用</span><span class="provider-import-meta">全球通用端点</span></span>
</a>
<a class="provider-import-button provider-mistral" href="ccr://provider?name=Mistral&amp;base_url=https%3A%2F%2Fapi.mistral.ai%2Fv1&amp;protocol=openai_chat_completions" aria-label="导入 Mistral 官方供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/mistral.webp" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Mistral 官方</span><span class="provider-import-meta">Chat Completions</span></span>
</a>
<a class="provider-import-button provider-moonshot" href="ccr://provider?name=Moonshot+Kimi&amp;base_url=https%3A%2F%2Fapi.moonshot.cn%2Fv1&amp;protocol=openai_chat_completions&amp;models=moonshot-v1-8k" aria-label="导入月之暗面 Kimi 供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">月之暗面 Kimi</span><span class="provider-import-meta">Chat Completions</span></span>
</a>
<a class="provider-import-button provider-bailian" href="ccr://provider?name=Alibaba+Bailian&amp;base_url=https%3A%2F%2Fdashscope.aliyuncs.com%2Fcompatible-mode%2Fv1&amp;protocol=openai_chat_completions" aria-label="导入阿里百炼供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/bailian.ico" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">阿里百炼</span><span class="provider-import-meta">DashScope 兼容</span></span>
</a>
<a class="provider-import-button provider-siliconflow" href="ccr://provider?name=SiliconFlow&amp;base_url=https%3A%2F%2Fapi.siliconflow.cn%2Fv1&amp;protocol=openai_chat_completions" aria-label="导入硅基流动供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/siliconflow.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">硅基流动</span><span class="provider-import-meta">Chat Completions</span></span>
</a>
</div>
## 嵌入式按钮组件
CCR 也提供了一个无框架的按钮脚本,供应商可以嵌入到自己的网页,让用户一键把该供应商导入 CCR。脚本会自动注册 Web Components。
### HTML 写法
```html
<script src="https://cdn.ccrdesk.top/ccr-provider-buttons.js" defer></script>
<ccr-provider-button
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"
></ccr-provider-button>
```
如果配置较大,可以只传 manifest
```html
<script src="https://cdn.ccrdesk.top/ccr-provider-buttons.js" defer></script>
<ccr-provider-button
name="Example AI"
manifest="https://example.com/.well-known/ccr-provider.json"
></ccr-provider-button>
```
### JS 写法
```html
<div id="ccr-buttons"></div>
<script src="https://cdn.ccrdesk.top/ccr-provider-buttons.js"></script>
<script>
CCRProviderButtons.render("#ccr-buttons", {
name: "Example AI",
base_url: "https://api.example.com/v1",
api_key: "sk-user-key",
protocol: "openai_chat_completions",
models: ["example-chat", "example-coder"],
icon: "https://example.com/icon.png",
source: "https://example.com"
});
</script>
```
### render 参数
`CCRProviderButtons.render(target, options)``<ccr-provider-button>` 支持同一组参数,参数名与 `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` 等别名。

View file

@ -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/",

View file

@ -5,6 +5,7 @@ import { configurationSlugFromPath, zhConfigurationDocs } from "../../configurat
export function getStaticPaths() {
const activeLabels: Record<string, string> = {
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": "配置文件位置",

View file

@ -5,6 +5,7 @@ import { configurationSlugFromPath, enConfigurationDocs } from "../../../configu
export function getStaticPaths() {
const activeLabels: Record<string, string> = {
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",

View file

@ -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;
}
}

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<Electron.MessageBoxReturnValue> {
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);
}

View file

@ -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);

View file

@ -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<ProxyNetworkSnapshot>(fallbackProxyNetworkSnapshot);
const [proxyStatus, setProxyStatus] = useState<ProxyStatus>(fallbackProxyStatus);
const [actionBusy, setActionBusy] = useState<ServerActionBusy>("");
const [updateActionBusy, setUpdateActionBusy] = useState<"" | "check" | "download" | "install">("");
const [updateActionError, setUpdateActionError] = useState("");
const [updateDialogOpen, setUpdateDialogOpen] = useState(false);
const [updateDialogStatus, setUpdateDialogStatus] = useState<AppUpdateStatus>(fallbackUpdateStatus);
const [gatewayActionBusy, setGatewayActionBusy] = useState(false);
const [actionMessage, setActionMessage] = useState("");
const [actionError, setActionError] = useState("");
@ -249,6 +253,7 @@ function App() {
const [usageRange, setUsageRange] = useState<UsageStatsRange>("7d");
const [usageStats, setUsageStats] = useState<UsageStatsSnapshot>(fallbackUsageStats);
const [providerAccountSnapshots, setProviderAccountSnapshots] = useState<ProviderAccountSnapshot[]>([]);
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,

View file

@ -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<typeof AddApiKeyDialog>;
@ -45,6 +47,7 @@ export function AppDialogStack({
routingDelete?: ComponentProps<typeof DeleteRoutingRuleDialog>;
routingUpsert?: ComponentProps<typeof AddRoutingRuleDialog>;
settings?: ComponentProps<typeof AppSettingsDialog>;
update?: ComponentProps<typeof UpdateDialog>;
virtualModelUpsert?: ComponentProps<typeof VirtualModelDialog>;
}) {
const dialogs = [
@ -65,7 +68,8 @@ export function AppDialogStack({
extensionSettings ? { key: "extension-settings", node: <PluginSettingsDialog {...extensionSettings} /> } : null,
claudeDesignConfig ? { key: "extension-config", node: <ConfigureClaudeDesignDialog {...claudeDesignConfig} /> } : null,
cursorProxyConfig ? { key: "cursor-proxy-config", node: <ConfigureClaudeDesignDialog {...cursorProxyConfig} /> } : null,
settings ? { key: "settings", node: <AppSettingsDialog {...settings} /> } : null
settings ? { key: "settings", node: <AppSettingsDialog {...settings} /> } : null,
update ? { key: "update", node: <UpdateDialog {...update} /> } : null
].filter((dialog): dialog is { key: string; node: ReactElement } => Boolean(dialog));
return (

View file

@ -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";

View file

@ -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({
))}
</nav>
<div className="shrink-0 border-t border-border/60 p-2 max-[720px]:border-t max-[720px]:pt-2">
<div className="grid shrink-0 gap-1 border-t border-border/60 p-2 max-[720px]:border-t max-[720px]:pt-2">
<Button
className="flex h-9 w-full min-w-0 items-center gap-2 rounded-md px-2 text-left text-[12px] font-medium text-muted-foreground transition-all duration-150 hover:bg-muted/80 hover:text-foreground disabled:cursor-default disabled:opacity-70"
disabled={updateActionBusy}
onClick={() => void onCheckUpdate()}
title={updateActionBusy ? checkingForUpdatesLabel : checkForUpdatesLabel}
type="button"
unstyled
>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md">
{updateActionBusy ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
</span>
<span className="min-w-0 flex-1 truncate">{updateActionBusy ? checkingForUpdatesLabel : checkForUpdatesLabel}</span>
</Button>
<Button
className="flex h-9 w-full min-w-0 items-center gap-2 rounded-md px-2 text-left text-[12px] font-medium text-muted-foreground transition-all duration-150 hover:bg-muted/80 hover:text-foreground"
onClick={onOpenSettings}

View file

@ -0,0 +1,220 @@
import {
AppCopy, AppUpdateStatus, Button, Check, CircleAlert, cn, Dialog, DialogBody,
DialogContent, DialogFooter, DialogHeader, DialogTitle, LoaderCircle, RefreshCw, X
} from "../shared";
export type UpdateActionBusy = "" | "check" | "download" | "install";
export function UpdateDialog({
actionBusy,
actionError,
copy,
onCheck,
onClose,
onDownload,
onInstall,
status
}: {
actionBusy: UpdateActionBusy;
actionError: string;
copy: AppCopy;
onCheck: () => Promise<void>;
onClose: () => void;
onDownload: () => Promise<void>;
onInstall: () => Promise<void>;
status: AppUpdateStatus;
}) {
const t = (value: string) => copy.text[value] ?? value;
const busy = Boolean(actionBusy) || status.state === "checking" || status.state === "downloading" || status.state === "installing";
const canDownload = status.canDownload || status.state === "available";
const canInstall = status.canInstall || status.state === "downloaded";
const progressPercent = clampPercent(status.progress?.percent);
const error = actionError || status.lastError || "";
const installing = actionBusy === "install" || status.state === "installing";
return (
<Dialog onOpenChange={(open) => !open && !installing && onClose()} open>
<DialogContent className="max-w-[560px]">
<DialogHeader>
<div className="min-w-0">
<DialogTitle>{t("Online updates")}</DialogTitle>
</div>
<Button aria-label={copy.settings.close} disabled={installing} onClick={onClose} size="iconSm" title={copy.settings.close} type="button" variant="ghost">
<X className="h-4 w-4" />
</Button>
</DialogHeader>
<DialogBody className="grid gap-4">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[13px] font-semibold text-foreground">{updateStateLabel(status, t)}</div>
<div className="mt-1 text-[12px] leading-5 text-muted-foreground">{updateStateDescription(status, t)}</div>
</div>
<UpdateStateBadge label={updateStateLabel(status, t)} status={status} />
</div>
<div className="grid grid-cols-2 gap-2 max-[520px]:grid-cols-1">
<UpdateInfoRow label={t("Current version")} value={status.currentVersion} />
<UpdateInfoRow label={t("Available version")} value={status.availableVersion || "-"} />
<UpdateInfoRow label={t("Last checked")} value={formatUpdateDate(status.lastCheckedAt) || "-"} />
<UpdateInfoRow label={t("Feed URL")} value={status.feedUrl || "-"} />
</div>
{status.state === "downloading" ? (
<div className="grid gap-2 rounded-md border border-border bg-muted/20 px-3 py-3">
<div className="flex min-w-0 items-center justify-between gap-3 text-[11px] font-medium text-muted-foreground">
<span>{t("Downloading update")}</span>
<span>{progressPercent !== undefined ? `${progressPercent.toFixed(0)}%` : ""}</span>
</div>
<div
aria-label={t("Downloading update")}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={progressPercent ?? 0}
className="h-2 overflow-hidden rounded-full bg-muted"
role="progressbar"
>
<div className="h-full rounded-full bg-primary transition-[width] duration-200" style={{ width: `${progressPercent ?? 0}%` }} />
</div>
<div className="text-[11px] text-muted-foreground">{formatDownloadProgress(status.progress)}</div>
</div>
) : null}
{canInstall ? (
<div className="rounded-md border border-primary/20 bg-primary/10 px-3 py-2 text-[12px] text-primary">
{t("Update ready to install")}
</div>
) : null}
{!status.supported ? (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-[12px] text-muted-foreground">
{t("Updates are only available in packaged builds.")}
</div>
) : null}
{error ? (
<div className="flex min-w-0 items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[12px] text-destructive">
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 break-words">{error}</span>
</div>
) : null}
{status.releaseNotes ? (
<div className="grid gap-1 rounded-md border border-border bg-background px-3 py-2">
<div className="text-[11px] font-semibold text-muted-foreground">{t("Release notes")}</div>
<div className="max-h-36 overflow-auto whitespace-pre-wrap text-[12px] leading-5 text-foreground">{status.releaseNotes}</div>
</div>
) : null}
</DialogBody>
<DialogFooter>
<Button disabled={installing} onClick={onClose} type="button" variant="outline">
{t("Close")}
</Button>
<Button disabled={busy || !status.canCheck} onClick={() => void onCheck()} type="button" variant="outline">
{actionBusy === "check" || status.state === "checking" ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t("Check for updates")}
</Button>
<Button disabled={busy || !canDownload} onClick={() => void onDownload()} type="button" variant="outline">
{actionBusy === "download" || status.state === "downloading" ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t("Download update")}
</Button>
<Button disabled={busy || !canInstall} onClick={() => void onInstall()} type="button">
{installing ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
{t("Install and restart")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function UpdateInfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 rounded-md border border-border/70 bg-muted/20 px-3 py-2">
<div className="text-[10px] font-medium uppercase text-muted-foreground">{label}</div>
<div className="mt-1 min-w-0 truncate text-[12px] font-medium text-foreground" title={value}>{value}</div>
</div>
);
}
function UpdateStateBadge({ label, status }: { label: string; status: AppUpdateStatus }) {
return (
<span className={cn(
"shrink-0 rounded-full border px-2 py-1 text-[11px] font-medium",
status.state === "error"
? "border-destructive/25 bg-destructive/10 text-destructive"
: status.state === "available" || status.state === "downloaded" || status.state === "downloading"
? "border-primary/25 bg-primary/10 text-primary"
: "border-border bg-muted/40 text-muted-foreground"
)}>
{label}
</span>
);
}
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();
}

View file

@ -872,7 +872,6 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"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 不能为空。",

View file

@ -131,6 +131,7 @@ declare global {
waitBotGatewayQrLogin: (request: BotGatewayQrLoginWaitRequest) => Promise<BotGatewayQrLoginWaitResult>;
onBeforeQuit: (callback: () => void) => () => void;
onOpenSettingsRequest: (callback: () => void) => () => void;
onOpenUpdateRequest: (callback: () => void) => () => void;
onProviderDeepLink: (callback: (request: ProviderDeepLinkRequest) => void) => () => void;
onUpdateStatusChanged: (callback: (status: AppUpdateStatus) => void) => () => void;
};

View file

@ -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<string, GatewayProviderProtocol> = {
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<GatewayProviderProtocol>([
"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<string, unknown> | 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<string, un
validateProviderBaseUrl(endpoint);
const method = normalizeUsageMethod(
firstStringParam(params, ["usage_method", "usageMethod", "account_method", "accountMethod"]) ??
firstPayloadString(payload, ["usage_method", "usageMethod", "account_method", "accountMethod"])
firstStringParam(params, ["usage_method"]) ??
firstPayloadString(payload, ["usage_method"])
);
const headers = parseJsonRecordParam(params, payload, ["usage_headers", "usageHeaders", "account_headers", "accountHeaders"]);
const body = parseJsonValueParam(params, payload, ["usage_body", "usageBody", "account_body", "accountBody"]);
const headers = parseJsonRecordParam(params, payload, ["usage_headers"]);
const body = parseJsonValueParam(params, payload, ["usage_body"]);
const balancePath =
firstStringParam(params, ["balance", "balance_remaining", "balanceRemaining"]) ??
firstPayloadString(payload, ["balance", "balance_remaining", "balanceRemaining"]);
firstStringParam(params, ["balance"]) ??
firstPayloadString(payload, ["balance"]);
const subscriptionRemaining =
firstStringParam(params, ["subscription", "subscription_remaining", "subscriptionRemaining"]) ??
firstPayloadString(payload, ["subscription", "subscription_remaining", "subscriptionRemaining"]);
firstStringParam(params, ["subscription"]) ??
firstPayloadString(payload, ["subscription"]);
const subscriptionLimit =
firstStringParam(params, ["subscription_limit", "subscriptionLimit"]) ??
firstPayloadString(payload, ["subscription_limit", "subscriptionLimit"]);
firstStringParam(params, ["subscription_limit"]) ??
firstPayloadString(payload, ["subscription_limit"]);
const subscriptionReset =
firstStringParam(params, ["subscription_reset", "subscriptionReset", "reset_at", "resetAt"]) ??
firstPayloadString(payload, ["subscription_reset", "subscriptionReset", "reset_at", "resetAt"]);
firstStringParam(params, ["subscription_reset"]) ??
firstPayloadString(payload, ["subscription_reset"]);
const meters: ProviderAccountMappedMeterConfig[] = [];
if (balancePath) {
@ -304,7 +287,7 @@ function readDeepLinkAccount(params: URLSearchParams, payload: Record<string, un
kind: "balance",
label: "Balance",
remaining: balancePath,
unit: firstStringParam(params, ["balance_unit", "balanceUnit"]) ?? firstPayloadString(payload, ["balance_unit", "balanceUnit"]) ?? "USD"
unit: firstStringParam(params, ["balance_unit"]) ?? firstPayloadString(payload, ["balance_unit"]) ?? "USD"
});
}
if (subscriptionRemaining || subscriptionLimit) {
@ -315,8 +298,8 @@ function readDeepLinkAccount(params: URLSearchParams, payload: Record<string, un
limit: subscriptionLimit,
remaining: subscriptionRemaining,
resetAt: subscriptionReset,
unit: firstStringParam(params, ["subscription_unit", "subscriptionUnit"]) ?? firstPayloadString(payload, ["subscription_unit", "subscriptionUnit"]) ?? "tokens",
window: firstStringParam(params, ["subscription_window", "subscriptionWindow"]) ?? firstPayloadString(payload, ["subscription_window", "subscriptionWindow"]) ?? "monthly"
unit: firstStringParam(params, ["subscription_unit"]) ?? firstPayloadString(payload, ["subscription_unit"]) ?? "tokens",
window: firstStringParam(params, ["subscription_window"]) ?? firstPayloadString(payload, ["subscription_window"]) ?? "monthly"
});
}
@ -409,7 +392,7 @@ function parseJsonValueParam(
}
function readPayloadRecord(params: URLSearchParams): Record<string, unknown> | 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<string, unknown> | undefined): string[] {
const values = [
...params.getAll("model"),
...params.getAll("models"),
...params.getAll("models[]"),
...payloadModels(payload)
];
const seen = new Set<string>();
@ -533,7 +513,7 @@ function payloadModels(payload: Record<string, unknown> | 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");
}

View file

@ -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",