feat(docs): enhance documentation site with internationalization and layout updates

- Updated the demo API to support locale-based content rendering.
- Refactored the main layout to accommodate language-specific metadata and routing.
- Introduced new components for blog and documentation pages, ensuring proper language handling.
- Added styles for improved UI consistency across different sections.
- Removed deprecated layout files and streamlined the structure for better maintainability.
This commit is contained in:
leookun
2026-08-28 00:35:31 +08:00
parent 6c758f2b44
commit eca063e424
55 changed files with 6700 additions and 407 deletions
+3 -2
View File
@@ -243,8 +243,9 @@ function createSeries(count: number, step: number, end: number): OverviewTokenUs
const wave = 0.72 + ((index * 17) % 31) / 50;
return {
bucket_start_ms: end - (count - index) * step,
input_tokens: Math.round(18_000 * wave),
cache_read_tokens: Math.round(62_000 * wave),
// input : cache_read = 1 : 99,使默认口径缓存命中率恰为 99%
input_tokens: Math.round(800 * wave),
cache_read_tokens: Math.round(79_200 * wave),
cache_write_tokens: Math.round(7_500 * wave),
output_tokens: Math.round(12_500 * wave),
};
+7 -2
View File
@@ -1,8 +1,13 @@
import { installDemoApi } from "./api";
installDemoApi();
const params = new URLSearchParams(window.location.search);
const locale = params.get("locale") === "en-US" ? "en-US" : "zh-CN";
const theme = params.get("theme") === "default-light" ? "default-light" : "default-dark";
document.documentElement.dataset.platform = "macos";
localStorage.setItem("cursor-byok.locale", "zh-CN");
localStorage.setItem("cursor-byok.theme", "default-dark");
localStorage.setItem("cursor-byok.locale", locale);
localStorage.setItem("cursor-byok.theme", theme);
void import("../index");
-44
View File
@@ -1,44 +0,0 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { ArrowRight } from 'lucide-react';
import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog';
export const metadata: Metadata = {
title: '开发者博客',
description: 'cursor-byok 的架构决策、协议实现与开发进展。',
};
export default function BlogPage() {
const posts = sortBlogPages(blogSource.getPages());
return (
<main className="mx-auto w-full max-w-5xl px-6 py-16 sm:py-24">
<div className="max-w-2xl">
<p className="font-mono text-sm font-medium text-fd-primary">DEVELOPER BLOG</p>
<h1 className="mt-4 text-4xl font-bold tracking-tight">开发者博客</h1>
<p className="mt-4 text-lg leading-8 text-fd-muted-foreground">
记录 cursor-byok 的架构决策、协议实现和开发进展。
</p>
</div>
<div className="mt-12 divide-y border-y">
{posts.map((post) => (
<Link
key={post.url}
href={post.url}
className="group grid gap-3 py-7 transition-colors hover:text-fd-primary sm:grid-cols-[10rem_1fr_auto] sm:items-center"
>
<time className="text-sm text-fd-muted-foreground">{formatBlogDate(post.path)}</time>
<div>
<h2 className="font-semibold">{post.data.title}</h2>
<p className="mt-1 text-sm leading-6 text-fd-muted-foreground">
{post.data.description}
</p>
</div>
<ArrowRight className="hidden size-4 transition-transform group-hover:translate-x-1 sm:block" />
</Link>
))}
</div>
</main>
);
}
-6
View File
@@ -1,6 +0,0 @@
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
export default function Layout({ children }: LayoutProps<'/'>) {
return <HomeLayout {...baseOptions()}>{children}</HomeLayout>;
}
-135
View File
@@ -1,135 +0,0 @@
import Link from 'next/link';
import {
ArrowRight,
BookOpen,
Download,
Settings2,
Wrench,
} from 'lucide-react';
import { DesktopDemo } from '@/components/hero/DesktopDemo';
import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog';
import { appDescription, releaseUrl } from '@/lib/shared';
const docs = [
{
icon: BookOpen,
title: '快速开始',
description: '完成安装、初始化和第一次模型调用。',
href: '/docs',
},
{
icon: Settings2,
title: '模型配置',
description: '配置协议、服务地址、凭据和生成参数。',
href: '/docs/model-configuration',
},
{
icon: Wrench,
title: '故障排查',
description: '解决证书、连接和模型测试问题。',
href: '/docs/troubleshooting',
},
];
export default function HomePage() {
const posts = sortBlogPages(blogSource.getPages()).slice(0, 3);
return (
<main className="flex flex-1 flex-col">
<section className="border-b px-4 pb-16 pt-20 sm:px-6 sm:pb-24 sm:pt-28">
<div className="mx-auto max-w-6xl">
<div className="mx-auto max-w-3xl text-center">
<p className="font-mono text-sm font-medium text-fd-primary">开源 · 本地运行 · 自由接入</p>
<h1 className="mt-5 text-4xl font-bold tracking-tight sm:text-6xl">
{appDescription}
</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg leading-8 text-fd-muted-foreground">
在本机运行自己的模型网关,接入 OpenAI、Anthropic 等兼容服务,继续使用 Cursor Agent 的工具调用、Skills 和 MCP。
</p>
<div className="mt-10 flex flex-wrap justify-center gap-3">
<Link
href="/docs"
className="inline-flex items-center gap-2 rounded-lg bg-fd-primary px-5 py-3 font-medium text-fd-primary-foreground transition-opacity hover:opacity-90"
>
阅读文档
<ArrowRight className="size-4" />
</Link>
<a
href={releaseUrl}
className="inline-flex items-center gap-2 rounded-lg border bg-fd-card px-5 py-3 font-medium transition-colors hover:bg-fd-accent"
>
<Download className="size-4" />
下载最新版
</a>
</div>
</div>
<DesktopDemo />
</div>
</section>
<section className="border-b px-6 py-16 sm:py-20">
<div className="mx-auto max-w-5xl">
<div className="flex items-end justify-between gap-6">
<div>
<p className="font-mono text-sm font-medium text-fd-primary">DOCUMENTATION</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight">文档</h2>
</div>
<Link href="/docs" className="hidden items-center gap-2 text-sm font-medium sm:flex">
查看全部
<ArrowRight className="size-4" />
</Link>
</div>
<div className="mt-10 grid gap-4 md:grid-cols-3">
{docs.map(({ icon: Icon, title, description, href }) => (
<Link
key={href}
href={href}
className="group rounded-xl border bg-fd-card p-6 transition-colors hover:bg-fd-accent"
>
<Icon className="mb-5 size-5 text-fd-primary" />
<h3 className="flex items-center justify-between font-semibold">
{title}
<ArrowRight className="size-4 transition-transform group-hover:translate-x-1" />
</h3>
<p className="mt-2 text-sm leading-6 text-fd-muted-foreground">{description}</p>
</Link>
))}
</div>
</div>
</section>
<section className="px-6 py-16 sm:py-20">
<div className="mx-auto max-w-5xl">
<div className="flex items-end justify-between gap-6">
<div>
<p className="font-mono text-sm font-medium text-fd-primary">DEVELOPER BLOG</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight">开发者博客</h2>
</div>
<Link href="/blog" className="flex items-center gap-2 text-sm font-medium">
查看全部
<ArrowRight className="size-4" />
</Link>
</div>
<div className="mt-10 divide-y border-y">
{posts.map((post) => (
<Link
key={post.url}
href={post.url}
className="group grid gap-3 py-6 transition-colors hover:text-fd-primary sm:grid-cols-[10rem_1fr_auto] sm:items-center"
>
<time className="text-sm text-fd-muted-foreground">{formatBlogDate(post.path)}</time>
<div>
<h3 className="font-semibold">{post.data.title}</h3>
<p className="mt-1 text-sm text-fd-muted-foreground">{post.data.description}</p>
</div>
<ArrowRight className="hidden size-4 transition-transform group-hover:translate-x-1 sm:block" />
</Link>
))}
</div>
</div>
</section>
</main>
);
}
@@ -6,30 +6,42 @@ import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
import { createRelativeLink } from 'fumadocs-ui/mdx';
import { getMDXComponents } from '@/components/mdx';
import { blogSource, formatBlogDate } from '@/lib/blog';
import { i18n, isLanguage, type Language } from '@/lib/i18n';
export default async function BlogPostPage(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params;
const page = blogSource.getPage([slug]);
const copy: Record<Language, { back: string; team: string }> = {
zh: { back: '返回开发者博客', team: 'cursor-byok 开发团队' },
en: { back: 'Back to Developer Blog', team: 'The cursor-byok team' },
};
export default async function BlogPostPage(props: PageProps<'/[lang]/blog/[slug]'>) {
const { lang, slug } = await props.params;
if (!isLanguage(lang)) notFound();
const page = blogSource.getPage([slug], lang);
if (!page) notFound();
const t = copy[lang];
const prefix = lang === i18n.defaultLanguage ? '' : `/${lang}`;
const MDX = page.data.body;
return (
<main className="mx-auto w-full max-w-3xl px-6 py-12 sm:py-20">
<article>
<Link
href="/blog"
href={`${prefix}/blog`}
className="mb-10 inline-flex items-center gap-2 text-sm text-fd-muted-foreground transition-colors hover:text-fd-foreground"
>
<ArrowLeft className="size-4" />
返回开发者博客
{t.back}
</Link>
<header className="border-b pb-8">
<time className="text-sm text-fd-muted-foreground">{formatBlogDate(page.path)}</time>
<time className="text-sm text-fd-muted-foreground">
{formatBlogDate(page.path, lang)}
</time>
<h1 className="mt-4 text-3xl font-bold tracking-tight sm:text-4xl">{page.data.title}</h1>
<p className="mt-4 text-lg leading-8 text-fd-muted-foreground">{page.data.description}</p>
<p className="mt-5 text-sm font-medium">cursor-byok 开发团队</p>
<p className="mt-5 text-sm font-medium">{t.team}</p>
</header>
<div className="prose mt-10 min-w-0">
@@ -46,12 +58,18 @@ export default async function BlogPostPage(props: PageProps<'/blog/[slug]'>) {
}
export function generateStaticParams() {
return blogSource.getPages().map((page) => ({ slug: page.slugs[0] }));
return i18n.languages.flatMap((lang) =>
blogSource.getPages(lang).map((page) => ({ lang, slug: page.slugs[0] })),
);
}
export async function generateMetadata(props: PageProps<'/blog/[slug]'>): Promise<Metadata> {
const { slug } = await props.params;
const page = blogSource.getPage([slug]);
export async function generateMetadata(
props: PageProps<'/[lang]/blog/[slug]'>,
): Promise<Metadata> {
const { lang, slug } = await props.params;
if (!isLanguage(lang)) notFound();
const page = blogSource.getPage([slug], lang);
if (!page) notFound();
return {
+68
View File
@@ -0,0 +1,68 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { ArrowRight } from 'lucide-react';
import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog';
import { isLanguage, type Language } from '@/lib/i18n';
const copy: Record<Language, { title: string; description: string; intro: string }> = {
zh: {
title: '开发者博客',
description: 'cursor-byok 的架构决策、协议实现与开发进展。',
intro: '记录 cursor-byok 的架构决策、协议实现和开发进展。',
},
en: {
title: 'Developer Blog',
description: 'Architecture decisions, protocol work, and development progress of cursor-byok.',
intro: 'Notes on architecture decisions, protocol work, and development progress of cursor-byok.',
},
};
export async function generateMetadata(props: PageProps<'/[lang]/blog'>): Promise<Metadata> {
const { lang } = await props.params;
if (!isLanguage(lang)) notFound();
return {
title: copy[lang].title,
description: copy[lang].description,
};
}
export default async function BlogPage(props: PageProps<'/[lang]/blog'>) {
const { lang } = await props.params;
if (!isLanguage(lang)) notFound();
const t = copy[lang];
const posts = sortBlogPages(blogSource.getPages(lang));
return (
<main className="mx-auto w-full max-w-5xl px-6 py-16 sm:py-24">
<div className="max-w-2xl">
<p className="font-mono text-sm font-medium text-fd-primary">DEVELOPER BLOG</p>
<h1 className="mt-4 text-4xl font-bold tracking-tight">{t.title}</h1>
<p className="mt-4 text-lg leading-8 text-fd-muted-foreground">{t.intro}</p>
</div>
<div className="mt-12 divide-y border-y">
{posts.map((post) => (
<Link
key={post.url}
href={post.url}
className="group grid gap-3 py-7 transition-colors hover:text-fd-primary sm:grid-cols-[10rem_1fr_auto] sm:items-center"
>
<time className="text-sm text-fd-muted-foreground">
{formatBlogDate(post.path, lang)}
</time>
<div>
<h2 className="font-semibold">{post.data.title}</h2>
<p className="mt-1 text-sm leading-6 text-fd-muted-foreground">
{post.data.description}
</p>
</div>
<ArrowRight className="hidden size-4 transition-transform group-hover:translate-x-1 sm:block" />
</Link>
))}
</div>
</main>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
import { isLanguage, i18n } from '@/lib/i18n';
export default async function Layout({ params, children }: LayoutProps<'/[lang]'>) {
const { lang } = await params;
const base = baseOptions(isLanguage(lang) ? lang : i18n.defaultLanguage);
return (
<HomeLayout
{...base}
nav={{ ...base.nav, transparentMode: 'always' }}
className="home-fullbleed-nav"
>
{children}
</HomeLayout>
);
}
+252
View File
@@ -0,0 +1,252 @@
import Link from 'next/link';
import {
ArrowRight,
BookOpen,
Settings2,
Star,
Wrench,
} from 'lucide-react';
import { notFound } from 'next/navigation';
import { DesktopDemo } from '@/components/hero/DesktopDemo';
import { DownloadButton } from '@/components/hero/DownloadButton';
import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog';
import { formatStars, getRepoStats } from '@/lib/github';
import { i18n, isLanguage, type Language } from '@/lib/i18n';
import { releaseUrl, repositoryUrl } from '@/lib/shared';
const copy: Record<
Language,
{
pillReleased: (version: string) => string;
pillFallback: string;
title: string;
subtitle: string;
readDocs: string;
flow: [string, string, string];
docsEyebrow: string;
docsTitle: string;
viewAll: string;
docs: { icon: typeof BookOpen; title: string; description: string; href: string }[];
blogEyebrow: string;
blogTitle: string;
}
> = {
zh: {
pillReleased: (version) => `${version} 已发布 · 开源 · 本地运行`,
pillFallback: '开源 · 本地运行 · 自由接入',
title: 'Cursor 服务端的开源替代',
subtitle:
'在本机运行自己的模型网关,用自己的 API Key 接入 OpenAI、Anthropic 兼容服务或自定义端点,完整保留 Cursor Agent 的工具调用、Skills 和 MCP。',
readDocs: '阅读文档',
flow: ['Cursor 客户端', 'cursor-byok 本地服务', '你的模型 API(OpenAI / Anthropic 兼容)'],
docsEyebrow: 'DOCUMENTATION',
docsTitle: '文档',
viewAll: '查看全部',
docs: [
{
icon: BookOpen,
title: '快速开始',
description: '完成安装、初始化和第一次模型调用。',
href: '/docs',
},
{
icon: Settings2,
title: '模型配置',
description: '配置协议、服务地址、凭据和生成参数。',
href: '/docs/model-configuration',
},
{
icon: Wrench,
title: '故障排查',
description: '解决证书、连接和模型测试问题。',
href: '/docs/troubleshooting',
},
],
blogEyebrow: 'DEVELOPER BLOG',
blogTitle: '开发者博客',
},
en: {
pillReleased: (version) => `${version} released · Open source · Runs locally`,
pillFallback: 'Open source · Runs locally · Any provider',
title: "The open-source alternative to Cursor's backend",
subtitle:
'Run your own model gateway locally, connect OpenAI- and Anthropic-compatible services or custom endpoints with your own API keys, and keep Cursor Agent tool calling, Skills, and MCP intact.',
readDocs: 'Read the docs',
flow: ['Cursor client', 'cursor-byok local service', 'Your model API (OpenAI / Anthropic compatible)'],
docsEyebrow: 'DOCUMENTATION',
docsTitle: 'Documentation',
viewAll: 'View all',
docs: [
{
icon: BookOpen,
title: 'Quick Start',
description: 'Install, initialize, and make your first model call.',
href: '/docs',
},
{
icon: Settings2,
title: 'Model Configuration',
description: 'Configure protocols, server addresses, credentials, and parameters.',
href: '/docs/model-configuration',
},
{
icon: Wrench,
title: 'Troubleshooting',
description: 'Resolve certificate, connection, and model test issues.',
href: '/docs/troubleshooting',
},
],
blogEyebrow: 'DEVELOPER BLOG',
blogTitle: 'Developer Blog',
},
};
export default async function HomePage(props: PageProps<'/[lang]'>) {
const { lang } = await props.params;
if (!isLanguage(lang)) notFound();
const t = copy[lang];
const prefix = lang === i18n.defaultLanguage ? '' : `/${lang}`;
const posts = sortBlogPages(blogSource.getPages(lang)).slice(0, 3);
const { stars, version } = await getRepoStats();
return (
<main className="flex flex-1 flex-col">
<section className="relative -mt-14 overflow-hidden border-b px-4 pb-16 pt-34 sm:px-6 sm:pb-24 sm:pt-42">
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[linear-gradient(to_right,var(--color-fd-border)_1px,transparent_1px),linear-gradient(to_bottom,var(--color-fd-border)_1px,transparent_1px)] bg-[size:56px_56px] opacity-40 [mask-image:radial-gradient(ellipse_70%_60%_at_50%_0%,#000_20%,transparent_75%)]" />
<div className="absolute left-1/2 top-[-14rem] h-[26rem] w-[44rem] -translate-x-1/2 rounded-full bg-fd-primary/10 blur-3xl" />
</div>
<div className="mx-auto max-w-6xl">
<div className="mx-auto max-w-3xl text-center">
<a
href={releaseUrl}
className="inline-flex items-center gap-2 rounded-full border bg-fd-card px-4 py-1.5 text-sm text-fd-muted-foreground transition-colors hover:text-fd-foreground"
>
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-fd-primary opacity-50" />
<span className="relative inline-flex size-2 rounded-full bg-fd-primary" />
</span>
{version ? t.pillReleased(version) : t.pillFallback}
<ArrowRight className="size-3.5" />
</a>
<h1 className="mt-6 text-4xl font-bold tracking-tight sm:text-6xl">{t.title}</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg leading-8 text-fd-muted-foreground">
{t.subtitle}
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
<DownloadButton lang={lang} />
<Link
href={`${prefix}/docs`}
className="inline-flex items-center gap-2 rounded-lg border bg-fd-card px-5 py-3 font-medium transition-colors hover:bg-fd-accent"
>
{t.readDocs}
<ArrowRight className="size-4" />
</Link>
<a
href={repositoryUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-lg border bg-fd-card px-5 py-3 font-medium transition-colors hover:bg-fd-accent"
>
<GitHubIcon className="size-4" />
GitHub
{stars !== null ? (
<span className="flex items-center gap-1 border-l pl-2.5 text-sm text-fd-muted-foreground">
<Star className="size-3.5" />
{formatStars(stars)}
</span>
) : null}
</a>
</div>
</div>
<DesktopDemo lang={lang} />
<div className="mx-auto mt-10 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 font-mono text-xs text-fd-muted-foreground sm:text-sm">
<span>{t.flow[0]}</span>
<ArrowRight className="size-3.5 shrink-0" />
<span className="font-medium text-fd-foreground">{t.flow[1]}</span>
<ArrowRight className="size-3.5 shrink-0" />
<span>{t.flow[2]}</span>
</div>
</div>
</section>
<section className="border-b px-6 py-16 sm:py-20">
<div className="mx-auto max-w-5xl">
<div className="flex items-end justify-between gap-6">
<div>
<p className="font-mono text-sm font-medium text-fd-primary">{t.docsEyebrow}</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight">{t.docsTitle}</h2>
</div>
<Link
href={`${prefix}/docs`}
className="hidden items-center gap-2 text-sm font-medium sm:flex"
>
{t.viewAll}
<ArrowRight className="size-4" />
</Link>
</div>
<div className="mt-10 grid gap-4 md:grid-cols-3">
{t.docs.map(({ icon: Icon, title, description, href }) => (
<Link
key={href}
href={`${prefix}${href}`}
className="group rounded-xl border bg-fd-card p-6 transition-colors hover:bg-fd-accent"
>
<Icon className="mb-5 size-5 text-fd-primary" />
<h3 className="flex items-center justify-between font-semibold">
{title}
<ArrowRight className="size-4 transition-transform group-hover:translate-x-1" />
</h3>
<p className="mt-2 text-sm leading-6 text-fd-muted-foreground">{description}</p>
</Link>
))}
</div>
</div>
</section>
<section className="px-6 py-16 sm:py-20">
<div className="mx-auto max-w-5xl">
<div className="flex items-end justify-between gap-6">
<div>
<p className="font-mono text-sm font-medium text-fd-primary">{t.blogEyebrow}</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight">{t.blogTitle}</h2>
</div>
<Link href={`${prefix}/blog`} className="flex items-center gap-2 text-sm font-medium">
{t.viewAll}
<ArrowRight className="size-4" />
</Link>
</div>
<div className="mt-10 divide-y border-y">
{posts.map((post) => (
<Link
key={post.url}
href={post.url}
className="group grid gap-3 py-6 transition-colors hover:text-fd-primary sm:grid-cols-[10rem_1fr_auto] sm:items-center"
>
<time className="text-sm text-fd-muted-foreground">
{formatBlogDate(post.path, lang)}
</time>
<div>
<h3 className="font-semibold">{post.data.title}</h3>
<p className="mt-1 text-sm text-fd-muted-foreground">{post.data.description}</p>
</div>
<ArrowRight className="hidden size-4 transition-transform group-hover:translate-x-1 sm:block" />
</Link>
))}
</div>
</div>
</section>
</main>
);
}
function GitHubIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={className}>
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2Z" />
</svg>
);
}
@@ -13,16 +13,16 @@ import type { Metadata } from 'next';
import { createRelativeLink } from 'fumadocs-ui/mdx';
import { gitConfig } from '@/lib/shared';
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
export default async function Page(props: PageProps<'/[lang]/docs/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
const page = source.getPage(params.slug, params.lang);
if (!page) notFound();
const MDX = page.data.body;
const markdownUrl = getPageMarkdownUrl(page).url;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsPage toc={page.data.toc} full={page.data.full} tableOfContent={{ style: 'clerk' }}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
<div className="flex flex-row gap-2 items-center border-b pb-6">
@@ -48,9 +48,11 @@ export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise<Metadata> {
export async function generateMetadata(
props: PageProps<'/[lang]/docs/[[...slug]]'>,
): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
const page = source.getPage(params.slug, params.lang);
if (!page) notFound();
return {
+15
View File
@@ -0,0 +1,15 @@
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
import { i18n, isLanguage } from '@/lib/i18n';
export default async function Layout({ params, children }: LayoutProps<'/[lang]/docs'>) {
const { lang } = await params;
const language = isLanguage(lang) ? lang : i18n.defaultLanguage;
return (
<DocsLayout tree={source.getPageTree(language)} {...baseOptions(language)}>
{children}
</DocsLayout>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { RootProvider } from 'fumadocs-ui/provider/next';
import { i18nProvider } from 'fumadocs-ui/i18n';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { htmlLang, i18n, isLanguage } from '@/lib/i18n';
import { translations } from '@/lib/layout.shared';
import '../global.css';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://docs.leokun.cn';
export async function generateMetadata(props: LayoutProps<'/[lang]'>): Promise<Metadata> {
const { lang } = await props.params;
if (lang === 'en') {
return {
metadataBase: new URL(siteUrl),
title: {
default: 'cursor-byok Docs',
template: '%s | cursor-byok',
},
description: 'Installation, model configuration, and troubleshooting guide for cursor-byok.',
};
}
return {
metadataBase: new URL(siteUrl),
title: {
default: 'cursor-byok 文档',
template: '%s | cursor-byok',
},
description: 'cursor-byok 的安装、模型配置与故障排查指南。',
};
}
export function generateStaticParams() {
return i18n.languages.map((lang) => ({ lang }));
}
export default async function Layout({ params, children }: LayoutProps<'/[lang]'>) {
const { lang } = await params;
if (!isLanguage(lang)) notFound();
return (
<html lang={htmlLang[lang]} suppressHydrationWarning>
<body className="flex min-h-screen flex-col">
<RootProvider i18n={i18nProvider(translations, lang)}>{children}</RootProvider>
</body>
</html>
);
}
@@ -0,0 +1,29 @@
import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source';
import { i18n } from '@/lib/i18n';
import { notFound } from 'next/navigation';
export const revalidate = false;
export async function GET(
_req: Request,
{ params }: RouteContext<'/[lang]/llms.mdx/docs/[[...slug]]'>,
) {
const { lang, slug } = await params;
const page = source.getPage(slug?.slice(0, -1), lang);
if (!page) notFound();
return new Response(await getLLMText(page), {
headers: {
'Content-Type': 'text/markdown',
},
});
}
export function generateStaticParams() {
return i18n.languages.flatMap((lang) =>
source.getPages(lang).map((page) => ({
lang,
slug: getPageMarkdownUrl(page).segments,
})),
);
}
@@ -1,4 +1,5 @@
import { getPageImageUrl, source } from '@/lib/source';
import { i18n } from '@/lib/i18n';
import { notFound } from 'next/navigation';
import { ImageResponse } from 'next/og';
import { generate as DefaultImage } from 'fumadocs-ui/og';
@@ -6,9 +7,9 @@ import { appName } from '@/lib/shared';
export const revalidate = false;
export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) {
const { slug } = await params;
const page = source.getPage(slug.slice(0, -1));
export async function GET(_req: Request, { params }: RouteContext<'/[lang]/og/docs/[...slug]'>) {
const { lang, slug } = await params;
const page = source.getPage(slug.slice(0, -1), lang);
if (!page) notFound();
return new ImageResponse(
@@ -21,8 +22,10 @@ export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
lang: page.locale,
slug: getPageImageUrl(page).segments,
}));
return i18n.languages.flatMap((lang) =>
source.getPages(lang).map((page) => ({
lang,
slug: getPageImageUrl(page).segments,
})),
);
}
-11
View File
@@ -1,11 +0,0 @@
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
export default function Layout({ children }: LayoutProps<'/docs'>) {
return (
<DocsLayout tree={source.getPageTree()} {...baseOptions()}>
{children}
</DocsLayout>
);
}
+24
View File
@@ -14,3 +14,27 @@ html > body[data-scroll-locked] {
margin-right: 0 !important;
--removed-body-scroll-bar-size: 0px !important;
}
/* 文档页目录:隐藏默认折线,改用单条直线导轨 */
#nd-toc a[href^='#'] > svg,
#nd-toc div.absolute:has(> svg) {
display: none;
}
#nd-toc a[href^='#'] {
border-inline-start: 1px solid var(--color-fd-border);
}
#nd-toc a[href^='#'][data-active='true'] {
border-inline-start-color: var(--color-fd-primary);
}
/* 首页导航:通铺整行、无背景无分隔线 */
.home-fullbleed-nav {
--fd-layout-width: 100%;
}
.home-fullbleed-nav #nd-nav > * {
border-bottom-color: transparent;
backdrop-filter: none;
}
-26
View File
@@ -1,26 +0,0 @@
import { RootProvider } from 'fumadocs-ui/provider/next';
import { i18nProvider } from 'fumadocs-ui/i18n';
import type { Metadata } from 'next';
import { translations } from '@/lib/layout.shared';
import './global.css';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://docs.leokun.cn';
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
default: 'cursor-byok 文档',
template: '%s | cursor-byok',
},
description: 'cursor-byok 的安装、模型配置与故障排查指南。',
};
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body className="flex min-h-screen flex-col">
<RootProvider i18n={i18nProvider(translations)}>{children}</RootProvider>
</body>
</html>
);
}
@@ -1,23 +0,0 @@
import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source';
import { notFound } from 'next/navigation';
export const revalidate = false;
export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) {
const { slug } = await params;
const page = source.getPage(slug?.slice(0, -1));
if (!page) notFound();
return new Response(await getLLMText(page), {
headers: {
'Content-Type': 'text/markdown',
},
});
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
lang: page.locale,
slug: getPageMarkdownUrl(page).segments,
}));
}
@@ -1,17 +1,36 @@
.stage {
position: relative;
width: 100%;
max-width: 960px;
margin: 64px auto 0;
}
.halo {
position: absolute;
inset: -10% -14%;
background: radial-gradient(
50% 55% at 50% 45%,
color-mix(in srgb, var(--color-fd-primary) 9%, transparent),
transparent 72%
);
pointer-events: none;
}
.viewport {
position: relative;
width: 100%;
aspect-ratio: 960 / 620;
overflow: hidden;
background: #141414;
border: 1px solid rgb(255 255 255 / 10%);
background: #f5f5f5;
border: 1px solid var(--color-fd-border);
border-radius: 14px;
box-shadow:
0 42px 90px -34px rgb(0 0 0 / 35%),
0 12px 32px -16px rgb(0 0 0 / 28%);
}
.viewport[data-dark] {
background: #141414;
box-shadow:
0 42px 90px -34px rgb(0 0 0 / 50%),
0 12px 32px -16px rgb(0 0 0 / 42%);
@@ -23,15 +42,18 @@
display: block;
width: 100%;
height: 100%;
background: #141414;
background: transparent;
border: 0;
}
.stage p {
margin: 14px 0 0;
color: var(--color-fd-muted-foreground);
font-size: 12px;
text-align: center;
.poster {
object-fit: cover;
transition: opacity 0.6s ease;
}
.posterHidden {
opacity: 0;
pointer-events: none;
}
@media (max-width: 680px) {
+103 -7
View File
@@ -1,18 +1,114 @@
'use client';
import Image from 'next/image';
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { demoLocale, type Language } from '@/lib/i18n';
import styles from './DesktopDemo.module.css';
export function DesktopDemo() {
// 小屏或触屏设备内嵌交互体验差,只展示 poster 静态图
const embedQuery = '(min-width: 680px) and (hover: hover)';
const alt: Record<Language, string> = {
zh: 'cursor-byok 桌面端数据概览界面',
en: 'cursor-byok desktop overview dashboard',
};
function subscribeEmbedQuery(callback: () => void) {
const media = window.matchMedia(embedQuery);
media.addEventListener('change', callback);
return () => media.removeEventListener('change', callback);
}
function subscribeTheme(callback: () => void) {
const observer = new MutationObserver(callback);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}
export function DesktopDemo({ lang }: { lang: Language }) {
const stageRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
const embeddable = useSyncExternalStore(
subscribeEmbedQuery,
() => window.matchMedia(embedQuery).matches,
() => true,
);
const dark = useSyncExternalStore(
subscribeTheme,
() => document.documentElement.classList.contains('dark'),
() => true,
);
useEffect(() => {
if (!embeddable) return;
const stage = stageRef.current;
if (!stage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setMounted(true);
observer.disconnect();
}
},
{ rootMargin: '240px' },
);
observer.observe(stage);
return () => observer.disconnect();
}, [embeddable]);
return (
<div className={styles.stage}>
<div className={styles.viewport}>
<div ref={stageRef} className={styles.stage}>
<div className={styles.halo} aria-hidden />
<DemoViewport
key={`${dark ? 'dark' : 'light'}-${lang}`}
dark={dark}
lang={lang}
mounted={embeddable && mounted}
/>
</div>
);
}
function DemoViewport({
dark,
lang,
mounted,
}: {
dark: boolean;
lang: Language;
mounted: boolean;
}) {
const [frameLoaded, setFrameLoaded] = useState(false);
const [revealed, setRevealed] = useState(false);
const theme = dark ? 'default-dark' : 'default-light';
const demoUrl = `/product-demo/demo/index.html?theme=${theme}&locale=${demoLocale[lang]}`;
const posterUrl = `/images/product-demo-${dark ? 'dark' : 'light'}-${lang}.png`;
useEffect(() => {
if (!frameLoaded) return;
// iframe onLoad 只代表文档加载完成,等内部应用渲染后再揭开 poster
const timer = setTimeout(() => setRevealed(true), 900);
return () => clearTimeout(timer);
}, [frameLoaded]);
return (
<div className={styles.viewport} data-dark={dark || undefined}>
{mounted ? (
<iframe
title="Cursor BYOK 真实产品界面演示"
src="/product-demo/demo/index.html"
title={alt[lang]}
src={demoUrl}
className={styles.demo}
onLoad={() => setFrameLoaded(true)}
/>
</div>
<p>真实桌面端组件 · 使用隔离的 Mock 数据,可直接操作菜单、筛选和设置</p>
) : null}
<Image
src={posterUrl}
alt={alt[lang]}
fill
priority
unoptimized
className={revealed ? `${styles.poster} ${styles.posterHidden}` : styles.poster}
/>
</div>
);
}
@@ -0,0 +1,37 @@
'use client';
import { useSyncExternalStore } from 'react';
import { Download } from 'lucide-react';
import type { Language } from '@/lib/i18n';
import { releaseUrl } from '@/lib/shared';
function detectPlatform(): string | null {
const ua = navigator.userAgent;
if (/Mac/i.test(ua)) return 'macOS';
if (/Win/i.test(ua)) return 'Windows';
if (/Linux|X11/i.test(ua)) return 'Linux';
return null;
}
const labels: Record<Language, { platform: (platform: string) => string; fallback: string }> = {
zh: { platform: (platform) => `下载 ${platform} 版`, fallback: '下载最新版' },
en: { platform: (platform) => `Download for ${platform}`, fallback: 'Download' },
};
const subscribeNoop = () => () => {};
export function DownloadButton({ lang }: { lang: Language }) {
// 平台只在客户端可知;SSR 用通用文案,水合后替换为平台文案
const platform = useSyncExternalStore(subscribeNoop, detectPlatform, () => null);
const label = labels[lang];
return (
<a
href={releaseUrl}
className="inline-flex items-center gap-2 rounded-lg bg-fd-primary px-5 py-3 font-medium text-fd-primary-foreground transition-opacity hover:opacity-90"
>
<Download className="size-4" />
{platform ? label.platform(platform) : label.fallback}
</a>
);
}
@@ -0,0 +1,37 @@
---
title: Why We Built a New Documentation Site
description: Bringing the user guide and development notes back into the repository so docs evolve with the product.
---
cursor-byok has grown from simple model forwarding into multi-protocol model configuration, tool calling, session observability, and a cross-platform desktop app. Information scattered across release notes and discussion threads is no longer enough for first-time users, and it makes it harder for developers to understand the system's boundaries.
## Documentation is part of the product
The new documentation site lives in the same repository as the application code:
```text
apps/
├── desktop/ # Desktop app
└── docs/ # Documentation site
├── content/docs/
└── content/blog/
```
The user guide answers "how do I use it", while the developer blog records "why it is designed this way". The two kinds of content are maintained separately but share the same build and review pipeline.
## Why Fumadocs
Fumadocs provides the documentation layout, full-text search, code highlighting, table of contents, and the MDX content layer. We only need to maintain product information and visual styling instead of reimplementing generic documentation features.
The docs app stays independent: the desktop app does not take on Next.js or Fumadocs dependencies, and development, builds, and deployments remain separate.
## What we will write about
The developer blog will keep covering:
- Cursor protocol adaptation and model compatibility design.
- Trade-offs in Agent tool calling and multi-turn conversations.
- Local storage, observability, and performance work.
- Cross-platform desktop development and the release process.
These posts describe the current code. We do not keep compatibility notes for implementations that have been removed.
+93
View File
@@ -0,0 +1,93 @@
---
title: Quick Start
description: Install cursor-byok and let Cursor use your own model APIs.
icon: Rocket
---
cursor-byok is a Cursor model gateway that runs on your machine. It receives Cursor Agent requests, converts them, and forwards them to the OpenAI- or Anthropic-compatible service you configure.
<Callout type="warn" title="Before you start">
cursor-byok is an independent open-source project and is not affiliated with Cursor or its developers. The software itself is free, but model providers may charge for usage.
</Callout>
## Install and configure
<Steps>
<Step>
### Update Cursor
Download the latest official Cursor from [cursor.com](https://cursor.com) and install it over your existing copy, so the client stays up to date.
</Step>
<Step>
### Download and launch cursor-byok
Download the latest release for your operating system from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest), then launch cursor-byok.
</Step>
<Step>
### Initialize and configure a model
Open **Cursor Configuration**, follow the prompts to initialize the local CA, then add a model. Fill in the server address, API key, and model name, and run the connectivity test until it passes.
![Cursor Configuration page](/images/docs/cursor-config-en.png)
See [Model Configuration](./model-configuration.mdx) for how to choose the type and protocol for different models.
</Step>
<Step>
### Restart Cursor after the first setup
After completing the configuration for the first time, quit and restart Cursor once so the model list takes effect.
</Step>
<Step>
### Use it in Cursor
Keep cursor-byok running, start a new conversation in Cursor, pick the model you just configured from the model list (do not pick Auto), and start using Agent.
</Step>
</Steps>
## Coexists with your official account
The current design goal is coexistence with the official service — there is no more "stop the service" or account juggling:
- Sign in to Cursor with your own account. If you previously used a fake account generated by an old version, sign out of it first, then sign in with your own.
- If your account has official quota, there is no boundary between official models and your local models — switch and mix freely.
- **Auto always routes to official models**: picking Auto without quota results in an error, so select your configured model instead.
- Cursor features such as plugins and codebase indexing keep working as usual.
## Next steps
<Cards>
<Card title="Installation" description="Download, initialize, and run for the first time." href="/docs/installation" />
<Card title="Model Configuration" description="Choose a protocol and fill in upstream model parameters." href="/docs/model-configuration" />
<Card title="TAB Service" description="Choose how Cursor connects to Tab completion endpoints." href="/docs/tab-service" />
<Card title="Troubleshooting" description="Resolve certificate, connection, and model test issues." href="/docs/troubleshooting" />
</Cards>
## How data flows
```text
Cursor client
│ Agent requests and tool results
▼
cursor-byok local service
│ OpenAI- / Anthropic-compatible requests
▼
Your model API
```
API keys, model configurations, and app settings are stored on your machine. Model requests are still sent to the upstream provider you choose.
+34 -5
View File
@@ -10,13 +10,21 @@ cursor-byok 是运行在本机的 Cursor 模型网关。它接收 Cursor Agent
cursor-byok 是独立开源项目,与 Cursor 及其开发者没有关联。软件本身免费,但模型服务商可能按用量收费。
</Callout>
## 三步开始使用
## 安装与配置
<Steps>
<Step>
### 下载并启动
### 更新 Cursor
从 [cursor.com](https://cursor.com) 下载官方最新版 Cursor,直接覆盖安装,保持客户端为最新版本。
</Step>
<Step>
### 下载并启动 cursor-byok
从 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载与你的操作系统对应的最新版本,然后启动 cursor-byok。
@@ -26,7 +34,19 @@ cursor-byok 是独立开源项目,与 Cursor 及其开发者没有关联。软
### 初始化并配置模型
打开 **Cursor 配置**,按界面提示初始化本地 CA,然后添加模型。填写服务地址、API Key 和模型名称,并运行连通性测试。
打开 **Cursor 配置**,按界面提示初始化本地 CA,然后添加模型。填写服务地址、API Key 和模型名称,并运行连通性测试,测试通过即可。
![Cursor 配置页面](/images/docs/cursor-config-zh.png)
不同模型如何选择类型与协议,见[模型配置](./model-configuration.mdx)。
</Step>
<Step>
### 首次配置后重启 Cursor
首次完成配置时,完全退出并重新启动一次 Cursor,让模型列表生效。
</Step>
@@ -34,19 +54,28 @@ cursor-byok 是独立开源项目,与 Cursor 及其开发者没有关联。软
### 在 Cursor 中使用
保持 cursor-byok 运行,打开 Cursor,在模型列表中选择刚刚配置的模型,然后开始使用 Agent。
保持 cursor-byok 运行,在 Cursor 中新开一个对话,从模型列表中选择你刚配置的模型(不要选 Auto),开始使用 Agent。
</Step>
</Steps>
## 与官方账号并存
新版的设计目标是与官方服务并存,不再需要“关服务”“切账号”之类的操作:
- 直接在 Cursor 中登录你自己的账号。如果之前用过旧版生成的 fake 账户,先退出它,再登录自己的账号。
- 账号有官方额度时,官方模型和本地模型没有使用上的边界,可以随时切换混用。
- **Auto 走的是官方模型**:账号没有额度时选 Auto 会报错,请手动选择你配置的模型。
- 插件、代码库索引等 Cursor 功能都可以正常使用。
## 接下来
<Cards>
<Card title="安装指南" description="下载、初始化和首次运行。" href="/docs/installation" />
<Card title="模型配置" description="选择协议并填写上游模型参数。" href="/docs/model-configuration" />
<Card title="TAB 服务" description="选择 Cursor Tab 补全接口的连接方式。" href="/docs/tab-service" />
<Card title="故障排查" description="处理证书、连接与模型测试问题。" href="/docs/troubleshooting" />
<Card title="查看源码" description="了解实现或参与项目开发。" href="https://github.com/leookun/cursor-byok" external />
</Cards>
## 数据如何流转
@@ -0,0 +1,68 @@
---
title: Installation
description: Download cursor-byok, complete local initialization, and verify it is running.
icon: Download
---
## Download the app
Go to the [latest release page](https://github.com/leookun/cursor-byok/releases/latest) and download the installer for macOS, Windows, or Linux.
<Callout type="info">
Prefer the latest stable release. The release page lists the installers and release notes for each version.
</Callout>
## First launch
<Steps>
<Step>
### Open Cursor Settings
After launching the app, go to **Cursor Settings**. If the local CA has not been initialized, the page shows an initialization entry.
</Step>
<Step>
### Initialize the local CA
Click **Initialize CA**. The local CA is used to inspect HTTPS requests from Cursor on your device; its files never leave your machine.
When the system asks for authorization, follow the instructions shown in the app to complete the trust step in your terminal, then return to the app and click **I have initialized, refresh**.
</Step>
<Step>
### Add your first model
Click **Add Model**, choose the OpenAI or Anthropic type, fill in the upstream service parameters, and save. See [Model Configuration](./model-configuration.mdx) for details on each field.
</Step>
<Step>
### Run the connectivity test
Click **Test** on the model. Once the test passes, the model appears in Cursor's model list.
</Step>
</Steps>
## Verify the installation
After configuration, confirm that:
- The Cursor Settings page no longer shows the CA initialization prompt.
- At least one model passes the connectivity test.
- cursor-byok stays running.
- The configured display name shows up in Cursor's model list.
If any of these steps fail, head to [Troubleshooting](./troubleshooting.mdx).
## Updates
Check for new versions in the app settings, or visit [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) directly. You do not need to delete existing model configurations before updating.
+5
View File
@@ -0,0 +1,5 @@
{
"title": "User Guide",
"root": true,
"pages": ["index", "installation", "model-configuration", "tab-service", "troubleshooting"]
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"title": "使用指南",
"root": true,
"pages": ["index", "installation", "model-configuration", "troubleshooting"]
"pages": ["index", "installation", "model-configuration", "tab-service", "troubleshooting"]
}
@@ -0,0 +1,83 @@
---
title: Model Configuration
description: Configure the model protocol, server address, credentials, and generation parameters.
icon: Settings2
---
Each model configuration is an independent upstream channel that can use a different provider, protocol, credentials, and generation parameters.
![Edit model dialog](/images/docs/model-edit-en.png)
## Choosing the type and protocol
Picking by model family avoids most compatibility issues:
| Model family | Model type | Request protocol |
| --- | --- | --- |
| Claude family | **Anthropic** | — |
| GPT / OpenAI family | **OpenAI** | **Responses API** |
| Everything else | **OpenAI** | **Chat Completions API** |
<Callout type="warn" title="Always use the Responses API for GPT models">
Running GPT models through Chat Completions loses prompt caching, which makes them noticeably slower and more expensive.
</Callout>
## Required fields
### Model type
Choose the upstream interface format:
- **OpenAI**: supports the Responses API and the Chat Completions API.
- **Anthropic**: supports Messages API-compatible services.
### Request protocol
For the OpenAI type, also choose **Responses API** or **Chat Completions API**. This option determines the request and response format; it does not change the server address you entered.
### Server address
You can enter the provider's base URL, or choose to use a full request URL:
- With a base URL, cursor-byok appends the standard endpoint path for the chosen protocol.
- With a full request URL, cursor-byok uses the address as-is.
Prefer the built-in provider presets in the UI to avoid protocol and endpoint mismatches.
### API key
Enter the access key required by the upstream service. The key is stored on your machine and used to send model requests.
### Model name
Enter the model identifier accepted by the provider's API. You can also click **Fetch Models** to load the model list returned by the API.
## Display information
- **Display name**: the name shown in Cursor's model list; it does not change the model identifier sent upstream.
- **Notes**: shown in Cursor's model description.
## Optional parameters
Fill in the following fields based on the model's capabilities; when left empty, the app or upstream defaults are used:
- Context window tokens
- Max output tokens
- Reasoning or thinking effort
- Custom headers
- Extra OpenAI or Anthropic parameters
<Callout type="warn" title="Extra parameter format">
Custom headers and extra parameters must be JSON objects. Extra parameters directly affect upstream requests, so only add fields the provider explicitly supports.
</Callout>
## Test the configuration
Run **Test** after saving. The result shows time to first token, generation speed, total duration, and the model output, which helps you confirm:
1. The address and protocol match.
2. The API key is valid.
3. The model identifier exists and is accessible.
4. The upstream returns streamed content correctly.
Once the test passes, switch to Cursor and start using the model.
@@ -6,6 +6,22 @@ icon: Settings2
每个模型配置都是一个独立的上游通道,可以使用不同服务商、协议、凭据和生成参数。
![编辑模型弹窗](/images/docs/model-edit-zh.png)
## 如何选择类型与协议
按上游模型系列选择,可以避免绝大多数兼容性问题:
| 模型系列 | 模型类型 | 请求协议 |
| --- | --- | --- |
| Claude 系列 | **Anthropic** | — |
| GPT / OpenAI 系列 | **OpenAI** | **Responses API** |
| 其他所有模型 | **OpenAI** | **Chat Completions API** |
<Callout type="warn" title="GPT 系列务必用 Responses API">
GPT 系列走 Chat Completions 会丢失提示词缓存,速度和费用都会明显变差。
</Callout>
## 必填字段
### 模型类型
+27
View File
@@ -0,0 +1,27 @@
---
title: TAB Service
description: Choose how Cursor connects to Tab completion endpoints.
icon: Zap
---
Cursor's Tab completion does not go through model channels — it is handled by a separate Tab service. cursor-byok offers three connection modes under **System settings → TAB settings**.
![TAB settings in System settings](/images/docs/tab-settings-en.png)
## The three modes
### Use public service (default)
Uses the public Tab service hosted by the project author. Works out of the box with no configuration.
### Direct
Connects directly to the official Tab service of the account signed in to your Cursor client. Suitable if your account has official quota.
### Custom
Deploy your own [cursor-tab-server](https://github.com/leookun/cursor-byok/tree/archive/v0.0.49/cursor-tab-server), then enter your service URL in **TAB service address**. The original endpoint path is appended to that address.
<Callout type="info">
After changing TAB settings, restart Cursor and start a new conversation to make sure the connection mode takes effect.
</Callout>
+27
View File
@@ -0,0 +1,27 @@
---
title: TAB 服务
description: 选择 Cursor Tab 补全接口的连接方式。
icon: Zap
---
Cursor 的 Tab 补全不走模型通道,而是由单独的 Tab 服务处理。cursor-byok 在 **系统设置 → TAB 设置** 中提供三种连接方式。
![系统设置中的 TAB 设置](/images/docs/tab-settings-zh.png)
## 三种模式
### 使用公益服务(默认)
使用由项目作者部署的公共 Tab 服务,开箱即用,无需任何配置。
### 直连
直接使用你在 Cursor 客户端中登录账号的官方 Tab 服务。适合账号本身有官方额度的用户。
### 自定义
自行部署 [cursor-tab-server](https://github.com/leookun/cursor-byok/tree/archive/v0.0.49/cursor-tab-server),然后在 **TAB 服务地址** 中填入你的服务地址。原接口路径会追加到该地址之后。
<Callout type="info">
修改 TAB 设置后,建议重启 Cursor 并新开一个对话,确保连接方式生效。
</Callout>
@@ -0,0 +1,83 @@
---
title: Troubleshooting
description: Resolve issues with the local CA, the management service, model connections, and Cursor's model list.
icon: Wrench
---
## Local CA needs to be initialized
Go to **Cursor Settings** and click **Initialize CA**. The local CA stays on your machine and is used to inspect HTTPS requests from Cursor.
## Local CA needs to be trusted by the system
Click **Open terminal to install CA** and follow the terminal prompts to complete system authorization. When done, return to the app and click **I have initialized, refresh**.
If the status does not update, quit cursor-byok completely, restart it, and open the Cursor Settings page again.
## Cannot connect to the local management service
1. Quit and restart cursor-byok completely.
2. Check whether security software is blocking local loopback connections.
3. If you changed the management service port, reset it to `0` so the app picks an available port at startup.
4. Launch the app again and refresh the page.
## Model connectivity test fails
Check based on the test error:
- **Authentication error**: confirm the API key is valid and the account can access the target model.
- **Endpoint not found**: confirm the model type, request protocol, and server address match.
- **Model not found**: use **Fetch Models** to check the model identifiers returned by the upstream.
- **Invalid parameters**: temporarily remove custom headers and extra parameters, then test again.
- **Connection timeout**: check your network, system proxy, and the upstream service status.
## Models do not show up in Cursor or do not take effect
First confirm all of the following:
1. The local CA status is healthy.
2. At least one model configuration is saved.
3. The model connectivity test passes.
4. cursor-byok is running.
If it still does not work, run the full restart sequence in order:
1. Quit cursor-byok completely from the tray and start it again.
2. Quit and restart Cursor completely.
3. Start a new conversation and refresh the model list.
4. Pick your own configured model — **do not pick Auto**.
## Quota or account errors when picking Auto
Auto only routes to official Cursor models and never uses your locally configured models. If your account has no official quota, picking Auto fails — select your configured model from the model list instead.
If your account does have quota, official models and local models mix freely with no usage boundary.
## Still using an old fake account
The current design coexists with your official account — fake accounts and "stop the service" workflows are no longer needed:
1. Sign out of the fake account generated by an old version in Cursor.
2. Sign in with your own Cursor account.
Once signed in with your own account, Cursor features such as plugins and codebase indexing work as usual.
## Requests fail even though the test passes
The model test only verifies basic connectivity. Agent requests also involve longer context, tool definitions, and streaming responses. Check:
- Whether the upstream model supports tool calling.
- Whether the context window and max output tokens fit the model's limits.
- Whether custom parameters are compatible with the actual protocol.
- The upstream status code and response body in the call details.
## Report an issue
If the problem persists, file an issue on [GitHub Issues](https://github.com/leookun/cursor-byok/issues) and include:
- Your operating system and cursor-byok version.
- The selected model type and request protocol.
- The redacted server address and error message.
- Steps to reproduce.
Never share API keys or other credentials publicly.
+24 -3
View File
@@ -31,15 +31,36 @@ icon: Wrench
- **参数错误**:暂时关闭自定义 Headers 和额外参数,再重新测试。
- **连接超时**:检查网络、系统代理和上游服务状态。
## Cursor 中看不到模型
## Cursor 中看不到模型或模型不生效
确认以下条件全部满足:
先确认以下条件全部满足:
1. 本地 CA 状态正常。
2. 已保存至少一个模型配置。
3. 模型连通性测试成功。
4. cursor-byok 正在运行。
5. 重启 Cursor 后重新打开模型列表。
仍然不生效时,按顺序执行一遍完整的重启流程:
1. 从托盘完全退出 cursor-byok,重新启动。
2. 完全退出并重新启动 Cursor。
3. 新开一个对话,刷新模型列表。
4. 选择你自己配置的模型,**不要选 Auto**。
## 选 Auto 时报额度或账户错误
Auto 只会路由到 Cursor 官方模型,不会使用你配置的本地模型。账号没有官方额度时选 Auto 就会报错——请在模型列表中手动选择你配置的模型。
如果账号本身有额度,官方模型和本地模型可以随意混用,没有使用上的边界。
## 还在使用旧版的 fake 账户
新版的设计是与官方账号并存,不再需要 fake 账户,也没有“关服务”之类的操作:
1. 在 Cursor 中退出旧版生成的 fake 账户。
2. 登录你自己的 Cursor 账号。
登录自己的账号后,插件、代码库索引等 Cursor 功能都可以正常使用。
## 请求失败但测试成功
+4 -2
View File
@@ -1,6 +1,7 @@
import { loader } from 'fumadocs-core/source';
import { pageSchema } from 'fumadocs-core/source/schema';
import { defineCollections } from 'fumadocs-mdx/macro';
import { htmlLang, i18n, type Language } from './i18n';
const blog = defineCollections({
type: 'doc',
@@ -11,6 +12,7 @@ const blog = defineCollections({
export const blogSource = loader({
baseUrl: '/blog',
source: blog.toFumadocsSource(),
i18n,
});
export function getBlogDate(path: string): Date {
@@ -19,8 +21,8 @@ export function getBlogDate(path: string): Date {
return match ? new Date(`${match[1]}T00:00:00Z`) : new Date(0);
}
export function formatBlogDate(path: string): string {
return new Intl.DateTimeFormat('zh-CN', {
export function formatBlogDate(path: string, lang: Language = i18n.defaultLanguage): string {
return new Intl.DateTimeFormat(htmlLang[lang], {
year: 'numeric',
month: 'long',
day: 'numeric',
+39
View File
@@ -0,0 +1,39 @@
import { gitConfig } from './shared';
export type RepoStats = {
stars: number | null;
version: string | null;
};
async function fetchJson(url: string): Promise<Record<string, unknown> | null> {
try {
const res = await fetch(url, {
headers: { Accept: 'application/vnd.github+json' },
next: { revalidate: 3600 },
});
if (!res.ok) return null;
return (await res.json()) as Record<string, unknown>;
} catch {
return null;
}
}
export async function getRepoStats(): Promise<RepoStats> {
const base = `https://api.github.com/repos/${gitConfig.user}/${gitConfig.repo}`;
const [repo, release] = await Promise.all([
fetchJson(base),
fetchJson(`${base}/releases/latest`),
]);
return {
stars: typeof repo?.stargazers_count === 'number' ? repo.stargazers_count : null,
version: typeof release?.tag_name === 'string' ? release.tag_name : null,
};
}
export function formatStars(stars: number): string {
return new Intl.NumberFormat('en', {
notation: 'compact',
maximumFractionDigits: 1,
}).format(stars);
}
+25
View File
@@ -0,0 +1,25 @@
import { defineI18n } from 'fumadocs-core/i18n';
export const i18n = defineI18n({
defaultLanguage: 'zh',
languages: ['zh', 'en'],
hideLocale: 'default-locale',
});
export type Language = (typeof i18n)['languages'][number];
export function isLanguage(value: string): value is Language {
return i18n.languages.some((lang) => lang === value);
}
/** HTML `lang` attribute values. */
export const htmlLang: Record<Language, string> = {
zh: 'zh-CN',
en: 'en',
};
/** Locale ids understood by the desktop app / demo. */
export const demoLocale: Record<Language, string> = {
zh: 'zh-CN',
en: 'en-US',
};
+16 -8
View File
@@ -1,27 +1,35 @@
import { zhCN } from '@fumadocs/language/zh-cn';
import { defineTranslations } from 'fumadocs-core/i18n';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import { uiTranslations } from 'fumadocs-ui/i18n';
import { i18n, type Language } from './i18n';
import { appName, gitConfig, releaseUrl } from './shared';
export const translations = defineTranslations().extend(uiTranslations()).preset(zhCN());
export const translations = i18n.translations().extend(uiTranslations()).preset('zh', zhCN());
const navLabels: Record<Language, { docs: string; blog: string; download: string }> = {
zh: { docs: '文档', blog: '开发者博客', download: '下载' },
en: { docs: 'Docs', blog: 'Blog', download: 'Download' },
};
export function baseOptions(lang: Language): BaseLayoutProps {
const labels = navLabels[lang];
const prefix = lang === i18n.defaultLanguage ? '' : `/${lang}`;
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: appName,
},
links: [
{
text: '文档',
url: '/docs',
text: labels.docs,
url: `${prefix}/docs`,
},
{
text: '开发者博客',
url: '/blog',
text: labels.blog,
url: `${prefix}/blog`,
},
{
text: '下载',
text: labels.download,
url: releaseUrl,
external: true,
},
+13 -2
View File
@@ -3,6 +3,7 @@ import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
import { docsContentRoute, docsImageRoute, docsRoute } from './shared';
import { defineDocs } from 'fumadocs-mdx/macro';
import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';
import { i18n } from './i18n';
const docs = defineDocs({
dir: 'content/docs',
@@ -22,14 +23,22 @@ export const source = loader({
baseUrl: docsRoute,
source: docs.toFumadocsSource(),
plugins: [lucideIconsPlugin()],
i18n,
});
/** Locale URL prefix segment; empty for the default (hidden) locale. */
function localeSegment(page: (typeof source)['$inferPage']): string | undefined {
return page.locale === i18n.defaultLanguage ? undefined : page.locale;
}
export function getPageImageUrl(page: (typeof source)['$inferPage']) {
const segments = [...page.slugs, 'image.png'];
return {
segments,
url: '/' + [page.locale, ...docsImageRoute.split('/'), ...segments].filter(Boolean).join('/'),
url:
'/' +
[localeSegment(page), ...docsImageRoute.split('/'), ...segments].filter(Boolean).join('/'),
};
}
@@ -38,7 +47,9 @@ export function getPageMarkdownUrl(page: (typeof source)['$inferPage']) {
return {
segments,
url: '/' + [page.locale, ...docsContentRoute.split('/'), ...segments].filter(Boolean).join('/'),
url:
'/' +
[localeSegment(page), ...docsContentRoute.split('/'), ...segments].filter(Boolean).join('/'),
};
}
+4
View File
@@ -0,0 +1,4 @@
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
// 站点全部页面在构建期静态生成,无需增量缓存 / 队列等运行时缓存设施。
export default defineCloudflareConfig({});
+5379 -86
View File
File diff suppressed because it is too large Load Diff
+10 -4
View File
@@ -11,16 +11,21 @@
"start": "next start",
"types:check": "next typegen && tsc --noEmit",
"lint": "eslint",
"check": "npm run types:check && npm run lint && npm run build"
"check": "npm run types:check && npm run lint && npm run build",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
},
"dependencies": {
"@fumadocs/language": "^0.2.4",
"@opennextjs/cloudflare": "^1.20.4",
"cnfast": "^0.1.0",
"fumadocs-core": "16.15.2",
"fumadocs-mdx": "15.3.1",
"fumadocs-ui": "npm:@fumadocs/base-ui@16.15.2",
"lucide-react": "^1.34.0",
"next": "16.3.2",
"next": "16.3",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
@@ -31,9 +36,10 @@
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"eslint": "^9.39.4",
"eslint-config-next": "16.3.2",
"eslint-config-next": "16.3",
"postcss": "^8.5.26",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"wrangler": "^4.127.0"
}
}
+30 -9
View File
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextRequest, NextResponse, type NextFetchEvent } from 'next/server';
import { isMarkdownPreferred, rewritePath } from 'fumadocs-core/negotiation';
import { createI18nMiddleware } from 'fumadocs-core/i18n/middleware';
import { i18n, type Language } from '@/lib/i18n';
import { docsContentRoute, docsRoute } from '@/lib/shared';
const { rewrite: rewriteDocs } = rewritePath(
@@ -11,22 +13,41 @@ const { rewrite: rewriteSuffix } = rewritePath(
`${docsContentRoute}{/*path}/content.md`,
);
export default function proxy(request: NextRequest) {
const result = rewriteSuffix(request.nextUrl.pathname);
if (result) {
return NextResponse.rewrite(new URL(result, request.nextUrl));
const i18nMiddleware = createI18nMiddleware(i18n);
/** Split the locale prefix (if any) from a pathname. */
function splitLocale(pathname: string): { locale: Language; rest: string } {
for (const lang of i18n.languages) {
if (pathname === `/${lang}`) return { locale: lang, rest: '/' };
if (pathname.startsWith(`/${lang}/`)) {
return { locale: lang, rest: pathname.slice(lang.length + 1) };
}
}
return { locale: i18n.defaultLanguage, rest: pathname };
}
export default function proxy(request: NextRequest, event: NextFetchEvent) {
const { locale, rest } = splitLocale(request.nextUrl.pathname);
const suffixTarget = rewriteSuffix(rest);
if (suffixTarget) {
return NextResponse.rewrite(new URL(`/${locale}${suffixTarget}`, request.nextUrl));
}
if (isMarkdownPreferred(request)) {
const result = rewriteDocs(request.nextUrl.pathname);
const target = rewriteDocs(rest);
if (result) {
return NextResponse.rewrite(new URL(result, request.nextUrl), {
if (target) {
return NextResponse.rewrite(new URL(`/${locale}${target}`, request.nextUrl), {
// this URL has two representations, selected by `Accept`
headers: { Vary: 'Accept' },
});
}
}
return NextResponse.next();
return i18nMiddleware(request, event);
}
export const config = {
matcher: ['/((?!api|_next|favicon.ico|images/|product-demo/|llms\\.txt|llms-full\\.txt).*)'],
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB