mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-28 19:01:32 +08:00
Add interactive docs demos
This commit is contained in:
+41
-1
@@ -1,5 +1,33 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
import sitemap from "@astrojs/sitemap";
|
||||
import react from "@astrojs/react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
/**
|
||||
* The reused UI package imports images as `import url from "@/assets/x.png"`
|
||||
* and expects a URL STRING (as esbuild produces). Under Astro, `astro:assets`
|
||||
* intercepts those and returns an `{ src, ... }` metadata object, which then
|
||||
* renders as `<img src="[object Object]">`. This plugin forces every image
|
||||
* imported from the UI package's assets dir to Vite's plain `?url` string,
|
||||
* WITHOUT modifying the UI source.
|
||||
*/
|
||||
function uiAssetsAsUrlPlugin() {
|
||||
const uiAssetsPath = new URL("./../packages/ui/src/assets", import.meta.url).pathname;
|
||||
const imageExt = /\.(png|jpe?g|svg|webp|gif|ico|avif)$/i;
|
||||
return {
|
||||
name: "ui-assets-as-url",
|
||||
enforce: "pre",
|
||||
async resolveId(source, importer, options) {
|
||||
const resolved = await this.resolve(source, importer, { ...options, skipSelf: true });
|
||||
if (!resolved) return null;
|
||||
const id = resolved.id.split("?")[0];
|
||||
if (id.startsWith(uiAssetsPath) && imageExt.test(id)) {
|
||||
return `${id}?url`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const site = process.env.ASTRO_SITE ?? "https://ccrdesk.top";
|
||||
const base = process.env.ASTRO_BASE ?? "/";
|
||||
@@ -60,7 +88,19 @@ export default defineConfig({
|
||||
site,
|
||||
base,
|
||||
output: "static",
|
||||
integrations: [sitemap({ filter: (page) => !isRedirectPage(page) })],
|
||||
integrations: [
|
||||
react(),
|
||||
sitemap({ filter: (page) => !isRedirectPage(page) }),
|
||||
],
|
||||
vite: {
|
||||
plugins: [tailwindcss(), uiAssetsAsUrlPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": new URL("./../packages/ui/src", import.meta.url).pathname,
|
||||
"@ccr/core": new URL("./../packages/core/src", import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
},
|
||||
markdown: {
|
||||
shikiConfig: {
|
||||
themes: {
|
||||
|
||||
Generated
+2505
-156
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -12,7 +12,21 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/sitemap": "^3.7.3",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"astro": "7.0.0",
|
||||
"lucide-astro": "^0.556.0"
|
||||
"lucide-astro": "^0.556.0",
|
||||
"tailwindcss": "^4.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/react": "^6.0.2",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"baseui": "^16.1.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"styletron-engine-atomic": "^1.6.2",
|
||||
"styletron-react": "^6.1.1",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,8 @@ const pageMarkdown = doc.rawContent();
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<slot name="interactive" />
|
||||
|
||||
<div class="doc-markdown" data-markdown-content>
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Docs wrapper rendering the REAL, UNMODIFIED LogsView (request logs) from the
|
||||
* UI package, with empty demo data + no-op callbacks + i18n + BaseProvider.
|
||||
* Client:only on an isolated bare page. (Observability/AgentAnalysisView is a
|
||||
* SEPARATE component — ObservabilityViewDemo.)
|
||||
*/
|
||||
import { LogsView } from "@/pages/home/components/network-logs";
|
||||
import { BaseUiProvider } from "@/lib/baseui-provider";
|
||||
import { AppI18nContext, appCopy } from "@/pages/home/shared/i18n";
|
||||
import { getRequestLogData } from "./mockData";
|
||||
import { DemoShell, readDemoLocale } from "./demoRuntime";
|
||||
import type { RequestLogListFilter, RequestLogPage } from "@ccr/core/contracts/app";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export default function LogsViewDemo() {
|
||||
const locale = readDemoLocale();
|
||||
return (
|
||||
<AppI18nContext.Provider value={locale === "zh" ? appCopy.zh : appCopy.en}>
|
||||
<DemoShell
|
||||
locale={locale}
|
||||
title={{
|
||||
zh: "请求日志 — 查看每个请求的详情、状态与耗时",
|
||||
en: "Request logs — inspect each request's details, status, and latency",
|
||||
}}
|
||||
contentClassName="docs-demo-content--data"
|
||||
>
|
||||
<BaseUiProvider>
|
||||
<div className="docs-demo-app-surface docs-demo-app-surface--logs">
|
||||
<LogsView
|
||||
enabled
|
||||
error=""
|
||||
filter={{} as RequestLogListFilter}
|
||||
loading={false}
|
||||
page={getRequestLogData()}
|
||||
refreshLogs={noop}
|
||||
updateFilter={noop}
|
||||
onEnable={noop}
|
||||
onFocusedRequestHandled={noop}
|
||||
/>
|
||||
</div>
|
||||
</BaseUiProvider>
|
||||
</DemoShell>
|
||||
</AppI18nContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Docs wrapper rendering the REAL, UNMODIFIED AgentAnalysisView (observability)
|
||||
* with real fixture data from the local CCR. selectedSession is wired to local
|
||||
* state so clicking "Details" opens the detail panel; the session detail data
|
||||
* (trace runs etc.) is injected into the snapshot only when a session is
|
||||
* selected, and stripped otherwise so the dialog starts closed and can close.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { AgentAnalysisView } from "@/pages/home/components/dashboard";
|
||||
import { BaseUiProvider } from "@/lib/baseui-provider";
|
||||
import { AppI18nContext, appCopy } from "@/pages/home/shared/i18n";
|
||||
import { getAgentAnalysisData } from "./mockData";
|
||||
import { DemoShell, readDemoLocale } from "./demoRuntime";
|
||||
import type { AgentFilterValue } from "@/pages/home/shared/options";
|
||||
import type { AgentAnalysisSessionSelection, AgentAnalysisSnapshot } from "@ccr/core/contracts/app";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
// Split the fixture once: base snapshot (no detail) + the session detail data
|
||||
const FULL_SNAPSHOT = getAgentAnalysisData();
|
||||
const SESSION_DETAIL = FULL_SNAPSHOT.selectedSession;
|
||||
const BASE_SNAPSHOT: AgentAnalysisSnapshot = { ...FULL_SNAPSHOT, selectedSession: undefined };
|
||||
|
||||
export default function ObservabilityViewDemo() {
|
||||
const locale = readDemoLocale();
|
||||
const [selectedSession, setSelectedSession] = useState<AgentAnalysisSessionSelection | undefined>(undefined);
|
||||
|
||||
// Inject the session detail only when the user has selected a session
|
||||
const snapshot: AgentAnalysisSnapshot = selectedSession
|
||||
? { ...BASE_SNAPSHOT, selectedSession: SESSION_DETAIL }
|
||||
: BASE_SNAPSHOT;
|
||||
|
||||
return (
|
||||
<AppI18nContext.Provider value={locale === "zh" ? appCopy.zh : appCopy.en}>
|
||||
<DemoShell
|
||||
locale={locale}
|
||||
title={{
|
||||
zh: "Agent 观测 — 查看执行轨迹、工具调用与结果",
|
||||
en: "Agent observability — inspect execution traces, tool calls, and results",
|
||||
}}
|
||||
contentClassName="docs-demo-content--data"
|
||||
>
|
||||
<BaseUiProvider>
|
||||
<div className="docs-demo-app-surface docs-demo-app-surface--observability">
|
||||
<AgentAnalysisView
|
||||
agentFilter={"all" as AgentFilterValue}
|
||||
error=""
|
||||
loading={false}
|
||||
range="today"
|
||||
refreshAnalysis={noop}
|
||||
setAgentFilter={noop}
|
||||
setRange={noop}
|
||||
selectedSession={selectedSession}
|
||||
setSelectedSession={setSelectedSession}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
</div>
|
||||
</BaseUiProvider>
|
||||
</DemoShell>
|
||||
</AppI18nContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Docs wrapper rendering the REAL, UNMODIFIED ProfileView + AddProfileDialog.
|
||||
* The "Play tutorial" cursor: clicks "Add profile" → dialog opens → clicks the
|
||||
* ModelSelector → picks a model → clicks "Add" — the real intermediate form.
|
||||
* Includes demo providers so the ModelSelector has models to pick.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { AddProfileDialog, ProfileView } from "@/pages/home/components/profiles";
|
||||
import { BaseUiProvider } from "@/lib/baseui-provider";
|
||||
import { AppI18nContext, appCopy } from "@/pages/home/shared/i18n";
|
||||
import { fallbackConfig } from "@/pages/home/shared/fallbacks";
|
||||
import { createProfileDraft } from "@/pages/home/shared/profiles";
|
||||
import { profileAgentOptions } from "@/pages/home/shared/options";
|
||||
import {
|
||||
DemoShell,
|
||||
readDemoLocale,
|
||||
sleep,
|
||||
VirtualCursor,
|
||||
createCursorApi,
|
||||
findByText,
|
||||
} from "./demoRuntime";
|
||||
import type { AppConfig, GatewayProviderConfig, ProfileConfig, ProfileRuntimeStatus } from "@ccr/core/contracts/app";
|
||||
import type { AddProfileDraft } from "@/pages/home/shared/types";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const DEMO_PROVIDERS: GatewayProviderConfig[] = [
|
||||
{ name: "OpenAI", provider: "openai", type: "openai_chat_completions", api_base_url: "https://api.openai.com/v1", api_key: "sk-••••", models: ["gpt-4o", "gpt-4o-mini"] },
|
||||
{ name: "Anthropic", provider: "anthropic", type: "anthropic_messages", api_base_url: "https://api.anthropic.com/v1", api_key: "sk-ant-••••", models: ["claude-sonnet-4-20250514"] },
|
||||
];
|
||||
|
||||
const DEMO_CONFIG: AppConfig = { ...fallbackConfig, Providers: DEMO_PROVIDERS };
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[^\p{L}\p{N}\s-]/gu, "").replace(/[\s_]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "profile";
|
||||
}
|
||||
|
||||
export default function ProfileViewDemo() {
|
||||
const locale = readDemoLocale();
|
||||
const [config, setConfig] = useState<AppConfig>(DEMO_CONFIG);
|
||||
const [draft, setDraft] = useState<AddProfileDraft>(() => createProfileDraft());
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [cursor, setCursor] = useState({ x: -100, y: -100, clicking: false, visible: false });
|
||||
const cursorApi = useMemo(() => createCursorApi(setCursor), []);
|
||||
|
||||
function openAddProfile() {
|
||||
setDraft(createProfileDraft());
|
||||
setDialogOpen(true);
|
||||
}
|
||||
function changeDraft(patch: Partial<AddProfileDraft>) {
|
||||
setDraft((d) => ({ ...d, ...patch }));
|
||||
}
|
||||
async function submitProfile(): Promise<boolean> {
|
||||
const profile = {
|
||||
id: slugify(draft.name || draft.agent),
|
||||
name: draft.name || draft.agent,
|
||||
agent: draft.agent,
|
||||
model: draft.model,
|
||||
enabled: true,
|
||||
} as ProfileConfig;
|
||||
setConfig((cfg) => ({ ...cfg, profile: { ...cfg.profile, profiles: [...(cfg.profile?.profiles ?? []), profile] } }));
|
||||
setDialogOpen(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
const canSubmit = Boolean(draft.model.trim());
|
||||
|
||||
async function play() {
|
||||
if (playing) return;
|
||||
setPlaying(true);
|
||||
try {
|
||||
const waitFor = async (findFn: () => HTMLElement | null, ms = 3000): Promise<HTMLElement | null> => {
|
||||
let el: HTMLElement | null = null;
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < ms) { el = findFn(); if (el) return el; await sleep(100); }
|
||||
return el;
|
||||
};
|
||||
|
||||
// 1) click "Add profile" (text button — NO aria-label)
|
||||
const addBtn = await waitFor(() => findByText("button", ["添加配置", "Add profile"]));
|
||||
if (addBtn) await cursorApi.click(addBtn);
|
||||
await sleep(800);
|
||||
|
||||
// 2) click the ModelSelector trigger (button[aria-haspopup="dialog"] in the dialog)
|
||||
const modelTrigger = await waitFor(() =>
|
||||
document.querySelector('[role="dialog"]')?.querySelector<HTMLElement>('button[aria-haspopup="dialog"]') ?? null
|
||||
);
|
||||
if (modelTrigger) await cursorApi.click(modelTrigger);
|
||||
|
||||
// 3) click a model button in the ModelSelector popover (contains "gpt-4o")
|
||||
const modelBtn = await waitFor(() => findByText("button", ["gpt-4o"]), 4000);
|
||||
if (modelBtn) await cursorApi.click(modelBtn);
|
||||
// safety: ensure draft.model is set so canSubmit becomes true
|
||||
changeDraft({ model: "OpenAI/gpt-4o" });
|
||||
await sleep(600);
|
||||
|
||||
// 4) click "Add" button (must be ENABLED — wait for canSubmit)
|
||||
const saveBtn = await waitFor(() => {
|
||||
const dlg = document.querySelector('[role="dialog"]');
|
||||
if (!dlg) return null;
|
||||
return Array.from(dlg.querySelectorAll<HTMLElement>("button")).find(
|
||||
(b) => {
|
||||
const t = (b.textContent || "").trim().toLowerCase();
|
||||
return (t === "添加" || t === "add") && !b.disabled;
|
||||
}
|
||||
) ?? null;
|
||||
}, 5000);
|
||||
if (saveBtn) await cursorApi.click(saveBtn);
|
||||
} finally {
|
||||
cursorApi.hide();
|
||||
setPlaying(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppI18nContext.Provider value={locale === "zh" ? appCopy.zh : appCopy.en}>
|
||||
<DemoShell
|
||||
locale={locale}
|
||||
title={{
|
||||
zh: "Agent 配置 — 为 Claude Code / Codex 等创建配置并选择默认模型",
|
||||
en: "Agent configuration — create a profile for Claude Code / Codex and choose its default model",
|
||||
}}
|
||||
onPlay={play}
|
||||
playing={playing}
|
||||
>
|
||||
<VirtualCursor state={cursor} />
|
||||
<BaseUiProvider>
|
||||
<div className="docs-demo-app-surface docs-demo-app-surface--manager">
|
||||
<ProfileView
|
||||
addProfile={openAddProfile}
|
||||
applyError=""
|
||||
config={config}
|
||||
copyProfileCliCommand={noop}
|
||||
editProfile={noop}
|
||||
openProfileApp={noop}
|
||||
profileRuntimeStatus={{ profiles: [] } as ProfileRuntimeStatus}
|
||||
removeProfile={noop}
|
||||
stopProfileApp={noop}
|
||||
updateProfileItem={noop}
|
||||
/>
|
||||
</div>
|
||||
{dialogOpen && (
|
||||
<AddProfileDialog
|
||||
agentOptions={profileAgentOptions}
|
||||
botConfigs={[]}
|
||||
canSubmit={canSubmit}
|
||||
draft={draft}
|
||||
error=""
|
||||
mode="add"
|
||||
onChange={changeDraft}
|
||||
onCreateBot={noop}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
providers={config.Providers ?? []}
|
||||
onSubmit={submitProfile}
|
||||
/>
|
||||
)}
|
||||
</BaseUiProvider>
|
||||
</DemoShell>
|
||||
</AppI18nContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Docs wrapper that renders the REAL, UNMODIFIED ProvidersView + Add/Edit and
|
||||
* Delete dialogs from the UI package, wired to local state so the page is
|
||||
* fully interactive (add / edit / remove / enable-disable) — no UI source
|
||||
* changes, no backend. Bridge-only features (protocol probe, model discovery,
|
||||
* connection check, usage) safely no-op. Rendered client:only on an isolated
|
||||
* bare page (pages/guides/provider-app.astro) that loads the full UI stylesheet.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { ProvidersView, AddProviderDialog, DeleteProviderDialog } from "@/pages/home/components/providers";
|
||||
import { BaseUiProvider } from "@/lib/baseui-provider";
|
||||
import { AppI18nContext, appCopy } from "@/pages/home/shared/i18n";
|
||||
import {
|
||||
createProviderDraft,
|
||||
createProviderDraftFromProvider,
|
||||
mergeProviderModelLists,
|
||||
splitLines,
|
||||
} from "@/pages/home/shared/providers";
|
||||
import { setProviderPresets } from "@/pages/home/shared/external";
|
||||
import type { AddProviderDraft } from "@/pages/home/shared/types";
|
||||
import type { GatewayProviderConfig, ProviderAccountSnapshot } from "@ccr/core/contracts/app";
|
||||
import {
|
||||
DemoShell,
|
||||
readDemoLocale,
|
||||
sleep,
|
||||
VirtualCursor,
|
||||
createCursorApi,
|
||||
findByAria,
|
||||
findByText,
|
||||
findInputByLabels,
|
||||
} from "./demoRuntime";
|
||||
// The UI's getProviderPresets() reads a runtime cache (shared/external.tsx) that
|
||||
// the desktop app fills from the bridge on startup; with no bridge in docs it
|
||||
// stays empty and the preset picker shows nothing. Seed it from the @ccr/core
|
||||
// static list so the demo shows the real built-in presets.
|
||||
import { getProviderPresets as getCoreProviderPresets } from "@ccr/core/providers/presets";
|
||||
setProviderPresets(getCoreProviderPresets());
|
||||
|
||||
const notify = (_: string) => {};
|
||||
|
||||
function slugifyName(name: string): string {
|
||||
return (
|
||||
name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "provider"
|
||||
);
|
||||
}
|
||||
|
||||
const INITIAL_PROVIDERS: GatewayProviderConfig[] = [
|
||||
{
|
||||
name: "OpenAI",
|
||||
provider: "openai",
|
||||
type: "openai_chat_completions",
|
||||
api_base_url: "https://api.openai.com/v1",
|
||||
api_key: "sk-••••••••••••••••••••",
|
||||
models: ["gpt-4o", "gpt-4o-mini"],
|
||||
},
|
||||
{
|
||||
name: "Anthropic",
|
||||
provider: "anthropic",
|
||||
type: "anthropic_messages",
|
||||
api_base_url: "https://api.anthropic.com/v1",
|
||||
api_key: "sk-ant-••••••••••••••••",
|
||||
models: ["claude-sonnet-4-20250514"],
|
||||
},
|
||||
];
|
||||
|
||||
export default function ProvidersViewDemo() {
|
||||
const locale = readDemoLocale();
|
||||
const [providers, setProviders] = useState<GatewayProviderConfig[]>(INITIAL_PROVIDERS);
|
||||
const [draft, setDraft] = useState<AddProviderDraft>(() => createProviderDraft(INITIAL_PROVIDERS));
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editIndex, setEditIndex] = useState<number | undefined>(undefined);
|
||||
const [deleteIndex, setDeleteIndex] = useState<number | undefined>(undefined);
|
||||
const [dialogMode, setDialogMode] = useState<"add" | "edit">("add");
|
||||
const [dialogTitle, setDialogTitle] = useState<string | undefined>(undefined);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [cursor, setCursor] = useState({ x: -100, y: -100, clicking: false, visible: false });
|
||||
const cursorApi = useMemo(() => createCursorApi(setCursor), []);
|
||||
|
||||
function openAdd() {
|
||||
setEditIndex(undefined);
|
||||
setDialogMode("add");
|
||||
setDialogTitle(undefined);
|
||||
setDraft(createProviderDraft(providers));
|
||||
setDialogOpen(true);
|
||||
}
|
||||
function openEdit(index: number) {
|
||||
setEditIndex(index);
|
||||
setDialogMode("edit");
|
||||
setDialogTitle(undefined);
|
||||
setDraft(createProviderDraftFromProvider(providers[index]));
|
||||
setDialogOpen(true);
|
||||
}
|
||||
function changeDraft(patch: Partial<AddProviderDraft>) {
|
||||
setDraft((current) => ({ ...current, ...patch }));
|
||||
}
|
||||
function setEnabled(index: number, enabled: boolean) {
|
||||
setProviders((prev) =>
|
||||
prev.map((provider, idx) => (idx === index ? { ...provider, enabled: enabled ? undefined : false } : provider))
|
||||
);
|
||||
}
|
||||
async function submit(): Promise<boolean> {
|
||||
const models = mergeProviderModelLists(draft.selectedModels, splitLines(draft.modelsText));
|
||||
const provider: GatewayProviderConfig = {
|
||||
name: draft.name.trim() || "provider",
|
||||
provider: slugifyName(draft.name),
|
||||
type: draft.protocol,
|
||||
api_base_url: draft.baseUrl.trim(),
|
||||
api_key: draft.apiKey.trim(),
|
||||
models,
|
||||
id: slugifyName(draft.name),
|
||||
};
|
||||
setProviders((prev) =>
|
||||
editIndex === undefined ? [...prev, provider] : prev.map((item, idx) => (idx === editIndex ? provider : item))
|
||||
);
|
||||
setDialogOpen(false);
|
||||
setEditIndex(undefined);
|
||||
return true;
|
||||
}
|
||||
function confirmDelete() {
|
||||
if (deleteIndex === undefined) return;
|
||||
setProviders((prev) => prev.filter((_, idx) => idx !== deleteIndex));
|
||||
setDeleteIndex(undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Automated "add a provider" walkthrough with a VISIBLE, REAL-TIME cursor.
|
||||
* Every action is a REAL click/type on the actual element — the user sees the
|
||||
* mouse move to and click each thing, following the correct 4-step wizard:
|
||||
* 1) click Add → click preset combobox → click "OpenAI" option → click Next
|
||||
* 2) type API key → click Next
|
||||
* 3) click "Custom model" → type model ID → click ✓ → click Next
|
||||
* 4) click "Done"
|
||||
*/
|
||||
async function play() {
|
||||
if (playing) return;
|
||||
setPlaying(true);
|
||||
try {
|
||||
const waitFor = async (findFn: () => HTMLElement | null, ms = 3000): Promise<HTMLElement | null> => {
|
||||
let el: HTMLElement | null = null;
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < ms) {
|
||||
el = findFn();
|
||||
if (el) return el;
|
||||
await sleep(100);
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
// ── Step 1: open Add dialog ──
|
||||
const addBtn = await waitFor(() => findByAria("button", ["添加供应商", "Add provider"]));
|
||||
if (addBtn) await cursorApi.click(addBtn);
|
||||
await sleep(800);
|
||||
|
||||
// ── Step 1: click preset combobox (opens dropdown) ──
|
||||
const combo = await waitFor(() =>
|
||||
findByText("span", ["预设供应商", "preset provider"])?.parentElement?.querySelector<HTMLElement>('[role="button"]') ?? null
|
||||
);
|
||||
if (combo) await cursorApi.click(combo);
|
||||
|
||||
// ── Step 1: click "OpenAI" option ──
|
||||
const option = await waitFor(() => findByText('[role="option"]', ["OpenAI"]));
|
||||
if (option) await cursorApi.click(option);
|
||||
await sleep(500);
|
||||
|
||||
// ── Step 1 → 2: click "Next" ──
|
||||
const next1 = await waitFor(() => findByText("button", ["下一步", "Next"]));
|
||||
if (next1) await cursorApi.click(next1);
|
||||
await sleep(500);
|
||||
|
||||
// ── Step 2: type API key ──
|
||||
const keyInput = await waitFor(() => findInputByLabels(["API 密钥", "API key"])) as HTMLInputElement | null;
|
||||
if (keyInput) await cursorApi.type(keyInput, "sk-demo-tutorial-key");
|
||||
await sleep(300);
|
||||
|
||||
// ── Step 2 → 3: click "Next" ──
|
||||
const next2 = await waitFor(() => findByText("button", ["下一步", "Next"]));
|
||||
if (next2) await cursorApi.click(next2);
|
||||
await sleep(500);
|
||||
|
||||
// ── Step 3: click "Custom model" button ──
|
||||
const customBtn = await waitFor(() => findByAria("button", ["自定义模型", "Custom model"]));
|
||||
if (customBtn) await cursorApi.click(customBtn);
|
||||
|
||||
// ── Step 3: type model ID ──
|
||||
const modelInput = await waitFor(() => findByAria("input", ["自定义模型", "Custom model"])) as HTMLInputElement | null;
|
||||
if (modelInput) await cursorApi.type(modelInput, "gpt-4o");
|
||||
await sleep(200);
|
||||
|
||||
// ── Step 3: click "Add custom model" (✓) ──
|
||||
const addModelBtn = await waitFor(() => findByAria("button", ["添加自定义模型", "Add custom model"]));
|
||||
if (addModelBtn) await cursorApi.click(addModelBtn);
|
||||
await sleep(400);
|
||||
|
||||
// ── Step 3 → 4: click "Next" ──
|
||||
const next3 = await waitFor(() => findByText("button", ["下一步", "Next"]));
|
||||
if (next3) await cursorApi.click(next3);
|
||||
await sleep(500);
|
||||
|
||||
// ── Step 4: click "Done" (submit) ──
|
||||
const doneBtn = await waitFor(() => findByText("button", ["完成", "Done"]));
|
||||
if (doneBtn) await cursorApi.click(doneBtn);
|
||||
} finally {
|
||||
cursorApi.hide();
|
||||
setPlaying(false);
|
||||
}
|
||||
}
|
||||
|
||||
const dialogModels = mergeProviderModelLists(draft.selectedModels, splitLines(draft.modelsText));
|
||||
const canSubmit = Boolean(draft.name.trim() && draft.baseUrl.trim()) && dialogModels.length > 0;
|
||||
const deleteItem = deleteIndex === undefined ? undefined : providers[deleteIndex];
|
||||
|
||||
return (
|
||||
<AppI18nContext.Provider value={locale === "zh" ? appCopy.zh : appCopy.en}>
|
||||
<DemoShell
|
||||
locale={locale}
|
||||
title={{
|
||||
zh: "供应商管理 — 添加、编辑、移除上游模型供应商",
|
||||
en: "Provider management — add, edit, and remove upstream model providers",
|
||||
}}
|
||||
onPlay={play}
|
||||
playing={playing}
|
||||
>
|
||||
<VirtualCursor state={cursor} />
|
||||
<BaseUiProvider>
|
||||
<div className="docs-demo-app-surface docs-demo-app-surface--manager">
|
||||
<ProvidersView
|
||||
accountSnapshots={[] as ProviderAccountSnapshot[]}
|
||||
providers={providers.map((provider, index) => ({ provider, index }))}
|
||||
addProvider={openAdd}
|
||||
editProvider={openEdit}
|
||||
removeProvider={setDeleteIndex}
|
||||
setProviderEnabled={setEnabled}
|
||||
notify={notify}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{dialogOpen && (
|
||||
<AddProviderDialog
|
||||
mode={dialogMode}
|
||||
title={dialogTitle}
|
||||
draft={draft}
|
||||
providers={providers}
|
||||
canSubmit={canSubmit}
|
||||
onChange={changeDraft}
|
||||
onClose={() => {
|
||||
setDialogOpen(false);
|
||||
setEditIndex(undefined);
|
||||
}}
|
||||
onSubmit={submit}
|
||||
error=""
|
||||
probeLoading={false}
|
||||
providerPlugins={[]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteItem && (
|
||||
<DeleteProviderDialog provider={deleteItem} onClose={() => setDeleteIndex(undefined)} onConfirm={confirmDelete} />
|
||||
)}
|
||||
</BaseUiProvider>
|
||||
</DemoShell>
|
||||
</AppI18nContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
html.interactive-demo-html {
|
||||
min-height: 100%;
|
||||
overflow: auto;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
body.interactive-demo-page {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.docs-demo-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
min-width: 320px;
|
||||
padding: clamp(12px, 2.3vw, 22px);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in oklab, var(--card) 52%, transparent), transparent 34%),
|
||||
radial-gradient(circle at 12% 0%, color-mix(in oklab, var(--primary) 9%, transparent), transparent 32%),
|
||||
var(--background);
|
||||
}
|
||||
|
||||
.docs-demo-window {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in oklab, var(--border) 72%, transparent);
|
||||
border-radius: 18px;
|
||||
background: color-mix(in oklab, var(--card) 92%, var(--background));
|
||||
box-shadow:
|
||||
0 22px 54px rgba(15, 23, 42, 0.13),
|
||||
0 2px 8px rgba(15, 23, 42, 0.06),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.42);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.docs-demo-title {
|
||||
display: flex;
|
||||
min-height: 66px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 14px clamp(16px, 2.2vw, 24px);
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--border) 62%, transparent);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in oklab, var(--card) 98%, var(--background)), color-mix(in oklab, var(--card) 86%, var(--background)));
|
||||
}
|
||||
|
||||
.docs-demo-title-copy {
|
||||
min-width: 0;
|
||||
color: var(--foreground);
|
||||
font-size: clamp(14px, 1.35vw, 18px);
|
||||
font-weight: 720;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.docs-demo-play-button {
|
||||
display: inline-flex;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 15px;
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 84%, transparent);
|
||||
border-radius: 9px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in oklab, var(--primary) 92%, white), var(--primary));
|
||||
color: var(--primary-foreground);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
box-shadow:
|
||||
0 7px 18px color-mix(in oklab, var(--primary) 20%, transparent),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.24);
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
.docs-demo-play-button:hover:not(:disabled) {
|
||||
border-color: var(--primary);
|
||||
box-shadow:
|
||||
0 9px 22px color-mix(in oklab, var(--primary) 26%, transparent),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.28);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.docs-demo-play-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.docs-demo-play-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.docs-demo-notice {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px clamp(16px, 2.2vw, 24px);
|
||||
border-bottom: 1px solid color-mix(in oklab, #f4b740 32%, var(--border));
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in oklab, #f4b740 13%, var(--card)), color-mix(in oklab, #f4b740 7%, var(--card)));
|
||||
color: color-mix(in oklab, #8a4b00 88%, var(--foreground));
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.docs-demo-notice-icon {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.docs-demo-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: clamp(14px, 2vw, 20px);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in oklab, var(--background) 86%, var(--card)), var(--background));
|
||||
}
|
||||
|
||||
.docs-demo-content--data {
|
||||
padding: clamp(12px, 1.8vw, 18px);
|
||||
}
|
||||
|
||||
.docs-demo-app-surface {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.docs-demo-app-surface--manager,
|
||||
.docs-demo-app-surface--logs,
|
||||
.docs-demo-app-surface--observability {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.docs-demo-app-surface--logs {
|
||||
min-height: 780px;
|
||||
}
|
||||
|
||||
.docs-demo-app-surface--observability {
|
||||
min-height: 910px;
|
||||
}
|
||||
|
||||
.docs-demo-app-surface :where(.bg-card) {
|
||||
background-color: color-mix(in oklab, var(--card) 94%, var(--background));
|
||||
}
|
||||
|
||||
.docs-demo-app-surface :where(.border-border, .border-border\/60, .border-border\/50) {
|
||||
border-color: color-mix(in oklab, var(--border) 72%, transparent);
|
||||
}
|
||||
|
||||
.docs-demo-app-surface :where(.shadow-card, .shadow-card-hover, .shadow-card-elevated) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.docs-demo-app-surface :where(input, select, textarea) {
|
||||
border-color: color-mix(in oklab, var(--input) 86%, transparent);
|
||||
}
|
||||
|
||||
.docs-demo-app-surface :where(button, input, select, textarea):focus-visible {
|
||||
outline: 2px solid color-mix(in oklab, var(--ring) 58%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.docs-demo-app-surface--manager > div,
|
||||
.docs-demo-app-surface--logs > div,
|
||||
.docs-demo-app-surface--observability > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.docs-demo-app-surface--logs .network-shell,
|
||||
.docs-demo-app-surface--observability .network-shell {
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 14px 38px rgba(15, 23, 42, 0.12),
|
||||
0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .docs-demo-window {
|
||||
box-shadow:
|
||||
0 22px 64px rgba(0, 0, 0, 0.42),
|
||||
0 2px 8px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .docs-demo-title {
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in oklab, var(--card) 96%, #20242b), color-mix(in oklab, var(--card) 88%, var(--background)));
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .docs-demo-notice {
|
||||
border-bottom-color: color-mix(in oklab, #f4b740 18%, var(--border));
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in oklab, #f4b740 16%, var(--card)), color-mix(in oklab, #f4b740 8%, var(--card)));
|
||||
color: #f8d48a;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .docs-demo-window {
|
||||
box-shadow:
|
||||
0 22px 64px rgba(0, 0, 0, 0.42),
|
||||
0 2px 8px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .docs-demo-title {
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in oklab, var(--card) 96%, #20242b), color-mix(in oklab, var(--card) 88%, var(--background)));
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .docs-demo-notice {
|
||||
border-bottom-color: color-mix(in oklab, #f4b740 18%, var(--border));
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in oklab, #f4b740 16%, var(--card)), color-mix(in oklab, #f4b740 8%, var(--card)));
|
||||
color: #f8d48a;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.docs-demo-shell {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.docs-demo-window {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.docs-demo-title {
|
||||
min-height: auto;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.docs-demo-play-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.docs-demo-notice,
|
||||
.docs-demo-content {
|
||||
padding-inline: 13px;
|
||||
}
|
||||
|
||||
.docs-demo-app-surface--manager article {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.docs-demo-app-surface--manager .grid > * {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.docs-demo-app-surface--logs,
|
||||
.docs-demo-app-surface--observability {
|
||||
min-height: 720px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.docs-demo-play-button {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.docs-demo-play-button:hover:not(:disabled) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Shared runtime for the interactive docs demos: locale, the title + "tutorial
|
||||
* demo only" notice, and a VIRTUAL CURSOR used by the "Play tutorial" button.
|
||||
*
|
||||
* The play walkthroughs move a visible cursor to each real element and actually
|
||||
* click / type it (dispatching real DOM events so the unmodified UI components'
|
||||
* own handlers fire) — the user sees the mouse operate, step by step.
|
||||
*/
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
export type Locale = "zh" | "en";
|
||||
|
||||
export function readDemoLocale(): Locale {
|
||||
if (typeof window === "undefined") return "zh";
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).get("locale") === "en" ? "en" : "zh";
|
||||
} catch {
|
||||
return "zh";
|
||||
}
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Title + notice */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const NOTICE_TEXT: Record<Locale, string> = {
|
||||
zh: "该组件仅供教程演示。",
|
||||
en: "This component is for tutorial demonstration only.",
|
||||
};
|
||||
|
||||
function PlayIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DemoShell({
|
||||
children,
|
||||
contentClassName,
|
||||
locale,
|
||||
onPlay,
|
||||
playing,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
contentClassName?: string;
|
||||
locale: Locale;
|
||||
onPlay?: () => void;
|
||||
playing?: boolean;
|
||||
title: Record<Locale, string>;
|
||||
}) {
|
||||
return (
|
||||
<main className="docs-demo-shell">
|
||||
<section className="docs-demo-window" aria-label={title[locale]}>
|
||||
<DemoTitle locale={locale} title={title} onPlay={onPlay} playing={playing} />
|
||||
<DemoNotice locale={locale} />
|
||||
<div className={["docs-demo-content", contentClassName].filter(Boolean).join(" ")}>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function DemoTitle({
|
||||
locale,
|
||||
title,
|
||||
onPlay,
|
||||
playing,
|
||||
}: {
|
||||
locale: Locale;
|
||||
title: Record<Locale, string>;
|
||||
onPlay?: () => void;
|
||||
playing?: boolean;
|
||||
}) {
|
||||
const buttonLabel = playing
|
||||
? locale === "zh" ? "播放中…" : "Playing…"
|
||||
: locale === "zh" ? "播放教程" : "Play tutorial";
|
||||
return (
|
||||
<div className="docs-demo-title">
|
||||
<div className="docs-demo-title-copy">
|
||||
{title[locale]}
|
||||
</div>
|
||||
{onPlay ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPlay}
|
||||
disabled={playing}
|
||||
className="docs-demo-play-button"
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>{buttonLabel}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DemoNotice({ locale }: { locale: Locale }) {
|
||||
return (
|
||||
<div className="docs-demo-notice">
|
||||
<span className="docs-demo-notice-icon" aria-hidden="true">i</span>
|
||||
<span>{NOTICE_TEXT[locale]}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Virtual cursor */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface CursorState {
|
||||
x: number;
|
||||
y: number;
|
||||
clicking: boolean;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const MOVE_MS = 480;
|
||||
|
||||
export function VirtualCursor({ state }: { state: CursorState }) {
|
||||
if (!state.visible) return null;
|
||||
const cursorStyle: CSSProperties = {
|
||||
position: "fixed",
|
||||
left: state.x,
|
||||
top: state.y,
|
||||
transform: `translate(-2px,-2px) scale(${state.clicking ? 0.82 : 1})`,
|
||||
transition: `left ${MOVE_MS}ms cubic-bezier(.45,1.15,.5,1), top ${MOVE_MS}ms cubic-bezier(.45,1.15,.5,1), transform 120ms`,
|
||||
zIndex: 99999,
|
||||
pointerEvents: "none",
|
||||
willChange: "left, top",
|
||||
lineHeight: 0,
|
||||
};
|
||||
return (
|
||||
<div style={cursorStyle} aria-hidden="true">
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" style={{ filter: "drop-shadow(0 1px 2px rgba(0,0,0,.45))" }}>
|
||||
<path
|
||||
d="M4 2l16 8-7 1.6L9.5 19z"
|
||||
fill="#ffffff"
|
||||
stroke="#0f766e"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{state.clicking ? (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: -6,
|
||||
top: -6,
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: "999px",
|
||||
border: "2px solid #0f766e",
|
||||
background: "rgba(15,118,110,0.18)",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function center(el: Element): { x: number; y: number } {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
||||
}
|
||||
|
||||
/** Trigger a real click via the native HTMLElement.click() method — the most
|
||||
* reliable way to fire React onClick handlers (delegated at the root). */
|
||||
function fireClick(el: Element) {
|
||||
(el as HTMLElement).click();
|
||||
}
|
||||
|
||||
export interface CursorApi {
|
||||
moveTo(el: Element): Promise<void>;
|
||||
/** Move to the element and show a click animation WITHOUT dispatching an event
|
||||
* (use when the action is driven by state, but you still want the visual). */
|
||||
pointAt(el: Element): Promise<void>;
|
||||
click(el: Element): Promise<void>;
|
||||
type(el: HTMLInputElement | HTMLTextAreaElement, text: string): Promise<void>;
|
||||
hide(): void;
|
||||
}
|
||||
|
||||
export function createCursorApi(
|
||||
setCursor: (updater: (prev: CursorState) => CursorState) => void
|
||||
): CursorApi {
|
||||
let rafId: number | null = null;
|
||||
let trackedEl: Element | null = null;
|
||||
let lastX = -100;
|
||||
let lastY = -100;
|
||||
|
||||
/** Start a rAF loop that continuously reads the tracked element's position
|
||||
* and updates the cursor — so even if the element moves (dialog opens,
|
||||
* wizard advances, scroll changes) the cursor stays on target. */
|
||||
function startTracking(el: Element) {
|
||||
stopTracking();
|
||||
trackedEl = el;
|
||||
try {
|
||||
el.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
} catch {
|
||||
/* not scrollable */
|
||||
}
|
||||
const tick = () => {
|
||||
if (!trackedEl || !trackedEl.isConnected) {
|
||||
rafId = null;
|
||||
return;
|
||||
}
|
||||
const r = trackedEl.getBoundingClientRect();
|
||||
const cx = r.left + r.width / 2;
|
||||
const cy = r.top + r.height / 2;
|
||||
if (cx !== lastX || cy !== lastY) {
|
||||
lastX = cx;
|
||||
lastY = cy;
|
||||
setCursor((c) => ({ ...c, visible: true, x: cx, y: cy }));
|
||||
}
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function stopTracking() {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
trackedEl = null;
|
||||
}
|
||||
|
||||
return {
|
||||
async moveTo(el) {
|
||||
startTracking(el);
|
||||
await sleep(MOVE_MS + 100);
|
||||
},
|
||||
async pointAt(el) {
|
||||
startTracking(el);
|
||||
await sleep(MOVE_MS + 100);
|
||||
setCursor((c) => ({ ...c, clicking: true }));
|
||||
await sleep(160);
|
||||
setCursor((c) => ({ ...c, clicking: false }));
|
||||
await sleep(140);
|
||||
stopTracking();
|
||||
},
|
||||
async click(el) {
|
||||
startTracking(el);
|
||||
await sleep(MOVE_MS + 100);
|
||||
setCursor((c) => ({ ...c, clicking: true }));
|
||||
await sleep(160);
|
||||
fireClick(el);
|
||||
await sleep(220);
|
||||
setCursor((c) => ({ ...c, clicking: false }));
|
||||
stopTracking();
|
||||
},
|
||||
async type(el, text) {
|
||||
startTracking(el);
|
||||
await sleep(MOVE_MS + 100);
|
||||
setCursor((c) => ({ ...c, clicking: true }));
|
||||
await sleep(140);
|
||||
el.focus();
|
||||
setCursor((c) => ({ ...c, clicking: false }));
|
||||
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
|
||||
let value = "";
|
||||
for (const ch of text) {
|
||||
value += ch;
|
||||
setter?.call(el, value);
|
||||
el.dispatchEvent(new InputEvent("input", { bubbles: true }));
|
||||
await sleep(55);
|
||||
}
|
||||
await sleep(180);
|
||||
stopTracking();
|
||||
},
|
||||
hide() {
|
||||
stopTracking();
|
||||
setCursor((c) => ({ ...c, visible: false }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Element finders (robust across locales) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** First element matching `selector` whose aria-label/title matches any of `labels`
|
||||
* (case-insensitive). Preferred over findByText for interactive controls, since
|
||||
* their visible text is localized (and may be icon-only). */
|
||||
export function findByAria(
|
||||
selector: string,
|
||||
labels: string[],
|
||||
root: ParentNode = document
|
||||
): HTMLElement | null {
|
||||
const lower = labels.map((l) => l.toLowerCase());
|
||||
for (const el of Array.from(root.querySelectorAll<HTMLElement>(selector))) {
|
||||
const a = (el.getAttribute("aria-label") || el.getAttribute("title") || "").toLowerCase();
|
||||
if (lower.some((s) => a === s || a.includes(s))) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** First element matching `selector` whose text includes any of `texts`. */
|
||||
export function findByText(
|
||||
selector: string,
|
||||
texts: string[],
|
||||
root: ParentNode = document
|
||||
): HTMLElement | null {
|
||||
const lower = texts.map((t) => t.toLowerCase());
|
||||
for (const el of Array.from(root.querySelectorAll<HTMLElement>(selector))) {
|
||||
const t = (el.textContent || "").trim().toLowerCase();
|
||||
if (lower.some((s) => t === s || t.includes(s))) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** An input/textarea whose enclosing <label> contains any of `labelTexts`. */
|
||||
export function findInputByLabels(
|
||||
labelTexts: string[],
|
||||
root: ParentNode = document
|
||||
): HTMLInputElement | HTMLTextAreaElement | null {
|
||||
const lower = labelTexts.map((t) => t.toLowerCase());
|
||||
for (const label of Array.from(root.querySelectorAll("label"))) {
|
||||
const t = (label.textContent || "").toLowerCase();
|
||||
if (!lower.some((s) => t.includes(s))) continue;
|
||||
const field = label.querySelector<HTMLInputElement | HTMLTextAreaElement>("input, textarea");
|
||||
if (field) return field;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,333 @@
|
||||
{
|
||||
"generatedAt": "2026-08-06T04:40:17.110Z",
|
||||
"items": [
|
||||
{
|
||||
"cacheReadTokens": 248128,
|
||||
"cacheWriteTokens": 0,
|
||||
"client": "Profile: Claude Code",
|
||||
"completedAt": "2026-08-06T04:39:16.380Z",
|
||||
"costUsd": 0.06461688,
|
||||
"createdAt": "2026-08-06T04:39:12.878Z",
|
||||
"credentialChain": [],
|
||||
"credentialId": "",
|
||||
"credentialSaturated": false,
|
||||
"durationMs": 3502,
|
||||
"error": "",
|
||||
"id": 7034,
|
||||
"inputTokens": 52,
|
||||
"isStream": false,
|
||||
"method": "POST",
|
||||
"model": "glm-5.2",
|
||||
"ok": true,
|
||||
"outputTokens": 7,
|
||||
"path": "/v1/messages",
|
||||
"provider": "智谱 AI (国内) - Coding Plan",
|
||||
"reasoningTokens": 0,
|
||||
"requestedModel": "",
|
||||
"requestBody": {
|
||||
"text": "{\"model\":\"glm-5.2\",\"max_tokens\":64,\"system\":\"[object Object],[object Object]…\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"The following is the user's CLAUDE.md configuration. Treat it as context about the user's environment and intent. If it explicitly authorizes the SPECIFIC action under review — same operation, same target — you may weigh that as user intent to allow. Generic encouragement (\\\"be autonomous\\\", \\\"don't ask\\\", \\\"I trust you\\\") is not authorization and must not lower your block threshold.\\n\\n<user_claude_md>\\n Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT:…\",\"cache_control\":{\"type\":\"ephemeral\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"<transcript>\\n\"},{\"type\":\"text\",\"text\":\"{\\\"user\\\":\\\"该项目的ui是单独的一个包,你需要分析代码看看将docs增加一个交互式文档的可能性\\\"}\\n{\\\"Bash\\\":\\\"ls -la && echo \\\\\\\"---ROOT PACKAGE---\\\\\\\" && cat package.json 2>/dev/null | head -60\\\"}\\n{\\\"Bash\\\":\\\"echo \\\\\\\"=== docs dir ===\\\\\\\" && ls -la docs/ 2>/dev/null && echo \\\\\\\"=== docs package.json ===\\\\\\\" && cat docs/package.json 2>/dev/null\\\"}\\n{\\\"Bash\\\":\\\"echo \\\\\\\"=== packages ===\\\\\\\" && ls -la packages/ && echo \\\\\\\"\\\\\\\" && echo \\\\\\\"=== UI package.json ===\\\\\\\" && cat packages/*/package.json 2>/dev/null | head -100\\\"}\\n{\\\"Bash\\\":\\\"echo \\\\\\\"=== astro.config.mjs ===\\\\\\\" && cat docs…\"},{\"type\":\"text\",\"text\":\"{\\\"Bash\\\":\\\"cd /Volumes/Data/Products/claude-code-router\\\\necho \\\\\\\"=== AgentSetup.tsx (full) ===\\\\\\\" && cat docs/src/components/interactive/AgentSetup.tsx\\\\necho \\\\\\\"\\\\\\\"\\\\necho \\\\\\\"=== observability config shape (AppConfig.observability) ===\\\\\\\" && sed -n '/observability: {/,/},/p' packages/core/src/config/default-config.ts | head -30\\\"}\\n\"},{\"type\":\"text\",\"text\":\"{\\\"Bash\\\":\\\"cd /Volumes/Data/Products/claude-code-router\\\\necho \\\\\\\"=== switch.tsx API ===\\\\\\\" && sed -n '1,50p' packages/ui/src/components/ui/switch.tsx\\\\necho \\\\\\\"\\\\\\\"\\\\necho \\\\\\\"=== observability.md (en) headings + lead ===\\\\\\\" && grep -nE \\\\\\\"^---|^title:|^lead:|^## \\\\\\\" docs/src/content/docs/en/configuration/observability.md | head -15\\\"}\\n\"},{\"type\":\"text\",\"text\":\"{\\\"Bash\\\":\\\"cd /Volumes/Data/Products/claude-code-router\\\\ngrep -rn \\\\\\\"requestLogBodyCapture\\\\\\\\|RequestLogBodyCapture\\\\\\\" packages/core/src/contracts/app.ts | head -5\\\\necho \\\\\\\"--- the type ---\\\\\\\" && sed -n '/RequestLogBodyCapture\\\\\\\\|requestLogBodyCapture/p' packages/core/src/contracts/app.ts | head -3\\\"}\\n\"}]}],\"stop_sequences\":[\"</severity>\"],\"thinking\":{\"type\":\"disabled\"},\"metadata\":{\"user_id\":\"{\\\"device_id\\\":\\\"ccb6c3843c6572f996c9aaa081e521d1b6e050d37cac75b9299321716e197882\\\",\\\"account_uuid\\\":\\\"\\\",\\\"session_id\\\":\\\"ea811d1f-6b77-440d-8329-010d5505da9a\\\"}\"}}",
|
||||
"sizeBytes": 2672,
|
||||
"encoding": "utf8",
|
||||
"truncated": false
|
||||
},
|
||||
"requestHeaders": {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "claude-cli/2.1.222 (external, cli)",
|
||||
"x-claude-code-session-id": "ea811d1f-6b77-440d-8329-010d5505da9a",
|
||||
"x-stainless-arch": "arm64",
|
||||
"x-stainless-lang": "js",
|
||||
"x-stainless-os": "MacOS",
|
||||
"x-stainless-package-version": "0.94.0",
|
||||
"x-stainless-retry-count": "0",
|
||||
"x-stainless-runtime": "node",
|
||||
"x-stainless-runtime-version": "v26.3.0",
|
||||
"x-stainless-timeout": "60",
|
||||
"anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-app": "cli",
|
||||
"accept-encoding": "gzip, deflate, br, zstd",
|
||||
"x-auth-api-key-id": "[redacted]",
|
||||
"x-auth-sub": "[redacted]",
|
||||
"x-client-request-id": "ecc99371-50b9-4061-b369-8b84d3dbfaf4",
|
||||
"x-ccr-route-reason": "builtin:claude-code",
|
||||
"x-ccr-route-source": "builtin",
|
||||
"x-ccr-routed-model": " AI (-) - Coding Plan/glm-5.2"
|
||||
},
|
||||
"requestId": "ecc99371-50b9-4061-b369-8b84d3dbfaf4",
|
||||
"routeAttemptCount": 0,
|
||||
"routeHopCount": 0,
|
||||
"routeTraceTruncated": false,
|
||||
"retryAttempts": [],
|
||||
"resolvedModel": "",
|
||||
"responseBody": {
|
||||
"contentType": "application/json",
|
||||
"encoding": "utf8",
|
||||
"sizeBytes": 341,
|
||||
"text": "{\"id\":\"msg_20260806123913b2f8b68392db41b9\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"glm-5.2\",\"content\":[{\"type\":\"text\",\"text\":\"<severity>3\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":52,\"output_tokens\":7,\"cache_read_input_tokens\":248128,\"server_tool_use\":{\"web_search_requests\":0},\"service_tier\":\"standard\"}}",
|
||||
"truncated": false
|
||||
},
|
||||
"responseHeaders": {
|
||||
"access-control-allow-headers": "Content-Type, Authorization, X-API-Key, X-Codex-Access-Token, Anthropic-Version, Anthropic-Beta, X-Gateway-Model-List-Format",
|
||||
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-max-age": "86400",
|
||||
"connection": "keep-alive",
|
||||
"content-type": "application/json",
|
||||
"date": "Thu, 06 Aug 2026 04:39:16 GMT",
|
||||
"keep-alive": "timeout=72",
|
||||
"set-cookie": "[redacted]",
|
||||
"strict-transport-security": "max-age=31536000; includeSubDomains",
|
||||
"transfer-encoding": "chunked",
|
||||
"vary": "Accept-Encoding, Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
|
||||
"x-ccr-provider-protocol": "anthropic_messages",
|
||||
"x-gateway-billing-cache-duration-seconds": "0",
|
||||
"x-gateway-billing-cache-read-cost": "0.00000000",
|
||||
"x-gateway-billing-cache-read-tokens": "248128",
|
||||
"x-gateway-billing-cache-write-cost": "0.00000000",
|
||||
"x-gateway-billing-cache-write-tokens": "0",
|
||||
"x-gateway-billing-currency": "USD",
|
||||
"x-gateway-billing-input-cost": "0.00000000",
|
||||
"x-gateway-billing-input-tokens": "52",
|
||||
"x-gateway-billing-output-cost": "0.00000000",
|
||||
"x-gateway-billing-output-tokens": "7",
|
||||
"x-gateway-billing-provider": "anthropic",
|
||||
"x-gateway-billing-tiered-cost": "0.00000000",
|
||||
"x-gateway-billing-total-cost": "0.00000000",
|
||||
"x-gateway-billing-total-tokens": "248187",
|
||||
"x-gateway-target-provider": "anthropic",
|
||||
"x-gateway-target-provider-name": "provider-ai---coding-plan-5c5ed39dcc",
|
||||
"x-log-id": "20260806123913b2f8b68392db41b9",
|
||||
"x-process-time": "3.372643"
|
||||
},
|
||||
"responseModel": "",
|
||||
"statusCode": 200,
|
||||
"totalTokens": 248187,
|
||||
"url": "http://127.0.0.1:3456/v1/messages?beta=true"
|
||||
},
|
||||
{
|
||||
"cacheReadTokens": 248064,
|
||||
"cacheWriteTokens": 0,
|
||||
"client": "Profile: Claude Code",
|
||||
"completedAt": "2026-08-06T04:39:46.481Z",
|
||||
"costUsd": 0.06608704,
|
||||
"createdAt": "2026-08-06T04:39:42.947Z",
|
||||
"credentialChain": [],
|
||||
"credentialId": "",
|
||||
"credentialSaturated": false,
|
||||
"durationMs": 3534,
|
||||
"error": "",
|
||||
"id": 7036,
|
||||
"inputTokens": 1114,
|
||||
"isStream": false,
|
||||
"method": "POST",
|
||||
"model": "glm-5.2",
|
||||
"ok": true,
|
||||
"outputTokens": 7,
|
||||
"path": "/v1/messages",
|
||||
"provider": "智谱 AI (国内) - Coding Plan",
|
||||
"reasoningTokens": 0,
|
||||
"requestedModel": "",
|
||||
"requestBody": {
|
||||
"text": "{\"model\":\"glm-5.2\",\"max_tokens\":64,\"system\":\"[object Object],[object Object]…\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"The following is the user's CLAUDE.md configuration. Treat it as context about the user's environment and intent. If it explicitly authorizes the SPECIFIC action under review — same operation, same target — you may weigh that as user intent to allow. Generic encouragement (\\\"be autonomous\\\", \\\"don't ask\\\", \\\"I trust you\\\") is not authorization and must not lower your block threshold.\\n\\n<user_claude_md>\\n Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT:…\",\"cache_control\":{\"type\":\"ephemeral\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"<transcript>\\n\"},{\"type\":\"text\",\"text\":\"{\\\"user\\\":\\\"该项目的ui是单独的一个包,你需要分析代码看看将docs增加一个交互式文档的可能性\\\"}\\n{\\\"Bash\\\":\\\"ls -la && echo \\\\\\\"---ROOT PACKAGE---\\\\\\\" && cat package.json 2>/dev/null | head -60\\\"}\\n{\\\"Bash\\\":\\\"echo \\\\\\\"=== docs dir ===\\\\\\\" && ls -la docs/ 2>/dev/null && echo \\\\\\\"=== docs package.json ===\\\\\\\" && cat docs/package.json 2>/dev/null\\\"}\\n{\\\"Bash\\\":\\\"echo \\\\\\\"=== packages ===\\\\\\\" && ls -la packages/ && echo \\\\\\\"\\\\\\\" && echo \\\\\\\"=== UI package.json ===\\\\\\\" && cat packages/*/package.json 2>/dev/null | head -100\\\"}\\n{\\\"Bash\\\":\\\"echo \\\\\\\"=== astro.config.mjs ===\\\\\\\" && cat docs…\"},{\"type\":\"text\",\"text\":\"{\\\"Bash\\\":\\\"cd /Volumes/Data/Products/claude-code-router\\\\necho \\\\\\\"=== AgentSetup.tsx (full) ===\\\\\\\" && cat docs/src/components/interactive/AgentSetup.tsx\\\\necho \\\\\\\"\\\\\\\"\\\\necho \\\\\\\"=== observability config shape (AppConfig.observability) ===\\\\\\\" && sed -n '/observability: {/,/},/p' packages/core/src/config/default-config.ts | head -30\\\"}\\n\"},{\"type\":\"text\",\"text\":\"{\\\"Bash\\\":\\\"cd /Volumes/Data/Products/claude-code-router\\\\necho \\\\\\\"=== switch.tsx API ===\\\\\\\" && sed -n '1,50p' packages/ui/src/components/ui/switch.tsx\\\\necho \\\\\\\"\\\\\\\"\\\\necho \\\\\\\"=== observability.md (en) headings + lead ===\\\\\\\" && grep -nE \\\\\\\"^---|^title:|^lead:|^## \\\\\\\" docs/src/content/docs/en/configuration/observability.md | head -15\\\"}\\n\"},{\"type\":\"text\",\"text\":\"{\\\"Bash\\\":\\\"cd /Volumes/Data/Products/claude-code-router\\\\ngrep -rn \\\\\\\"requestLogBodyCapture\\\\\\\\|RequestLogBodyCapture\\\\\\\" packages/core/src/contracts/app.ts | head -5\\\\necho \\\\\\\"--- the type ---\\\\\\\" && sed -n '/RequestLogBodyCapture\\\\\\\\|requestLogBodyCapture/p' packages/core/src/contracts/app.ts | head -3\\\"}\\n\"}]}],\"stop_sequences\":[\"</severity>\"],\"thinking\":{\"type\":\"disabled\"},\"metadata\":{\"user_id\":\"{\\\"device_id\\\":\\\"ccb6c3843c6572f996c9aaa081e521d1b6e050d37cac75b9299321716e197882\\\",\\\"account_uuid\\\":\\\"\\\",\\\"session_id\\\":\\\"ea811d1f-6b77-440d-8329-010d5505da9a\\\"}\"}}",
|
||||
"sizeBytes": 2672,
|
||||
"encoding": "utf8",
|
||||
"truncated": false
|
||||
},
|
||||
"requestHeaders": {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "claude-cli/2.1.222 (external, cli)",
|
||||
"x-claude-code-session-id": "ea811d1f-6b77-440d-8329-010d5505da9a",
|
||||
"x-stainless-arch": "arm64",
|
||||
"x-stainless-lang": "js",
|
||||
"x-stainless-os": "MacOS",
|
||||
"x-stainless-package-version": "0.94.0",
|
||||
"x-stainless-retry-count": "0",
|
||||
"x-stainless-runtime": "node",
|
||||
"x-stainless-runtime-version": "v26.3.0",
|
||||
"x-stainless-timeout": "60",
|
||||
"anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-app": "cli",
|
||||
"accept-encoding": "gzip, deflate, br, zstd",
|
||||
"x-auth-api-key-id": "[redacted]",
|
||||
"x-auth-sub": "[redacted]",
|
||||
"x-client-request-id": "5f6ec040-6c01-43ed-8efd-ae86b948f19d",
|
||||
"x-ccr-route-reason": "builtin:claude-code",
|
||||
"x-ccr-route-source": "builtin",
|
||||
"x-ccr-routed-model": " AI (-) - Coding Plan/glm-5.2"
|
||||
},
|
||||
"requestId": "5f6ec040-6c01-43ed-8efd-ae86b948f19d",
|
||||
"routeAttemptCount": 0,
|
||||
"routeHopCount": 0,
|
||||
"routeTraceTruncated": false,
|
||||
"retryAttempts": [],
|
||||
"resolvedModel": "",
|
||||
"responseBody": {
|
||||
"contentType": "application/json",
|
||||
"encoding": "utf8",
|
||||
"sizeBytes": 343,
|
||||
"text": "{\"id\":\"msg_202608061239438bd51267c753488e\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"glm-5.2\",\"content\":[{\"type\":\"text\",\"text\":\"<severity>0\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":1114,\"output_tokens\":7,\"cache_read_input_tokens\":248064,\"server_tool_use\":{\"web_search_requests\":0},\"service_tier\":\"standard\"}}",
|
||||
"truncated": false
|
||||
},
|
||||
"responseHeaders": {
|
||||
"access-control-allow-headers": "Content-Type, Authorization, X-API-Key, X-Codex-Access-Token, Anthropic-Version, Anthropic-Beta, X-Gateway-Model-List-Format",
|
||||
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-max-age": "86400",
|
||||
"connection": "keep-alive",
|
||||
"content-type": "application/json",
|
||||
"date": "Thu, 06 Aug 2026 04:39:46 GMT",
|
||||
"keep-alive": "timeout=72",
|
||||
"set-cookie": "[redacted]",
|
||||
"strict-transport-security": "max-age=31536000; includeSubDomains",
|
||||
"transfer-encoding": "chunked",
|
||||
"vary": "Accept-Encoding, Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
|
||||
"x-ccr-provider-protocol": "anthropic_messages",
|
||||
"x-gateway-billing-cache-duration-seconds": "0",
|
||||
"x-gateway-billing-cache-read-cost": "0.00000000",
|
||||
"x-gateway-billing-cache-read-tokens": "248064",
|
||||
"x-gateway-billing-cache-write-cost": "0.00000000",
|
||||
"x-gateway-billing-cache-write-tokens": "0",
|
||||
"x-gateway-billing-currency": "USD",
|
||||
"x-gateway-billing-input-cost": "0.00000000",
|
||||
"x-gateway-billing-input-tokens": "1114",
|
||||
"x-gateway-billing-output-cost": "0.00000000",
|
||||
"x-gateway-billing-output-tokens": "7",
|
||||
"x-gateway-billing-provider": "anthropic",
|
||||
"x-gateway-billing-tiered-cost": "0.00000000",
|
||||
"x-gateway-billing-total-cost": "0.00000000",
|
||||
"x-gateway-billing-total-tokens": "249185",
|
||||
"x-gateway-target-provider": "anthropic",
|
||||
"x-gateway-target-provider-name": "provider-ai---coding-plan-5c5ed39dcc",
|
||||
"x-log-id": "202608061239438bd51267c753488e",
|
||||
"x-process-time": "3.405638"
|
||||
},
|
||||
"responseModel": "",
|
||||
"statusCode": 200,
|
||||
"totalTokens": 249185,
|
||||
"url": "http://127.0.0.1:3456/v1/messages?beta=true"
|
||||
},
|
||||
{
|
||||
"cacheReadTokens": 246080,
|
||||
"cacheWriteTokens": 0,
|
||||
"client": "Profile: Claude Code",
|
||||
"completedAt": "2026-08-06T04:38:43.250Z",
|
||||
"costUsd": 0.0652856,
|
||||
"createdAt": "2026-08-06T04:38:40.465Z",
|
||||
"credentialChain": [],
|
||||
"credentialId": "",
|
||||
"credentialSaturated": false,
|
||||
"durationMs": 2785,
|
||||
"error": "",
|
||||
"id": 7030,
|
||||
"inputTokens": 910,
|
||||
"isStream": false,
|
||||
"method": "POST",
|
||||
"model": "glm-5.2",
|
||||
"ok": true,
|
||||
"outputTokens": 7,
|
||||
"path": "/v1/messages",
|
||||
"provider": "智谱 AI (国内) - Coding Plan",
|
||||
"reasoningTokens": 0,
|
||||
"requestedModel": "",
|
||||
"requestBody": {
|
||||
"text": "{\"model\":\"glm-5.2\",\"max_tokens\":64,\"system\":\"[object Object],[object Object]…\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"The following is the user's CLAUDE.md configuration. Treat it as context about the user's environment and intent. If it explicitly authorizes the SPECIFIC action under review — same operation, same target — you may weigh that as user intent to allow. Generic encouragement (\\\"be autonomous\\\", \\\"don't ask\\\", \\\"I trust you\\\") is not authorization and must not lower your block threshold.\\n\\n<user_claude_md>\\n Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT:…\",\"cache_control\":{\"type\":\"ephemeral\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"<transcript>\\n\"},{\"type\":\"text\",\"text\":\"{\\\"user\\\":\\\"该项目的ui是单独的一个包,你需要分析代码看看将docs增加一个交互式文档的可能性\\\"}\\n{\\\"Bash\\\":\\\"ls -la && echo \\\\\\\"---ROOT PACKAGE---\\\\\\\" && cat package.json 2>/dev/null | head -60\\\"}\\n{\\\"Bash\\\":\\\"echo \\\\\\\"=== docs dir ===\\\\\\\" && ls -la docs/ 2>/dev/null && echo \\\\\\\"=== docs package.json ===\\\\\\\" && cat docs/package.json 2>/dev/null\\\"}\\n{\\\"Bash\\\":\\\"echo \\\\\\\"=== packages ===\\\\\\\" && ls -la packages/ && echo \\\\\\\"\\\\\\\" && echo \\\\\\\"=== UI package.json ===\\\\\\\" && cat packages/*/package.json 2>/dev/null | head -100\\\"}\\n{\\\"Bash\\\":\\\"echo \\\\\\\"=== astro.config.mjs ===\\\\\\\" && cat docs…\"},{\"type\":\"text\",\"text\":\"{\\\"Bash\\\":\\\"cd /Volumes/Data/Products/claude-code-router\\\\necho \\\\\\\"=== AgentSetup.tsx (full) ===\\\\\\\" && cat docs/src/components/interactive/AgentSetup.tsx\\\\necho \\\\\\\"\\\\\\\"\\\\necho \\\\\\\"=== observability config shape (AppConfig.observability) ===\\\\\\\" && sed -n '/observability: {/,/},/p' packages/core/src/config/default-config.ts | head -30\\\"}\\n\"},{\"type\":\"text\",\"text\":\"{\\\"Bash\\\":\\\"cd /Volumes/Data/Products/claude-code-router\\\\necho \\\\\\\"=== switch.tsx API ===\\\\\\\" && sed -n '1,50p' packages/ui/src/components/ui/switch.tsx\\\\necho \\\\\\\"\\\\\\\"\\\\necho \\\\\\\"=== observability.md (en) headings + lead ===\\\\\\\" && grep -nE \\\\\\\"^---|^title:|^lead:|^## \\\\\\\" docs/src/content/docs/en/configuration/observability.md | head -15\\\"}\\n\"},{\"type\":\"text\",\"text\":\"{\\\"Bash\\\":\\\"cd /Volumes/Data/Products/claude-code-router\\\\ngrep -rn \\\\\\\"requestLogBodyCapture\\\\\\\\|RequestLogBodyCapture\\\\\\\" packages/core/src/contracts/app.ts | head -5\\\\necho \\\\\\\"--- the type ---\\\\\\\" && sed -n '/RequestLogBodyCapture\\\\\\\\|requestLogBodyCapture/p' packages/core/src/contracts/app.ts | head -3\\\"}\\n\"}]}],\"stop_sequences\":[\"</severity>\"],\"thinking\":{\"type\":\"disabled\"},\"metadata\":{\"user_id\":\"{\\\"device_id\\\":\\\"ccb6c3843c6572f996c9aaa081e521d1b6e050d37cac75b9299321716e197882\\\",\\\"account_uuid\\\":\\\"\\\",\\\"session_id\\\":\\\"ea811d1f-6b77-440d-8329-010d5505da9a\\\"}\"}}",
|
||||
"sizeBytes": 2672,
|
||||
"encoding": "utf8",
|
||||
"truncated": false
|
||||
},
|
||||
"requestHeaders": {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "claude-cli/2.1.222 (external, cli)",
|
||||
"x-claude-code-session-id": "ea811d1f-6b77-440d-8329-010d5505da9a",
|
||||
"x-stainless-arch": "arm64",
|
||||
"x-stainless-lang": "js",
|
||||
"x-stainless-os": "MacOS",
|
||||
"x-stainless-package-version": "0.94.0",
|
||||
"x-stainless-retry-count": "0",
|
||||
"x-stainless-runtime": "node",
|
||||
"x-stainless-runtime-version": "v26.3.0",
|
||||
"x-stainless-timeout": "60",
|
||||
"anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-app": "cli",
|
||||
"accept-encoding": "gzip, deflate, br, zstd",
|
||||
"x-auth-api-key-id": "[redacted]",
|
||||
"x-auth-sub": "[redacted]",
|
||||
"x-client-request-id": "06515494-55b8-4edb-a3d9-dad4e9cfbbc1",
|
||||
"x-ccr-route-reason": "builtin:claude-code",
|
||||
"x-ccr-route-source": "builtin",
|
||||
"x-ccr-routed-model": " AI (-) - Coding Plan/glm-5.2"
|
||||
},
|
||||
"requestId": "06515494-55b8-4edb-a3d9-dad4e9cfbbc1",
|
||||
"routeAttemptCount": 0,
|
||||
"routeHopCount": 0,
|
||||
"routeTraceTruncated": false,
|
||||
"retryAttempts": [],
|
||||
"resolvedModel": "",
|
||||
"responseBody": {
|
||||
"contentType": "application/json",
|
||||
"encoding": "utf8",
|
||||
"sizeBytes": 343,
|
||||
"text": "{\"id\":\"msg_20260806123840b3092c789f7a42ee\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"glm-5.2\",\"content\":[{\"type\":\"text\",\"text\":\"<severity>15\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":910,\"output_tokens\":7,\"cache_read_input_tokens\":246080,\"server_tool_use\":{\"web_search_requests\":0},\"service_tier\":\"standard\"}}",
|
||||
"truncated": false
|
||||
},
|
||||
"responseHeaders": {
|
||||
"access-control-allow-headers": "Content-Type, Authorization, X-API-Key, X-Codex-Access-Token, Anthropic-Version, Anthropic-Beta, X-Gateway-Model-List-Format",
|
||||
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-max-age": "86400",
|
||||
"connection": "keep-alive",
|
||||
"content-type": "application/json",
|
||||
"date": "Thu, 06 Aug 2026 04:38:43 GMT",
|
||||
"keep-alive": "timeout=72",
|
||||
"set-cookie": "[redacted]",
|
||||
"strict-transport-security": "max-age=31536000; includeSubDomains",
|
||||
"transfer-encoding": "chunked",
|
||||
"vary": "Accept-Encoding, Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
|
||||
"x-ccr-provider-protocol": "anthropic_messages",
|
||||
"x-gateway-billing-cache-duration-seconds": "0",
|
||||
"x-gateway-billing-cache-read-cost": "0.00000000",
|
||||
"x-gateway-billing-cache-read-tokens": "246080",
|
||||
"x-gateway-billing-cache-write-cost": "0.00000000",
|
||||
"x-gateway-billing-cache-write-tokens": "0",
|
||||
"x-gateway-billing-currency": "USD",
|
||||
"x-gateway-billing-input-cost": "0.00000000",
|
||||
"x-gateway-billing-input-tokens": "910",
|
||||
"x-gateway-billing-output-cost": "0.00000000",
|
||||
"x-gateway-billing-output-tokens": "7",
|
||||
"x-gateway-billing-provider": "anthropic",
|
||||
"x-gateway-billing-tiered-cost": "0.00000000",
|
||||
"x-gateway-billing-total-cost": "0.00000000",
|
||||
"x-gateway-billing-total-tokens": "246997",
|
||||
"x-gateway-target-provider": "anthropic",
|
||||
"x-gateway-target-provider-name": "provider-ai---coding-plan-5c5ed39dcc",
|
||||
"x-log-id": "20260806123840b3092c789f7a42ee",
|
||||
"x-process-time": "2.672226"
|
||||
},
|
||||
"responseModel": "",
|
||||
"statusCode": 200,
|
||||
"totalTokens": 246997,
|
||||
"url": "http://127.0.0.1:3456/v1/messages?beta=true"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"credentials": [],
|
||||
"models": [
|
||||
"glm-5.2"
|
||||
],
|
||||
"providers": [
|
||||
"智谱 AI (国内) - Coding Plan"
|
||||
]
|
||||
},
|
||||
"page": 1,
|
||||
"pageSize": 10,
|
||||
"total": 216,
|
||||
"totalPages": 22
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Real data fixtures exported from a running local CCR instance via the
|
||||
* management RPC. NOT mocked — these are actual request logs and agent
|
||||
* analysis snapshots captured from the app.
|
||||
*/
|
||||
import type { AgentAnalysisSnapshot, RequestLogPage } from "@ccr/core/contracts/app";
|
||||
|
||||
import requestLogsFixture from "./fixtures-request-logs.json";
|
||||
import agentAnalysisFixture from "./fixtures-agent-analysis.json";
|
||||
|
||||
export function getRequestLogData(): RequestLogPage {
|
||||
return requestLogsFixture as unknown as RequestLogPage;
|
||||
}
|
||||
|
||||
export function getAgentAnalysisData(): AgentAnalysisSnapshot {
|
||||
return agentAnalysisFixture as unknown as AgentAnalysisSnapshot;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Connect Agent Config
|
||||
pageTitle: Connect Agent Config
|
||||
eyebrow: Quick start
|
||||
lead: "After connecting a provider, use this page to connect your agent to CCR: add a profile in Agent Config, pick a model, then open the agent from CCR and verify it in request logs. Covers Claude Code, Codex, Grok CLI, Kimi CLI, ZCode, and more."
|
||||
lead: "The interactive panel at the top of this page connects to your running CCR to set a default model for Claude Code / Codex. The steps below cover adding a profile in Agent Config, picking a model, opening the agent from CCR, and verifying it in request logs."
|
||||
---
|
||||
|
||||
## General guidance
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Add a provider
|
||||
pageTitle: Add a provider
|
||||
eyebrow: Quick start
|
||||
lead: "Add an upstream model provider to CCR: pick a preset or custom endpoint, enter the API endpoint and credentials, let CCR auto-detect protocols and models, then verify the full path with a connectivity check."
|
||||
lead: "The interactive panel at the top of this page connects to your running CCR to add a provider directly. Prefer the desktop app? The steps below cover choosing a preset or custom endpoint, entering credentials, letting CCR auto-detect protocols and models, and verifying with a connectivity check."
|
||||
---
|
||||
|
||||
## Add the provider
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: 接入 Agent 配置
|
||||
pageTitle: 接入 Agent 配置
|
||||
eyebrow: 快速开始
|
||||
lead: "供应商配置完成后,用本页把你的 Agent 接入 CCR:在 Agent 配置里添加配置、选择模型,再从 CCR 打开并在请求日志中验证。覆盖 Claude Code、Codex、Grok CLI、Kimi CLI、ZCode 等。"
|
||||
lead: "页面顶部的交互式面板可直接连到你正在运行的 CCR,为 Claude Code / Codex 设置默认模型;或按下方说明在 Agent 配置里添加配置、从 CCR 打开并在请求日志中验证。"
|
||||
---
|
||||
|
||||
## 通用建议
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: 接入供应商
|
||||
pageTitle: 接入供应商
|
||||
eyebrow: 快速开始
|
||||
lead: 在 CCR 中添加上游模型供应商:选择预设或自定义端点,填写 API 地址和凭据,CCR 会自动探测协议与模型,最后用连通性检查确认整条链路可用。
|
||||
lead: "页面顶部的交互式面板可直接连到你正在运行的 CCR 添加供应商;或按下方说明在桌面端配置——选择预设或自定义端点、填写凭据,CCR 会自动探测协议与模型,再用连通性检查确认。"
|
||||
---
|
||||
|
||||
## 添加供应商
|
||||
|
||||
@@ -3,15 +3,19 @@ import DocPage from "../../../components/DocPage.astro";
|
||||
import { pageKeyFromSlug } from "../../../docs-structure";
|
||||
import { enGuideDocs, sectionSlugFromPath } from "../../../section-docs";
|
||||
|
||||
// provider, agent-profile and observability have dedicated pages with embedded interactive UI.
|
||||
export function getStaticPaths() {
|
||||
return Object.entries(enGuideDocs).map(([filePath, mod]) => {
|
||||
const slug = sectionSlugFromPath(filePath);
|
||||
const dedicated = new Set(["provider", "agent-profile", "observability"]);
|
||||
return Object.entries(enGuideDocs)
|
||||
.filter(([filePath]) => !dedicated.has(sectionSlugFromPath(filePath)))
|
||||
.map(([filePath, mod]) => {
|
||||
const slug = sectionSlugFromPath(filePath);
|
||||
|
||||
return {
|
||||
params: { slug },
|
||||
props: { mod, activeSidebarItem: pageKeyFromSlug("guides", "en", slug) },
|
||||
};
|
||||
});
|
||||
return {
|
||||
params: { slug },
|
||||
props: { mod, activeSidebarItem: pageKeyFromSlug("guides", "en", slug) },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const { mod, activeSidebarItem } = Astro.props;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import DocPage from "../../../components/DocPage.astro";
|
||||
import { pageKeyFromSlug } from "../../../docs-structure";
|
||||
import { enGuideDocs, sectionSlugFromPath } from "../../../section-docs";
|
||||
|
||||
const agentProfileDoc = Object.entries(enGuideDocs).find(
|
||||
([filePath]) => sectionSlugFromPath(filePath) === "agent-profile"
|
||||
)?.[1];
|
||||
const appUrl = `${import.meta.env.BASE_URL}guides/agent-profile-app/?locale=en`;
|
||||
---
|
||||
|
||||
<DocPage
|
||||
locale="en"
|
||||
pageKey="guides"
|
||||
doc={agentProfileDoc}
|
||||
activeSidebarItem={pageKeyFromSlug("guides", "en", "agent-profile")}
|
||||
>
|
||||
<iframe
|
||||
slot="interactive"
|
||||
src={appUrl}
|
||||
title="CCR Agent Config — live UI"
|
||||
loading="lazy"
|
||||
class="interactive-frame interactive-frame--manager"
|
||||
/>
|
||||
</DocPage>
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import DocPage from "../../../components/DocPage.astro";
|
||||
import { pageKeyFromSlug } from "../../../docs-structure";
|
||||
import { enGuideDocs, sectionSlugFromPath } from "../../../section-docs";
|
||||
|
||||
const observabilityDoc = Object.entries(enGuideDocs).find(
|
||||
([filePath]) => sectionSlugFromPath(filePath) === "observability"
|
||||
)?.[1];
|
||||
const base = import.meta.env.BASE_URL;
|
||||
const logsUrl = `${base}guides/logs-app/?locale=en`;
|
||||
const obsUrl = `${base}guides/observability-app/?locale=en`;
|
||||
---
|
||||
|
||||
<DocPage
|
||||
locale="en"
|
||||
pageKey="guides"
|
||||
doc={observabilityDoc}
|
||||
activeSidebarItem={pageKeyFromSlug("guides", "en", "observability")}
|
||||
>
|
||||
<div slot="interactive" class="interactive-stack">
|
||||
<iframe src={logsUrl} title="CCR request logs — live UI" loading="lazy" class="interactive-frame interactive-frame--logs" />
|
||||
<iframe src={obsUrl} title="CCR observability — live UI" loading="lazy" class="interactive-frame interactive-frame--observability" />
|
||||
</div>
|
||||
</DocPage>
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import DocPage from "../../../components/DocPage.astro";
|
||||
import { pageKeyFromSlug } from "../../../docs-structure";
|
||||
import { enGuideDocs, sectionSlugFromPath } from "../../../section-docs";
|
||||
|
||||
const providerDoc = Object.entries(enGuideDocs).find(
|
||||
([filePath]) => sectionSlugFromPath(filePath) === "provider"
|
||||
)?.[1];
|
||||
const appUrl = `${import.meta.env.BASE_URL}guides/provider-app/?locale=en`;
|
||||
---
|
||||
|
||||
<DocPage
|
||||
locale="en"
|
||||
pageKey="guides"
|
||||
doc={providerDoc}
|
||||
activeSidebarItem={pageKeyFromSlug("guides", "en", "provider")}
|
||||
>
|
||||
<iframe
|
||||
slot="interactive"
|
||||
src={appUrl}
|
||||
title="CCR providers — live UI"
|
||||
loading="lazy"
|
||||
class="interactive-frame interactive-frame--manager"
|
||||
/>
|
||||
</DocPage>
|
||||
@@ -3,15 +3,19 @@ import DocPage from "../../components/DocPage.astro";
|
||||
import { pageKeyFromSlug } from "../../docs-structure";
|
||||
import { sectionSlugFromPath, zhGuideDocs } from "../../section-docs";
|
||||
|
||||
// provider, agent-profile and observability have dedicated pages with embedded interactive UI.
|
||||
export function getStaticPaths() {
|
||||
return Object.entries(zhGuideDocs).map(([filePath, mod]) => {
|
||||
const slug = sectionSlugFromPath(filePath);
|
||||
const dedicated = new Set(["provider", "agent-profile", "observability"]);
|
||||
return Object.entries(zhGuideDocs)
|
||||
.filter(([filePath]) => !dedicated.has(sectionSlugFromPath(filePath)))
|
||||
.map(([filePath, mod]) => {
|
||||
const slug = sectionSlugFromPath(filePath);
|
||||
|
||||
return {
|
||||
params: { slug },
|
||||
props: { mod, activeSidebarItem: pageKeyFromSlug("guides", "zh", slug) },
|
||||
};
|
||||
});
|
||||
return {
|
||||
params: { slug },
|
||||
props: { mod, activeSidebarItem: pageKeyFromSlug("guides", "zh", slug) },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const { mod, activeSidebarItem } = Astro.props;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import "@/styles/globals.css";
|
||||
import "../../components/interactive/demo.css";
|
||||
import ProfileViewDemo from "../../components/interactive/ProfileViewDemo.tsx";
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="interactive-demo-html">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CCR — Agent Config (demo)</title>
|
||||
<script is:inline>
|
||||
(() => {
|
||||
document.documentElement.lang =
|
||||
new URLSearchParams(location.search).get("locale") === "en" ? "en" : "zh-CN";
|
||||
try {
|
||||
const storedTheme = localStorage.getItem("ccr-docs-theme");
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
document.documentElement.dataset.theme =
|
||||
storedTheme === "light" || storedTheme === "dark"
|
||||
? storedTheme
|
||||
: prefersDark
|
||||
? "dark"
|
||||
: "light";
|
||||
} catch {
|
||||
document.documentElement.dataset.theme = "light";
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="interactive-demo-page">
|
||||
<ProfileViewDemo client:only="react" />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import DocPage from "../../components/DocPage.astro";
|
||||
import { pageKeyFromSlug } from "../../docs-structure";
|
||||
import { zhGuideDocs, sectionSlugFromPath } from "../../section-docs";
|
||||
|
||||
const agentProfileDoc = Object.entries(zhGuideDocs).find(
|
||||
([filePath]) => sectionSlugFromPath(filePath) === "agent-profile"
|
||||
)?.[1];
|
||||
const appUrl = `${import.meta.env.BASE_URL}guides/agent-profile-app/?locale=zh`;
|
||||
---
|
||||
|
||||
<DocPage
|
||||
locale="zh"
|
||||
pageKey="guides"
|
||||
doc={agentProfileDoc}
|
||||
activeSidebarItem={pageKeyFromSlug("guides", "zh", "agent-profile")}
|
||||
>
|
||||
<iframe
|
||||
slot="interactive"
|
||||
src={appUrl}
|
||||
title="CCR Agent 配置 — 实际界面"
|
||||
loading="lazy"
|
||||
class="interactive-frame interactive-frame--manager"
|
||||
/>
|
||||
</DocPage>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import "@/styles/globals.css";
|
||||
import "../../components/interactive/demo.css";
|
||||
import LogsViewDemo from "../../components/interactive/LogsViewDemo.tsx";
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="interactive-demo-html">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CCR — Logs (demo)</title>
|
||||
<script is:inline>
|
||||
(() => {
|
||||
document.documentElement.lang =
|
||||
new URLSearchParams(location.search).get("locale") === "en" ? "en" : "zh-CN";
|
||||
try {
|
||||
const storedTheme = localStorage.getItem("ccr-docs-theme");
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
document.documentElement.dataset.theme =
|
||||
storedTheme === "light" || storedTheme === "dark"
|
||||
? storedTheme
|
||||
: prefersDark
|
||||
? "dark"
|
||||
: "light";
|
||||
} catch {
|
||||
document.documentElement.dataset.theme = "light";
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="interactive-demo-page">
|
||||
<LogsViewDemo client:only="react" />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import "@/styles/globals.css";
|
||||
import "../../components/interactive/demo.css";
|
||||
import ObservabilityViewDemo from "../../components/interactive/ObservabilityViewDemo.tsx";
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="interactive-demo-html">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CCR — Observability (demo)</title>
|
||||
<script is:inline>
|
||||
(() => {
|
||||
document.documentElement.lang =
|
||||
new URLSearchParams(location.search).get("locale") === "en" ? "en" : "zh-CN";
|
||||
try {
|
||||
const storedTheme = localStorage.getItem("ccr-docs-theme");
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
document.documentElement.dataset.theme =
|
||||
storedTheme === "light" || storedTheme === "dark"
|
||||
? storedTheme
|
||||
: prefersDark
|
||||
? "dark"
|
||||
: "light";
|
||||
} catch {
|
||||
document.documentElement.dataset.theme = "light";
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="interactive-demo-page">
|
||||
<ObservabilityViewDemo client:only="react" />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import DocPage from "../../components/DocPage.astro";
|
||||
import { pageKeyFromSlug } from "../../docs-structure";
|
||||
import { zhGuideDocs, sectionSlugFromPath } from "../../section-docs";
|
||||
|
||||
const observabilityDoc = Object.entries(zhGuideDocs).find(
|
||||
([filePath]) => sectionSlugFromPath(filePath) === "observability"
|
||||
)?.[1];
|
||||
const base = import.meta.env.BASE_URL;
|
||||
const logsUrl = `${base}guides/logs-app/?locale=zh`;
|
||||
const obsUrl = `${base}guides/observability-app/?locale=zh`;
|
||||
---
|
||||
|
||||
<DocPage
|
||||
locale="zh"
|
||||
pageKey="guides"
|
||||
doc={observabilityDoc}
|
||||
activeSidebarItem={pageKeyFromSlug("guides", "zh", "observability")}
|
||||
>
|
||||
<div slot="interactive" class="interactive-stack">
|
||||
<iframe src={logsUrl} title="CCR 请求日志 — 实际界面" loading="lazy" class="interactive-frame interactive-frame--logs" />
|
||||
<iframe src={obsUrl} title="CCR 观测 — 实际界面" loading="lazy" class="interactive-frame interactive-frame--observability" />
|
||||
</div>
|
||||
</DocPage>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import "@/styles/globals.css";
|
||||
import "../../components/interactive/demo.css";
|
||||
import ProvidersViewDemo from "../../components/interactive/ProvidersViewDemo.tsx";
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="interactive-demo-html">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CCR — Providers (demo)</title>
|
||||
<script is:inline>
|
||||
(() => {
|
||||
document.documentElement.lang =
|
||||
new URLSearchParams(location.search).get("locale") === "en" ? "en" : "zh-CN";
|
||||
try {
|
||||
const storedTheme = localStorage.getItem("ccr-docs-theme");
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
document.documentElement.dataset.theme =
|
||||
storedTheme === "light" || storedTheme === "dark"
|
||||
? storedTheme
|
||||
: prefersDark
|
||||
? "dark"
|
||||
: "light";
|
||||
} catch {
|
||||
document.documentElement.dataset.theme = "light";
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="interactive-demo-page">
|
||||
<ProvidersViewDemo client:only="react" />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import DocPage from "../../components/DocPage.astro";
|
||||
import { pageKeyFromSlug } from "../../docs-structure";
|
||||
import { zhGuideDocs, sectionSlugFromPath } from "../../section-docs";
|
||||
|
||||
const providerDoc = Object.entries(zhGuideDocs).find(
|
||||
([filePath]) => sectionSlugFromPath(filePath) === "provider"
|
||||
)?.[1];
|
||||
const appUrl = `${import.meta.env.BASE_URL}guides/provider-app/?locale=zh`;
|
||||
---
|
||||
|
||||
<DocPage
|
||||
locale="zh"
|
||||
pageKey="guides"
|
||||
doc={providerDoc}
|
||||
activeSidebarItem={pageKeyFromSlug("guides", "zh", "provider")}
|
||||
>
|
||||
<iframe
|
||||
slot="interactive"
|
||||
src={appUrl}
|
||||
title="CCR 供应商 — 实际界面"
|
||||
loading="lazy"
|
||||
class="interactive-frame interactive-frame--manager"
|
||||
/>
|
||||
</DocPage>
|
||||
@@ -1118,6 +1118,40 @@ h1 {
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.interactive-frame,
|
||||
.interactive-stack {
|
||||
margin: 0 0 38px;
|
||||
}
|
||||
|
||||
.interactive-stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.interactive-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in oklab, var(--border) 78%, transparent);
|
||||
border-radius: 18px;
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
0 18px 42px rgba(24, 24, 27, 0.09),
|
||||
0 2px 7px rgba(24, 24, 27, 0.04);
|
||||
}
|
||||
|
||||
.interactive-frame--manager {
|
||||
height: 840px;
|
||||
}
|
||||
|
||||
.interactive-frame--logs {
|
||||
height: 980px;
|
||||
}
|
||||
|
||||
.interactive-frame--observability {
|
||||
height: 1120px;
|
||||
}
|
||||
|
||||
.doc-article > p,
|
||||
.doc-article section > p,
|
||||
.doc-markdown > p {
|
||||
@@ -2312,6 +2346,19 @@ pre code span {
|
||||
padding-inline: 18px;
|
||||
}
|
||||
|
||||
.interactive-frame {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.interactive-frame--manager {
|
||||
height: 780px;
|
||||
}
|
||||
|
||||
.interactive-frame--logs,
|
||||
.interactive-frame--observability {
|
||||
height: 860px;
|
||||
}
|
||||
|
||||
.doc-markdown > table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
|
||||
@@ -176,6 +176,20 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse,
|
||||
return;
|
||||
}
|
||||
|
||||
// CORS: allow cross-origin browsers (e.g. the docs setup wizard) to call the
|
||||
// RPC endpoint. Origin-gated to loopback + CCR_WEB_ALLOWED_ORIGINS. This only
|
||||
// relaxes the browser same-origin policy; the x-ccr-web-auth token still
|
||||
// authorizes /api/ccr/rpc, so no credential is exposed.
|
||||
const corsOrigin = allowedWebCorsOrigin(request);
|
||||
if (corsOrigin) {
|
||||
applyWebCorsHeaders(response, corsOrigin);
|
||||
}
|
||||
if (request.method === "OPTIONS") {
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = requestUrl(request);
|
||||
if (url.pathname === "/api/ccr/rpc") {
|
||||
await handleRpcRequest(request, response, security);
|
||||
@@ -598,6 +612,43 @@ function isAllowedWebRequestHost(request: IncomingMessage, security: WebManageme
|
||||
return Boolean(hostname && isAllowedWebHostname(hostname, security));
|
||||
}
|
||||
|
||||
const loopbackWebCorsHosts = new Set(["localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"]);
|
||||
|
||||
/**
|
||||
* Returns the request `Origin` if cross-origin callers are permitted, else
|
||||
* undefined. Permits loopback origins (any port) unconditionally so local tools
|
||||
* like the docs setup wizard work, plus any exact origin listed in the
|
||||
* `CCR_WEB_ALLOWED_ORIGINS` env var (comma-separated) for remote deployments.
|
||||
*/
|
||||
function allowedWebCorsOrigin(request: IncomingMessage): string | undefined {
|
||||
const origin = readHeaderValue(request.headers.origin);
|
||||
if (!origin) return undefined;
|
||||
const normalizedOrigin = origin.replace(/\/$/, "");
|
||||
const configured = readEnvString("CCR_WEB_ALLOWED_ORIGINS");
|
||||
if (configured) {
|
||||
const allow = new Set(
|
||||
configured
|
||||
.split(",")
|
||||
.map((value) => value.trim().replace(/\/$/, ""))
|
||||
.filter(Boolean)
|
||||
);
|
||||
if (allow.has(normalizedOrigin)) return origin;
|
||||
}
|
||||
try {
|
||||
if (loopbackWebCorsHosts.has(normalizeHostname(new URL(origin).hostname))) return origin;
|
||||
} catch {
|
||||
/* malformed origin — treat as not allowed */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function applyWebCorsHeaders(response: ServerResponse, origin: string): void {
|
||||
response.setHeader("Access-Control-Allow-Origin", origin);
|
||||
response.setHeader("Vary", "Origin");
|
||||
response.setHeader("Access-Control-Allow-Headers", "Content-Type, x-ccr-web-auth");
|
||||
response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
}
|
||||
|
||||
function isAllowedWebHostname(hostname: string, security: WebManagementSecurityContext): boolean {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
return security.allowedHostnames.has(normalized) ||
|
||||
|
||||
Reference in New Issue
Block a user