Update docs deployment and provider import copy
33
.github/workflows/cdn.yml
vendored
Normal 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 }}
|
||||
4
.github/workflows/docs.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
After Width: | Height: | Size: 761 KiB |
302
cdn/public/ccr-provider-buttons.js
Normal 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
|
|
@ -0,0 +1,3 @@
|
|||
name = "claude-code-router-cdn"
|
||||
compatibility_date = "2026-06-28"
|
||||
pages_build_output_dir = "./public"
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
ccrdesk.top
|
||||
BIN
docs/public/ccr-icon.png
Normal file
|
After Width: | Height: | Size: 761 KiB |
BIN
docs/public/provider-icons/anthropic.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
docs/public/provider-icons/bailian.ico
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
docs/public/provider-icons/deepseek.ico
Normal file
|
After Width: | Height: | Size: 205 KiB |
1
docs/public/provider-icons/gemini.svg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
docs/public/provider-icons/mistral.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
docs/public/provider-icons/moonshot.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/public/provider-icons/openai.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
docs/public/provider-icons/openrouter.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/public/provider-icons/siliconflow.png
Normal file
|
After Width: | Height: | Size: 358 B |
219
docs/public/provider-icons/zai-global-coding.svg
Normal 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 |
219
docs/public/provider-icons/zai-global-general.svg
Normal 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 |
BIN
docs/public/provider-icons/zhipu-cn-coding.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
docs/public/provider-icons/zhipu-cn-general.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -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.
|
||||
|
|
|
|||
281
docs/src/content/docs/en/configuration/extensions.md
Normal 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.
|
||||
271
docs/src/content/docs/en/configuration/provider-deeplink.md
Normal 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&base_url=https%3A%2F%2Fapi.openai.com%2Fv1&protocol=openai_responses&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&base_url=https%3A%2F%2Fapi.anthropic.com&protocol=anthropic_messages&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&base_url=https%3A%2F%2Fgenerativelanguage.googleapis.com&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&base_url=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1&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&base_url=https%3A%2F%2Fapi.deepseek.com&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&base_url=https%3A%2F%2Fopen.bigmodel.cn%2Fapi%2Fcoding%2Fpaas%2Fv4&protocol=openai_chat_completions&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&base_url=https%3A%2F%2Fopen.bigmodel.cn%2Fapi%2Fpaas%2Fv4&protocol=openai_chat_completions&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&base_url=https%3A%2F%2Fapi.z.ai%2Fapi%2Fcoding%2Fpaas%2Fv4&protocol=openai_chat_completions&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&base_url=https%3A%2F%2Fapi.z.ai%2Fapi%2Fpaas%2Fv4&protocol=openai_chat_completions&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&base_url=https%3A%2F%2Fapi.mistral.ai%2Fv1&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&base_url=https%3A%2F%2Fapi.moonshot.cn%2Fv1&protocol=openai_chat_completions&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&base_url=https%3A%2F%2Fdashscope.aliyuncs.com%2Fcompatible-mode%2Fv1&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&base_url=https%3A%2F%2Fapi.siliconflow.cn%2Fv1&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.
|
||||
|
|
@ -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 平台接力。
|
||||
|
|
|
|||
281
docs/src/content/docs/zh/configuration/extensions.md
Normal 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 白名单,避免把本地鉴权信息转发到不可信服务。
|
||||
271
docs/src/content/docs/zh/configuration/provider-deeplink.md
Normal 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&base_url=https%3A%2F%2Fapi.openai.com%2Fv1&protocol=openai_responses&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&base_url=https%3A%2F%2Fapi.anthropic.com&protocol=anthropic_messages&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&base_url=https%3A%2F%2Fgenerativelanguage.googleapis.com&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&base_url=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1&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&base_url=https%3A%2F%2Fapi.deepseek.com&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&base_url=https%3A%2F%2Fopen.bigmodel.cn%2Fapi%2Fcoding%2Fpaas%2Fv4&protocol=openai_chat_completions&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&base_url=https%3A%2F%2Fopen.bigmodel.cn%2Fapi%2Fpaas%2Fv4&protocol=openai_chat_completions&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&base_url=https%3A%2F%2Fapi.z.ai%2Fapi%2Fcoding%2Fpaas%2Fv4&protocol=openai_chat_completions&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&base_url=https%3A%2F%2Fapi.z.ai%2Fapi%2Fpaas%2Fv4&protocol=openai_chat_completions&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&base_url=https%3A%2F%2Fapi.mistral.ai%2Fv1&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&base_url=https%3A%2F%2Fapi.moonshot.cn%2Fv1&protocol=openai_chat_completions&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&base_url=https%3A%2F%2Fdashscope.aliyuncs.com%2Fcompatible-mode%2Fv1&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&base_url=https%3A%2F%2Fapi.siliconflow.cn%2Fv1&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` 等别名。
|
||||
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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": "配置文件位置",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
220
src/renderer/pages/home/components/update.tsx
Normal 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();
|
||||
}
|
||||
|
|
@ -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 不能为空。",
|
||||
|
|
|
|||
1
src/renderer/types/electron.d.ts
vendored
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||