mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-24 20:09:02 +00:00
Draw the app's own pages with @hanzo/gui
What was left of Tailwind and base-ui in apps/docs was the pages around the docs: the front page, the hero and product sections on /docs, the catalogs, search, feedback, the 404, the blog and the sign-in screens. Each is a gui part on the theme now, and the app source carries no utility class, no Radix and no base-ui import. The front page follows the reader's theme instead of forcing a dark subtree, and its API Reference card pointed at /reference, which is not a route; it goes to /docs/openapi. Search is one gui dialog opened from the rail or with Cmd-K, over the same static index, with the jump and the ask-an-agent rows it had. The feedback control sends a docs.feedback event on the one telemetry stream; it used to hand the reader's answer to a stub that logged it to their own console. The blog sits in the docs shell on the same page frame, and a post no longer prints its title twice. The registry build, the fork's preview components, the unused AI-search dialogs, the API page and the tree's icon plugin go: nothing rendered them, and the icons were an SVG tree serialised into every page. Claude-Session: https://claude.ai/code/session_01HuMDfgFssPUptYUzgZ6Cp8
This commit is contained in:
@@ -362,6 +362,7 @@ excluded — it is upstream's):
|
||||
|---|---|---|---|
|
||||
| `apps/docs` app+components+lib, before phase 1 | 2,189 / 52 | 2 | 0 |
|
||||
| `apps/docs` after phase 1 | 1,906 / 37 | 2 | 0 |
|
||||
| `apps/docs` after phase 3 | 0 / 0 | 0 | 0 |
|
||||
| `packages/base-ui` (@hanzo/docs-base-ui) | 2,774 / 76 | 0 | 10 |
|
||||
| `packages/radix-ui` (@hanzo/docs-ui, via openapi/twoslash/typescript) | 2,568 / 71 | 9 | 0 |
|
||||
| `packages/openapi` | 794 / 23 | 3 | 0 |
|
||||
@@ -380,7 +381,7 @@ Folds: plain state + gui stacks. Icons: `@hanzogui/lucide-icons-2`. Theme:
|
||||
|---|---|---|---|
|
||||
| 1 | Chrome on every page: rail, tree, bar, outline, page header, search trigger, account, theme | base-ui `DocsLayout`/`DocsPage`/sidebar/toc, next-themes, the corner dock | shipped (f11eb49dc2) |
|
||||
| 2 | Page bodies: markdown's elements (`components/mdx/prose.tsx`), the parts content names — Callout, Cards, Tabs, Steps, Accordion, Files, code blocks and code tabs (`blocks.tsx`, `tabs.tsx`, `code.tsx`) — and `Example`; content imports none of them; ported and studio pages reach them through the `parts` aliases in next.config | Tailwind typography, base-ui MDX components, shiki.css, the HoverCard link preview (a `title` now) | shipped |
|
||||
| 3 | App pages and app parts: landing, catalogs, product sections, provider strip, hero, feedback, search dialog + AI search, 404, blog (HomeLayout), login/callback | base-ui search dialog, `RootProvider`, the remaining Tailwind in `app/` and `components/` | next |
|
||||
| 3 | App pages and app parts: landing (on the theme now, no forced dark), hero, product sections, provider strip, catalogs, search (`search.tsx` + `finder.tsx`, ⌘K), feedback (a real `docs.feedback` event, not a console stub), 404, blog (in the shell, on the page frame), sign-in screens | base-ui search dialog, `RootProvider`, HomeLayout, the registry build and the dead preview / AI-search / API-page modules | shipped |
|
||||
| 4 | The build: drop `@import 'tailwindcss'`, the presets, `@tailwindcss/postcss` + `postcss.config.mjs`, `tailwind-merge`, `cva`, every `@radix-ui/*` and `@base-ui/*`, `@hanzo/docs-base-ui`/`@hanzo/docs-ui` from `apps/docs`; delete what nothing else consumes (`packages/gui` stub, the registry build) | — | |
|
||||
|
||||
**Known gap in @hanzo/ui (8.27.18):** its `.d.ts` augments `@hanzogui/web`
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
'use client';
|
||||
import { Check, Share } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
|
||||
import { useCopyButton } from '@hanzo/docs-base-ui/utils/use-copy-button';
|
||||
|
||||
export function ShareButton({ url }: { url: string }) {
|
||||
const [isChecked, onCopy] = useCopyButton(() => {
|
||||
void navigator.clipboard.writeText(`${window.location.origin}${url}`);
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(buttonVariants({ variant: 'primary', size: 'md', className: 'gap-2' }))}
|
||||
onClick={onCopy}
|
||||
>
|
||||
{isChecked ? <Check className="size-4" /> : <Share className="size-4" />}
|
||||
{isChecked ? 'Copied URL' : 'Share Post'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +1,50 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import type { TOCItemType } from '@hanzo/docs-core/toc';
|
||||
import { InlineTOC } from '@hanzo/docs-base-ui/components/inline-toc';
|
||||
import path from 'node:path';
|
||||
import { blog } from '@/lib/source';
|
||||
import { createMetadata } from '@/lib/metadata';
|
||||
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
|
||||
import { ShareButton } from '@/app/(home)/blog/[slug]/page.client';
|
||||
import { getMDXComponents } from '@/components/mdx';
|
||||
import path from 'node:path';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { Body } from '@/components/mdx/prose';
|
||||
import { Page as Frame } from '@/components/page';
|
||||
|
||||
interface BlogPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
// Force static generation for GitHub Pages export
|
||||
// Force static generation for the export.
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
const DAY = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' });
|
||||
|
||||
export default async function Page(props: BlogPageProps) {
|
||||
const params = await props.params;
|
||||
const page = blog.getPage([params.slug]);
|
||||
const components = getMDXComponents();
|
||||
|
||||
if (!page) notFound();
|
||||
|
||||
// Create a clean copy of data to avoid proxy trap issues during static generation
|
||||
// The proxy wrapping by Next.js can cause ownKeys invariant violations
|
||||
const rawData = page.data as unknown as { author: string; date?: string; title: string; description?: string; load: () => Promise<{ body: React.ComponentType<{ components: object }>; toc: TOCItemType[] }> };
|
||||
const author = rawData.author;
|
||||
const date = rawData.date;
|
||||
const pageTitle = rawData.title;
|
||||
const pageDescription = rawData.description;
|
||||
const loadFn = rawData.load;
|
||||
const { body: Mdx, toc } = await loadFn();
|
||||
// A plain copy of the data: Next's proxy around it trips an ownKeys invariant
|
||||
// during static generation.
|
||||
const data = page.data as unknown as {
|
||||
author: string;
|
||||
date?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
load: () => Promise<{ body: React.ComponentType<{ components: object }>; toc: TOCItemType[] }>;
|
||||
};
|
||||
const { body: Mdx, toc } = await data.load();
|
||||
const when = DAY.format(new Date(data.date ?? path.basename(page.path, path.extname(page.path))));
|
||||
|
||||
return (
|
||||
<article className="flex flex-col mx-auto w-full max-w-[800px] px-4 py-8">
|
||||
<div className="flex flex-row gap-4 text-sm mb-8">
|
||||
<div>
|
||||
<p className="mb-1 text-fd-muted-foreground">Written by</p>
|
||||
<p className="font-medium">{author}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-sm text-fd-muted-foreground">At</p>
|
||||
<p className="font-medium">
|
||||
{new Date(
|
||||
date ?? path.basename(page.path, path.extname(page.path)),
|
||||
).toDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-semibold mb-4">{pageTitle}</h1>
|
||||
<p className="text-fd-muted-foreground mb-8">{pageDescription}</p>
|
||||
|
||||
<div className="prose min-w-0 flex-1">
|
||||
<div className="flex flex-row items-center gap-2 mb-8 not-prose">
|
||||
<ShareButton url={page.url} />
|
||||
<Link
|
||||
href="/blog"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'sm',
|
||||
variant: 'secondary',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
Back
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<InlineTOC items={toc} />
|
||||
<Mdx components={components} />
|
||||
</div>
|
||||
</article>
|
||||
<Frame
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
crumbs={[{ name: 'Blog', url: '/blog' }, { name: data.author ? `${data.author} · ${when}` : when }]}
|
||||
toc={toc}
|
||||
>
|
||||
<Body>
|
||||
<Mdx components={getMDXComponents()} />
|
||||
</Body>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,64 +1,31 @@
|
||||
import Link from 'next/link';
|
||||
import { blog } from '@/lib/source';
|
||||
import { PathUtils } from '@hanzo/docs/core/source';
|
||||
import { Posts } from '@/components/posts';
|
||||
|
||||
// Blog frontmatter type (matches source.config.ts schema)
|
||||
// Blog frontmatter (source.config.ts). A post with no date is dated by its file.
|
||||
interface BlogData {
|
||||
title: string;
|
||||
description?: string;
|
||||
author: string;
|
||||
date: string | Date;
|
||||
date?: string | Date;
|
||||
}
|
||||
|
||||
function getName(path: string) {
|
||||
return PathUtils.basename(path, PathUtils.extname(path));
|
||||
}
|
||||
const DAY = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' });
|
||||
|
||||
export default function Page() {
|
||||
const posts = [...blog.getPages()].sort(
|
||||
(a, b) =>
|
||||
new Date((b.data as unknown as BlogData).date ?? getName(b.path)).getTime() -
|
||||
new Date((a.data as unknown as BlogData).date ?? getName(a.path)).getTime(),
|
||||
);
|
||||
const posts = blog
|
||||
.getPages()
|
||||
.map((page) => {
|
||||
const data = page.data as unknown as BlogData;
|
||||
const when = new Date(data.date ?? PathUtils.basename(page.path, PathUtils.extname(page.path)));
|
||||
return { page, data, when };
|
||||
})
|
||||
.sort((a, b) => b.when.getTime() - a.when.getTime())
|
||||
.map(({ page, data, when }) => ({
|
||||
url: page.url,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
date: DAY.format(when),
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-page px-4 pb-12 md:py-12">
|
||||
{/* The backdrop is the brand's own architectural grid — @hanzo/brand's
|
||||
hero treatment, drawn in CSS. It replaced 2.2 MB of upstream orange
|
||||
gradient art that came through the fork and matched no Hanzo palette. */}
|
||||
<div className="relative dark mb-2 overflow-hidden rounded-2xl border border-fd-border bg-fd-card p-8 z-2 md:p-12">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 -z-1"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(to right, rgba(255,255,255,0.07) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.07) 1px, transparent 1px)',
|
||||
backgroundSize: '50px 50px',
|
||||
}}
|
||||
/>
|
||||
<h1 className="mb-4 text-3xl text-landing-foreground font-mono font-medium">
|
||||
Hanzo Docs Blog
|
||||
</h1>
|
||||
<p className="text-sm font-mono text-landing-foreground-200">
|
||||
Latest announcements and updates.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-3 xl:grid-cols-4">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.url}
|
||||
href={post.url}
|
||||
className="flex flex-col bg-fd-card rounded-2xl border shadow-sm p-4 transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground"
|
||||
>
|
||||
<p className="font-medium">{post.data.title}</p>
|
||||
<p className="text-sm text-fd-muted-foreground">{post.data.description}</p>
|
||||
|
||||
<p className="mt-auto pt-4 text-xs text-fd-muted-foreground">
|
||||
{new Date((post.data as unknown as BlogData).date ?? getName(post.path)).toDateString()}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
return <Posts posts={posts} />;
|
||||
}
|
||||
|
||||
@@ -1,48 +1,13 @@
|
||||
import { HomeLayout } from '@hanzo/docs-base-ui/layouts/home';
|
||||
import { baseOptions, linkItems } from '@/components/layouts/shared';
|
||||
import { MeetHanzo } from '@/components/meet-hanzo';
|
||||
import { HanzoPreFooterCTA } from '@hanzogui/shell';
|
||||
import { Shell } from '@/components/shell';
|
||||
import { Footer } from '@/components/footer';
|
||||
|
||||
// The blog sits in the same chrome as the docs: one rail, one bar, one way to
|
||||
// find anything from any page.
|
||||
export default function Layout({ children }: LayoutProps<'/'>) {
|
||||
return (
|
||||
<>
|
||||
<HomeLayout
|
||||
{...baseOptions()}
|
||||
themeSwitch={{ enabled: false }}
|
||||
links={[
|
||||
...linkItems,
|
||||
{
|
||||
// NO `on`. useLinkItems routes 'nav' to the desktop rail ONLY — which is
|
||||
// max-lg:hidden and never reaches the mobile collapsible — so the home page
|
||||
// had no ecosystem menu at all below 1024px (measured at 390 and 834: zero
|
||||
// affordance). Omitting `on` puts the item in BOTH lists, which is what
|
||||
// "one affordance, every width" actually requires. The removed app launcher
|
||||
// carried the same shape and was equally invisible there, so this was never
|
||||
// a regression — just never true.
|
||||
type: 'custom',
|
||||
secondary: true,
|
||||
// NOT compact. This entry renders in the desktop rail AND in the mobile
|
||||
// collapsible — a VERTICAL list where every other row is a word, so a bare
|
||||
// glyph there is precisely the unlabeled affordance that was removed. The
|
||||
// home header has no sidebar competing for width, so the label costs it
|
||||
// nothing. `compact` exists for the docs header, which is a fixed 56px row.
|
||||
children: <MeetHanzo />,
|
||||
},
|
||||
]}
|
||||
className="dark:bg-neutral-950 dark:[--color-fd-background:var(--color-neutral-950)]"
|
||||
>
|
||||
{children}
|
||||
</HomeLayout>
|
||||
{/* The pre-footer is glass drawn for a dark ground, and the blog is light
|
||||
for a light reader: on the white body its heading printed at 2.38:1 and
|
||||
its links at 4.19:1. It sits on the same dark ground the landing gives
|
||||
it, with the site's foreground as @hanzo/ui's --text-primary (its ramp
|
||||
has no `.dark`; see the landing layout). */}
|
||||
<div className="t_dark bg-fd-background text-fd-foreground [--text-primary:var(--color-fd-foreground)]">
|
||||
<HanzoPreFooterCTA surface="hanzo.ai" />
|
||||
</div>
|
||||
<Footer />
|
||||
<Shell>{children}</Shell>
|
||||
<Footer cta />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,14 @@
|
||||
import { Shell } from '@/components/shell';
|
||||
import { HanzoPreFooterCTA } from '@hanzogui/shell';
|
||||
import { Footer } from '@/components/footer';
|
||||
|
||||
// The landing page carries the SAME chrome as /docs — rail, tree, bar — because
|
||||
// it is where a reader does not yet know what is here, and the tree is the
|
||||
// table of contents.
|
||||
//
|
||||
// The page itself paints a dark palette of its own (literal white-on-neutral),
|
||||
// so the page, and only the page, is held dark with gui's `t_dark` on a wrapper:
|
||||
// the chrome around it follows the reader's theme like every other route. That
|
||||
// wrapper goes when the landing moves onto gui tokens.
|
||||
export default function Layout({ children }: LayoutProps<'/'>) {
|
||||
return (
|
||||
<>
|
||||
<Shell>
|
||||
<div className="t_dark bg-fd-background text-fd-foreground [--text-primary:var(--color-fd-foreground)]">
|
||||
{children}
|
||||
</div>
|
||||
</Shell>
|
||||
<div className="t_dark bg-fd-background text-fd-foreground [--text-primary:var(--color-fd-foreground)]">
|
||||
<HanzoPreFooterCTA surface="hanzo.ai" />
|
||||
</div>
|
||||
<Footer />
|
||||
<Shell>{children}</Shell>
|
||||
<Footer cta />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,772 +1,38 @@
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
Code2,
|
||||
Database,
|
||||
FileJson2,
|
||||
Globe,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Server,
|
||||
Shield,
|
||||
ShoppingCart,
|
||||
Sparkle,
|
||||
Terminal,
|
||||
Workflow,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardTitle, XStack } from '@hanzo/ui';
|
||||
import { Grid } from '@hanzo/ui/grid';
|
||||
import { Agent } from '@/components/agent';
|
||||
import { Tabs, Tab } from '@hanzo/docs-base-ui/components/tabs';
|
||||
import { HeroField } from '@/components/hero-field';
|
||||
import { CodeBlock } from '@/components/code-block';
|
||||
import { Landing } from '@/components/landing';
|
||||
import { Example } from '@/components/example';
|
||||
import { Tab, Tabs } from '@/components/mdx/tabs';
|
||||
|
||||
/* -- The nine domains -- category-first, mirrors the console shell ---------- */
|
||||
// The install and first-call tabs are the same two steps /docs/quickstart shows,
|
||||
// highlighted here on the server so the page ships tokens, not a highlighter.
|
||||
// Every SDK tab is the catalogue read — the same call the quickstart makes.
|
||||
const install = [
|
||||
['npm', 'bash', 'npm i -g hanzo'],
|
||||
['Script', 'bash', 'curl -fsSL hanzo.sh | sh'],
|
||||
['Homebrew', 'bash', 'brew install hanzoai/tap/hanzo'],
|
||||
['From source', 'bash', 'git clone https://github.com/hanzoai/cli && cd cli && cargo install --path .'],
|
||||
] as const;
|
||||
|
||||
const domains = [
|
||||
{
|
||||
name: 'Identity & trust',
|
||||
desc: 'Who someone is, what they may touch, and where secrets stay safe. IAM, AuthZ, KMS, MPC, Zero-Trust.',
|
||||
icon: Shield,
|
||||
href: '/docs/openapi/iam',
|
||||
tag: 'Identity',
|
||||
},
|
||||
{
|
||||
name: 'Intelligence',
|
||||
desc: 'The mind of the cloud — every model, agent, and tool you can call. Models, Agents, MCP, Embeddings, Prompts, GPUs, Functions.',
|
||||
icon: Sparkle,
|
||||
href: '/docs/openapi/ai',
|
||||
tag: 'Intelligence',
|
||||
},
|
||||
{
|
||||
name: 'Data',
|
||||
desc: 'Somewhere to put your data and read it back fast. SQL, Vector, KV, Search, Object, Base, DocDB.',
|
||||
icon: Database,
|
||||
href: '/docs/openapi/provisioning',
|
||||
tag: 'Data',
|
||||
},
|
||||
{
|
||||
name: 'Streams',
|
||||
desc: 'Move messages and run work in the background, reliably. PubSub, Tasks, Pipelines, Crawl.',
|
||||
icon: Workflow,
|
||||
href: '/docs/openapi/pubsub',
|
||||
tag: 'Streams',
|
||||
},
|
||||
{
|
||||
name: 'Observability',
|
||||
desc: 'See exactly what your system is doing, live. Metrics, Logs, Traces, Sessions, Evals, Analytics.',
|
||||
icon: Activity,
|
||||
href: '/docs/openapi/o11y',
|
||||
tag: 'Observe',
|
||||
},
|
||||
{
|
||||
name: 'Commerce',
|
||||
desc: 'Turn usage into money — meter it, price it, bill it, reward it. Commerce, Billing, Marketplace, Referrals.',
|
||||
icon: ShoppingCart,
|
||||
href: '/docs/openapi/commerce',
|
||||
tag: 'Commerce',
|
||||
},
|
||||
{
|
||||
name: 'Platform',
|
||||
desc: 'Ship your code and run it anywhere. Gateway, Machines, Edge, Registry.',
|
||||
icon: Server,
|
||||
href: '/docs/openapi/gateway',
|
||||
tag: 'Platform',
|
||||
},
|
||||
{
|
||||
name: 'Applications',
|
||||
desc: 'The finished products people use every day. Chat, Studio, Dev, Integrations, Apps.',
|
||||
icon: LayoutGrid,
|
||||
href: '/docs/openapi/git',
|
||||
tag: 'Apps',
|
||||
},
|
||||
{
|
||||
name: 'Chain',
|
||||
desc: 'The networks the cloud speaks to — enumerate them, call one, read a holder\'s balances. Web3, Explorer.',
|
||||
icon: Globe,
|
||||
href: '/docs/openapi/web3',
|
||||
tag: 'Chain',
|
||||
},
|
||||
];
|
||||
const use = [
|
||||
['CLI', 'bash', `hanzo auth login\n\nhanzo "explain quantum computing"\nhanzo models list\nhanzo projects deploy my-app`],
|
||||
['TypeScript', 'typescript', `// npm i hanzoai\nimport { AiApi, Configuration } from 'hanzoai'\n\nconst ai = new AiApi(new Configuration())\n\nconst { data } = await ai.getModels()\nconsole.log(data.data?.length, 'models')`],
|
||||
['Python', 'python', `# pip install hanzoai\nfrom hanzoai.cloud import AiApi, ApiClient, Configuration\n\nai = AiApi(ApiClient(Configuration()))\n\nprint(len(ai.get_models().data or []), "models")`],
|
||||
['Go', 'go', `// go get github.com/hanzoai/go-sdk/v8\nimport hanzoai "github.com/hanzoai/go-sdk/v8"\n\nclient := hanzoai.NewAPIClient(hanzoai.NewConfiguration())\n\nmodels, _, err := client.AiAPI.GetModels(ctx).Execute()`],
|
||||
['Rust', 'rust', `// cargo add hanzo-client\nuse hanzo_client::apis::{ai_api, configuration::Configuration};\n\nlet cfg = Configuration::new();\n\nlet models = ai_api::get_models(&cfg).await?;`],
|
||||
['HTTP', 'bash', `curl https://api.hanzo.ai/v1/chat/completions \\\n -H "Authorization: Bearer $HANZO_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"zen5","messages":[{"role":"user","content":"Hello!"}]}'`],
|
||||
] as const;
|
||||
|
||||
/* -- Developer quick links -------------------------------------------------- */
|
||||
|
||||
const devLinks = [
|
||||
{
|
||||
name: 'The Network',
|
||||
desc: 'Run the whole cloud yourself',
|
||||
href: '/docs/network',
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
name: 'SDKs',
|
||||
desc: 'Python, TypeScript, Go, Rust, C++, Swift, Kotlin',
|
||||
href: '/docs/sdks',
|
||||
icon: Code2,
|
||||
},
|
||||
{
|
||||
name: 'API Reference',
|
||||
desc: 'Every /v1 endpoint, live',
|
||||
href: '/reference',
|
||||
icon: FileJson2,
|
||||
},
|
||||
{
|
||||
name: 'Architecture',
|
||||
desc: 'One binary, one contract',
|
||||
href: '/docs/architecture',
|
||||
icon: Package,
|
||||
},
|
||||
];
|
||||
|
||||
/* -- Page ------------------------------------------------------------------- */
|
||||
|
||||
export default function Page() {
|
||||
function Group({ rows }: { rows: readonly (readonly [string, string, string])[] }) {
|
||||
return (
|
||||
// `[grid-area:main]` because this page hangs off DocsLayout, whose container
|
||||
// is a named 5-column grid — sidebar, header, main, toc. An unplaced child
|
||||
// is auto-placed into an implicit row under column one and renders 268px
|
||||
// wide beneath the sidebar. The docs pages get this from DocsPage; this one
|
||||
// is hand-built, so it says it itself.
|
||||
//
|
||||
// Widths below are sized to the column the grid gives it — roughly 1170px at
|
||||
// a 1440 viewport, not the 1400px the full-bleed home layout had — so the
|
||||
// page is laid out for the space it has rather than clamped into it. Every
|
||||
// `md:`/`lg:` column count that assumed the full viewport moved up one step,
|
||||
// because the sidebar takes 268px from `md` up and 768→1024→1280 step by 256.
|
||||
<main className="[grid-area:main] min-w-0 pb-6 md:pb-12">
|
||||
{/* -- Hero ---------------------------------------------------------- */}
|
||||
{/* One column, centred. It was `flex flex-col items-center`, which is the
|
||||
same picture drawn with the wrong primitive: a column of blocks IS a
|
||||
one-track grid, and saying so means `justifyItems` centres the children
|
||||
without each of them having to be told. `minmax(0, 1fr)` pins the
|
||||
track to the section; the implicit `auto` track grows to the cards
|
||||
grid's intrinsic 896px and overflows a phone. */}
|
||||
<section
|
||||
className="relative mx-auto w-full max-w-5xl px-6 pt-24 pb-16 md:pt-36 md:pb-24"
|
||||
style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', justifyItems: 'center', textAlign: 'center' }}
|
||||
>
|
||||
{/* The atmosphere behind the headline, as dots rather than a gradient —
|
||||
the halftone IS the mark, and a gradient carries none of it. `fade`
|
||||
dissolves the canvas rectangle on both axes so the field ends in the
|
||||
page instead of on an edge you can see. It is its own client island
|
||||
because its shape is a function; see components/hero-field.tsx. */}
|
||||
<HeroField />
|
||||
|
||||
{/* The model families first. They are the reason to choose the
|
||||
platform; a capability count is the reason to choose nothing. */}
|
||||
<Link
|
||||
href="/docs/models"
|
||||
className="relative mb-8 rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm text-neutral-400 backdrop-blur transition-colors hover:border-white/20 hover:text-white"
|
||||
style={{ display: 'inline-grid', gridAutoFlow: 'column', alignItems: 'center', columnGap: 8 }}
|
||||
>
|
||||
<span className="rounded-full bg-white" style={{ width: 6, height: 6 }} />
|
||||
Zen generates · Enso routes · Kai decides
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Link>
|
||||
|
||||
{/* Display optics, measured off hanzo.ai: leading 1.0 and tracking
|
||||
-0.025em are most of why that type reads as SET rather than merely
|
||||
large — at display size the default 1.05 opens a gap the eye reads as
|
||||
two lines instead of one block. The ink is #e5e5e5, not #fff: pure
|
||||
white halates against a near-black ground, thickening the stems. */}
|
||||
<h1 className="relative text-5xl md:text-6xl lg:text-7xl font-bold tracking-tight leading-none">
|
||||
<span className="text-[#e5e5e5]">Build anything<br className="hidden sm:block" /> with Hanzo.</span>
|
||||
</h1>
|
||||
<p className="relative mt-5 max-w-xl text-neutral-300 text-lg md:text-xl leading-relaxed">
|
||||
Every model. Every tool. One key. Start in the browser, ship from your
|
||||
terminal, and when you want it on your own hardware, take the whole thing
|
||||
with you — it is the same software we run in production.
|
||||
</p>
|
||||
|
||||
{/* -- Install command -- the main CTA ------------------------------ */}
|
||||
<div className="relative mt-10 w-full max-w-lg">
|
||||
<Card
|
||||
bg="rgba(255,255,255,0.03)"
|
||||
borderColor="rgba(255,255,255,0.08)"
|
||||
rounded={16}
|
||||
p={4}
|
||||
py={4}
|
||||
gap={0}
|
||||
>
|
||||
{/* `$` then the command: a fixed leader and a track that takes the
|
||||
rest. The `flex-1` that used to be on the span was the child
|
||||
claiming the width; the track owns it now. */}
|
||||
<CardContent
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto minmax(0, 1fr)',
|
||||
alignItems: 'center',
|
||||
columnGap: 12,
|
||||
borderRadius: 12,
|
||||
background: '#0a0a0a',
|
||||
padding: '16px 20px',
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-sm text-neutral-400 select-none">$</span>
|
||||
<span className="font-mono text-sm text-white" style={{ textAlign: 'left' }}>curl hanzo.sh | sh</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="text-xs text-neutral-400 mt-3">
|
||||
Installs the <code className="text-neutral-400 bg-white/5 px-1 py-0.5 rounded">hanzo</code> CLI.
|
||||
Then <code className="text-neutral-400 bg-white/5 px-1 py-0.5 rounded">hanzo auth login</code> to get a key.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* -- The other way in: hand it to an agent ------------------------ */}
|
||||
{/* Beside the install line, because they are the two ways to start and a
|
||||
reader arriving with an agent open should not have to find that out
|
||||
further down. It is the SAME control the doc pages carry, so what it
|
||||
copies here is what it copies there — one prompt, one place it is
|
||||
written, and it sets the agent up to use Hanzo's models and skills
|
||||
rather than merely to call the API.
|
||||
|
||||
It also has to be here rather than in the bar: this page has no table
|
||||
of contents, which is where the doc pages host it, so without this the
|
||||
the landing page offered no way to hand anything to an agent at all. */}
|
||||
<div className="relative mt-6" style={{ display: 'grid', justifyItems: 'center', rowGap: 8 }}>
|
||||
<Agent />
|
||||
<p className="text-xs text-neutral-400">
|
||||
Or hand this to your agent — it installs the CLI, the MCP server and
|
||||
the{' '}
|
||||
<Link
|
||||
href="https://hanzoskills.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-neutral-300 underline underline-offset-4 hover:text-white"
|
||||
>
|
||||
skills
|
||||
</Link>
|
||||
, then points its own model calls at Hanzo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* The three doors, in descending order of how much you type. The same
|
||||
three the docs masthead offers, so the choice a reader makes here is
|
||||
the one they keep. Replaces the stats bar: 67 / 436 / 600+ / 7 asked a
|
||||
reader to be impressed before they knew what the thing was, and the
|
||||
counts went stale the moment a service shipped. */}
|
||||
{/* The track floor is the whole responsive story: a column never goes
|
||||
below 260px, and there are never more than three. Breakpoints cannot
|
||||
express this, which is why the three they replaced read 3 → 1 → 3 —
|
||||
at 768 the sidebar has appeared but the viewport has not grown to pay
|
||||
for it, so a viewport query says "wide" while the column is 500px and
|
||||
a third of it put "Build with App" on three lines. `auto-fill`
|
||||
measures the COLUMN, so the sidebar is simply part of the arithmetic
|
||||
and the row is right at every width without being told about any. */}
|
||||
<Grid
|
||||
columns={{ min: 260, max: 3 }}
|
||||
gap={16}
|
||||
style={{ position: 'relative', marginTop: 48, width: '100%', maxWidth: 896 }}
|
||||
>
|
||||
{[
|
||||
{
|
||||
eyebrow: 'No code',
|
||||
title: 'Build with App',
|
||||
body: 'Describe it in English and watch it build. Chat, agents and MCP tools in the browser.',
|
||||
href: 'https://hanzo.app',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
eyebrow: 'In your terminal',
|
||||
title: 'Build with Dev',
|
||||
body: 'Our coding agent, in your repo. Or bring Claude Code and Codex — they work here too.',
|
||||
href: '/docs/cli',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Lower level',
|
||||
title: 'Build with API',
|
||||
body: 'Over 400 models behind one REST endpoint, with SDKs for every language we ship.',
|
||||
href: '/docs/openapi',
|
||||
},
|
||||
].map((d) => (
|
||||
// Three rows — eyebrow, title, body — so the gaps are declared once
|
||||
// on the card instead of as a margin on each child that has to know
|
||||
// what follows it.
|
||||
<Link
|
||||
key={d.title}
|
||||
href={d.href}
|
||||
{...(d.external ? { target: '_blank', rel: 'noreferrer' } : {})}
|
||||
className="group rounded-2xl border border-white/[0.08] bg-white/[0.02] p-6 transition-all duration-200 hover:-translate-y-0.5 hover:border-white/20 hover:bg-white/[0.05]"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto auto auto',
|
||||
alignContent: 'start',
|
||||
rowGap: 6,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium text-neutral-300">
|
||||
{d.eyebrow}
|
||||
</span>
|
||||
<span
|
||||
className="text-lg font-semibold text-white"
|
||||
style={{ display: 'grid', gridAutoFlow: 'column', justifyContent: 'start', alignItems: 'center', columnGap: 6 }}
|
||||
>
|
||||
{d.title}
|
||||
<ArrowRight className="size-4 opacity-40 transition-all group-hover:translate-x-0.5 group-hover:opacity-100" />
|
||||
</span>
|
||||
<span className="text-sm leading-relaxed text-neutral-300">{d.body}</span>
|
||||
</Link>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
</section>
|
||||
|
||||
<div className="mx-auto w-full max-w-5xl px-6 md:px-8 space-y-24">
|
||||
|
||||
{/* -- Quick Start Guide ------------------------------------------- */}
|
||||
<section>
|
||||
<h2 className="text-3xl font-bold tracking-tight mb-3">
|
||||
Quick Start
|
||||
</h2>
|
||||
<p className="text-neutral-300 text-sm mb-8">
|
||||
Install the CLI, log in, and reach every capability from your terminal.
|
||||
</p>
|
||||
{/* Install, then use — the same two steps /docs/quickstart shows, and
|
||||
the same order of tabs, so the front page and the quickstart do not
|
||||
teach different install lines. npm is first because it is the one
|
||||
most readers already have. There is no pip tab: PyPI `hanzo` is the
|
||||
orchestration library and installs a `hanzo-py` command, so it
|
||||
cannot be a fourth way to get this binary. */}
|
||||
<Tabs items={['npm', 'Script', 'Homebrew', 'From source']}>
|
||||
<Tab value="npm">
|
||||
<CodeBlock code={`npm i -g hanzo`} lang="bash" />
|
||||
</Tab>
|
||||
<Tab value="Script">
|
||||
<CodeBlock code={`curl -fsSL hanzo.sh | sh`} lang="bash" />
|
||||
</Tab>
|
||||
<Tab value="Homebrew">
|
||||
<CodeBlock code={`brew install hanzoai/tap/hanzo`} lang="bash" />
|
||||
</Tab>
|
||||
<Tab value="From source">
|
||||
<CodeBlock
|
||||
code={`git clone https://github.com/hanzoai/cli && cd cli && cargo install --path .`}
|
||||
lang="bash"
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<p className="text-neutral-300 text-sm mt-6 mb-3">
|
||||
Sign in once, then reach every capability from the terminal — or from
|
||||
your own code.
|
||||
</p>
|
||||
|
||||
<Tabs items={['CLI', 'TypeScript', 'Python', 'Go', 'Rust', 'HTTP']}>
|
||||
<Tab value="CLI">
|
||||
<CodeBlock
|
||||
code={`hanzo auth login\n\nhanzo "explain quantum computing"\nhanzo models list\nhanzo projects deploy my-app`}
|
||||
lang="bash"
|
||||
/>
|
||||
</Tab>
|
||||
{/* Every SDK tab is the catalogue read, the same call /docs/quickstart
|
||||
opens with. It is the one operation all four generated clients
|
||||
express and the one that answers without a credential, so a reader
|
||||
can run the tab they are looking at. A typed chat call is not a
|
||||
fourth thing they could paste here: cloud declares
|
||||
/v1/chat/completions as an address with no requestBody, so every
|
||||
generated method takes no prompt — the HTTP tab is where a
|
||||
completion goes until the document describes its shape. */}
|
||||
<Tab value="TypeScript">
|
||||
<CodeBlock
|
||||
code={`// npm i hanzoai\nimport { AiApi, Configuration } from 'hanzoai'\n\nconst ai = new AiApi(new Configuration())\n\nconst { data } = await ai.getModels()\nconsole.log(data.data?.length, 'models')`}
|
||||
lang="typescript"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab value="Python">
|
||||
<CodeBlock
|
||||
code={`# pip install hanzoai\nfrom hanzoai.cloud import AiApi, ApiClient, Configuration\n\nai = AiApi(ApiClient(Configuration()))\n\nprint(len(ai.get_models().data or []), "models")`}
|
||||
lang="python"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab value="Go">
|
||||
<CodeBlock
|
||||
code={`// go get github.com/hanzoai/go-sdk/v8\nimport hanzoai "github.com/hanzoai/go-sdk/v8"\n\nclient := hanzoai.NewAPIClient(hanzoai.NewConfiguration())\n\nmodels, _, err := client.AiAPI.GetModels(ctx).Execute()`}
|
||||
lang="go"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab value="Rust">
|
||||
<CodeBlock
|
||||
code={`// cargo add hanzo-client\nuse hanzo_client::apis::{ai_api, configuration::Configuration};\n\nlet cfg = Configuration::new();\n\nlet models = ai_api::get_models(&cfg).await?;`}
|
||||
lang="rust"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab value="HTTP">
|
||||
<CodeBlock
|
||||
code={`curl https://api.hanzo.ai/v1/chat/completions \\\n -H "Authorization: Bearer $HANZO_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"zen5","messages":[{"role":"user","content":"Hello!"}]}'`}
|
||||
lang="bash"
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</section>
|
||||
|
||||
{/* -- Nine domains -- category-first ------------------------------- */}
|
||||
<section>
|
||||
<h2 className="text-3xl font-bold tracking-tight mb-3">
|
||||
One binary. The whole platform.
|
||||
</h2>
|
||||
<p className="text-neutral-300 text-sm mb-8">
|
||||
Every capability has one name and one route. Browse by domain — click any card to go deep.
|
||||
</p>
|
||||
{/* 220, not 240: the column at 768 is 457px, and 240 needs 492 for a
|
||||
second track, so a 40px difference in one number is the whole gap
|
||||
between 2-up and 1-up there. Measured at the three real container
|
||||
widths — 342 / 457 / 960 — this gives 1 / 2 / 3. */}
|
||||
<Grid columns={{ min: 220, max: 3 }} gap={12}>
|
||||
{domains.map((item) => {
|
||||
const isExternal = !!(item as { external?: boolean }).external;
|
||||
const linkProps = isExternal
|
||||
? { target: '_blank' as const, rel: 'noreferrer noopener' }
|
||||
: {};
|
||||
return (
|
||||
// Four rows: the icon/tag line, the name, the description, and
|
||||
// the footer pushed to the bottom by `1fr` on the description
|
||||
// row. Before, the footer was a separate flex child relying on
|
||||
// the frame's own gap; now the card states its own shape and the
|
||||
// minimum height has something to distribute.
|
||||
<Card
|
||||
key={item.name}
|
||||
className="group"
|
||||
minH={140}
|
||||
bg="rgba(255,255,255,0.02)"
|
||||
borderColor="rgba(255,255,255,0.08)"
|
||||
hoverStyle={{ borderColor: 'rgba(255,255,255,0.2)', bg: 'rgba(255,255,255,0.05)' }}
|
||||
rounded={16}
|
||||
py={0}
|
||||
gap={0}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto auto 1fr auto',
|
||||
transition: 'all 300ms',
|
||||
}}
|
||||
>
|
||||
{/* The whole card is the click target, so the link has no text
|
||||
of its own — it takes its accessible name from the card
|
||||
title it covers. Without this a screen reader announces a
|
||||
bare "link". */}
|
||||
{isExternal ? (
|
||||
<a
|
||||
href={item.href}
|
||||
{...linkProps}
|
||||
aria-label={item.name}
|
||||
className="absolute inset-0 z-10"
|
||||
/>
|
||||
) : (
|
||||
<Link href={item.href} aria-label={item.name} className="absolute inset-0 z-10" />
|
||||
)}
|
||||
<div
|
||||
className="p-5 sm:p-6 pb-0"
|
||||
style={{ display: 'grid', gridTemplateColumns: '1fr auto', alignItems: 'center', marginBottom: 12 }}
|
||||
>
|
||||
<div className="rounded-xl bg-white/[0.06] p-2" style={{ justifySelf: 'start' }}>
|
||||
<item.icon className="size-5 text-neutral-400 group-hover:text-white transition-colors" />
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-neutral-400">{item.tag}</span>
|
||||
</div>
|
||||
<CardTitle px={24} fontSize={14} fontWeight="600" color="#ffffff" mb={6}>
|
||||
{item.name}
|
||||
</CardTitle>
|
||||
<p className="px-5 sm:px-6 text-xs text-neutral-400 leading-relaxed group-hover:text-neutral-200 transition-colors">
|
||||
{item.desc}
|
||||
</p>
|
||||
<span
|
||||
className="p-5 sm:p-6 pt-3 text-[10px] font-medium text-neutral-400 group-hover:text-white transition-colors"
|
||||
style={{ display: 'grid', gridAutoFlow: 'column', justifyContent: 'start', alignItems: 'center', columnGap: 4 }}
|
||||
>
|
||||
{isExternal ? 'Visit' : 'View docs'} <ArrowRight className="size-3" />
|
||||
</span>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</section>
|
||||
|
||||
{/* -- Developer Tools --------------------------------------------- */}
|
||||
<section>
|
||||
<h2 className="text-3xl font-bold tracking-tight mb-3">
|
||||
Developer Tools
|
||||
</h2>
|
||||
<p className="text-neutral-300 text-sm mb-8">
|
||||
SDKs, APIs, and protocols for every stack.
|
||||
</p>
|
||||
{/* 230 is the 1 / 2 / 4 story the three breakpoints told, measured at
|
||||
the column rather than the window: 1-up on a phone, 2-up once the
|
||||
column clears ~476, and never more than four however wide it gets. */}
|
||||
<Grid columns={{ min: 230, max: 4 }} gap={16}>
|
||||
{devLinks.map((t) => (
|
||||
<Card
|
||||
key={t.name}
|
||||
className="group"
|
||||
bg="rgba(255,255,255,0.02)"
|
||||
borderColor="rgba(255,255,255,0.08)"
|
||||
hoverStyle={{ borderColor: 'rgba(255,255,255,0.2)', bg: 'rgba(255,255,255,0.04)' }}
|
||||
rounded={16}
|
||||
py={0}
|
||||
gap={0}
|
||||
style={{ position: 'relative', transition: 'all 300ms' }}
|
||||
>
|
||||
<Link href={t.href} aria-label={t.name} className="absolute inset-0 z-10" />
|
||||
<CardContent
|
||||
p={24}
|
||||
style={{ display: 'grid', gridTemplateRows: 'auto auto auto', justifyItems: 'start', rowGap: 4 }}
|
||||
>
|
||||
<t.icon className="size-5 text-neutral-400 mb-3 group-hover:text-white transition-colors" />
|
||||
<CardTitle fontSize={14} fontWeight="600" color="#ffffff">{t.name}</CardTitle>
|
||||
<p className="text-xs text-neutral-400 group-hover:text-neutral-200 transition-colors">{t.desc}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
</section>
|
||||
|
||||
{/* -- Models & providers ------------------------------------------ */}
|
||||
<section>
|
||||
<h2 className="text-3xl font-bold tracking-tight mb-3">
|
||||
Every model, one API
|
||||
</h2>
|
||||
<p className="text-neutral-300 text-sm mb-8">
|
||||
Over 400 models across every major provider — call any of them with one credential, one request shape.
|
||||
</p>
|
||||
<Grid columns={{ min: 120, max: 8 }} gap={12}>
|
||||
{[
|
||||
{ name: 'Zen', spec: 'Open weights' },
|
||||
{ name: 'OpenAI', spec: 'GPT' },
|
||||
{ name: 'Anthropic', spec: 'Claude' },
|
||||
{ name: 'Qwen', spec: 'Open' },
|
||||
{ name: 'Llama', spec: 'Open' },
|
||||
{ name: 'DeepSeek', spec: 'Open' },
|
||||
{ name: 'Mistral', spec: 'Open' },
|
||||
{ name: 'Gemma', spec: 'Open' },
|
||||
].map((p) => (
|
||||
<Card
|
||||
key={p.name}
|
||||
bg="rgba(255,255,255,0.02)"
|
||||
borderColor="rgba(255,255,255,0.06)"
|
||||
rounded={8}
|
||||
py={0}
|
||||
gap={0}
|
||||
>
|
||||
<CardContent style={{ display: 'grid', rowGap: 4, padding: '12px 16px', textAlign: 'center' }}>
|
||||
<span className="text-xs font-semibold text-white">{p.name}</span>
|
||||
<span className="text-[10px] text-neutral-400">{p.spec}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
</section>
|
||||
|
||||
{/* -- SDKs + connectors ------------------------------------------- */}
|
||||
<Grid columns={{ min: 470, max: 2 }} gap={16}>
|
||||
{[
|
||||
{
|
||||
icon: Code2,
|
||||
title: 'SDKs in every language',
|
||||
body: (
|
||||
<>
|
||||
Generated from one contract — the same <code className="font-mono">/v1</code> surface, typed for your stack.
|
||||
</>
|
||||
),
|
||||
chips: ['Python', 'TypeScript', 'Go', 'Rust', 'C++', 'Dart'],
|
||||
href: '/docs/sdks',
|
||||
cta: 'SDK reference',
|
||||
},
|
||||
{
|
||||
icon: Workflow,
|
||||
title: 'Every tool, one MCP surface',
|
||||
body: 'Native connectors + the open MCP registry — Slack, GitHub, Notion, Stripe, and more — exposed as MCP tools any agent can call.',
|
||||
chips: ['Slack', 'GitHub', 'Notion', 'Stripe', 'Google', 'Linear', '+700 more'],
|
||||
href: '/docs/mcp',
|
||||
cta: 'MCP tools',
|
||||
},
|
||||
].map((s) => (
|
||||
<Card
|
||||
key={s.title}
|
||||
bg="rgba(255,255,255,0.02)"
|
||||
borderColor="rgba(255,255,255,0.08)"
|
||||
rounded={16}
|
||||
py={0}
|
||||
gap={0}
|
||||
overflow="hidden"
|
||||
>
|
||||
<CardContent
|
||||
p={32}
|
||||
style={{ display: 'grid', gridTemplateRows: 'auto auto auto auto', justifyItems: 'start', rowGap: 8 }}
|
||||
>
|
||||
<span style={{ display: 'grid', gridAutoFlow: 'column', alignItems: 'center', columnGap: 12 }}>
|
||||
<s.icon className="size-5 text-neutral-400" />
|
||||
<CardTitle fontSize={20} fontWeight="700" letterSpacing={-0.4}>{s.title}</CardTitle>
|
||||
</span>
|
||||
<p className="text-xs text-neutral-400 mb-4">{s.body}</p>
|
||||
{/* A wrapping run of chips is the one place flex is still the
|
||||
right answer — grid cannot wrap items of unequal intrinsic
|
||||
width without giving them a track — so it is said with gui's
|
||||
XStack, whose style props travel to native, rather than with
|
||||
tailwind's. */}
|
||||
<XStack flexWrap="wrap" gap={8}>
|
||||
{s.chips.map((c) => (
|
||||
<span key={c} className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs text-neutral-400">
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</XStack>
|
||||
<Link
|
||||
href={s.href}
|
||||
className="mt-4 text-xs font-medium text-neutral-400 hover:text-white transition-colors"
|
||||
style={{ display: 'inline-grid', gridAutoFlow: 'column', alignItems: 'center', columnGap: 4 }}
|
||||
>
|
||||
{s.cta} <ArrowRight className="size-3" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* -- What the CLI can do ----------------------------------------- */}
|
||||
<Card
|
||||
bg="rgba(255,255,255,0.02)"
|
||||
borderColor="rgba(255,255,255,0.08)"
|
||||
rounded={16}
|
||||
py={0}
|
||||
gap={0}
|
||||
overflow="hidden"
|
||||
>
|
||||
<div className="p-8 md:p-10" style={{ display: 'grid', rowGap: 24 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'auto minmax(0, 1fr)', alignItems: 'center', columnGap: 12 }}>
|
||||
<div className="rounded-xl bg-white/[0.06] p-2.5">
|
||||
<Terminal className="size-5 text-neutral-400" />
|
||||
</div>
|
||||
<div style={{ display: 'grid', rowGap: 2 }}>
|
||||
<CardTitle fontSize={24} fontWeight="700" letterSpacing={-0.5}>
|
||||
The <code className="font-mono">hanzo</code> CLI
|
||||
</CardTitle>
|
||||
<span className="text-xs text-neutral-400">A ~15 MB Rust client for any live cloud — prod, laptop, or self-host</span>
|
||||
</div>
|
||||
</div>
|
||||
<Grid columns={{ min: 200, max: 3 }} gap={12}>
|
||||
{[
|
||||
{ cmd: 'hanzo chat', desc: 'Chat with any model interactively' },
|
||||
{ cmd: 'hanzo models list', desc: 'Browse every available model' },
|
||||
{ cmd: 'hanzo keys create', desc: 'Create and manage API keys' },
|
||||
{ cmd: 'hanzo deploy', desc: 'Deploy apps with git push' },
|
||||
{ cmd: 'hanzo logs', desc: 'Stream logs from any service' },
|
||||
{ cmd: 'hanzo storage', desc: 'Manage S3-compatible storage' },
|
||||
{ cmd: 'hanzo secrets', desc: 'Manage secrets and env vars' },
|
||||
{ cmd: 'hanzo bot', desc: 'Deploy and manage AI bots' },
|
||||
{ cmd: 'hanzo flow', desc: 'Run workflow automations' },
|
||||
].map((item) => (
|
||||
<Card
|
||||
key={item.cmd}
|
||||
bg="rgba(255,255,255,0.02)"
|
||||
borderColor="rgba(255,255,255,0.06)"
|
||||
rounded={8}
|
||||
py={0}
|
||||
gap={0}
|
||||
>
|
||||
<CardContent style={{ display: 'grid', rowGap: 4, padding: '12px 16px' }}>
|
||||
<span className="text-xs font-mono font-medium text-white">{item.cmd}</span>
|
||||
<span className="text-[11px] text-neutral-400">{item.desc}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* -- Zen Models Banner ------------------------------------------- */}
|
||||
<Card
|
||||
bg="rgba(255,255,255,0.02)"
|
||||
borderColor="rgba(255,255,255,0.08)"
|
||||
rounded={16}
|
||||
py={0}
|
||||
gap={0}
|
||||
overflow="hidden"
|
||||
>
|
||||
<div className="p-8 md:p-10" style={{ display: 'grid', rowGap: 24 }}>
|
||||
<div style={{ display: 'grid', gridAutoFlow: 'column', justifyContent: 'start', alignItems: 'center', columnGap: 12 }}>
|
||||
<Sparkle className="size-5 text-neutral-400" />
|
||||
<CardTitle fontSize={24} fontWeight="700" letterSpacing={-0.5}>
|
||||
Zen
|
||||
</CardTitle>
|
||||
<span className="rounded-full bg-white/5 px-2 py-0.5 font-mono text-xs text-neutral-400">open weights</span>
|
||||
</div>
|
||||
<p className="text-neutral-300 text-sm leading-relaxed max-w-2xl">
|
||||
The generative family: reasoning, code, vision, speech, embeddings and safety. Weights on
|
||||
Hugging Face; the same ids hosted on api.hanzo.ai, routed by Enso.
|
||||
</p>
|
||||
<Grid columns={{ min: 130, max: 6 }} gap={12}>
|
||||
{[
|
||||
{ name: 'zen6', spec: '27B dense, 1M context' },
|
||||
{ name: 'zen6-coder', spec: 'Agentic code' },
|
||||
{ name: 'zen6-flash', spec: 'Ternary vision' },
|
||||
{ name: 'zen5', spec: 'Hosted' },
|
||||
{ name: 'zen-embedding', spec: 'Retrieval' },
|
||||
{ name: 'zen-guard', spec: 'Safety' },
|
||||
].map((m) => (
|
||||
<Card
|
||||
key={m.name}
|
||||
bg="rgba(255,255,255,0.02)"
|
||||
borderColor="rgba(255,255,255,0.06)"
|
||||
rounded={8}
|
||||
py={0}
|
||||
gap={0}
|
||||
>
|
||||
<CardContent style={{ display: 'grid', rowGap: 4, padding: 12 }}>
|
||||
<span className="text-xs font-mono font-medium text-white">{m.name}</span>
|
||||
<span className="text-[10px] text-neutral-400">{m.spec}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
<XStack flexWrap="wrap" gap={12} alignItems="center">
|
||||
<Link
|
||||
href="/docs/models/zen"
|
||||
className="rounded-full border border-white/20 px-4 py-2 text-xs text-white transition-colors hover:bg-white/5"
|
||||
>
|
||||
Zen models →
|
||||
</Link>
|
||||
<a
|
||||
href="https://huggingface.co/zenlm"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-xs text-neutral-400 hover:text-white transition-colors"
|
||||
>
|
||||
HuggingFace →
|
||||
</a>
|
||||
</XStack>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* -- CTA --------------------------------------------------------- */}
|
||||
<section className="relative text-center py-16">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_400px_200px_at_50%_50%,rgba(255,255,255,0.03),transparent_70%)]" />
|
||||
<h2 className="relative text-3xl md:text-4xl font-bold tracking-tight mb-3">
|
||||
Start building
|
||||
</h2>
|
||||
<p className="relative text-neutral-400 text-sm mb-3">
|
||||
Free tier with generous limits. No credit card required.
|
||||
</p>
|
||||
<div className="relative font-mono text-sm text-neutral-400 mb-8">
|
||||
curl hanzo.sh | sh
|
||||
</div>
|
||||
<XStack flexWrap="wrap" gap={12} alignItems="center" justifyContent="center" position="relative">
|
||||
<a
|
||||
href="https://hanzo.id/signup?redirect_uri=https://console.hanzo.ai"
|
||||
className="rounded-full bg-white px-8 py-3 text-sm font-medium text-black transition-colors hover:bg-neutral-200"
|
||||
style={{ display: 'inline-grid', gridAutoFlow: 'column', alignItems: 'center', columnGap: 4 }}
|
||||
>
|
||||
Sign Up Free
|
||||
<ArrowRight className="size-4" />
|
||||
</a>
|
||||
<Link
|
||||
href="/docs"
|
||||
className="rounded-full border border-white/15 px-8 py-3 text-sm font-medium text-white transition-colors hover:bg-white/5"
|
||||
>
|
||||
Browse Documentation
|
||||
</Link>
|
||||
</XStack>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<Tabs items={rows.map(([name]) => name)}>
|
||||
{rows.map(([name, lang, code]) => (
|
||||
<Tab key={name} value={name}>
|
||||
<Example lang={lang} code={code} />
|
||||
</Tab>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <Landing install={<Group rows={install} />} use={<Group rows={use} />} />;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Action } from '@/components/action'
|
||||
import { Status } from '@/components/status'
|
||||
import { iam } from '@/lib/iam'
|
||||
|
||||
export default function CallbackPage() {
|
||||
@@ -16,29 +18,18 @@ export default function CallbackPage() {
|
||||
.catch(() => setError('Authentication failed. Please try again.'))
|
||||
}, [])
|
||||
|
||||
if (error) {
|
||||
if (error)
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-neutral-950">
|
||||
<div className="text-center">
|
||||
<h2 className="mb-2 text-lg font-semibold text-white">Sign In Failed</h2>
|
||||
<p className="mb-4 text-sm text-neutral-400">{error}</p>
|
||||
<a
|
||||
href="/login"
|
||||
className="inline-block rounded-full bg-white px-4 py-1.5 text-sm font-medium text-black hover:bg-neutral-200 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<Status
|
||||
title="Sign in failed"
|
||||
note={error}
|
||||
action={
|
||||
<Action tone="loud" render="a" href="/login" mt={6} rounded={999}>
|
||||
Try again
|
||||
</Action>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-neutral-950">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
<p className="text-sm text-neutral-400">Signing in...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <Status note="Signing in…" />
|
||||
}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { createMetadata } from '@/lib/metadata';
|
||||
import { source } from '@/lib/source';
|
||||
import { inlineCode } from '@/lib/inline-code';
|
||||
import { Mermaid } from '@/components/mdx/mermaid';
|
||||
import { PageFeedback } from '@/components/feedback';
|
||||
import { Feedback } from '@/components/feedback';
|
||||
import { getMDXComponents } from '@/components/mdx';
|
||||
import { A, Body } from '@/components/mdx/prose';
|
||||
import { Card, Cards } from '@/components/mdx/blocks';
|
||||
import { Page as Frame } from '@/components/page';
|
||||
import { getBreadcrumbItems } from '@hanzo/docs-core/breadcrumb';
|
||||
import { findNeighbour, findSiblings } from '@hanzo/docs-core/page-tree';
|
||||
import { NotFound } from '@/components/layouts/not-found';
|
||||
import { MdxErrorBoundary } from '@/components/mdx-error-boundary';
|
||||
import { getSuggestions } from './suggestions';
|
||||
import { PathUtils } from '@hanzo/docs-core/source';
|
||||
|
||||
export const revalidate = false;
|
||||
@@ -22,12 +21,9 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
|
||||
if (!page)
|
||||
return (
|
||||
<NotFound
|
||||
getSuggestions={async () => (params.slug ? getSuggestions(params.slug.join(' ')) : [])}
|
||||
/>
|
||||
);
|
||||
// The export holds only the pages the source lists; any other address is the
|
||||
// site's 404 (app/not-found.tsx).
|
||||
if (!page) notFound();
|
||||
|
||||
const { body: Mdx, toc, lastModified } = await page.data.load();
|
||||
const tree = source.getPageTree();
|
||||
@@ -65,7 +61,7 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
|
||||
</MdxErrorBoundary>
|
||||
{page.data.index ? <DocsCategory url={page.url} /> : null}
|
||||
</Body>
|
||||
<PageFeedback />
|
||||
<Feedback />
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { Suggestion } from '@/components/layouts/not-found';
|
||||
import { publishableKey, searchEndpoint } from '@/lib/hanzo/client';
|
||||
|
||||
interface HanzoSuggestionHit {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface HanzoSuggestionResponse {
|
||||
hits: HanzoSuggestionHit[];
|
||||
}
|
||||
|
||||
export async function getSuggestions(pathname: string): Promise<Suggestion[]> {
|
||||
if (!publishableKey) return [];
|
||||
|
||||
const res = await fetch(searchEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${publishableKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: pathname,
|
||||
mode: 'vector',
|
||||
limit: 5,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) return [];
|
||||
|
||||
const data = (await res.json()) as HanzoSuggestionResponse;
|
||||
|
||||
const seen = new Set<string>();
|
||||
return data.hits.flatMap((hit) => {
|
||||
if (seen.has(hit.url)) return [];
|
||||
seen.add(hit.url);
|
||||
return {
|
||||
id: hit.id,
|
||||
href: hit.url,
|
||||
title: hit.title,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -37,12 +37,13 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={`${Zen.variable} ${ZenMono.variable} t_dark`} suppressHydrationWarning>
|
||||
<body>
|
||||
<NextProvider>
|
||||
<TreeProvider tree={clientTree()}>
|
||||
<Provider>{children}</Provider>
|
||||
</TreeProvider>
|
||||
</NextProvider>
|
||||
<Analytics product="docs" />
|
||||
<Analytics product="docs">
|
||||
<NextProvider>
|
||||
<TreeProvider tree={clientTree()}>
|
||||
<Provider>{children}</Provider>
|
||||
</TreeProvider>
|
||||
</NextProvider>
|
||||
</Analytics>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Action } from '@/components/action'
|
||||
import { Status } from '@/components/status'
|
||||
import { iam } from '@/lib/iam'
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -19,29 +21,18 @@ export default function LoginPage() {
|
||||
.catch(() => setError('Failed to load authentication. Please try again.'))
|
||||
}, [])
|
||||
|
||||
if (error) {
|
||||
if (error)
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-neutral-950">
|
||||
<div className="text-center">
|
||||
<h2 className="mb-2 text-lg font-semibold text-white">Sign In Error</h2>
|
||||
<p className="mb-4 text-sm text-neutral-400">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="rounded-full bg-white px-4 py-1.5 text-sm font-medium text-black hover:bg-neutral-200 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Status
|
||||
title="Sign in error"
|
||||
note={error}
|
||||
action={
|
||||
<Action tone="loud" render="button" type="button" onPress={() => window.location.reload()} mt={6} rounded={999}>
|
||||
Try again
|
||||
</Action>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-neutral-950">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
<p className="text-sm text-neutral-400">Redirecting to sign in...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <Status note="Redirecting to sign in…" />
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Shell } from '@/components/shell';
|
||||
import { Footer } from '@/components/footer';
|
||||
import { NotFound } from '@/components/layouts/not-found';
|
||||
import { Missing } from '@/components/missing';
|
||||
|
||||
// The export writes this page as 404.html, and the edge serves it for any path
|
||||
// the site does not hold (ingress staticFiles errorPage404). A miss is answered
|
||||
@@ -17,18 +17,18 @@ export const metadata: Metadata = {
|
||||
// Static, because a static export has no request to reason about: the path that
|
||||
// missed is not known at build time.
|
||||
const suggestions = [
|
||||
{ id: 'docs', href: '/docs', title: 'Documentation' },
|
||||
{ id: 'quickstart', href: '/docs/quickstart', title: 'Quickstart' },
|
||||
{ id: 'openapi', href: '/docs/openapi', title: 'API reference' },
|
||||
{ id: 'sdks', href: '/docs/sdks', title: 'SDKs' },
|
||||
{ id: 'mcp', href: '/docs/mcp', title: 'MCP' },
|
||||
{ href: '/docs', title: 'Documentation' },
|
||||
{ href: '/docs/quickstart', title: 'Quickstart' },
|
||||
{ href: '/docs/openapi', title: 'API reference' },
|
||||
{ href: '/docs/sdks', title: 'SDKs' },
|
||||
{ href: '/docs/mcp', title: 'MCP' },
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<Shell>
|
||||
<NotFound getSuggestions={async () => suggestions} />
|
||||
<Missing suggestions={suggestions} />
|
||||
</Shell>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { RootProvider } from '@hanzo/docs-base-ui/provider/base';
|
||||
import { NextThemeProvider, useRootTheme } from '@hanzogui/next-theme';
|
||||
import { Hanzo } from '@hanzo/ui';
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const SearchDialog = dynamic(() => import('@/components/layouts/search'), {
|
||||
ssr: false,
|
||||
});
|
||||
import { SearchProvider } from '@/components/search';
|
||||
|
||||
// ONE authority for the theme: the `t_dark` / `t_light` class on <html>.
|
||||
//
|
||||
@@ -17,9 +12,6 @@ const SearchDialog = dynamic(() => import('@/components/layouts/search'), {
|
||||
// tokens, @hanzo/ui's design tokens and this app's CSS all key on it. `<Hanzo>`
|
||||
// takes the same name, so the components and the document cannot disagree:
|
||||
// useRootTheme reads the class the script already wrote, and a toggle moves both.
|
||||
//
|
||||
// RootProvider stays for the search dialog alone (its context and ⌘K) until
|
||||
// search moves onto gui; its next-themes copy is off.
|
||||
export function Provider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useRootTheme({ fallback: 'dark' });
|
||||
|
||||
@@ -31,9 +23,7 @@ export function Provider({ children }: { children: ReactNode }) {
|
||||
onChangeTheme={(name) => setTheme(name === 'light' ? 'light' : 'dark')}
|
||||
>
|
||||
<Hanzo theme={theme}>
|
||||
<RootProvider search={{ SearchDialog }} theme={{ enabled: false }}>
|
||||
{children}
|
||||
</RootProvider>
|
||||
<SearchProvider>{children}</SearchProvider>
|
||||
</Hanzo>
|
||||
</NextThemeProvider>
|
||||
);
|
||||
|
||||
@@ -1,484 +0,0 @@
|
||||
'use client';
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
type SyntheticEvent,
|
||||
use,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Loader2, MessageCircleIcon, RefreshCw, SearchIcon, Send, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
|
||||
import { useChat, type UseChatHelpers } from '@ai-sdk/react';
|
||||
import { DefaultChatTransport, type Tool, type UIMessage, type UIToolInvocation } from 'ai';
|
||||
import { Markdown } from '../markdown';
|
||||
import { Presence } from '@radix-ui/react-presence';
|
||||
|
||||
export type ChatUIMessage = UIMessage<
|
||||
never,
|
||||
{
|
||||
client: {
|
||||
location: string;
|
||||
};
|
||||
}
|
||||
>;
|
||||
|
||||
export type SearchTool = Tool<{ query: string; limit: number }>;
|
||||
|
||||
const Context = createContext<{
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
chat: UseChatHelpers<ChatUIMessage>;
|
||||
} | null>(null);
|
||||
|
||||
export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div'>) {
|
||||
const { setOpen } = useAISearchContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 flex items-start gap-2 border rounded-xl bg-fd-secondary text-fd-secondary-foreground shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="px-3 py-2 flex-1">
|
||||
<p className="text-sm font-medium mb-2">AI Chat</p>
|
||||
<p className="text-xs text-fd-muted-foreground">
|
||||
AI can be inaccurate, please verify the answers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
aria-label="Close"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'icon-sm',
|
||||
color: 'ghost',
|
||||
className: 'text-fd-muted-foreground rounded-full',
|
||||
}),
|
||||
)}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AISearchInputActions() {
|
||||
const { messages, status, setMessages, regenerate } = useChatContext();
|
||||
const isLoading = status === 'streaming';
|
||||
|
||||
if (messages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isLoading && messages.at(-1)?.role === 'assistant' && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
size: 'sm',
|
||||
className: 'rounded-full gap-1.5',
|
||||
}),
|
||||
)}
|
||||
onClick={() => regenerate()}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
size: 'sm',
|
||||
className: 'rounded-full',
|
||||
}),
|
||||
)}
|
||||
onClick={() => setMessages([])}
|
||||
>
|
||||
Clear Chat
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StorageKeyInput = '__ai_search_input';
|
||||
export function AISearchInput(props: ComponentProps<'form'>) {
|
||||
const { status, sendMessage, stop } = useChatContext();
|
||||
const [input, setInput] = useState(() => localStorage.getItem(StorageKeyInput) ?? '');
|
||||
const isLoading = status === 'streaming' || status === 'submitted';
|
||||
const onStart = (e?: SyntheticEvent) => {
|
||||
e?.preventDefault();
|
||||
const message = input.trim();
|
||||
if (message.length === 0) return;
|
||||
|
||||
void sendMessage({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
type: 'data-client',
|
||||
data: {
|
||||
location: location.href,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
});
|
||||
setInput('');
|
||||
localStorage.removeItem(StorageKeyInput);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) document.getElementById('nd-ai-input')?.focus();
|
||||
}, [isLoading]);
|
||||
|
||||
return (
|
||||
<form {...props} className={cn('flex items-start pe-2', props.className)} onSubmit={onStart}>
|
||||
<Input
|
||||
value={input}
|
||||
placeholder={isLoading ? 'AI is answering...' : 'Ask a question'}
|
||||
autoFocus
|
||||
className="p-3"
|
||||
disabled={status === 'streaming' || status === 'submitted'}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
localStorage.setItem(StorageKeyInput, e.target.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!event.shiftKey && event.key === 'Enter') {
|
||||
onStart(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<button
|
||||
key="bn"
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
className: 'transition-all rounded-full mt-2 gap-2',
|
||||
}),
|
||||
)}
|
||||
onClick={stop}
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin text-fd-muted-foreground" />
|
||||
Abort Answer
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
key="bn"
|
||||
type="submit"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'primary',
|
||||
className: 'transition-all rounded-full mt-2',
|
||||
}),
|
||||
)}
|
||||
disabled={input.length === 0}
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function List(props: Omit<ComponentProps<'div'>, 'dir'>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
function callback() {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'instant',
|
||||
});
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(callback);
|
||||
callback();
|
||||
|
||||
const element = containerRef.current?.firstElementChild;
|
||||
|
||||
if (element) {
|
||||
observer.observe(element);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
{...props}
|
||||
className={cn('fd-scroll-container overflow-y-auto min-w-0 flex flex-col', props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Input(props: ComponentProps<'textarea'>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const shared = cn('col-start-1 row-start-1', props.className);
|
||||
|
||||
return (
|
||||
<div className="grid flex-1">
|
||||
<textarea
|
||||
id="nd-ai-input"
|
||||
{...props}
|
||||
className={cn(
|
||||
'resize-none bg-transparent placeholder:text-fd-muted-foreground focus-visible:outline-none',
|
||||
shared,
|
||||
)}
|
||||
/>
|
||||
<div ref={ref} className={cn(shared, 'break-all invisible')}>
|
||||
{`${props.value?.toString() ?? ''}\n`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const roleName: Record<string, string> = {
|
||||
user: 'you',
|
||||
assistant: 'Hanzo Docs',
|
||||
};
|
||||
|
||||
function Message({ message, ...props }: { message: ChatUIMessage } & ComponentProps<'div'>) {
|
||||
let markdown = '';
|
||||
const searchCalls: UIToolInvocation<SearchTool>[] = [];
|
||||
|
||||
for (const part of message.parts ?? []) {
|
||||
if (part.type === 'text') {
|
||||
markdown += part.text;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.type.startsWith('tool-')) {
|
||||
const toolName = part.type.slice('tool-'.length);
|
||||
const p = part as UIToolInvocation<Tool>;
|
||||
|
||||
if (toolName !== 'search' || !p.toolCallId) continue;
|
||||
searchCalls.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()} {...props}>
|
||||
<p
|
||||
className={cn(
|
||||
'mb-1 text-sm font-medium text-fd-muted-foreground',
|
||||
message.role === 'assistant' && 'text-fd-primary',
|
||||
)}
|
||||
>
|
||||
{roleName[message.role] ?? 'unknown'}
|
||||
</p>
|
||||
<div className="prose text-sm">
|
||||
<Markdown text={markdown} />
|
||||
</div>
|
||||
|
||||
{searchCalls.map((call) => {
|
||||
return (
|
||||
<div
|
||||
key={call.toolCallId}
|
||||
className="flex flex-row gap-2 items-center mt-3 rounded-lg border bg-fd-secondary text-fd-muted-foreground text-xs p-2"
|
||||
>
|
||||
<SearchIcon className="size-4" />
|
||||
{call.state === 'output-error' || call.state === 'output-denied' ? (
|
||||
<p className="text-fd-error">{call.errorText ?? 'Failed to search'}</p>
|
||||
) : (
|
||||
<p>{!call.output ? 'Searching…' : `${call.output.length} search results`}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AISearch({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const chat = useChat<ChatUIMessage>({
|
||||
id: 'search',
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<Context value={useMemo(() => ({ chat, open, setOpen }), [chat, open])}>{children}</Context>
|
||||
);
|
||||
}
|
||||
|
||||
export function AISearchTrigger({
|
||||
position = 'default',
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<'button'> & { position?: 'default' | 'float' }) {
|
||||
const { open, setOpen } = useAISearchContext();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
className={cn(
|
||||
position === 'float' && [
|
||||
'fixed bottom-4 gap-3 w-24 inset-e-[calc(--spacing(4)+var(--removed-body-scroll-bar-size,0px))] shadow-lg z-20 transition-[translate,opacity]',
|
||||
open && 'translate-y-10 opacity-0',
|
||||
],
|
||||
className,
|
||||
)}
|
||||
onClick={() => setOpen(!open)}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AISearchPanel() {
|
||||
const { open, setOpen } = useAISearchContext();
|
||||
useHotKey();
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes ask-ai-open {
|
||||
from {
|
||||
translate: 100% 0;
|
||||
}
|
||||
to {
|
||||
translate: 0 0;
|
||||
}
|
||||
}
|
||||
@keyframes ask-ai-close {
|
||||
from {
|
||||
width: var(--ai-chat-width);
|
||||
}
|
||||
to {
|
||||
width: 0px;
|
||||
}
|
||||
}`}
|
||||
</style>
|
||||
<Presence present={open}>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-30 backdrop-blur-xs bg-fd-overlay lg:hidden',
|
||||
open ? 'animate-fd-fade-in' : 'animate-fd-fade-out',
|
||||
)}
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
</Presence>
|
||||
<Presence present={open}>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden z-30 bg-fd-card text-fd-card-foreground [--ai-chat-width:400px] 2xl:[--ai-chat-width:460px]',
|
||||
'max-lg:fixed max-lg:inset-x-2 max-lg:inset-y-4 max-lg:border max-lg:rounded-2xl max-lg:shadow-xl',
|
||||
'lg:sticky lg:top-0 lg:h-dvh lg:border-s lg:ms-auto lg:in-[#nd-docs-layout]:[grid-area:toc] lg:in-[#nd-notebook-layout]:row-span-full lg:in-[#nd-notebook-layout]:col-start-5',
|
||||
open
|
||||
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms]'
|
||||
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col size-full p-2 lg:p-3 lg:w-(--ai-chat-width)">
|
||||
<AISearchPanelHeader />
|
||||
<AISearchPanelList className="flex-1" />
|
||||
<div className="rounded-xl border bg-fd-secondary text-fd-secondary-foreground shadow-sm has-focus-visible:shadow-md">
|
||||
<AISearchInput />
|
||||
<div className="flex items-center gap-1.5 p-1 empty:hidden">
|
||||
<AISearchInputActions />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Presence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AISearchPanelList({ className, style, ...props }: ComponentProps<'div'>) {
|
||||
const chat = useChatContext();
|
||||
const messages = chat.messages.filter((msg) => msg.role !== 'system');
|
||||
|
||||
return (
|
||||
<List
|
||||
className={cn('py-4 overscroll-contain', className)}
|
||||
style={{
|
||||
maskImage:
|
||||
'linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 1rem), transparent 100%)',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-sm text-fd-muted-foreground/80 size-full flex flex-col items-center justify-center text-center gap-2">
|
||||
<MessageCircleIcon fill="currentColor" stroke="none" />
|
||||
<p onClick={(e) => e.stopPropagation()}>Start a new chat below.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col px-3 gap-4">
|
||||
{chat.error && (
|
||||
<div className="p-2 bg-fd-secondary text-fd-secondary-foreground border rounded-lg">
|
||||
<p className="text-xs text-fd-muted-foreground mb-1">
|
||||
Request Failed: {chat.error.name}
|
||||
</p>
|
||||
<p className="text-sm">{chat.error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((item) => (
|
||||
<Message key={item.id} message={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
export function useHotKey() {
|
||||
const { open, setOpen } = useAISearchContext();
|
||||
|
||||
const onKeyPress = useEffectEvent((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
setOpen(false);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (e.key === '/' && (e.metaKey || e.ctrlKey) && !open) {
|
||||
setOpen(true);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', onKeyPress);
|
||||
return () => window.removeEventListener('keydown', onKeyPress);
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useAISearchContext() {
|
||||
return use(Context)!;
|
||||
}
|
||||
|
||||
function useChatContext() {
|
||||
return use(Context)!.chat;
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { remark } from 'remark';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
|
||||
import {
|
||||
Children,
|
||||
type ComponentProps,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
use,
|
||||
useDeferredValue,
|
||||
} from 'react';
|
||||
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
|
||||
import { DynamicCodeBlock } from '@hanzo/docs-base-ui/components/dynamic-codeblock';
|
||||
import defaultMdxComponents from '@hanzo/docs-base-ui/mdx';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import type { ElementContent, Root, RootContent } from 'hast';
|
||||
|
||||
export interface Processor {
|
||||
process: (content: string) => Promise<ReactNode>;
|
||||
}
|
||||
|
||||
export function rehypeWrapWords() {
|
||||
return (tree: Root) => {
|
||||
visit(tree, ['text', 'element'], (node, index, parent) => {
|
||||
if (node.type === 'element' && node.tagName === 'pre') return 'skip';
|
||||
if (node.type !== 'text' || !parent || index === undefined) return;
|
||||
|
||||
const words = node.value.split(/(?=\s)/);
|
||||
|
||||
// Create new span nodes for each word and whitespace
|
||||
const newNodes: ElementContent[] = words.flatMap((word) => {
|
||||
if (word.length === 0) return [];
|
||||
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: {
|
||||
class: 'animate-fd-fade-in',
|
||||
},
|
||||
children: [{ type: 'text', value: word }],
|
||||
};
|
||||
});
|
||||
|
||||
Object.assign(node, {
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: {},
|
||||
children: newNodes,
|
||||
} satisfies RootContent);
|
||||
return 'skip';
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createProcessor(): Processor {
|
||||
const processor = remark().use(remarkGfm).use(remarkRehype).use(rehypeWrapWords);
|
||||
|
||||
return {
|
||||
async process(content) {
|
||||
const nodes = processor.parse({ value: content });
|
||||
const hast = await processor.run(nodes);
|
||||
|
||||
return toJsxRuntime(hast, {
|
||||
development: false,
|
||||
jsx,
|
||||
jsxs,
|
||||
Fragment,
|
||||
components: {
|
||||
...defaultMdxComponents,
|
||||
pre: Pre,
|
||||
img: undefined, // use JSX
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Pre(props: ComponentProps<'pre'>) {
|
||||
const code = Children.only(props.children) as ReactElement;
|
||||
const codeProps = code.props as ComponentProps<'code'>;
|
||||
const content = codeProps.children;
|
||||
if (typeof content !== 'string') return null;
|
||||
|
||||
let lang =
|
||||
codeProps.className
|
||||
?.split(' ')
|
||||
.find((v) => v.startsWith('language-'))
|
||||
?.slice('language-'.length) ?? 'text';
|
||||
|
||||
if (lang === 'mdx') lang = 'md';
|
||||
|
||||
return <DynamicCodeBlock lang={lang} code={content.trimEnd()} />;
|
||||
}
|
||||
|
||||
const processor = createProcessor();
|
||||
|
||||
export function Markdown({ text }: { text: string }) {
|
||||
const deferredText = useDeferredValue(text);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<p className="invisible">{text}</p>}>
|
||||
<Renderer text={deferredText} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const cache = new Map<string, Promise<ReactNode>>();
|
||||
|
||||
function Renderer({ text }: { text: string }) {
|
||||
const result = cache.get(text) ?? processor.process(text);
|
||||
cache.set(text, result);
|
||||
|
||||
return use(result);
|
||||
}
|
||||
@@ -1,477 +0,0 @@
|
||||
'use client';
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
type SyntheticEvent,
|
||||
use,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Loader2, MessageCircleIcon, RefreshCw, Send, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
|
||||
import Link from '@hanzo/docs-core/link';
|
||||
import type { ProvideLinksToolSchema } from '@/lib/ai/qa-schema';
|
||||
import type { z } from 'zod';
|
||||
import { Markdown } from '../markdown';
|
||||
import { Presence } from '@radix-ui/react-presence';
|
||||
import { useHanzoChat, type ChatMessage, type HanzoChat } from '@/lib/hanzo/use-chat';
|
||||
|
||||
const Context = createContext<{
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
chat: HanzoChat;
|
||||
} | null>(null);
|
||||
|
||||
// Citation URLs come from untrusted model output: `provideLinks` is parsed by a
|
||||
// loose schema whose `url` is any string, so a model can emit `javascript:` or
|
||||
// `data:`. Render a link only when its href is site-relative ('/…') or resolves
|
||||
// to http(s) — the render-time scheme allowlist.
|
||||
function isSafeHref(url: string | null | undefined): boolean {
|
||||
if (!url) return false;
|
||||
const u = url.trim();
|
||||
if (u.startsWith('/') && !u.startsWith('//')) return true;
|
||||
try {
|
||||
const { protocol } = new URL(u, 'https://docs.hanzo.ai');
|
||||
return protocol === 'https:' || protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div'>) {
|
||||
const { setOpen } = useAISearchContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 flex items-start gap-2 border rounded-xl bg-fd-secondary text-fd-secondary-foreground shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="px-3 py-2 flex-1">
|
||||
<p className="text-sm font-medium mb-2">AI Chat</p>
|
||||
<p className="text-xs text-fd-muted-foreground">
|
||||
Powered by{' '}
|
||||
<a href="https://hanzo.ai" target="_blank" rel="noreferrer noopener">
|
||||
Hanzo AI
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
aria-label="Close"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'icon-sm',
|
||||
color: 'ghost',
|
||||
className: 'text-fd-muted-foreground rounded-full',
|
||||
}),
|
||||
)}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AISearchInputActions() {
|
||||
const { messages, status, setMessages, regenerate } = useChatContext();
|
||||
const isLoading = status === 'streaming';
|
||||
|
||||
if (messages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isLoading && messages.at(-1)?.role === 'assistant' && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
size: 'sm',
|
||||
className: 'rounded-full gap-1.5',
|
||||
}),
|
||||
)}
|
||||
onClick={() => regenerate()}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
size: 'sm',
|
||||
className: 'rounded-full',
|
||||
}),
|
||||
)}
|
||||
onClick={() => setMessages([])}
|
||||
>
|
||||
Clear Chat
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StorageKeyInput = '__ai_search_input';
|
||||
export function AISearchInput(props: ComponentProps<'form'>) {
|
||||
const { status, sendMessage, stop } = useChatContext();
|
||||
const [input, setInput] = useState(() => localStorage.getItem(StorageKeyInput) ?? '');
|
||||
const isLoading = status === 'streaming' || status === 'submitted';
|
||||
const onStart = (e?: SyntheticEvent) => {
|
||||
e?.preventDefault();
|
||||
const message = input.trim();
|
||||
if (message.length === 0) return;
|
||||
|
||||
void sendMessage({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
type: 'data-client',
|
||||
data: {
|
||||
location: location.href,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
});
|
||||
setInput('');
|
||||
localStorage.removeItem(StorageKeyInput);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) document.getElementById('nd-ai-input')?.focus();
|
||||
}, [isLoading]);
|
||||
|
||||
return (
|
||||
<form {...props} className={cn('flex items-start pe-2', props.className)} onSubmit={onStart}>
|
||||
<Input
|
||||
value={input}
|
||||
placeholder={isLoading ? 'AI is answering...' : 'Ask a question'}
|
||||
autoFocus
|
||||
className="p-3"
|
||||
disabled={status === 'streaming' || status === 'submitted'}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
localStorage.setItem(StorageKeyInput, e.target.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!event.shiftKey && event.key === 'Enter') {
|
||||
onStart(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<button
|
||||
key="bn"
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
className: 'transition-all rounded-full mt-2 gap-2',
|
||||
}),
|
||||
)}
|
||||
onClick={stop}
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin text-fd-muted-foreground" />
|
||||
Abort Answer
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
key="bn"
|
||||
type="submit"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'primary',
|
||||
className: 'transition-all rounded-full mt-2',
|
||||
}),
|
||||
)}
|
||||
disabled={input.length === 0}
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function List(props: Omit<ComponentProps<'div'>, 'dir'>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
function callback() {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'instant',
|
||||
});
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(callback);
|
||||
callback();
|
||||
|
||||
const element = containerRef.current?.firstElementChild;
|
||||
|
||||
if (element) {
|
||||
observer.observe(element);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
{...props}
|
||||
className={cn('fd-scroll-container overflow-y-auto min-w-0 flex flex-col', props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Input(props: ComponentProps<'textarea'>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const shared = cn('col-start-1 row-start-1', props.className);
|
||||
|
||||
return (
|
||||
<div className="grid flex-1">
|
||||
<textarea
|
||||
id="nd-ai-input"
|
||||
{...props}
|
||||
className={cn(
|
||||
'resize-none bg-transparent placeholder:text-fd-muted-foreground focus-visible:outline-none',
|
||||
shared,
|
||||
)}
|
||||
/>
|
||||
<div ref={ref} className={cn(shared, 'break-all invisible')}>
|
||||
{`${props.value?.toString() ?? ''}\n`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const roleName: Record<string, string> = {
|
||||
user: 'you',
|
||||
assistant: 'Hanzo Docs',
|
||||
};
|
||||
|
||||
function Message({ message, ...props }: { message: ChatMessage } & ComponentProps<'div'>) {
|
||||
let markdown = '';
|
||||
let links: z.infer<typeof ProvideLinksToolSchema>['links'] = [];
|
||||
|
||||
for (const part of message.parts ?? []) {
|
||||
if (part.type === 'text') {
|
||||
markdown += part.text;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.type === 'tool-provideLinks' && part.input) {
|
||||
links = (part.input as z.infer<typeof ProvideLinksToolSchema>).links;
|
||||
}
|
||||
}
|
||||
|
||||
const safeLinks = (links ?? []).filter((item) => isSafeHref(item?.url));
|
||||
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()} {...props}>
|
||||
<p
|
||||
className={cn(
|
||||
'mb-1 text-sm font-medium text-fd-muted-foreground',
|
||||
message.role === 'assistant' && 'text-fd-primary',
|
||||
)}
|
||||
>
|
||||
{roleName[message.role] ?? 'unknown'}
|
||||
</p>
|
||||
<div className="prose text-sm">
|
||||
<Markdown text={markdown} />
|
||||
</div>
|
||||
{safeLinks.length > 0 && (
|
||||
<div className="mt-2 flex flex-row flex-wrap items-center gap-1">
|
||||
{safeLinks.map((item, i) => (
|
||||
<Link
|
||||
key={i}
|
||||
href={item.url}
|
||||
className="block text-xs rounded-lg border p-3 hover:bg-fd-accent hover:text-fd-accent-foreground"
|
||||
>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-fd-muted-foreground">Reference {item.label}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AISearch({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const chat = useHanzoChat();
|
||||
|
||||
return (
|
||||
<Context value={useMemo(() => ({ chat, open, setOpen }), [chat, open])}>{children}</Context>
|
||||
);
|
||||
}
|
||||
|
||||
export function AISearchTrigger({
|
||||
position = 'default',
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<'button'> & { position?: 'default' | 'float' }) {
|
||||
const { open, setOpen } = useAISearchContext();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
className={cn(
|
||||
position === 'float' && [
|
||||
'fixed bottom-4 gap-3 w-24 inset-e-[calc(--spacing(4)+var(--removed-body-scroll-bar-size,0px))] shadow-lg z-20 transition-[translate,opacity]',
|
||||
open && 'translate-y-10 opacity-0',
|
||||
],
|
||||
className,
|
||||
)}
|
||||
onClick={() => setOpen(!open)}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AISearchPanel() {
|
||||
const { open, setOpen } = useAISearchContext();
|
||||
useHotKey();
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes ask-ai-open {
|
||||
from {
|
||||
translate: 100% 0;
|
||||
}
|
||||
to {
|
||||
translate: 0 0;
|
||||
}
|
||||
}
|
||||
@keyframes ask-ai-close {
|
||||
from {
|
||||
width: var(--ai-chat-width);
|
||||
}
|
||||
to {
|
||||
width: 0px;
|
||||
}
|
||||
}`}
|
||||
</style>
|
||||
<Presence present={open}>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-30 backdrop-blur-xs bg-fd-overlay lg:hidden',
|
||||
open ? 'animate-fd-fade-in' : 'animate-fd-fade-out',
|
||||
)}
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
</Presence>
|
||||
<Presence present={open}>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden z-30 bg-fd-card text-fd-card-foreground [--ai-chat-width:400px] 2xl:[--ai-chat-width:460px]',
|
||||
'max-lg:fixed max-lg:inset-x-2 max-lg:inset-y-4 max-lg:border max-lg:rounded-2xl max-lg:shadow-xl',
|
||||
'lg:sticky lg:top-0 lg:h-dvh lg:border-s lg:ms-auto lg:in-[#nd-docs-layout]:[grid-area:toc] lg:in-[#nd-notebook-layout]:row-span-full lg:in-[#nd-notebook-layout]:col-start-5',
|
||||
open
|
||||
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms]'
|
||||
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col size-full p-2 lg:p-3 lg:w-(--ai-chat-width)">
|
||||
<AISearchPanelHeader />
|
||||
<AISearchPanelList className="flex-1" />
|
||||
<div className="rounded-xl border bg-fd-secondary text-fd-secondary-foreground shadow-sm has-focus-visible:shadow-md">
|
||||
<AISearchInput />
|
||||
<div className="flex items-center gap-1.5 p-1 empty:hidden">
|
||||
<AISearchInputActions />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Presence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AISearchPanelList({ className, style, ...props }: ComponentProps<'div'>) {
|
||||
const chat = useChatContext();
|
||||
const messages = chat.messages.filter((msg) => msg.role !== 'system');
|
||||
|
||||
return (
|
||||
<List
|
||||
className={cn('py-4 overscroll-contain', className)}
|
||||
style={{
|
||||
maskImage:
|
||||
'linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 1rem), transparent 100%)',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-sm text-fd-muted-foreground/80 size-full flex flex-col items-center justify-center text-center gap-2">
|
||||
<MessageCircleIcon fill="currentColor" stroke="none" />
|
||||
<p onClick={(e) => e.stopPropagation()}>Start a new chat below.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col px-3 gap-4">
|
||||
{messages.map((item) => (
|
||||
<Message key={item.id} message={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
export function useHotKey() {
|
||||
const { open, setOpen } = useAISearchContext();
|
||||
|
||||
const onKeyPress = useEffectEvent((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
setOpen(false);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (e.key === '/' && (e.metaKey || e.ctrlKey) && !open) {
|
||||
setOpen(true);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', onKeyPress);
|
||||
return () => window.removeEventListener('keydown', onKeyPress);
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useAISearchContext() {
|
||||
return use(Context)!;
|
||||
}
|
||||
|
||||
function useChatContext() {
|
||||
return use(Context)!.chat;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { openapi, hasSpecs } from '@/lib/openapi';
|
||||
import { defaultShikiOptions } from '@/lib/shiki';
|
||||
import { createAPIPage } from '@hanzo/docs-openapi/ui';
|
||||
|
||||
export const APIPage = hasSpecs
|
||||
? createAPIPage(openapi, {
|
||||
shikiOptions: defaultShikiOptions,
|
||||
})
|
||||
: null;
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
/**
|
||||
* The live catalogs — models, connectors, installs — as client islands. Each
|
||||
* fetches its data in the browser, so none holds up the page it sits in.
|
||||
*/
|
||||
export const ModelsCatalog = dynamic(() => import('@/components/models-catalog').then((m) => m.ModelsCatalog));
|
||||
export const ConnectorsCatalog = dynamic(() => import('@/components/connectors-catalog').then((m) => m.ConnectorsCatalog));
|
||||
export const InstallCatalog = dynamic(() => import('@/components/install-catalog').then((m) => m.InstallCatalog));
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Text, XStack } from '@hanzo/gui';
|
||||
import { loud, muted } from '@/lib/ink';
|
||||
|
||||
/** One-of-several filter, as a row of pills; the chosen one is outlined. */
|
||||
export function Chips<T extends string | undefined>({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
items: readonly { label: string; value: T }[];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<XStack gap={6} flexWrap="wrap">
|
||||
{items.map((item) => {
|
||||
const on = item.value === value;
|
||||
return (
|
||||
<Text
|
||||
key={item.label}
|
||||
render="button"
|
||||
type="button"
|
||||
onPress={() => onChange(item.value)}
|
||||
aria-pressed={on}
|
||||
fontSize={12}
|
||||
lineHeight={18}
|
||||
px={10}
|
||||
py={3}
|
||||
rounded={999}
|
||||
borderWidth={1}
|
||||
borderColor={on ? '$color12' : '$borderColor'}
|
||||
bg="transparent"
|
||||
cursor="pointer"
|
||||
{...(on ? loud : muted)}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import * as Base from '@hanzo/docs-base-ui/components/codeblock';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { highlight } from '@hanzo/docs-core/highlight';
|
||||
import { shikiConfig } from '@/lib/shiki';
|
||||
|
||||
export interface CodeBlockProps {
|
||||
code: string;
|
||||
wrapper?: Base.CodeBlockProps;
|
||||
lang: string;
|
||||
}
|
||||
|
||||
export async function CodeBlock({ code, lang, wrapper }: CodeBlockProps) {
|
||||
const rendered = await highlight(code, {
|
||||
config: shikiConfig,
|
||||
lang,
|
||||
components: {
|
||||
pre: Base.Pre,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Base.CodeBlock {...wrapper} className={cn('my-0', wrapper?.className)}>
|
||||
{rendered}
|
||||
</Base.CodeBlock>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,12 @@
|
||||
// Rendered inline in the docs (never a link-out). Same lazy-island + fd-token
|
||||
// pattern as <ModelsCatalog/>.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search, Plug, Boxes, ArrowUpRight } from 'lucide-react';
|
||||
import { Text, XStack, YStack } from '@hanzo/gui';
|
||||
import { Plug, Boxes, ArrowUpRight } from '@hanzogui/lucide-icons-2';
|
||||
import { Grid } from '@hanzo/ui/grid';
|
||||
import { Action } from '@/components/action';
|
||||
import { Field } from '@/components/field';
|
||||
import { muted } from '@/lib/ink';
|
||||
|
||||
const MCP_REGISTRY = 'https://registry.modelcontextprotocol.io/v0/servers?limit=100';
|
||||
|
||||
@@ -48,10 +53,12 @@ export function ConnectorsCatalog() {
|
||||
fetch(cur ? `${MCP_REGISTRY}&cursor=${encodeURIComponent(cur)}` : MCP_REGISTRY)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||
.then((d: { servers: RegItem[]; metadata?: { nextCursor?: string } }) => {
|
||||
// The registry lists a server once per published version, so a name is
|
||||
// kept once, across pages and within one.
|
||||
setServers((prev) => {
|
||||
const seen = new Set(prev.map((s) => s.name));
|
||||
const fresh = d.servers.map((i) => i.server).filter((s) => s.name && !seen.has(s.name));
|
||||
return [...prev, ...fresh];
|
||||
const byName = new Map(prev.map((s) => [s.name, s]));
|
||||
for (const { server } of d.servers) if (server.name && !byName.has(server.name)) byName.set(server.name, server);
|
||||
return [...byName.values()];
|
||||
});
|
||||
setCursor(d.metadata?.nextCursor ?? null);
|
||||
})
|
||||
@@ -72,88 +79,109 @@ export function ConnectorsCatalog() {
|
||||
}, [q]);
|
||||
|
||||
return (
|
||||
<div className="not-prose my-6">
|
||||
<label className="relative mb-5 flex items-center">
|
||||
<Search className="pointer-events-none absolute left-2.5 size-4 text-fd-muted-foreground" />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search connectors and MCP servers…"
|
||||
className="w-full rounded-md border border-fd-border bg-fd-background py-2 pl-8 pr-3 text-sm outline-none focus:border-fd-primary"
|
||||
/>
|
||||
</label>
|
||||
<YStack gap={28}>
|
||||
<Field value={q} onChange={setQ} placeholder="Search connectors and MCP servers…" />
|
||||
|
||||
{/* Native connectors */}
|
||||
{nativeFiltered.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Plug className="size-4 text-fd-primary" />
|
||||
<h3 className="m-0 text-base font-semibold text-fd-foreground">Native connectors</h3>
|
||||
<span className="text-xs text-fd-muted-foreground">link once in the console</span>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{nativeFiltered.map((c) => {
|
||||
return (
|
||||
<div key={c.id} className="rounded-lg border border-fd-border bg-fd-card p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plug className="size-4 text-fd-foreground" />
|
||||
<span className="font-medium text-fd-foreground">{c.label}</span>
|
||||
<span className="ml-auto rounded border border-fd-border px-1.5 py-0.5 text-[10px] text-fd-muted-foreground">{c.method}</span>
|
||||
</div>
|
||||
<p className="mt-1.5 mb-0 text-sm text-fd-muted-foreground">{c.blurb}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<YStack render="section" gap={10}>
|
||||
<Heading icon={<Plug size={16} color="$color12" />} title="Native connectors" note="link once in the console" />
|
||||
<Grid columns={{ min: 220 }} gap={8}>
|
||||
{nativeFiltered.map((c) => (
|
||||
<YStack key={c.id} gap={6} p={12} rounded="$4" borderWidth={1} borderColor="$borderColor" bg="$panel">
|
||||
<XStack gap={8} items="center">
|
||||
<Plug size={14} color="$color12" />
|
||||
<Text flex={1} fontSize={14} fontWeight="500" color="$color12">
|
||||
{c.label}
|
||||
</Text>
|
||||
<Badge>{c.method}</Badge>
|
||||
</XStack>
|
||||
<Text fontSize={14} whiteSpace="normal" {...muted}>
|
||||
{c.blurb}
|
||||
</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</Grid>
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
{/* MCP servers — live from the official registry */}
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Boxes className="size-4 text-fd-primary" />
|
||||
<h3 className="m-0 text-base font-semibold text-fd-foreground">MCP servers</h3>
|
||||
<span className="text-xs text-fd-muted-foreground">
|
||||
{servers.length ? `${servers.length}+ indexed · Model Context Protocol registry` : 'loading the registry…'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<YStack render="section" gap={10}>
|
||||
<Heading
|
||||
icon={<Boxes size={16} color="$color12" />}
|
||||
title="MCP servers"
|
||||
note={servers.length ? `${servers.length}+ indexed · Model Context Protocol registry` : 'loading the registry…'}
|
||||
/>
|
||||
{err ? (
|
||||
<p className="text-sm text-fd-muted-foreground">
|
||||
<Text fontSize={14} whiteSpace="normal" {...muted}>
|
||||
Couldn’t reach the MCP registry ({err}). Browse it at{' '}
|
||||
<a className="text-fd-primary underline" href="https://registry.modelcontextprotocol.io">registry.modelcontextprotocol.io</a>.
|
||||
</p>
|
||||
<Text render="a" href="https://registry.modelcontextprotocol.io" color="$color12" textDecorationLine="underline">
|
||||
registry.modelcontextprotocol.io
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-lg border border-fd-border">
|
||||
<YStack rounded="$4" borderWidth={1} borderColor="$borderColor" overflow="hidden">
|
||||
{filtered.map((s, i) => (
|
||||
<div key={s.name} className={`flex items-start gap-3 p-3 ${i > 0 ? 'border-t border-fd-border/60' : ''}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium text-fd-foreground">{s.title || s.name}</span>
|
||||
<span className="rounded border border-fd-border px-1.5 py-0.5 text-[10px] text-fd-muted-foreground">{connectVia(s)}</span>
|
||||
</div>
|
||||
<div className="truncate font-mono text-xs text-fd-muted-foreground">{s.name}</div>
|
||||
{s.description ? <p className="mt-1 mb-0 line-clamp-2 text-sm text-fd-muted-foreground">{s.description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<YStack key={s.name} gap={2} p={12} borderTopWidth={i ? 1 : 0} borderColor="$borderColor">
|
||||
<XStack gap={8} items="center">
|
||||
<Text fontSize={14} fontWeight="500" color="$color12" numberOfLines={1}>
|
||||
{s.title || s.name}
|
||||
</Text>
|
||||
<Badge>{connectVia(s)}</Badge>
|
||||
</XStack>
|
||||
<Text fontFamily="$mono" fontSize={12} numberOfLines={1} {...muted}>
|
||||
{s.name}
|
||||
</Text>
|
||||
{s.description ? (
|
||||
<Text fontSize={14} numberOfLines={2} whiteSpace="normal" {...muted}>
|
||||
{s.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
))}
|
||||
{!filtered.length && !loading ? <div className="p-4 text-sm text-fd-muted-foreground">No servers match “{q}”.</div> : null}
|
||||
</div>
|
||||
{!filtered.length && !loading ? (
|
||||
<Text p={16} fontSize={14} {...muted}>
|
||||
No servers match “{q}”.
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
{!q && cursor ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadPage(cursor)}
|
||||
disabled={loading}
|
||||
className="mt-3 inline-flex items-center gap-1.5 rounded-md border border-fd-border px-3 py-1.5 text-sm text-fd-foreground hover:bg-fd-muted disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Loading…' : 'Load more'} <ArrowUpRight className="size-3.5" />
|
||||
</button>
|
||||
<XStack>
|
||||
<Action tone="line" render="button" type="button" onPress={() => loadPage(cursor)} disabled={loading} opacity={loading ? 0.5 : 1}>
|
||||
<Text fontSize={13} color="$color12">
|
||||
{loading ? 'Loading…' : 'Load more'}
|
||||
</Text>
|
||||
<ArrowUpRight size={13} color="$color12" />
|
||||
</Action>
|
||||
</XStack>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Heading({ icon, title, note }: { icon: React.ReactNode; title: string; note: string }) {
|
||||
return (
|
||||
<XStack gap={8} items="center" flexWrap="wrap">
|
||||
{icon}
|
||||
<Text render="h3" fontSize={16} fontWeight="600" color="$color12">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize={12} {...muted}>
|
||||
{note}
|
||||
</Text>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Badge({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Text fontSize={10} px={6} py={1} rounded={4} borderWidth={1} borderColor="$borderColor" {...muted}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { fetchContributors } from '@/lib/get-contributors';
|
||||
|
||||
export interface ContributorCounterProps extends HTMLAttributes<HTMLDivElement> {
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
displayCount?: number;
|
||||
}
|
||||
|
||||
export default async function ContributorCounter({
|
||||
repoOwner,
|
||||
repoName,
|
||||
displayCount = 20,
|
||||
...props
|
||||
}: ContributorCounterProps): Promise<React.ReactElement> {
|
||||
const contributors = await fetchContributors(repoOwner, repoName);
|
||||
const topContributors = contributors
|
||||
.filter((contributor) => contributor.login !== repoOwner)
|
||||
.slice(0, displayCount);
|
||||
|
||||
return (
|
||||
<div {...props} className={cn('flex flex-col items-center gap-4', props.className)}>
|
||||
<div className="flex flex-row flex-wrap items-center justify-center md:pe-4">
|
||||
{topContributors.map((contributor, i) => (
|
||||
<a
|
||||
key={contributor.login}
|
||||
href={`https://github.com/${contributor.login}`}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
className="size-10 overflow-hidden rounded-full border-4 border-fd-background bg-fd-background md:-mr-4 md:size-12"
|
||||
style={{
|
||||
zIndex: topContributors.length - i,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={contributor.avatar_url}
|
||||
alt={`${contributor.login}'s avatar`}
|
||||
unoptimized
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
{displayCount < contributors.length ? (
|
||||
<div className="size-12 content-center rounded-full bg-fd-secondary text-center">
|
||||
+{contributors.length - displayCount}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-center text-sm text-fd-muted-foreground">
|
||||
Some of our best contributors.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Text, XStack, YStack } from '@hanzo/gui';
|
||||
import { Action } from '@/components/action';
|
||||
import { muted } from '@/lib/ink';
|
||||
|
||||
// The masthead. Three doors, one model, and nothing else above the fold.
|
||||
//
|
||||
@@ -70,136 +73,100 @@ const PATHS = [
|
||||
// description, and a hero that printed them again made /docs say its name twice.
|
||||
export function DocsHero() {
|
||||
const [active, setActive] = useState<string>(PATHS[0].id);
|
||||
|
||||
const path = PATHS.find((d) => d.id === active) ?? PATHS[0];
|
||||
|
||||
|
||||
return (
|
||||
<div className="not-prose mb-12">
|
||||
{/* Entrance and panel motion. Colocated rather than pushed into globals.css:
|
||||
these keyframes have exactly one consumer, and globals.css is where the
|
||||
font-token overrides live — not a file to touch for a decoration.
|
||||
Everything is gated on prefers-reduced-motion, which resolves the whole
|
||||
block to no animation rather than a faster one. */}
|
||||
<style>{`
|
||||
@keyframes hanzo-rise {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.hanzo-rise {
|
||||
opacity: 0;
|
||||
animation: hanzo-rise .55s cubic-bezier(.16,1,.3,1) forwards;
|
||||
animation-delay: var(--d, 0ms);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hanzo-rise { animation: none; opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<YStack gap={16}>
|
||||
{/* The three doors. Each is a tab: hovering or focusing selects it, so the
|
||||
panel below follows the pointer and a reader compares paths by moving
|
||||
across them rather than by clicking three times. */}
|
||||
<div className="mb-4 grid gap-3 sm:grid-cols-3">
|
||||
{PATHS.map((d, i) => {
|
||||
<XStack gap={12} $max-sm={{ flexDirection: 'column' }}>
|
||||
{PATHS.map((d) => {
|
||||
const on = d.id === active;
|
||||
return (
|
||||
<button
|
||||
<YStack
|
||||
key={d.id}
|
||||
render="button"
|
||||
type="button"
|
||||
onMouseEnter={() => setActive(d.id)}
|
||||
onHoverIn={() => setActive(d.id)}
|
||||
onFocus={() => setActive(d.id)}
|
||||
onClick={() => setActive(d.id)}
|
||||
onPress={() => setActive(d.id)}
|
||||
aria-pressed={on}
|
||||
className={[
|
||||
'hanzo-rise group flex flex-col rounded-xl border p-5 text-left',
|
||||
'transition-[transform,border-color,background-color] duration-200',
|
||||
'hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring',
|
||||
on
|
||||
? 'border-fd-foreground/30 bg-fd-accent/50'
|
||||
: 'border-fd-border bg-fd-card/40 hover:border-fd-foreground/20',
|
||||
].join(' ')}
|
||||
style={{ ['--d' as string]: `${180 + i * 70}ms` }}
|
||||
flex={1}
|
||||
gap={6}
|
||||
p={18}
|
||||
rounded="$5"
|
||||
borderWidth={1}
|
||||
borderColor={on ? '$color8' : '$borderColor'}
|
||||
bg={on ? '$hover' : '$panel'}
|
||||
items="flex-start"
|
||||
cursor="pointer"
|
||||
hoverStyle={{ borderColor: '$color8' }}
|
||||
>
|
||||
<span className="mb-2 text-xs font-medium text-fd-muted-foreground">
|
||||
<Text fontSize="$1" fontWeight="500" {...muted}>
|
||||
{d.eyebrow}
|
||||
</span>
|
||||
<span className="mb-1.5 flex items-center gap-1.5 text-base font-semibold text-fd-foreground">
|
||||
{d.title}
|
||||
<span
|
||||
aria-hidden
|
||||
className={[
|
||||
'transition-[transform,opacity] duration-200',
|
||||
on ? 'translate-x-0.5 opacity-100' : 'opacity-40',
|
||||
].join(' ')}
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-sm leading-relaxed text-fd-muted-foreground">{d.body}</span>
|
||||
</button>
|
||||
</Text>
|
||||
<Text fontSize={15} fontWeight="600" color="$color12">
|
||||
{d.title} {on ? '→' : ''}
|
||||
</Text>
|
||||
<Text fontSize="$2" lineHeight={20} text="left" whiteSpace="normal" {...muted}>
|
||||
{d.body}
|
||||
</Text>
|
||||
</YStack>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</XStack>
|
||||
|
||||
{/* The selected path's real first step. Keyed on the path id so React
|
||||
remounts it and the entrance animation replays on every switch — the
|
||||
movement is what tells you the panel answers the thing you just hovered. */}
|
||||
<div
|
||||
className="hanzo-rise mb-8 overflow-hidden rounded-xl border border-fd-border bg-fd-card/40"
|
||||
style={{ ['--d' as string]: '400ms' }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-fd-border px-4 py-2.5">
|
||||
<span className="text-xs font-medium text-fd-muted-foreground">
|
||||
{/* The selected path's real first step. */}
|
||||
<YStack rounded="$5" borderWidth={1} borderColor="$borderColor" bg="$panel" overflow="hidden">
|
||||
<XStack justify="space-between" items="center" gap={12} px={16} py={10} borderBottomWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$1" fontWeight="500" {...muted}>
|
||||
{path.lang}
|
||||
</span>
|
||||
<Link
|
||||
href={path.href}
|
||||
{...(path.external ? { target: '_blank', rel: 'noreferrer' } : {})}
|
||||
className="text-xs text-fd-muted-foreground underline-offset-4 transition-colors hover:text-fd-foreground hover:underline"
|
||||
</Text>
|
||||
<Text
|
||||
render={path.external ? <a href={path.href} target="_blank" rel="noreferrer" /> : <Link href={path.href} prefetch={false} />}
|
||||
fontSize="$1"
|
||||
{...muted}
|
||||
hoverStyle={{ color: '$color12' }}
|
||||
>
|
||||
{path.cta} →
|
||||
</Link>
|
||||
</div>
|
||||
<pre key={path.id} className="hanzo-rise overflow-x-auto px-4 py-3.5 text-[13px] leading-relaxed text-fd-foreground">
|
||||
<code>{path.code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text render="pre" fontFamily="$mono" fontSize={13} lineHeight={21} px={16} py={14} whiteSpace="pre" overflowX="auto" color="$color12">
|
||||
{path.code}
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
{/* The models, because they are the reason to choose the platform at all. */}
|
||||
<Link
|
||||
href="/docs/models"
|
||||
className="hanzo-rise group mb-8 flex flex-wrap items-center gap-x-3 gap-y-1 rounded-xl border border-fd-border bg-fd-card/40 px-5 py-4 transition-colors hover:border-fd-foreground/25 hover:bg-fd-accent/40"
|
||||
style={{ ['--d' as string]: '460ms' }}
|
||||
<XStack
|
||||
render={<Link href="/docs/models" prefetch={false} />}
|
||||
flexWrap="wrap"
|
||||
items="center"
|
||||
gap={12}
|
||||
px={18}
|
||||
py={14}
|
||||
rounded="$5"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$panel"
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<span className="rounded border border-fd-border px-1.5 py-0.5 text-[11px] font-medium text-fd-muted-foreground">
|
||||
<Text fontSize={11} fontWeight="500" px={6} py={1} rounded={6} borderWidth={1} borderColor="$borderColor" {...muted}>
|
||||
Models
|
||||
</span>
|
||||
<span className="text-sm font-medium text-fd-foreground">
|
||||
</Text>
|
||||
<Text fontSize="$3" fontWeight="500" color="$color12">
|
||||
Zen generates, Enso routes, Kai decides
|
||||
</span>
|
||||
<span className="text-sm text-fd-muted-foreground">
|
||||
Open weights, one router, typed decisions.{' '}
|
||||
<span className="text-fd-foreground/70 underline-offset-4 group-hover:underline">
|
||||
See the models →
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</Text>
|
||||
<Text fontSize="$3" {...muted}>
|
||||
Open weights, one router, typed decisions. See the models →
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
{/* The action row, in the order a reader needs it: start, hand the docs to an
|
||||
assistant, or — for anyone who would rather be shown than told — go build
|
||||
something that plays. */}
|
||||
<div
|
||||
className="hanzo-rise flex flex-wrap items-center gap-3"
|
||||
style={{ ['--d' as string]: '520ms' }}
|
||||
>
|
||||
<Link
|
||||
href="/docs/quickstart"
|
||||
className="inline-flex h-10 items-center rounded-full bg-fd-primary px-6 text-sm font-medium text-fd-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
<XStack>
|
||||
<Action tone="loud" render={<Link href="/docs/quickstart" prefetch={false} />} height={40} px={22} rounded={999}>
|
||||
Get started
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Action>
|
||||
</XStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Text, TextArea, XStack, YStack } from '@hanzo/gui';
|
||||
import { ThumbsDown, ThumbsUp } from '@hanzogui/lucide-icons-2';
|
||||
import { useAnalytics } from '@hanzo/docs-analytics';
|
||||
import { Action } from '@/components/action';
|
||||
import { muted } from '@/lib/ink';
|
||||
|
||||
/**
|
||||
* "Was this page useful?" at the foot of every page.
|
||||
*
|
||||
* The answer goes where every other signal from this site goes: an event on
|
||||
* the one telemetry stream (`docs.feedback`, POST /v1/event), with the page, the
|
||||
* verdict and whatever the reader wrote. It used to be handed to a stub that
|
||||
* logged it to the reader's own console and thanked them, so nothing anyone
|
||||
* said reached anyone. A page remembers that it was answered, in this browser.
|
||||
*/
|
||||
type Verdict = 'good' | 'bad';
|
||||
|
||||
export function Feedback() {
|
||||
const page = usePathname();
|
||||
const analytics = useAnalytics();
|
||||
const [verdict, setVerdict] = useState<Verdict | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
const key = `docs.feedback:${page}`;
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setSent(localStorage.getItem(key) !== null);
|
||||
} catch {
|
||||
/* storage refused: ask again */
|
||||
}
|
||||
setVerdict(null);
|
||||
setNote('');
|
||||
}, [key]);
|
||||
|
||||
const send = () => {
|
||||
if (!verdict) return;
|
||||
analytics.capture('docs.feedback', { page, verdict, note: note.trim() || undefined });
|
||||
try {
|
||||
localStorage.setItem(key, verdict);
|
||||
} catch {
|
||||
/* the event is what matters */
|
||||
}
|
||||
setSent(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<YStack mt={48} py={16} gap={12} borderTopWidth={1} borderBottomWidth={1} borderColor="$borderColor">
|
||||
{sent ? (
|
||||
<Text fontSize="$3" {...muted}>
|
||||
Thanks — that reached the team.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<XStack gap={8} items="center" flexWrap="wrap">
|
||||
<Text fontSize="$3" fontWeight="500" color="$color12" mr={4}>
|
||||
Was this page useful?
|
||||
</Text>
|
||||
{(['good', 'bad'] as const).map((v) => (
|
||||
<Action
|
||||
key={v}
|
||||
tone={verdict === v ? 'loud' : 'line'}
|
||||
render="button"
|
||||
type="button"
|
||||
aria-pressed={verdict === v}
|
||||
onPress={() => setVerdict(v)}
|
||||
rounded={999}
|
||||
>
|
||||
{v === 'good' ? (
|
||||
<ThumbsUp size={14} color={verdict === v ? '$background' : '$color11'} />
|
||||
) : (
|
||||
<ThumbsDown size={14} color={verdict === v ? '$background' : '$color11'} />
|
||||
)}
|
||||
<Text fontSize="$2" fontWeight="500" color={verdict === v ? '$background' : '$color11'}>
|
||||
{v === 'good' ? 'Yes' : 'No'}
|
||||
</Text>
|
||||
</Action>
|
||||
))}
|
||||
</XStack>
|
||||
{verdict && (
|
||||
<YStack gap={8}>
|
||||
<TextArea
|
||||
value={note}
|
||||
onChangeText={setNote}
|
||||
placeholder={verdict === 'good' ? 'What helped? (optional)' : 'What was missing or wrong? (optional)'}
|
||||
aria-label="Feedback"
|
||||
minH={84}
|
||||
p={10}
|
||||
fontSize="$3"
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$panel"
|
||||
color="$color12"
|
||||
placeholderTextColor="$color10"
|
||||
/>
|
||||
<XStack>
|
||||
<Action tone="loud" render="button" type="button" onPress={send}>
|
||||
Send
|
||||
</Action>
|
||||
</XStack>
|
||||
</YStack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -1,516 +0,0 @@
|
||||
'use client';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
|
||||
import { CornerDownRightIcon, MessageSquare, ThumbsDown, ThumbsUp } from 'lucide-react';
|
||||
import {
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
type SyntheticEvent,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
} from 'react';
|
||||
import { Collapsible, CollapsibleContent } from '@hanzo/docs-base-ui/components/ui/collapsible';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@hanzo/docs-base-ui/components/ui/popover';
|
||||
import type { FeedbackBlockProps } from '@hanzo/docs-core/mdx-plugins/remark-feedback-block';
|
||||
import {
|
||||
actionResponse,
|
||||
blockFeedback,
|
||||
pageFeedback,
|
||||
type ActionResponse,
|
||||
type BlockFeedback,
|
||||
type PageFeedback,
|
||||
} from './schema';
|
||||
import { z } from 'zod/mini';
|
||||
import { usePathname } from '@hanzo/docs-core/framework';
|
||||
|
||||
const rateButtonVariants = cva(
|
||||
'inline-flex items-center gap-2 px-3 py-2 rounded-full font-medium border text-sm [&_svg]:size-4 disabled:cursor-not-allowed',
|
||||
{
|
||||
variants: {
|
||||
active: {
|
||||
true: 'bg-fd-accent text-fd-accent-foreground [&_svg]:fill-current',
|
||||
false: 'text-fd-muted-foreground',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const pageFeedbackResult = z.extend(pageFeedback, {
|
||||
response: actionResponse,
|
||||
});
|
||||
|
||||
const blockFeedbackResult = z.extend(blockFeedback, {
|
||||
response: actionResponse,
|
||||
});
|
||||
|
||||
/**
|
||||
* A feedback component to be attached at the end of page
|
||||
*/
|
||||
export function Feedback({
|
||||
onSendAction,
|
||||
}: {
|
||||
onSendAction: (feedback: PageFeedback) => Promise<ActionResponse>;
|
||||
}) {
|
||||
const url = usePathname() ?? '/';
|
||||
const { previous, setPrevious } = useSubmissionStorage(url, (v) => {
|
||||
const result = pageFeedbackResult.safeParse(v);
|
||||
return result.success ? result.data : null;
|
||||
});
|
||||
const [opinion, setOpinion] = useState<'good' | 'bad' | null>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function submit(e?: SyntheticEvent) {
|
||||
if (opinion == null) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const feedback: PageFeedback = {
|
||||
url: location.href,
|
||||
opinion,
|
||||
message,
|
||||
};
|
||||
|
||||
const response = await onSendAction(feedback);
|
||||
setPrevious({
|
||||
response,
|
||||
...feedback,
|
||||
});
|
||||
setMessage('');
|
||||
setOpinion(null);
|
||||
});
|
||||
|
||||
e?.preventDefault();
|
||||
}
|
||||
|
||||
const activeOpinion = previous?.opinion ?? opinion;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={opinion !== null || previous !== null}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) setOpinion(null);
|
||||
}}
|
||||
className="border-y py-3"
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<p className="text-sm font-medium pe-2">How is this guide?</p>
|
||||
<button
|
||||
disabled={previous !== null}
|
||||
className={cn(
|
||||
rateButtonVariants({
|
||||
active: activeOpinion === 'good',
|
||||
}),
|
||||
)}
|
||||
onClick={() => {
|
||||
setOpinion('good');
|
||||
}}
|
||||
>
|
||||
<ThumbsUp />
|
||||
Good
|
||||
</button>
|
||||
<button
|
||||
disabled={previous !== null}
|
||||
className={cn(
|
||||
rateButtonVariants({
|
||||
active: activeOpinion === 'bad',
|
||||
}),
|
||||
)}
|
||||
onClick={() => {
|
||||
setOpinion('bad');
|
||||
}}
|
||||
>
|
||||
<ThumbsDown />
|
||||
Bad
|
||||
</button>
|
||||
</div>
|
||||
<CollapsibleContent className="mt-3">
|
||||
{previous ? (
|
||||
<div className="px-3 py-6 flex flex-col items-center gap-3 bg-fd-card text-fd-muted-foreground text-sm text-center rounded-xl">
|
||||
<p>Thank you for your feedback!</p>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<a
|
||||
href={previous.response?.githubUrl}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'primary',
|
||||
}),
|
||||
'text-xs',
|
||||
)}
|
||||
>
|
||||
View on GitHub
|
||||
</a>
|
||||
|
||||
<button
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
}),
|
||||
'text-xs',
|
||||
)}
|
||||
onClick={() => {
|
||||
setOpinion(previous.opinion);
|
||||
setPrevious(null);
|
||||
}}
|
||||
>
|
||||
Submit Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form className="flex flex-col gap-3" onSubmit={submit}>
|
||||
<textarea
|
||||
autoFocus
|
||||
required
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="border rounded-lg bg-fd-secondary text-fd-secondary-foreground p-3 resize-none focus-visible:outline-none placeholder:text-fd-muted-foreground"
|
||||
placeholder="Leave your feedback..."
|
||||
onKeyDown={(e) => {
|
||||
if (!e.shiftKey && e.key === 'Enter') {
|
||||
submit(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(buttonVariants({ color: 'outline' }), 'w-fit px-3')}
|
||||
disabled={isPending}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export interface FeedbackTextProps {
|
||||
onSendAction: (feedback: BlockFeedback) => Promise<ActionResponse>;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A feedback component for each content block in page, should be used with `remark-feedback-block`.
|
||||
*
|
||||
* See https://hanzoai.github.io/docs/docs/guides/integrations/feedback.
|
||||
*/
|
||||
export function FeedbackText({ onSendAction, children }: FeedbackTextProps) {
|
||||
const [popup, _setPopup] = useState<{
|
||||
mode: 'tooltip' | 'expanded';
|
||||
blockId: string;
|
||||
selection: string;
|
||||
range: Range;
|
||||
} | null>(null);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
open: popup !== null,
|
||||
placement: 'bottom',
|
||||
middleware: [offset(6), flip(), shift({ padding: 8 })],
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
function expandPopup() {
|
||||
if (popup?.mode !== 'tooltip') return;
|
||||
|
||||
const highlight = new Highlight(popup.range);
|
||||
CSS.highlights.set('fd-feedback-text', highlight);
|
||||
|
||||
_setPopup({ ...popup, mode: 'expanded' });
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
if (popup?.mode === 'expanded') {
|
||||
CSS.highlights.delete('fd-feedback-text');
|
||||
}
|
||||
|
||||
_setPopup(null);
|
||||
}
|
||||
|
||||
const updateSelectionPopover = useEffectEvent(() => {
|
||||
if (popup && popup.mode === 'expanded') return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!container || !selection || selection.isCollapsed || selection.rangeCount === 0) {
|
||||
closePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0).cloneRange();
|
||||
if (!container.contains(range.commonAncestorContainer)) {
|
||||
closePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionText = selection.toString().trim();
|
||||
// also prevent cross-paragraph selection
|
||||
if (selectionText.length === 0 || selectionText.includes('\n')) {
|
||||
closePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const element =
|
||||
range.startContainer instanceof Element
|
||||
? range.startContainer
|
||||
: range.startContainer.parentElement;
|
||||
const blockId = element?.closest('[data-block="feedback"]')?.id;
|
||||
if (!blockId) {
|
||||
closePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
refs.setReference({
|
||||
getBoundingClientRect() {
|
||||
return range.getBoundingClientRect();
|
||||
},
|
||||
contextElement: container,
|
||||
});
|
||||
|
||||
_setPopup({ mode: 'tooltip', range, selection: selectionText, blockId });
|
||||
});
|
||||
|
||||
const closeOnEscape = useEffectEvent((event: KeyboardEvent) => {
|
||||
if (popup === null) return;
|
||||
if (event.key === 'Escape') closePopup();
|
||||
});
|
||||
|
||||
const closeOnPointerDown = useEffectEvent((event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (popup === null || !(target instanceof Node)) return;
|
||||
|
||||
if (
|
||||
refs.floating.current?.contains(target) ||
|
||||
(popup.mode === 'tooltip' && containerRef.current?.contains(target))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
closePopup();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let frame: number | null = null;
|
||||
|
||||
function scheduleSelectionPopover() {
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
updateSelectionPopover();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('selectionchange', scheduleSelectionPopover);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
document.addEventListener('pointerdown', closeOnPointerDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
document.removeEventListener('pointerdown', closeOnPointerDown);
|
||||
document.removeEventListener('selectionchange', scheduleSelectionPopover);
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="prose-no-margin [&_::highlight(fd-feedback-text)]:bg-fd-primary [&_::highlight(fd-feedback-text)]:text-fd-primary-foreground"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{popup && (
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
className={cn(
|
||||
'not-prose z-40 text-sm bg-fd-popover text-fd-popover-foreground border overflow-hidden shadow-lg rounded-xl w-30 h-9.5 box-content transition-[width,height]',
|
||||
popup.mode === 'expanded' ? 'w-[300px] h-32 max-w-[98vw]' : 'select-none',
|
||||
)}
|
||||
style={floatingStyles}
|
||||
>
|
||||
{popup.mode === 'tooltip' ? (
|
||||
<div className="w-30 h-9.5 p-1">
|
||||
<button
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost', size: 'sm' }),
|
||||
'size-full gap-1.5',
|
||||
)}
|
||||
onClick={expandPopup}
|
||||
>
|
||||
<CornerDownRightIcon className="size-4 text-fd-muted-foreground" />
|
||||
Feedback
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<FeedbackTextForm
|
||||
blockId={popup.blockId}
|
||||
selection={popup.selection}
|
||||
onSendAction={onSendAction}
|
||||
onClose={closePopup}
|
||||
container={{ className: 'p-2 w-[300px] h-32 max-w-[98vw] animate-fd-fade-in' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackTextForm({
|
||||
blockId,
|
||||
selection,
|
||||
onSendAction,
|
||||
onClose,
|
||||
container,
|
||||
}: {
|
||||
container: HTMLAttributes<HTMLElement>;
|
||||
blockId: string;
|
||||
selection: string;
|
||||
onSendAction: (feedback: BlockFeedback) => Promise<ActionResponse>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { previous, setPrevious } = useSubmissionStorage(blockId, (v) => {
|
||||
const result = blockFeedbackResult.safeParse(v);
|
||||
if (result.success) return result.data;
|
||||
return null;
|
||||
});
|
||||
const [message, setMessage] = useState('');
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function submit(e?: SyntheticEvent) {
|
||||
startTransition(async () => {
|
||||
const feedback: BlockFeedback = {
|
||||
blockId,
|
||||
blockBody: selection,
|
||||
url: location.href,
|
||||
message,
|
||||
};
|
||||
|
||||
const response = await onSendAction(feedback);
|
||||
setPrevious({
|
||||
response,
|
||||
...feedback,
|
||||
});
|
||||
setMessage('');
|
||||
});
|
||||
|
||||
e?.preventDefault();
|
||||
}
|
||||
|
||||
if (previous)
|
||||
return (
|
||||
<div
|
||||
{...container}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-2 text-fd-muted-foreground text-center',
|
||||
container.className,
|
||||
)}
|
||||
>
|
||||
<p>Thank you for your feedback!</p>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<a
|
||||
href={previous.response?.githubUrl}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'primary',
|
||||
}),
|
||||
'text-xs',
|
||||
)}
|
||||
>
|
||||
View on GitHub
|
||||
</a>
|
||||
|
||||
<button
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
}),
|
||||
'text-xs',
|
||||
)}
|
||||
onClick={() => {
|
||||
setPrevious(null);
|
||||
}}
|
||||
>
|
||||
Submit Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
{...container}
|
||||
className={cn('flex flex-col gap-2', container.className)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<textarea
|
||||
autoFocus
|
||||
required
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="border rounded-lg bg-fd-secondary text-fd-secondary-foreground p-3 resize-none focus-visible:outline-none placeholder:text-fd-muted-foreground"
|
||||
placeholder="Leave your feedback..."
|
||||
onKeyDown={(e) => {
|
||||
if (!e.shiftKey && e.key === 'Enter') {
|
||||
submit(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2 mt-auto">
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(buttonVariants({ variant: 'primary', size: 'sm' }), 'gap-1.5')}
|
||||
disabled={isPending}
|
||||
>
|
||||
<CornerDownRightIcon className="size-4" />
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(buttonVariants({ variant: 'secondary', size: 'sm' }), 'gap-1.5')}
|
||||
disabled={isPending}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function useSubmissionStorage<Result>(key: string, validate: (v: unknown) => Result | null) {
|
||||
const storageKey = `docs-feedback-${key}`;
|
||||
const [value, setValue] = useState<Result | null>(null);
|
||||
const validateCallback = useEffectEvent(validate);
|
||||
|
||||
useEffect(() => {
|
||||
const item = localStorage.getItem(storageKey);
|
||||
if (item === null) return;
|
||||
const validated = validateCallback(JSON.parse(item));
|
||||
|
||||
if (validated !== null) setValue(validated);
|
||||
}, [storageKey]);
|
||||
|
||||
return {
|
||||
previous: value,
|
||||
setPrevious(result: Result | null) {
|
||||
if (result) localStorage.setItem(storageKey, JSON.stringify(result));
|
||||
else localStorage.removeItem(storageKey);
|
||||
|
||||
setValue(result);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { FeedbackBlockProps } from '@hanzo/docs-core/mdx-plugins/remark-feedback-block';
|
||||
import { Feedback as FeedbackInner, FeedbackText as FeedbackBlockInner } from './client';
|
||||
import type { ActionResponse, BlockFeedback, PageFeedback } from './schema';
|
||||
|
||||
async function mockPageAction(feedback: PageFeedback): Promise<ActionResponse> {
|
||||
console.log('Feedback submitted (static export):', feedback);
|
||||
return { githubUrl: '#' };
|
||||
}
|
||||
|
||||
async function mockBlockAction(feedback: BlockFeedback): Promise<ActionResponse> {
|
||||
console.log('Block feedback submitted (static export):', feedback);
|
||||
return { githubUrl: '#' };
|
||||
}
|
||||
|
||||
export function PageFeedback() {
|
||||
return <FeedbackInner onSendAction={mockPageAction} />;
|
||||
}
|
||||
|
||||
export function PageFeedbackBlock({
|
||||
children,
|
||||
...props
|
||||
}: FeedbackBlockProps & { children: ReactNode }) {
|
||||
return (
|
||||
<FeedbackBlockInner {...props} onSendAction={mockBlockAction}>
|
||||
{children}
|
||||
</FeedbackBlockInner>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { z } from 'zod/mini';
|
||||
|
||||
export const blockFeedback = z.object({
|
||||
/** full URL of page where fired */
|
||||
url: z.string(),
|
||||
blockId: z.string(),
|
||||
message: z.string(),
|
||||
|
||||
/** the referenced text of block */
|
||||
blockBody: z.string(),
|
||||
});
|
||||
|
||||
export const pageFeedback = z.object({
|
||||
opinion: z.enum(['good', 'bad']),
|
||||
/** full URL of page where fired */
|
||||
url: z.string(),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export const actionResponse = z.object({
|
||||
githubUrl: z.optional(z.string()),
|
||||
});
|
||||
|
||||
export type BlockFeedback = z.infer<typeof blockFeedback>;
|
||||
export type PageFeedback = z.infer<typeof pageFeedback>;
|
||||
export type ActionResponse = z.infer<typeof actionResponse>;
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { Input, XStack } from '@hanzo/gui';
|
||||
import { Search } from '@hanzogui/lucide-icons-2';
|
||||
|
||||
/** A filter field: a glass and a one-line input, for the catalogs. */
|
||||
export function Field({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
width,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
width?: number;
|
||||
}) {
|
||||
return (
|
||||
<XStack
|
||||
items="center"
|
||||
gap={8}
|
||||
px={10}
|
||||
height={36}
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$background"
|
||||
width={width ?? '100%'}
|
||||
$max-sm={{ width: '100%' }}
|
||||
>
|
||||
<Search size={14} color="$color10" />
|
||||
<Input
|
||||
unstyled
|
||||
flex={1}
|
||||
minW={0}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="$color10"
|
||||
fontSize={14}
|
||||
color="$color12"
|
||||
bg="transparent"
|
||||
borderWidth={0}
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Fragment, useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react';
|
||||
import { Input, Text, XStack, YStack } from '@hanzo/gui';
|
||||
import { ArrowRight, Check, ChevronRight, Hash, Search as Glass, Sparkle } from '@hanzogui/lucide-icons-2';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@hanzo/ui';
|
||||
import { useDocsSearch } from '@hanzo/docs-core/search/client';
|
||||
import type { SortedResult } from '@hanzo/docs-core/search';
|
||||
import { createMarkdownRenderer } from '@hanzo/docs-core/content/md';
|
||||
import type * as PageTree from '@hanzo/docs-core/page-tree';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { loud, muted } from '@/lib/ink';
|
||||
import { Chips } from '@/components/chips';
|
||||
import { useTree } from '@/lib/tree';
|
||||
import { AGENT_SETUP_PROMPT } from '@/lib/agent-setup-prompt';
|
||||
|
||||
/**
|
||||
* The search dialog. Full text over every page (the static index at
|
||||
* /api/search), a jump straight to a page whose title starts with what was
|
||||
* typed, and — when the words land nowhere — the question handed to the
|
||||
* reader's own agent, set up for Hanzo. Arrows move, Enter opens, Esc closes.
|
||||
*/
|
||||
|
||||
const TAGS = [
|
||||
{ label: 'All', value: undefined },
|
||||
{ label: 'Services', value: 'services' },
|
||||
{ label: 'SDKs', value: 'sdks' },
|
||||
{ label: 'API', value: 'openapi' },
|
||||
{ label: 'Products', value: 'products' },
|
||||
] as const;
|
||||
|
||||
type Item =
|
||||
| (SortedResult & { kind: 'result' })
|
||||
| { kind: 'action'; id: string; node: ReactNode; run: () => void };
|
||||
|
||||
// Result text arrives as Markdown with the matched words in <mark>.
|
||||
const md = createMarkdownRenderer({ remarkRehypeOptions: { allowDangerousHtml: true }, rehypePlugins: [rehypeRaw] });
|
||||
|
||||
const MD = {
|
||||
p: ({ children }: ComponentProps<'p'>) => <Text whiteSpace="normal">{children}</Text>,
|
||||
a: ({ children }: ComponentProps<'a'>) => <Text>{children}</Text>,
|
||||
mark: ({ children }: ComponentProps<'mark'>) => (
|
||||
<Text color="$color12" fontWeight="600" textDecorationLine="underline">
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
strong: ({ children }: ComponentProps<'strong'>) => <Text fontWeight="600">{children}</Text>,
|
||||
code: ({ children }: ComponentProps<'code'>) => (
|
||||
<Text fontFamily="$mono" fontSize="0.9em" px={3} rounded={4} bg="$hover">
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
pre: ({ children }: ComponentProps<'pre'>) => (
|
||||
<Text render="pre" fontFamily="$mono" fontSize={12} whiteSpace="pre" overflow="hidden" maxH={60}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
};
|
||||
|
||||
function pages(nodes: PageTree.Node[], out = new Map<string, PageTree.Item>()) {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'page' && typeof node.name === 'string') out.set(node.name.toLowerCase(), node);
|
||||
if (node.type === 'folder') {
|
||||
if (node.index) pages([node.index], out);
|
||||
pages(node.children, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function Finder({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||
const router = useRouter();
|
||||
const { tree } = useTree();
|
||||
const [tag, setTag] = useState<string | undefined>();
|
||||
const [asked, setAsked] = useState(false);
|
||||
const { search, setSearch, query } = useDocsSearch({ type: 'flexsearch-static', from: '/api/search', tag });
|
||||
const titles = useMemo(() => pages(tree.children), [tree]);
|
||||
|
||||
const items = useMemo<Item[] | null>(() => {
|
||||
const q = search.trim();
|
||||
const out: Item[] = [];
|
||||
if (q) {
|
||||
const lower = q.toLowerCase();
|
||||
for (const [name, page] of titles) {
|
||||
if (!name.startsWith(lower)) continue;
|
||||
out.push({
|
||||
kind: 'action',
|
||||
id: 'jump',
|
||||
node: (
|
||||
<XStack gap={8} items="center">
|
||||
<ArrowRight size={14} color="$color10" />
|
||||
<Text fontSize="$3" {...muted}>
|
||||
Jump to <Text {...loud} fontWeight="500">{page.name}</Text>
|
||||
</Text>
|
||||
</XStack>
|
||||
),
|
||||
run: () => router.push(page.url),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(query.data)) for (const r of query.data) out.push({ ...r, kind: 'result' });
|
||||
// A question is the fallback when the terms did not land, so it is last.
|
||||
if (q.length >= 3)
|
||||
out.push({
|
||||
kind: 'action',
|
||||
id: 'ask',
|
||||
node: (
|
||||
<XStack gap={8} items="center">
|
||||
{asked ? <Check size={14} color="$color10" /> : <Sparkle size={14} color="$color10" />}
|
||||
<Text fontSize="$3" {...muted}>
|
||||
{asked ? 'Copied — paste it into your agent' : <>Ask an agent <Text {...loud} fontWeight="500">“{q}”</Text></>}
|
||||
</Text>
|
||||
</XStack>
|
||||
),
|
||||
run: () =>
|
||||
void navigator.clipboard
|
||||
.writeText(
|
||||
`${AGENT_SETUP_PROMPT}\n\n---\n\nThen answer this using the Hanzo documentation at https://docs.hanzo.ai, and use the Hanzo skills at https://hanzoskills.com for any capability it touches:\n\n${q}`,
|
||||
)
|
||||
.then(() => setAsked(true)),
|
||||
});
|
||||
return q ? out : null;
|
||||
}, [search, query.data, titles, router, asked]);
|
||||
|
||||
const [active, setActive] = useState(0);
|
||||
useEffect(() => setActive(0), [items]);
|
||||
|
||||
const choose = (item: Item) => {
|
||||
if (item.kind === 'action') {
|
||||
item.run();
|
||||
if (item.id === 'ask') return;
|
||||
} else router.push(item.url);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// Keys are read from the document while the dialog is open, so they work
|
||||
// whether focus is in the field or on a row.
|
||||
const list = useRef(items);
|
||||
list.current = items;
|
||||
const at = useRef(active);
|
||||
at.current = active;
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const key = (e: KeyboardEvent) => {
|
||||
const rows = list.current;
|
||||
if (!rows?.length || e.isComposing) return;
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive((i) => (i + (e.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const row = rows[at.current];
|
||||
if (row) choose(row);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', key);
|
||||
return () => document.removeEventListener('keydown', key);
|
||||
});
|
||||
|
||||
const box = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
box.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' });
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent showCloseButton={false} p={0} gap={0} width="92%" maxW={640} overflow="hidden" bg="$background">
|
||||
<DialogTitle position="absolute" width={1} height={1} overflow="hidden" opacity={0}>
|
||||
Search the docs
|
||||
</DialogTitle>
|
||||
<XStack px={14} height={52} gap={10} items="center" borderBottomWidth={1} borderColor="$borderColor">
|
||||
<Glass size={18} color="$color10" opacity={query.isLoading ? 0.5 : 1} />
|
||||
<Input
|
||||
unstyled
|
||||
autoFocus
|
||||
flex={1}
|
||||
minW={0}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder="Search or ask"
|
||||
placeholderTextColor="$color10"
|
||||
fontSize={17}
|
||||
color="$color12"
|
||||
bg="transparent"
|
||||
borderWidth={0}
|
||||
outlineWidth={0}
|
||||
aria-label="Search the docs"
|
||||
/>
|
||||
<Text
|
||||
render="button"
|
||||
onPress={() => onOpenChange(false)}
|
||||
fontFamily="$mono"
|
||||
fontSize={11}
|
||||
px={6}
|
||||
py={2}
|
||||
rounded={6}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="transparent"
|
||||
cursor="pointer"
|
||||
{...muted}
|
||||
>
|
||||
ESC
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
{items && (
|
||||
<YStack ref={box as never} maxH={440} overflowY="auto" p={6} gap={2} borderBottomWidth={1} borderColor="$borderColor">
|
||||
{items.length === 0 ? (
|
||||
<Text py={40} text="center" fontSize="$3" {...muted}>
|
||||
Nothing matches.
|
||||
</Text>
|
||||
) : (
|
||||
items.map((item, i) => (
|
||||
<Row key={item.id} item={item} active={i === active} onHover={() => setActive(i)} onPress={() => choose(item)} />
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
<XStack px={12} py={10} gap={10} items="center" bg="$panel">
|
||||
<Text fontSize={12} {...muted}>
|
||||
Filter
|
||||
</Text>
|
||||
<Chips items={TAGS} value={tag} onChange={setTag} />
|
||||
</XStack>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ item, active, onHover, onPress }: { item: Item; active: boolean; onHover: () => void; onPress: () => void }) {
|
||||
const inset = item.kind === 'result' && item.type !== 'page';
|
||||
return (
|
||||
<YStack
|
||||
render="button"
|
||||
type="button"
|
||||
data-active={active ? 'true' : undefined}
|
||||
aria-selected={active}
|
||||
onPress={onPress}
|
||||
onHoverIn={onHover}
|
||||
position="relative"
|
||||
gap={4}
|
||||
px={10}
|
||||
py={8}
|
||||
pl={inset ? (item.kind === 'result' && item.type === 'heading' ? 34 : 22) : 10}
|
||||
rounded="$3"
|
||||
bg={active ? '$hover' : 'transparent'}
|
||||
borderWidth={0}
|
||||
cursor="pointer"
|
||||
items="flex-start"
|
||||
>
|
||||
{item.kind === 'action' ? (
|
||||
item.node
|
||||
) : (
|
||||
<>
|
||||
{item.breadcrumbs && item.breadcrumbs.length > 0 && (
|
||||
<XStack items="center" gap={2} flexWrap="wrap">
|
||||
{item.breadcrumbs.map((crumb, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <ChevronRight size={12} color="$color9" />}
|
||||
<Text fontSize={12} {...muted}>
|
||||
{crumb}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</XStack>
|
||||
)}
|
||||
{inset && <YStack position="absolute" l={12} t={0} b={0} width={1} bg="$borderColor" />}
|
||||
{item.type === 'heading' && (
|
||||
<YStack position="absolute" l={18} t={10}>
|
||||
<Hash size={13} color="$color10" />
|
||||
</YStack>
|
||||
)}
|
||||
<Text
|
||||
fontSize="$3"
|
||||
lineHeight={20}
|
||||
text="left"
|
||||
whiteSpace="normal"
|
||||
fontWeight={item.type === 'text' ? '400' : '500'}
|
||||
{...(item.type === 'text' ? muted : loud)}
|
||||
>
|
||||
<md.Markdown components={MD}>{item.content}</md.Markdown>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { HanzoFooter } from '@hanzogui/shell';
|
||||
import { YStack } from '@hanzo/gui';
|
||||
import { HanzoFooter, HanzoPreFooterCTA } from '@hanzogui/shell';
|
||||
|
||||
/**
|
||||
* The shared 6-column ecosystem footer (HANZO_FOOTER_COLUMNS + bottom legal
|
||||
* bar), byte-identical across every Hanzo property. Docs is hanzo.ai-adjacent
|
||||
* and is not itself a flagship product, so `currentProductId` stays unset.
|
||||
* The shared ecosystem footer, byte-identical across every Hanzo property, and
|
||||
* on the front page and the blog the pre-footer call to action above it.
|
||||
*
|
||||
* Both are drawn by @hanzogui/shell for a dark ground with a fixed white ink,
|
||||
* so they sit on the dark ground in both themes — on a light page the
|
||||
* pre-footer's heading measured 2.38:1 against the body. Docs is not itself a
|
||||
* flagship product, so no `currentProductId`.
|
||||
*/
|
||||
export function Footer() {
|
||||
return <HanzoFooter />;
|
||||
export function Footer({ cta = false }: { cta?: boolean }) {
|
||||
return (
|
||||
<YStack bg="#0a0a0a">
|
||||
{cta && <HanzoPreFooterCTA surface="hanzo.ai" />}
|
||||
<HanzoFooter />
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
'use client';
|
||||
import { lazy, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
ForceGraphMethods,
|
||||
ForceGraphProps,
|
||||
LinkObject,
|
||||
NodeObject,
|
||||
} from 'react-force-graph-2d';
|
||||
import { forceCollide, forceLink, forceManyBody } from 'd3-force';
|
||||
import { useRouter } from '@hanzo/docs/core/framework';
|
||||
|
||||
export interface Graph {
|
||||
links: Link[];
|
||||
nodes: Node[];
|
||||
}
|
||||
|
||||
export type Node = NodeObject<NodeType>;
|
||||
export type Link = LinkObject<NodeType, LinkType>;
|
||||
|
||||
export interface NodeType {
|
||||
text: string;
|
||||
description?: string;
|
||||
neighbors?: string[];
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type LinkType = Record<string, unknown>;
|
||||
|
||||
export interface GraphViewProps {
|
||||
graph: Graph;
|
||||
}
|
||||
|
||||
const ForceGraph2D = lazy(
|
||||
() => import('react-force-graph-2d'),
|
||||
) as typeof import('react-force-graph-2d').default;
|
||||
|
||||
export function GraphView(props: GraphViewProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [mount, setMount] = useState(false);
|
||||
useEffect(() => {
|
||||
setMount(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative border h-[600px] [&_canvas]:size-full rounded-xl overflow-hidden bg-fd-background"
|
||||
>
|
||||
{mount && <ClientOnly {...props} containerRef={ref} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientOnly({
|
||||
containerRef,
|
||||
graph,
|
||||
}: GraphViewProps & { containerRef: RefObject<HTMLDivElement | null> }) {
|
||||
const graphRef = useRef<ForceGraphMethods<Node, Link> | undefined>(undefined);
|
||||
const hoveredRef = useRef<Node | null>(null);
|
||||
const router = useRouter();
|
||||
const [tooltip, setTooltip] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleNodeHover = (node: Node | null) => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph) return;
|
||||
hoveredRef.current = node;
|
||||
|
||||
if (node) {
|
||||
const coords = graph.graph2ScreenCoords(node.x!, node.y!);
|
||||
setTooltip({
|
||||
x: coords.x + 4,
|
||||
y: coords.y + 4,
|
||||
content: node.description ?? 'No description',
|
||||
});
|
||||
} else {
|
||||
setTooltip(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Custom node rendering: circle with text label below
|
||||
const nodeCanvasObject: ForceGraphProps['nodeCanvasObject'] = (node, ctx) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const style = getComputedStyle(container);
|
||||
const fontSize = 14;
|
||||
const radius = 5;
|
||||
|
||||
// Draw circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x!, node.y!, radius, 0, 2 * Math.PI, false);
|
||||
|
||||
const hoverNode = hoveredRef.current;
|
||||
const isActive = hoverNode?.id === node.id || hoverNode?.neighbors?.includes(node.id as string);
|
||||
|
||||
ctx.fillStyle = isActive
|
||||
? style.getPropertyValue('--color-fd-primary')
|
||||
: style.getPropertyValue('--color-purple-300');
|
||||
ctx.fill();
|
||||
|
||||
// Draw text below the node
|
||||
ctx.font = `${fontSize}px Sans-Serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = getComputedStyle(container).getPropertyValue('color');
|
||||
ctx.fillText(node.text, node.x!, node.y! + radius + fontSize);
|
||||
};
|
||||
|
||||
const linkColor = (link: Link) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return '#999';
|
||||
const style = getComputedStyle(container);
|
||||
const hoverNode = hoveredRef.current;
|
||||
|
||||
if (
|
||||
hoverNode &&
|
||||
typeof link.source === 'object' &&
|
||||
typeof link.target === 'object' &&
|
||||
(hoverNode.id === link.source.id || hoverNode.id === link.target.id)
|
||||
) {
|
||||
return style.getPropertyValue('--color-fd-primary');
|
||||
}
|
||||
|
||||
return `color-mix(in oklab, ${style.getPropertyValue('--color-fd-muted-foreground')} 50%, transparent)`;
|
||||
};
|
||||
|
||||
// Enrich nodes with neighbors for hover effects
|
||||
const enrichedNodes = useMemo(() => {
|
||||
const { nodes, links } = structuredClone(graph);
|
||||
for (const node of nodes) {
|
||||
node.neighbors = links.flatMap((link) => {
|
||||
if (link.source === node.id) return link.target as string;
|
||||
if (link.target === node.id) return link.source as string;
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
links,
|
||||
};
|
||||
}, [graph]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ForceGraph2D<NodeType, LinkType>
|
||||
ref={{
|
||||
get current() {
|
||||
return graphRef.current;
|
||||
},
|
||||
set current(fg) {
|
||||
graphRef.current = fg;
|
||||
if (fg) {
|
||||
fg.d3Force('link', forceLink().distance(200));
|
||||
fg.d3Force('charge', forceManyBody().strength(10));
|
||||
fg.d3Force('collision', forceCollide(60));
|
||||
}
|
||||
},
|
||||
}}
|
||||
graphData={enrichedNodes}
|
||||
nodeCanvasObject={nodeCanvasObject}
|
||||
linkColor={linkColor}
|
||||
onNodeHover={handleNodeHover}
|
||||
onNodeClick={(node) => {
|
||||
router.push(node.url);
|
||||
}}
|
||||
linkWidth={2}
|
||||
enableNodeDrag
|
||||
enableZoomInteraction
|
||||
/>
|
||||
{tooltip && (
|
||||
<div
|
||||
className="absolute bg-fd-popover text-fd-popover-foreground size-fit p-2 border rounded-xl shadow-lg text-sm max-w-xs"
|
||||
style={{ top: tooltip.y, left: tooltip.x }}
|
||||
>
|
||||
{tooltip.content}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { YStack } from '@hanzo/gui';
|
||||
import { useThemeSetting } from '@hanzogui/next-theme';
|
||||
import { Dots } from '@hanzo/ui/dots';
|
||||
|
||||
/**
|
||||
* The hero's halftone, as a client island.
|
||||
* The hero's halftone, as a client island — `Dots` takes its shape as a
|
||||
* function, and a function cannot cross the server/client boundary.
|
||||
*
|
||||
* `Dots` takes its shape as `field`, a function of space and time — and a
|
||||
* function cannot cross the server/client boundary. The landing page is a
|
||||
* Server Component, so passing one there fails the build outright:
|
||||
*
|
||||
* Functions cannot be passed directly to Client Components unless you
|
||||
* explicitly expose it by marking it with "use server".
|
||||
*
|
||||
* So the function has to be DEFINED on the client side of the line, which is
|
||||
* what this file is. It is the whole reason the component exists.
|
||||
*
|
||||
* Module scope, not an inline arrow: `field` is a dependency of the effect that
|
||||
* paints, so a new identity every render would re-raster the canvas every
|
||||
* render.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The ENVELOPE: brightest at top centre, gone by the edges — the shape the CSS
|
||||
* `radial-gradient(ellipse 600px 300px at 50% 0%, …)` had. `Dots`'s own default
|
||||
* is SWELL, a DIAGONAL wave, which is ambient texture for a left-aligned surface
|
||||
* but reads as a smudge off to one side under a centred headline.
|
||||
*
|
||||
* Separated from the motion below because it is the half that must not move: it
|
||||
* is what keeps the field dim where the type sits.
|
||||
* The envelope is brightest at top centre and gone by the edges, so the field
|
||||
* stays dim where the type sits; one slow ring travels outward from behind the
|
||||
* headline, modulating the envelope between 0.6 and 1.0 and never switching a
|
||||
* dot off. Module scope, so the painting effect does not re-raster every render.
|
||||
*/
|
||||
const envelope = (x: number, y: number) => {
|
||||
const r = Math.hypot((x - 0.5) / 0.45, y / 0.5);
|
||||
return Math.max(0, 1 - r) ** 1.5;
|
||||
};
|
||||
|
||||
/**
|
||||
* One slow ring travelling outward from the same origin the envelope falls off
|
||||
* from — so the field reads as emanating from behind the headline rather than
|
||||
* drifting past it. A diagonal or a scroll would have a DIRECTION, and a
|
||||
* direction under centred type points somewhere the page does not go.
|
||||
*
|
||||
* The three constants are the whole character and each is bounded on purpose:
|
||||
*
|
||||
* 0.20 the amplitude, so the wave rides between 0.60 and 1.00 of the envelope
|
||||
* — a 40% swing peak to trough. It MODULATES and never extinguishes: the
|
||||
* shape is always the shape, and at its brightest it is exactly the
|
||||
* static field this replaced. Measured across the field over a full
|
||||
* period, the ratio to the envelope stays within [0.600, 1.000] and the
|
||||
* value never leaves [0, 1]. Deeper and the dots visibly switch on and
|
||||
* off, which reads as a loading state rather than as atmosphere.
|
||||
* 6.5 the ring count, about two visible across the field. More becomes a
|
||||
* moiré against the 7px cell pitch, which shimmers rather than breathes.
|
||||
* 0.5 the speed: one ring every 12.6s. Slow enough that nothing in peripheral
|
||||
* vision demands attention while a reader is reading.
|
||||
*
|
||||
* Where the headline sits the field peaks at 0.35 of full ink across the whole
|
||||
* cycle, so the motion never competes with the type it sits behind.
|
||||
*/
|
||||
const HERO = (x: number, y: number, t: number) =>
|
||||
envelope(x, y) * (0.8 + 0.2 * Math.cos(Math.hypot((x - 0.5) / 0.45, y / 0.5) * 6.5 - t * 0.5));
|
||||
|
||||
/**
|
||||
* Whether this reader has asked for less motion. Read once on mount and watched
|
||||
* after: the setting is changed while a page is open often enough — a system
|
||||
* theme flip carries it — that a value sampled once goes stale in place.
|
||||
*
|
||||
* It starts TRUE so the first paint is the still field. The alternative starts
|
||||
* animating and stops, which is the one outcome the setting exists to prevent.
|
||||
*/
|
||||
/** Reduced motion gets the still field, on the first paint too. */
|
||||
function useStill() {
|
||||
const [still, setStill] = useState(true);
|
||||
useEffect(() => {
|
||||
@@ -81,30 +35,15 @@ function useStill() {
|
||||
return still;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorative: hidden from assistive tech, and it cannot take a pointer.
|
||||
*
|
||||
* It ANIMATES now. It did not, on the argument that a full-hero canvas
|
||||
* repainting every frame is a real battery cost on a page whose job is to be
|
||||
* read — which is true, and is why the motion is a 12-second breath at a fifth
|
||||
* of the envelope's depth rather than anything a reader has to look away from.
|
||||
* Frozen, the field was a gradient drawn out of dots; moving, the dots are the
|
||||
* reason it is dots.
|
||||
*
|
||||
* Reduced motion still gets the still field, and gets it on the FIRST paint —
|
||||
* which is also what a reader sees if JavaScript never runs.
|
||||
*/
|
||||
/** Decorative: hidden from assistive tech, and it takes no pointer. The dots are
|
||||
* the page's ink at low alpha, so the field reads on either ground. */
|
||||
export function HeroField() {
|
||||
const still = useStill();
|
||||
const { resolvedTheme } = useThemeSetting();
|
||||
const ink = resolvedTheme === 'light' ? 'rgb(0 0 0 / 0.14)' : 'rgb(255 255 255 / 0.18)';
|
||||
return (
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<Dots
|
||||
field={HERO}
|
||||
animate={!still}
|
||||
cell={7}
|
||||
color="rgb(255 255 255 / 0.18)"
|
||||
fade={{ top: 0.06, bottom: 0.45, left: 0.1, right: 0.1 }}
|
||||
/>
|
||||
</div>
|
||||
<YStack aria-hidden pointerEvents="none" position="absolute" t={0} r={0} b={0} l={0} overflow="hidden">
|
||||
<Dots field={HERO} animate={!still} cell={7} color={ink} fade={{ top: 0.06, bottom: 0.45, left: 0.1, right: 0.1 }} />
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
// catalog is usable without leaving the page: a framework that installs from npm
|
||||
// shows the npm line, one that ships a tag shows the tag.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Search, Check, Copy } from 'lucide-react';
|
||||
import { Text, XStack, YStack } from '@hanzo/gui';
|
||||
import { Check, Copy } from '@hanzogui/lucide-icons-2';
|
||||
import { Grid } from '@hanzo/ui/grid';
|
||||
import { Chips } from '@/components/chips';
|
||||
import { Field } from '@/components/field';
|
||||
import { muted } from '@/lib/ink';
|
||||
|
||||
type Kind = 'web' | 'mobile' | 'server' | 'llm';
|
||||
|
||||
@@ -77,44 +82,54 @@ const TARGETS: Target[] = [
|
||||
{ id: 'mcp', label: 'MCP', kind: 'llm', install: 'npx @hanzo/mcp', docs: '/docs/mcp' },
|
||||
];
|
||||
|
||||
const KINDS: { id: Kind | 'all'; label: string }[] = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'web', label: 'Web' },
|
||||
{ id: 'mobile', label: 'Mobile' },
|
||||
{ id: 'server', label: 'Server' },
|
||||
{ id: 'llm', label: 'LLM' },
|
||||
const KINDS: { value: Kind | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'web', label: 'Web' },
|
||||
{ value: 'mobile', label: 'Mobile' },
|
||||
{ value: 'server', label: 'Server' },
|
||||
{ value: 'llm', label: 'LLM' },
|
||||
];
|
||||
|
||||
function Row({ t }: { t: Target }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-fd-border bg-fd-card p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{t.label}</span>
|
||||
<YStack gap={8} p={14} rounded="$4" borderWidth={1} borderColor="$borderColor" bg="$panel">
|
||||
<XStack justify="space-between" items="center" gap={8}>
|
||||
<Text fontSize={14} fontWeight="500" color="$color12">
|
||||
{t.label}
|
||||
</Text>
|
||||
{t.docs ? (
|
||||
<a href={t.docs} className="text-xs text-fd-muted-foreground underline-offset-2 hover:underline">
|
||||
<Text render="a" href={t.docs} fontSize={12} {...muted} hoverStyle={{ textDecorationLine: 'underline' }}>
|
||||
docs
|
||||
</a>
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
</XStack>
|
||||
<XStack
|
||||
render="button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
aria-label={`Copy install for ${t.label}`}
|
||||
onPress={() => {
|
||||
void navigator.clipboard.writeText(t.install);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
}}
|
||||
className="group flex items-center justify-between gap-2 rounded border border-fd-border bg-fd-secondary/40 px-2 py-1.5 text-left font-mono text-xs"
|
||||
aria-label={`Copy install for ${t.label}`}
|
||||
justify="space-between"
|
||||
items="center"
|
||||
gap={8}
|
||||
px={8}
|
||||
py={6}
|
||||
rounded="$2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$background"
|
||||
cursor="pointer"
|
||||
>
|
||||
<code className="truncate">{t.install}</code>
|
||||
{copied ? (
|
||||
<Check className="size-3.5 shrink-0" aria-hidden />
|
||||
) : (
|
||||
<Copy className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-60" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Text fontFamily="$mono" fontSize={12} numberOfLines={1} color="$color12">
|
||||
{t.install}
|
||||
</Text>
|
||||
{copied ? <Check size={13} color="$color11" /> : <Copy size={13} color="$color9" />}
|
||||
</XStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,61 +147,27 @@ export function InstallCatalog() {
|
||||
}, [q, kind]);
|
||||
|
||||
return (
|
||||
<div className="not-prose flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-[12rem]">
|
||||
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-fd-muted-foreground" aria-hidden />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search frameworks and platforms"
|
||||
aria-label="Search frameworks and platforms"
|
||||
className="w-full rounded-md border border-fd-border bg-fd-background py-1.5 pl-8 pr-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{KINDS.map((k) => (
|
||||
<button
|
||||
key={k.id}
|
||||
type="button"
|
||||
onClick={() => setKind(k.id)}
|
||||
aria-pressed={kind === k.id}
|
||||
className={
|
||||
'rounded-md px-2.5 py-1 text-sm ' +
|
||||
(kind === k.id
|
||||
? 'bg-fd-primary text-fd-primary-foreground'
|
||||
: 'text-fd-muted-foreground hover:bg-fd-secondary')
|
||||
}
|
||||
>
|
||||
{k.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<YStack gap={16}>
|
||||
<XStack gap={10} flexWrap="wrap" items="center">
|
||||
<YStack flex={1} minW={200}>
|
||||
<Field value={q} onChange={setQ} placeholder="Search frameworks and platforms" />
|
||||
</YStack>
|
||||
<Chips items={KINDS} value={kind} onChange={setKind} />
|
||||
</XStack>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Grid columns={{ min: 240 }} gap={12}>
|
||||
{shown.map((t) => (
|
||||
<Row key={t.id} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</Grid>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<p className="text-sm text-fd-muted-foreground">
|
||||
Nothing matches “{q}”. Every stack can use the{' '}
|
||||
<a href="/docs/openapi" className="underline underline-offset-2">
|
||||
HTTP API
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-fd-muted-foreground">
|
||||
{shown.length} of {TARGETS.length}. Anything not listed reaches the same endpoints over the{' '}
|
||||
<a href="/docs/openapi" className="underline underline-offset-2">
|
||||
HTTP API
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Text fontSize={14} whiteSpace="normal" {...muted}>
|
||||
{shown.length === 0 ? `Nothing matches “${q}”. Every stack can use the ` : `${shown.length} of ${TARGETS.length}. Anything not listed reaches the same endpoints over the `}
|
||||
<Text render="a" href="/docs/openapi" color="$color12" textDecorationLine="underline">
|
||||
HTTP API
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Text, XStack, YStack } from '@hanzo/gui';
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
Code2,
|
||||
Database,
|
||||
FileJson2,
|
||||
Globe,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Server,
|
||||
Shield,
|
||||
ShoppingCart,
|
||||
Sparkle,
|
||||
Terminal,
|
||||
Workflow,
|
||||
} from '@hanzogui/lucide-icons-2';
|
||||
import { Grid } from '@hanzo/ui/grid';
|
||||
import { Action } from '@/components/action';
|
||||
import { Agent } from '@/components/agent';
|
||||
import { HeroField } from '@/components/hero-field';
|
||||
import { muted } from '@/lib/ink';
|
||||
|
||||
/**
|
||||
* The front page, drawn on the theme like every other route: the headline and
|
||||
* the two ways in (install the CLI, or hand it to an agent), the three doors,
|
||||
* the quick start, the nine domains, the tools, the models, the CLI and Zen.
|
||||
*
|
||||
* The quick start's code is highlighted on the server and handed in, so the
|
||||
* page ships tokens rather than a highlighter.
|
||||
*/
|
||||
|
||||
const domains = [
|
||||
{ name: 'Identity & trust', desc: 'Who someone is, what they may touch, and where secrets stay safe. IAM, AuthZ, KMS, MPC, Zero-Trust.', Icon: Shield, href: '/docs/openapi/iam', tag: 'Identity' },
|
||||
{ name: 'Intelligence', desc: 'The mind of the cloud — every model, agent, and tool you can call. Models, Agents, MCP, Embeddings, Prompts, GPUs, Functions.', Icon: Sparkle, href: '/docs/openapi/ai', tag: 'Intelligence' },
|
||||
{ name: 'Data', desc: 'Somewhere to put your data and read it back fast. SQL, Vector, KV, Search, Object, Base, DocDB.', Icon: Database, href: '/docs/openapi/provisioning', tag: 'Data' },
|
||||
{ name: 'Streams', desc: 'Move messages and run work in the background, reliably. PubSub, Tasks, Pipelines, Crawl.', Icon: Workflow, href: '/docs/openapi/pubsub', tag: 'Streams' },
|
||||
{ name: 'Observability', desc: 'See exactly what your system is doing, live. Metrics, Logs, Traces, Sessions, Evals, Analytics.', Icon: Activity, href: '/docs/openapi/o11y', tag: 'Observe' },
|
||||
{ name: 'Commerce', desc: 'Turn usage into money — meter it, price it, bill it, reward it. Commerce, Billing, Marketplace, Referrals.', Icon: ShoppingCart, href: '/docs/openapi/commerce', tag: 'Commerce' },
|
||||
{ name: 'Platform', desc: 'Ship your code and run it anywhere. Gateway, Machines, Edge, Registry.', Icon: Server, href: '/docs/openapi/gateway', tag: 'Platform' },
|
||||
{ name: 'Applications', desc: 'The finished products people use every day. Chat, Studio, Dev, Integrations, Apps.', Icon: LayoutGrid, href: '/docs/openapi/git', tag: 'Apps' },
|
||||
{ name: 'Chain', desc: "The networks the cloud speaks to — enumerate them, call one, read a holder's balances. Web3, Explorer.", Icon: Globe, href: '/docs/openapi/web3', tag: 'Chain' },
|
||||
];
|
||||
|
||||
const tools = [
|
||||
{ name: 'The Network', desc: 'Run the whole cloud yourself', href: '/docs/network', Icon: Globe },
|
||||
{ name: 'SDKs', desc: 'Python, TypeScript, Go, Rust, C++, Swift, Kotlin', href: '/docs/sdks', Icon: Code2 },
|
||||
{ name: 'API Reference', desc: 'Every /v1 endpoint, live', href: '/docs/openapi', Icon: FileJson2 },
|
||||
{ name: 'Architecture', desc: 'One binary, one contract', href: '/docs/architecture', Icon: Package },
|
||||
];
|
||||
|
||||
const doors = [
|
||||
{ eyebrow: 'No code', title: 'Build with App', body: 'Describe it in English and watch it build. Chat, agents and MCP tools in the browser.', href: 'https://hanzo.app' },
|
||||
{ eyebrow: 'In your terminal', title: 'Build with Dev', body: 'Our coding agent, in your repo. Or bring Claude Code and Codex — they work here too.', href: '/docs/cli' },
|
||||
{ eyebrow: 'Lower level', title: 'Build with API', body: 'Over 400 models behind one REST endpoint, with SDKs for every language we ship.', href: '/docs/openapi' },
|
||||
];
|
||||
|
||||
const providers = [
|
||||
{ name: 'Zen', spec: 'Open weights' },
|
||||
{ name: 'OpenAI', spec: 'GPT' },
|
||||
{ name: 'Anthropic', spec: 'Claude' },
|
||||
{ name: 'Qwen', spec: 'Open' },
|
||||
{ name: 'Llama', spec: 'Open' },
|
||||
{ name: 'DeepSeek', spec: 'Open' },
|
||||
{ name: 'Mistral', spec: 'Open' },
|
||||
{ name: 'Gemma', spec: 'Open' },
|
||||
];
|
||||
|
||||
const surfaces = [
|
||||
{
|
||||
Icon: Code2,
|
||||
title: 'SDKs in every language',
|
||||
body: 'Generated from one contract — the same /v1 surface, typed for your stack.',
|
||||
chips: ['Python', 'TypeScript', 'Go', 'Rust', 'C++', 'Dart'],
|
||||
href: '/docs/sdks',
|
||||
cta: 'SDK reference',
|
||||
},
|
||||
{
|
||||
Icon: Workflow,
|
||||
title: 'Every tool, one MCP surface',
|
||||
body: 'Native connectors + the open MCP registry — Slack, GitHub, Notion, Stripe, and more — exposed as MCP tools any agent can call.',
|
||||
chips: ['Slack', 'GitHub', 'Notion', 'Stripe', 'Google', 'Linear', '+700 more'],
|
||||
href: '/docs/mcp',
|
||||
cta: 'MCP tools',
|
||||
},
|
||||
];
|
||||
|
||||
const commands = [
|
||||
{ cmd: 'hanzo chat', desc: 'Chat with any model interactively' },
|
||||
{ cmd: 'hanzo models list', desc: 'Browse every available model' },
|
||||
{ cmd: 'hanzo keys create', desc: 'Create and manage API keys' },
|
||||
{ cmd: 'hanzo deploy', desc: 'Deploy apps with git push' },
|
||||
{ cmd: 'hanzo logs', desc: 'Stream logs from any service' },
|
||||
{ cmd: 'hanzo storage', desc: 'Manage S3-compatible storage' },
|
||||
{ cmd: 'hanzo secrets', desc: 'Manage secrets and env vars' },
|
||||
{ cmd: 'hanzo bot', desc: 'Deploy and manage AI bots' },
|
||||
{ cmd: 'hanzo flow', desc: 'Run workflow automations' },
|
||||
];
|
||||
|
||||
const zen = [
|
||||
{ name: 'zen6', spec: '27B dense, 1M context' },
|
||||
{ name: 'zen6-coder', spec: 'Agentic code' },
|
||||
{ name: 'zen6-flash', spec: 'Ternary vision' },
|
||||
{ name: 'zen5', spec: 'Hosted' },
|
||||
{ name: 'zen-embedding', spec: 'Retrieval' },
|
||||
{ name: 'zen-guard', spec: 'Safety' },
|
||||
];
|
||||
|
||||
const CARD = { rounded: 16, borderWidth: 1, borderColor: '$borderColor', bg: '$panel' } as const;
|
||||
|
||||
function go(href: string) {
|
||||
return /^https?:/.test(href) ? <a href={href} target="_blank" rel="noreferrer" /> : <Link href={href} prefetch={false} />;
|
||||
}
|
||||
|
||||
function Section({ title, lead, children }: { title: string; lead: string; children: ReactNode }) {
|
||||
return (
|
||||
<YStack render="section" gap={8}>
|
||||
<Text render="h2" fontSize={30} lineHeight={36} fontWeight="700" letterSpacing={-0.6} color="$color12">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize={14} mb={20} whiteSpace="normal" {...muted}>
|
||||
{lead}
|
||||
</Text>
|
||||
{children}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({ name, spec, mono = false }: { name: string; spec: string; mono?: boolean }) {
|
||||
return (
|
||||
<YStack {...CARD} rounded={10} px={14} py={12} gap={3}>
|
||||
<Text fontSize={12} fontWeight="600" fontFamily={mono ? '$mono' : '$body'} color="$color12">
|
||||
{name}
|
||||
</Text>
|
||||
<Text fontSize={11} {...muted}>
|
||||
{spec}
|
||||
</Text>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function Landing({ install, use }: { install: ReactNode; use: ReactNode }) {
|
||||
return (
|
||||
<YStack render="main" minW={0} pb={48}>
|
||||
{/* The hero, centred over its halftone. */}
|
||||
<YStack position="relative" items="center" px={24} pt={120} pb={80} $max-md={{ pt: 72, pb: 56 }}>
|
||||
<HeroField />
|
||||
<XStack
|
||||
render={<Link href="/docs/models" prefetch={false} />}
|
||||
position="relative"
|
||||
items="center"
|
||||
gap={8}
|
||||
mb={32}
|
||||
px={16}
|
||||
py={8}
|
||||
rounded={999}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$panel"
|
||||
hoverStyle={{ borderColor: '$color8' }}
|
||||
>
|
||||
<YStack width={6} height={6} rounded={999} bg="$color12" />
|
||||
<Text fontSize={14} {...muted}>
|
||||
Zen generates · Enso routes · Kai decides
|
||||
</Text>
|
||||
<ArrowRight size={14} color="$color10" />
|
||||
</XStack>
|
||||
<Text
|
||||
render="h1"
|
||||
position="relative"
|
||||
text="center"
|
||||
fontSize={72}
|
||||
lineHeight={72}
|
||||
fontWeight="700"
|
||||
letterSpacing={-1.8}
|
||||
color="$color12"
|
||||
maxW={760}
|
||||
$max-lg={{ fontSize: 60, lineHeight: 60 }}
|
||||
$max-md={{ fontSize: 44, lineHeight: 46, letterSpacing: -1 }}
|
||||
>
|
||||
Build anything with Hanzo.
|
||||
</Text>
|
||||
<Text
|
||||
render="p"
|
||||
position="relative"
|
||||
mt={20}
|
||||
maxW={576}
|
||||
text="center"
|
||||
fontSize={19}
|
||||
lineHeight={30}
|
||||
whiteSpace="normal"
|
||||
{...muted}
|
||||
$max-md={{ fontSize: 17, lineHeight: 27 }}
|
||||
>
|
||||
Every model. Every tool. One key. Start in the browser, ship from your terminal, and when you want it on your own hardware, take the whole thing with you — it is the same software we run in production.
|
||||
</Text>
|
||||
|
||||
{/* The main call: install the CLI. */}
|
||||
<YStack position="relative" mt={40} width="100%" maxW={512} gap={12} items="center">
|
||||
<XStack {...CARD} width="100%" p={4}>
|
||||
<XStack flex={1} gap={12} items="center" px={20} py={16} rounded={12} bg="$background">
|
||||
<Text fontFamily="$mono" fontSize={14} select="none" {...muted}>
|
||||
$
|
||||
</Text>
|
||||
<Text fontFamily="$mono" fontSize={14} color="$color12">
|
||||
curl hanzo.sh | sh
|
||||
</Text>
|
||||
</XStack>
|
||||
</XStack>
|
||||
<Text fontSize={12} text="center" whiteSpace="normal" {...muted}>
|
||||
Installs the <Text fontFamily="$mono">hanzo</Text> CLI. Then <Text fontFamily="$mono">hanzo auth login</Text> to get a
|
||||
key.
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
{/* The other way in: hand it to an agent. */}
|
||||
<YStack position="relative" mt={24} gap={8} items="center">
|
||||
<Agent />
|
||||
<Text fontSize={12} text="center" whiteSpace="normal" {...muted}>
|
||||
Or hand this to your agent — it installs the CLI, the MCP server and the{' '}
|
||||
<Text render="a" href="https://hanzoskills.com" target="_blank" rel="noreferrer" color="$color12" textDecorationLine="underline">
|
||||
skills
|
||||
</Text>
|
||||
, then points its own model calls at Hanzo.
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
{/* The three doors, in descending order of how much you type. */}
|
||||
<YStack position="relative" mt={48} width="100%" maxW={896}>
|
||||
<Grid columns={{ min: 260, max: 3 }} gap={16}>
|
||||
{doors.map((d) => (
|
||||
<YStack key={d.title} render={go(d.href)} {...CARD} p={24} gap={6} hoverStyle={{ bg: '$hover', borderColor: '$color8' }}>
|
||||
<Text fontSize={12} fontWeight="500" {...muted}>
|
||||
{d.eyebrow}
|
||||
</Text>
|
||||
<XStack gap={6} items="center">
|
||||
<Text fontSize={18} fontWeight="600" color="$color12">
|
||||
{d.title}
|
||||
</Text>
|
||||
<ArrowRight size={16} color="$color10" />
|
||||
</XStack>
|
||||
<Text fontSize={14} lineHeight={22} whiteSpace="normal" {...muted}>
|
||||
{d.body}
|
||||
</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</Grid>
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
<YStack width="100%" maxW={1024} self="center" px={32} gap={96} $max-md={{ px: 20, gap: 72 }}>
|
||||
<Section title="Quick Start" lead="Install the CLI, log in, and reach every capability from your terminal.">
|
||||
<YStack gap={20}>
|
||||
{install}
|
||||
<Text fontSize={14} whiteSpace="normal" {...muted}>
|
||||
Sign in once, then reach every capability from the terminal — or from any SDK, over HTTP, or through MCP.
|
||||
</Text>
|
||||
{use}
|
||||
</YStack>
|
||||
</Section>
|
||||
|
||||
<Section title="One binary. The whole platform." lead="Every capability has one name and one route. Browse by domain — click any card to go deep.">
|
||||
<Grid columns={{ min: 220, max: 3 }} gap={12}>
|
||||
{domains.map(({ name, desc, Icon, href, tag }) => (
|
||||
<YStack key={name} render={<Link href={href} prefetch={false} />} {...CARD} p={24} gap={8} minH={140} hoverStyle={{ bg: '$hover', borderColor: '$color8' }}>
|
||||
<XStack justify="space-between" items="center" mb={4}>
|
||||
<XStack p={8} rounded={12} bg="$hover">
|
||||
<Icon size={18} color="$color11" />
|
||||
</XStack>
|
||||
<Text fontSize={11} fontWeight="500" {...muted}>
|
||||
{tag}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize={14} fontWeight="600" color="$color12">
|
||||
{name}
|
||||
</Text>
|
||||
<Text flex={1} fontSize={12} lineHeight={19} whiteSpace="normal" {...muted}>
|
||||
{desc}
|
||||
</Text>
|
||||
<XStack gap={4} items="center">
|
||||
<Text fontSize={11} fontWeight="500" {...muted}>
|
||||
View docs
|
||||
</Text>
|
||||
<ArrowRight size={12} color="$color10" />
|
||||
</XStack>
|
||||
</YStack>
|
||||
))}
|
||||
</Grid>
|
||||
</Section>
|
||||
|
||||
<Section title="Developer Tools" lead="SDKs, APIs, and protocols for every stack.">
|
||||
<Grid columns={{ min: 230, max: 4 }} gap={16}>
|
||||
{tools.map(({ name, desc, href, Icon }) => (
|
||||
<YStack key={name} render={<Link href={href} prefetch={false} />} {...CARD} p={24} gap={4} hoverStyle={{ bg: '$hover', borderColor: '$color8' }}>
|
||||
<XStack mb={10}>
|
||||
<Icon size={18} color="$color11" />
|
||||
</XStack>
|
||||
<Text fontSize={14} fontWeight="600" color="$color12">
|
||||
{name}
|
||||
</Text>
|
||||
<Text fontSize={12} whiteSpace="normal" {...muted}>
|
||||
{desc}
|
||||
</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</Grid>
|
||||
</Section>
|
||||
|
||||
<Section title="Every model, one API" lead="Over 400 models across every major provider — call any of them with one credential, one request shape.">
|
||||
<Grid columns={{ min: 120, max: 8 }} gap={12}>
|
||||
{providers.map((p) => (
|
||||
<Tile key={p.name} name={p.name} spec={p.spec} />
|
||||
))}
|
||||
</Grid>
|
||||
</Section>
|
||||
|
||||
<Grid columns={{ min: 470, max: 2 }} gap={16}>
|
||||
{surfaces.map(({ Icon, title, body, chips, href, cta }) => (
|
||||
<YStack key={title} {...CARD} p={32} gap={10} items="flex-start" $max-md={{ p: 24 }}>
|
||||
<XStack gap={12} items="center">
|
||||
<Icon size={18} color="$color11" />
|
||||
<Text fontSize={20} fontWeight="700" letterSpacing={-0.4} color="$color12">
|
||||
{title}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize={13} mb={8} whiteSpace="normal" {...muted}>
|
||||
{body}
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap={8}>
|
||||
{chips.map((c) => (
|
||||
<Text key={c} fontSize={12} px={12} py={6} rounded={999} borderWidth={1} borderColor="$borderColor" bg="$background" {...muted}>
|
||||
{c}
|
||||
</Text>
|
||||
))}
|
||||
</XStack>
|
||||
<XStack render={<Link href={href} prefetch={false} />} mt={12} gap={4} items="center">
|
||||
<Text fontSize={12} fontWeight="500" color="$color12">
|
||||
{cta}
|
||||
</Text>
|
||||
<ArrowRight size={12} color="$color12" />
|
||||
</XStack>
|
||||
</YStack>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* What the CLI can do. */}
|
||||
<YStack {...CARD} p={40} gap={24} $max-md={{ p: 24 }}>
|
||||
<XStack gap={12} items="center">
|
||||
<XStack p={10} rounded={12} bg="$hover">
|
||||
<Terminal size={18} color="$color11" />
|
||||
</XStack>
|
||||
<YStack gap={2} flex={1} minW={0}>
|
||||
<Text fontSize={24} fontWeight="700" letterSpacing={-0.5} color="$color12">
|
||||
The <Text fontFamily="$mono">hanzo</Text> CLI
|
||||
</Text>
|
||||
<Text fontSize={12} whiteSpace="normal" {...muted}>
|
||||
A ~15 MB Rust client for any live cloud — prod, laptop, or self-host
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<Grid columns={{ min: 200, max: 3 }} gap={12}>
|
||||
{commands.map((c) => (
|
||||
<Tile key={c.cmd} name={c.cmd} spec={c.desc} mono />
|
||||
))}
|
||||
</Grid>
|
||||
</YStack>
|
||||
|
||||
{/* Zen. */}
|
||||
<YStack {...CARD} p={40} gap={24} $max-md={{ p: 24 }}>
|
||||
<XStack gap={12} items="center">
|
||||
<Sparkle size={18} color="$color11" />
|
||||
<Text fontSize={24} fontWeight="700" letterSpacing={-0.5} color="$color12">
|
||||
Zen
|
||||
</Text>
|
||||
<Text fontFamily="$mono" fontSize={12} px={8} py={2} rounded={999} bg="$hover" {...muted}>
|
||||
open weights
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize={14} lineHeight={22} maxW={672} whiteSpace="normal" {...muted}>
|
||||
The generative family: reasoning, code, vision, speech, embeddings and safety. Weights on Hugging Face; the same ids hosted on api.hanzo.ai, routed by Enso.
|
||||
</Text>
|
||||
<Grid columns={{ min: 130, max: 6 }} gap={12}>
|
||||
{zen.map((m) => (
|
||||
<Tile key={m.name} name={m.name} spec={m.spec} mono />
|
||||
))}
|
||||
</Grid>
|
||||
<XStack flexWrap="wrap" gap={12} items="center">
|
||||
<Action tone="line" render={<Link href="/docs/models/zen" prefetch={false} />} rounded={999}>
|
||||
Zen models →
|
||||
</Action>
|
||||
<Text render="a" href="https://huggingface.co/zenlm" target="_blank" rel="noreferrer noopener" fontSize={12} {...muted} hoverStyle={{ color: '$color12' }}>
|
||||
HuggingFace →
|
||||
</Text>
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
{/* The close. */}
|
||||
<YStack render="section" items="center" gap={12} py={48}>
|
||||
<Text render="h2" fontSize={36} lineHeight={42} fontWeight="700" letterSpacing={-0.8} color="$color12" text="center">
|
||||
Start building
|
||||
</Text>
|
||||
<Text fontSize={14} text="center" {...muted}>
|
||||
Free tier with generous limits. No credit card required.
|
||||
</Text>
|
||||
<Text fontFamily="$mono" fontSize={14} mb={20} {...muted}>
|
||||
curl hanzo.sh | sh
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap={12} justify="center">
|
||||
<Action tone="loud" render="a" href="https://hanzo.id/signup?redirect_uri=https://console.hanzo.ai" height={44} px={28} rounded={999}>
|
||||
Sign up free
|
||||
</Action>
|
||||
<Action tone="line" render={<Link href="/docs" prefetch={false} />} height={44} px={28} rounded={999}>
|
||||
Browse documentation
|
||||
</Action>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import Link from '@hanzo/docs/core/link';
|
||||
import { type ReactNode, Suspense } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
|
||||
|
||||
export interface Suggestion {
|
||||
id: string;
|
||||
href: string;
|
||||
title: ReactNode;
|
||||
}
|
||||
|
||||
export interface NotFoundProps {
|
||||
getSuggestions: () => Promise<Suggestion[]>;
|
||||
}
|
||||
|
||||
export function NotFound(props: NotFoundProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center gap-4 p-8 [grid-area:main]">
|
||||
<h1 className="text-4xl font-bold font-mono">Page not found</h1>
|
||||
<p className="text-sm text-fd-muted-foreground">
|
||||
Nothing is published at this address. Search the docs from the rail, or start here.
|
||||
</p>
|
||||
<div className="w-full border border-fd-foreground/50 border-dashed p-4 max-w-[600px]">
|
||||
<Suspense
|
||||
fallback={<p className="text-sm text-fd-muted-foreground">Finding alternatives…</p>}
|
||||
>
|
||||
<Alternative {...props} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function Alternative({ getSuggestions }: NotFoundProps) {
|
||||
const suggestions = await getSuggestions();
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-fd-muted-foreground mb-2">No alternative found</p>
|
||||
<Link href="/" className={cn(buttonVariants({ variant: 'secondary' }))}>
|
||||
Return to Home
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-sm text-fd-muted-foreground mb-2">Maybe you meant</h2>
|
||||
|
||||
<div className="flex flex-col rounded-lg border bg-fd-card text-fd-card-foreground shadow-md overflow-hidden divide-y divide-fd-border">
|
||||
{suggestions.map((doc) => (
|
||||
<Link
|
||||
key={doc.id}
|
||||
href={doc.href}
|
||||
className="inline-flex items-center justify-between gap-4 text-sm px-3 py-2 hover:bg-fd-accent hover:text-fd-accent-foreground"
|
||||
>
|
||||
<p className="font-medium text-nowrap">{doc.title}</p>
|
||||
<code className="text-fd-muted-foreground truncate">{doc.href}</code>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
SearchDialog,
|
||||
SearchDialogClose,
|
||||
SearchDialogContent,
|
||||
SearchDialogFooter,
|
||||
SearchDialogHeader,
|
||||
SearchDialogIcon,
|
||||
SearchDialogInput,
|
||||
SearchDialogList,
|
||||
SearchDialogOverlay,
|
||||
type SearchItemType,
|
||||
type SharedProps,
|
||||
} from '@hanzo/docs-base-ui/components/dialog/search';
|
||||
import { useDocsSearch } from '@hanzo/docs/core/search/client';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@hanzo/docs-base-ui/components/ui/popover';
|
||||
import { ArrowRight, Check, ChevronDown, Sparkle } from 'lucide-react';
|
||||
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useTree } from '@/lib/tree';
|
||||
import type { Item, Node } from '@hanzo/docs/core/page-tree';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { AGENT_SETUP_PROMPT } from '@/lib/agent-setup-prompt';
|
||||
|
||||
const items = [
|
||||
{
|
||||
name: 'All',
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
name: 'Services',
|
||||
description: 'Hanzo Cloud services documentation',
|
||||
value: 'services',
|
||||
},
|
||||
{
|
||||
name: 'SDKs',
|
||||
description: 'Python, TypeScript, Go, Rust, and C SDKs',
|
||||
value: 'sdks',
|
||||
},
|
||||
{
|
||||
name: 'API',
|
||||
description: 'OpenAPI reference for all services',
|
||||
value: 'openapi',
|
||||
},
|
||||
{
|
||||
name: 'Products',
|
||||
description: 'Zen LM, Chat, MCP, Dev, ZAP',
|
||||
value: 'products',
|
||||
},
|
||||
];
|
||||
|
||||
export default function CustomSearchDialog(props: SharedProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tag, setTag] = useState<string | undefined>();
|
||||
const { search, setSearch, query } = useDocsSearch({
|
||||
type: 'flexsearch-static',
|
||||
from: '/api/search',
|
||||
tag,
|
||||
});
|
||||
const { tree: full } = useTree();
|
||||
const router = useRouter();
|
||||
const searchMap = useMemo(() => {
|
||||
const map = new Map<string, Item>();
|
||||
|
||||
function onNode(node: Node) {
|
||||
if (node.type === 'page' && typeof node.name === 'string') {
|
||||
map.set(node.name.toLowerCase(), node);
|
||||
} else if (node.type === 'folder') {
|
||||
if (node.index) onNode(node.index);
|
||||
for (const item of node.children) onNode(item);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of full.children) onNode(item);
|
||||
return map;
|
||||
}, [full]);
|
||||
// ASKING IS A DIFFERENT INTENTION FROM FINDING, and until now only one of them
|
||||
// had one. The field says "search or ask", full-text search matches TERMS,
|
||||
// and a question is mostly terms that are not in any page — measured, "api
|
||||
// key", "quickstart" and "kms" all return results while "how do I get an api
|
||||
// key" returns none. So the reader most in need of an answer got the emptiest
|
||||
// screen, under a label that had promised otherwise.
|
||||
//
|
||||
// The answer is not a chat surface bolted into this dialog. It is the one this
|
||||
// product already gives everywhere else: hand the question to the reader's own
|
||||
// agent, with the setup that points it at Hanzo's models and skills. Same
|
||||
// prompt as the Use Agent control, so there is one thing to keep true.
|
||||
const [asked, setAsked] = useState(false);
|
||||
const askAction = useMemo<SearchItemType | undefined>(() => {
|
||||
const q = search.trim();
|
||||
if (q.length < 3) return;
|
||||
return {
|
||||
id: 'ask-agent',
|
||||
type: 'action',
|
||||
node: (
|
||||
<div className="inline-flex items-center gap-2 text-fd-muted-foreground">
|
||||
{asked ? <Check className="size-4" /> : <Sparkle className="size-4" />}
|
||||
<p>
|
||||
{asked ? (
|
||||
'Copied — paste it into your agent'
|
||||
) : (
|
||||
<>
|
||||
Ask an agent<span className="font-medium text-fd-foreground"> “{q}”</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
onSelect: () => {
|
||||
void navigator.clipboard
|
||||
.writeText(
|
||||
`${AGENT_SETUP_PROMPT}\n\n---\n\nThen answer this using the Hanzo documentation at ` +
|
||||
`https://docs.hanzo.ai, and use the Hanzo skills at https://hanzoskills.com for any ` +
|
||||
`capability it touches:\n\n${q}`,
|
||||
)
|
||||
.then(() => setAsked(true));
|
||||
},
|
||||
};
|
||||
}, [asked, search]);
|
||||
|
||||
const pageTreeAction = useMemo<SearchItemType | undefined>(() => {
|
||||
if (search.length === 0) return;
|
||||
|
||||
const normalized = search.toLowerCase();
|
||||
for (const [k, page] of searchMap) {
|
||||
if (!k.startsWith(normalized)) continue;
|
||||
|
||||
return {
|
||||
id: 'quick-action',
|
||||
type: 'action',
|
||||
node: (
|
||||
<div className="inline-flex items-center gap-2 text-fd-muted-foreground">
|
||||
<ArrowRight className="size-4" />
|
||||
<p>
|
||||
Jump to <span className="font-medium text-fd-foreground">{page.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
onSelect: () => router.push(page.url),
|
||||
};
|
||||
}
|
||||
}, [router, search, searchMap]);
|
||||
|
||||
return (
|
||||
<SearchDialog search={search} onSearchChange={setSearch} isLoading={query.isLoading} {...props}>
|
||||
<SearchDialogOverlay />
|
||||
<SearchDialogContent>
|
||||
<SearchDialogHeader>
|
||||
<SearchDialogIcon />
|
||||
<SearchDialogInput />
|
||||
<SearchDialogClose />
|
||||
</SearchDialogHeader>
|
||||
<SearchDialogList
|
||||
items={
|
||||
query.data !== 'empty' || pageTreeAction || askAction
|
||||
? [
|
||||
...(pageTreeAction ? [pageTreeAction] : []),
|
||||
...(Array.isArray(query.data) ? query.data : []),
|
||||
// LAST, always. A question is the fallback when the terms did
|
||||
// not land, not a competitor to a page that did.
|
||||
...(askAction ? [askAction] : []),
|
||||
]
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<SearchDialogFooter className="flex flex-row flex-wrap gap-2 items-center">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
className={buttonVariants({
|
||||
size: 'sm',
|
||||
color: 'ghost',
|
||||
className: '-m-1.5 me-auto',
|
||||
})}
|
||||
>
|
||||
<span className="text-fd-muted-foreground/80 me-2">Filter</span>
|
||||
{items.find((item) => item.value === tag)?.name}
|
||||
<ChevronDown className="size-3.5 text-fd-muted-foreground" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col p-1 gap-1" align="start">
|
||||
{items.map((item, i) => {
|
||||
const isSelected = item.value === tag;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
setTag(item.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-lg text-start px-2 py-1.5',
|
||||
isSelected
|
||||
? 'text-fd-primary bg-fd-primary/10'
|
||||
: 'hover:text-fd-accent-foreground hover:bg-fd-accent',
|
||||
)}
|
||||
>
|
||||
<p className="font-medium mb-0.5">{item.name}</p>
|
||||
<p className="text-xs opacity-70">{item.description}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<a
|
||||
href="https://hanzo.ai"
|
||||
rel="noreferrer noopener"
|
||||
className="text-xs text-nowrap text-fd-muted-foreground"
|
||||
>
|
||||
Powered by Hanzo Search
|
||||
</a>
|
||||
</SearchDialogFooter>
|
||||
</SearchDialogContent>
|
||||
</SearchDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { BaseLayoutProps, LinkItemType } from '@hanzo/docs-base-ui/layouts/shared';
|
||||
import { HanzoMark } from '@hanzogui/shell';
|
||||
import { nav } from '@/lib/nav';
|
||||
|
||||
// The blog's HomeLayout (app/(home)) is the last caller of this, and it goes
|
||||
// when the blog moves onto the gui shell. The nav itself is lib/nav.
|
||||
export const linkItems: LinkItemType[] = nav.map((item) => ({
|
||||
text: item.text,
|
||||
url: item.url,
|
||||
active: item.exact ? 'url' : 'nested-url',
|
||||
}));
|
||||
|
||||
export function baseOptions(): BaseLayoutProps {
|
||||
return {
|
||||
nav: {
|
||||
title: (
|
||||
<>
|
||||
<HanzoMark size={18} />
|
||||
<span className="font-medium max-md:hidden">Hanzo AI</span>
|
||||
<span className="max-md:hidden rounded border border-fd-border px-1.5 py-0.5 text-[11px] font-medium text-fd-muted-foreground">
|
||||
Docs
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { remark } from 'remark';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
|
||||
import {
|
||||
Children,
|
||||
type ComponentProps,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
use,
|
||||
useDeferredValue,
|
||||
} from 'react';
|
||||
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
|
||||
import { DynamicCodeBlock } from '@hanzo/docs-base-ui/components/dynamic-codeblock';
|
||||
import defaultMdxComponents from '@hanzo/docs-base-ui/mdx';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import type { ElementContent, Root, RootContent } from 'hast';
|
||||
|
||||
export interface Processor {
|
||||
process: (content: string) => Promise<ReactNode>;
|
||||
}
|
||||
|
||||
export function rehypeWrapWords() {
|
||||
return (tree: Root) => {
|
||||
visit(tree, ['text', 'element'], (node, index, parent) => {
|
||||
if (node.type === 'element' && node.tagName === 'pre') return 'skip';
|
||||
if (node.type !== 'text' || !parent || index === undefined) return;
|
||||
|
||||
const words = node.value.split(/(?=\s)/);
|
||||
|
||||
// Create new span nodes for each word and whitespace
|
||||
const newNodes: ElementContent[] = words.flatMap((word) => {
|
||||
if (word.length === 0) return [];
|
||||
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: {
|
||||
class: 'animate-fd-fade-in',
|
||||
},
|
||||
children: [{ type: 'text', value: word }],
|
||||
};
|
||||
});
|
||||
|
||||
Object.assign(node, {
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: {},
|
||||
children: newNodes,
|
||||
} satisfies RootContent);
|
||||
return 'skip';
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createProcessor(): Processor {
|
||||
const processor = remark().use(remarkGfm).use(remarkRehype).use(rehypeWrapWords);
|
||||
|
||||
return {
|
||||
async process(content) {
|
||||
const nodes = processor.parse({ value: content });
|
||||
const hast = await processor.run(nodes);
|
||||
|
||||
return toJsxRuntime(hast, {
|
||||
development: false,
|
||||
jsx,
|
||||
jsxs,
|
||||
Fragment,
|
||||
components: {
|
||||
...defaultMdxComponents,
|
||||
pre: Pre,
|
||||
img: undefined, // use JSX
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Pre(props: ComponentProps<'pre'>) {
|
||||
const code = Children.only(props.children) as ReactElement;
|
||||
const codeProps = code.props as ComponentProps<'code'>;
|
||||
const content = codeProps.children;
|
||||
if (typeof content !== 'string') return null;
|
||||
|
||||
let lang =
|
||||
codeProps.className
|
||||
?.split(' ')
|
||||
.find((v) => v.startsWith('language-'))
|
||||
?.slice('language-'.length) ?? 'text';
|
||||
|
||||
if (lang === 'mdx') lang = 'md';
|
||||
|
||||
return <DynamicCodeBlock lang={lang} code={content.trimEnd()} />;
|
||||
}
|
||||
|
||||
const processor = createProcessor();
|
||||
|
||||
export function Markdown({ text }: { text: string }) {
|
||||
const deferredText = useDeferredValue(text);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<p className="invisible">{text}</p>}>
|
||||
<Renderer text={deferredText} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const cache = new Map<string, Promise<ReactNode>>();
|
||||
|
||||
function Renderer({ text }: { text: string }) {
|
||||
const result = cache.get(text) ?? processor.process(text);
|
||||
cache.set(text, result);
|
||||
|
||||
return use(result);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import * as Prose from '@/components/mdx/prose';
|
||||
import { Code, CodeBlockTab, CodeBlockTabs, CodeBlockTabsList, CodeBlockTabsTrigger } from '@/components/mdx/code';
|
||||
import { Tab, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/mdx/tabs';
|
||||
import { Accordion, Accordions, Callout, Card, Cards, File, Files, Folder, Step, Steps } from '@/components/mdx/blocks';
|
||||
import { ModelsCatalog, ConnectorsCatalog, InstallCatalog } from '@/components/preview/lazy';
|
||||
import { ModelsCatalog, ConnectorsCatalog, InstallCatalog } from '@/components/catalogs';
|
||||
import { DocsHero } from '@/components/docs-hero';
|
||||
import { ProductSection, ProductSections } from '@/components/product-section';
|
||||
import { ProviderStrip } from '@/components/provider-strip';
|
||||
|
||||
@@ -4,6 +4,15 @@ import Link from 'next/link';
|
||||
import { Children, useState, type ReactNode } from 'react';
|
||||
import { Text, XStack, YStack } from '@hanzo/gui';
|
||||
import {
|
||||
Boxes,
|
||||
Code,
|
||||
Key,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Rocket,
|
||||
Sparkle,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
ChevronRight,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
@@ -15,6 +24,7 @@ import {
|
||||
TriangleAlert,
|
||||
} from '@hanzogui/lucide-icons-2';
|
||||
import { muted } from '@/lib/ink';
|
||||
import { Grid } from '@hanzo/ui/grid';
|
||||
import { Lead, Small, flow } from '@/components/mdx/prose';
|
||||
|
||||
/**
|
||||
@@ -57,12 +67,18 @@ export function Callout({ type = 'info', title, icon, children }: { type?: Kind;
|
||||
|
||||
export function Cards({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<YStack gap={12} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(260px, 100%), 1fr))' }}>
|
||||
<Grid columns={{ min: 260 }} gap={12}>
|
||||
{children}
|
||||
</YStack>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A card's icon, named in the content (`<Card icon="Rocket">`). Content
|
||||
* cannot hand a drawn icon across to a client part, so it names one of these.
|
||||
*/
|
||||
const GLYPH = { Boxes, Code, Key, LayoutGrid, Package, Rocket, Sparkle, Sparkles, Terminal } as const;
|
||||
|
||||
export function Card({
|
||||
title,
|
||||
description,
|
||||
@@ -73,7 +89,7 @@ export function Card({
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
href?: string;
|
||||
icon?: ReactNode;
|
||||
icon?: ReactNode | keyof typeof GLYPH;
|
||||
external?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
@@ -92,7 +108,10 @@ export function Card({
|
||||
>
|
||||
{icon ? (
|
||||
<XStack width={30} height={30} mb={4} rounded="$3" borderWidth={1} borderColor="$borderColor" items="center" justify="center">
|
||||
{icon}
|
||||
{typeof icon === 'string' ? (() => {
|
||||
const Glyph = GLYPH[icon as keyof typeof GLYPH];
|
||||
return Glyph ? <Glyph size={15} color="$color11" /> : null;
|
||||
})() : icon}
|
||||
</XStack>
|
||||
) : null}
|
||||
<Text fontSize={15} lineHeight={22} fontWeight="600" color="$color12" whiteSpace="normal">
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, LayoutGrid } from 'lucide-react';
|
||||
import { MeetHanzoMenu } from '@hanzogui/shell';
|
||||
|
||||
/**
|
||||
* The one shared "Meet Hanzo" affordance — a nav trigger that opens the
|
||||
* universal <MeetHanzoMenu> (the ecosystem mega-menu driven by HANZO_FLAGSHIP +
|
||||
* MEET_HANZO_GROUPS). Replaces the bespoke try-hanzo-dropdown so docs discovers
|
||||
* the whole ecosystem the same way every other Hanzo property does.
|
||||
*
|
||||
* `anchor` (56px) matches the Fumadocs nav row height so the panel drops
|
||||
* flush beneath the header.
|
||||
*/
|
||||
// Keep the ecosystem menu's product items INSIDE the docs when we have a docs
|
||||
// page for them — instead of bouncing out to the hanzo.ai marketing home. Maps
|
||||
// the registry link id → its docs path; anything not listed keeps the canonical
|
||||
// ecosystem link. Passed to MeetHanzoMenu's `resolveHref` (opt-in; a no-op on
|
||||
// shell versions before that prop exists, so it's safe to ship ahead).
|
||||
const DOCS_HREFS: Record<string, string> = {
|
||||
models: '/docs/openapi/ai',
|
||||
enso: '/docs/openapi/ai',
|
||||
agents: '/docs/openapi/agent',
|
||||
mcp: '/docs/mcp',
|
||||
gateway: '/docs/openapi/gateway',
|
||||
api: '/docs/openapi',
|
||||
keys: '/docs/api-keys',
|
||||
};
|
||||
|
||||
/**
|
||||
* `compact` collapses the trigger to its icon below `lg`.
|
||||
*
|
||||
* The docs header is a fixed 56px row that also carries search, Sign In and Get API
|
||||
* Key. Below `lg` a text label does not fit beside them: Get API Key wraps and spills
|
||||
* out of the bar at 834, and clips off the right edge at 768 (measured). So the label
|
||||
* earns its space by breakpoint. The accessible name stays "Hanzo" at every width —
|
||||
* only the glyph changes, so nothing is lost to a screen reader or a keyboard user.
|
||||
*/
|
||||
export function MeetHanzo({ compact = false }: { compact?: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelId = 'meet-hanzo-menu';
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="Hanzo"
|
||||
className={`inline-flex shrink-0 items-center gap-1 rounded-full text-sm font-medium text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground ${
|
||||
compact ? 'size-9 justify-center lg:size-auto lg:px-3 lg:py-1.5' : 'px-3 py-1.5'
|
||||
}`}
|
||||
>
|
||||
{compact && <LayoutGrid className="size-4 lg:hidden" aria-hidden />}
|
||||
<span className={compact ? 'hidden lg:inline' : undefined}>Hanzo</span>
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={`size-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''} ${
|
||||
compact ? 'hidden lg:inline-block' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<MeetHanzoMenu
|
||||
id={panelId}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
anchor={56}
|
||||
resolveHref={(href, id) => DOCS_HREFS[id] ?? href}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Text, YStack } from '@hanzo/gui';
|
||||
import { muted } from '@/lib/ink';
|
||||
|
||||
/**
|
||||
* The 404 body. The export writes it once, as 404.html, and the edge serves it
|
||||
* for any address the site does not hold, so the likely destinations are a
|
||||
* fixed list rather than a guess about the address that missed.
|
||||
*/
|
||||
export function Missing({ suggestions }: { suggestions: { href: string; title: string }[] }) {
|
||||
return (
|
||||
<YStack items="center" gap={14} px={20} py={72}>
|
||||
<Text render="h1" fontSize={32} lineHeight={40} fontWeight="600" color="$color12" letterSpacing={-0.6}>
|
||||
Page not found
|
||||
</Text>
|
||||
<Text fontSize="$3" text="center" whiteSpace="normal" {...muted}>
|
||||
Nothing is published at this address. Search the docs from the rail, or start here.
|
||||
</Text>
|
||||
<YStack
|
||||
width="100%"
|
||||
maxW={520}
|
||||
mt={10}
|
||||
rounded="$5"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$panel"
|
||||
overflow="hidden"
|
||||
>
|
||||
{suggestions.map((s, i) => (
|
||||
<YStack
|
||||
key={s.href}
|
||||
render={<Link href={s.href} prefetch={false} />}
|
||||
flexDirection="row"
|
||||
justify="space-between"
|
||||
items="center"
|
||||
gap={16}
|
||||
px={14}
|
||||
py={11}
|
||||
borderTopWidth={i ? 1 : 0}
|
||||
borderColor="$borderColor"
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<Text fontSize="$3" fontWeight="500" color="$color12">
|
||||
{s.title}
|
||||
</Text>
|
||||
<Text fontFamily="$mono" fontSize={12} numberOfLines={1} {...muted}>
|
||||
{s.href}
|
||||
</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,12 @@
|
||||
// the current models/prices without a rebuild. Grouped by family, searchable,
|
||||
// theme-aware via Fumadocs fd-* tokens.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search, Copy, Check, Cpu, Sparkle, Zap, Box } from 'lucide-react';
|
||||
import { Text, XStack, YStack } from '@hanzo/gui';
|
||||
import { Field } from '@/components/field';
|
||||
import { Copy, Check, Cpu, Sparkle, Zap, Box } from '@hanzogui/lucide-icons-2';
|
||||
import { Figure, Pre } from '@/components/mdx/code';
|
||||
import { Table, Tbody, Td, Th, Thead, Tr } from '@/components/mdx/prose';
|
||||
import { muted } from '@/lib/ink';
|
||||
import { PROVIDER_ICONS, providerKey } from '@/components/provider-icons';
|
||||
|
||||
const ENDPOINT = 'https://api.hanzo.ai/v1/models';
|
||||
@@ -65,55 +70,56 @@ function initials(name: string): string {
|
||||
return name.replace(/[^\p{L}\p{N}]/gu, '').slice(0, 2).toUpperCase() || '?';
|
||||
}
|
||||
function ProviderChip({ name }: { name: string }) {
|
||||
// Real brand logo (mono, currentColor) when we have one; monogram otherwise.
|
||||
const svg = PROVIDER_ICONS[providerKey(name)];
|
||||
if (svg) {
|
||||
if (svg)
|
||||
return (
|
||||
<span
|
||||
<XStack
|
||||
aria-hidden
|
||||
className="inline-flex size-5 shrink-0 items-center justify-center text-fd-foreground [&>svg]:size-[18px]"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
width={18}
|
||||
height={18}
|
||||
shrink={0}
|
||||
color="$color12"
|
||||
dangerouslySetInnerHTML={{ __html: svg.replace('<svg', '<svg width="100%" height="100%"') }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const hue = hashHue(name.toLowerCase());
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-flex size-5 shrink-0 items-center justify-center rounded text-[9px] font-semibold leading-none"
|
||||
style={{
|
||||
background: `hsl(${hue} 55% 92%)`,
|
||||
color: `hsl(${hue} 60% 32%)`,
|
||||
boxShadow: `inset 0 0 0 1px hsl(${hue} 45% 82%)`,
|
||||
}}
|
||||
>
|
||||
{initials(name)}
|
||||
</span>
|
||||
<XStack aria-hidden width={20} height={20} shrink={0} rounded={4} items="center" justify="center" style={{ background: `hsl(${hue} 55% 92%)` }}>
|
||||
<Text fontSize={9} fontWeight="600" style={{ color: `hsl(${hue} 60% 32%)` }}>
|
||||
{initials(name)}
|
||||
</Text>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyId({ id }: { id: string }) {
|
||||
const [done, setDone] = useState(false);
|
||||
return (
|
||||
<button
|
||||
<XStack
|
||||
render="button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
title="Copy model id"
|
||||
onPress={() => {
|
||||
navigator.clipboard?.writeText(id).then(() => {
|
||||
setDone(true);
|
||||
setTimeout(() => setDone(false), 1200);
|
||||
});
|
||||
}}
|
||||
className="group inline-flex items-center gap-1.5 font-mono text-xs text-fd-muted-foreground hover:text-fd-foreground transition-colors"
|
||||
title="Copy model id"
|
||||
gap={6}
|
||||
items="center"
|
||||
bg="transparent"
|
||||
borderWidth={0}
|
||||
p={0}
|
||||
cursor="pointer"
|
||||
>
|
||||
<span>{id}</span>
|
||||
{done ? <Check className="size-3 text-green-500" /> : <Copy className="size-3 opacity-0 group-hover:opacity-60" />}
|
||||
</button>
|
||||
<Text fontFamily="$mono" fontSize={12} {...muted}>
|
||||
{id}
|
||||
</Text>
|
||||
{done ? <Check size={12} color="$green10" /> : <Copy size={12} color="$color9" />}
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
// What the catalogue actually says a model can do. It publishes booleans, not a
|
||||
// `features` list, so the chips are derived from those rather than left empty.
|
||||
function capabilities(m: Model): string[] {
|
||||
if (m.features?.length) return m.features;
|
||||
const out: string[] = [];
|
||||
@@ -125,29 +131,35 @@ function capabilities(m: Model): string[] {
|
||||
function ModelRow({ m }: { m: Model }) {
|
||||
const p = m.pricing;
|
||||
return (
|
||||
<tr className="border-t border-fd-border/60 hover:bg-fd-muted/40 transition-colors">
|
||||
<td className="py-2.5 pr-4 align-top">
|
||||
<div className="font-medium text-fd-foreground">{m.fullName || m.name || m.id}</div>
|
||||
<CopyId id={m.id} />
|
||||
{m.tier ? (
|
||||
<span className="ml-2 rounded bg-fd-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-fd-primary align-middle">
|
||||
{m.tier}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right align-top tabular-nums text-fd-muted-foreground">{ctx(m.context_window)}</td>
|
||||
<td className="py-2.5 px-3 text-right align-top tabular-nums">{price(p?.input)}</td>
|
||||
<td className="py-2.5 px-3 text-right align-top tabular-nums">{price(p?.output)}</td>
|
||||
<td className="py-2.5 pl-3 align-top">
|
||||
<div className="flex flex-wrap gap-1 justify-end">
|
||||
<Tr>
|
||||
<Td>
|
||||
<YStack gap={2} items="flex-start">
|
||||
<Text fontSize={14} fontWeight="500" color="$color12">
|
||||
{m.fullName || m.name || m.id}
|
||||
</Text>
|
||||
<XStack gap={8} items="center" flexWrap="wrap">
|
||||
<CopyId id={m.id} />
|
||||
{m.tier ? (
|
||||
<Text fontSize={11} fontWeight="500" px={6} rounded={4} bg="$hover" color="$color12">
|
||||
{m.tier}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
</YStack>
|
||||
</Td>
|
||||
<Td align="right">{ctx(m.context_window)}</Td>
|
||||
<Td align="right">{price(p?.input)}</Td>
|
||||
<Td align="right">{price(p?.output)}</Td>
|
||||
<Td align="right">
|
||||
<XStack gap={4} flexWrap="wrap" justify="flex-end">
|
||||
{capabilities(m).slice(0, 4).map((f) => (
|
||||
<span key={f} className="rounded border border-fd-border px-1.5 py-0.5 text-[10px] text-fd-muted-foreground">
|
||||
<Text key={f} fontSize={10} px={6} py={1} rounded={4} borderWidth={1} borderColor="$borderColor" {...muted}>
|
||||
{f}
|
||||
</span>
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</XStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,87 +230,97 @@ export function ModelsCatalog() {
|
||||
|
||||
if (err)
|
||||
return (
|
||||
<div className="my-4 rounded-lg border border-fd-border bg-fd-card p-4 text-sm text-fd-muted-foreground">
|
||||
<p className="mt-0">
|
||||
Couldn’t load the live catalog ({err}). <code>GET /v1/models</code> requires a bearer token, and this page
|
||||
has none to send — so read it with your own key instead:
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded bg-fd-muted/60 p-3 text-xs">
|
||||
{`curl -s ${ENDPOINT} \\\n -H "Authorization: Bearer $HANZO_API_KEY"`}
|
||||
</pre>
|
||||
<p className="mb-0">
|
||||
<a className="text-fd-primary underline" href="/docs/api-keys">
|
||||
Mint a key →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<YStack gap={10} p={16} rounded="$5" borderWidth={1} borderColor="$borderColor" bg="$panel">
|
||||
<Text fontSize={14} lineHeight={22} whiteSpace="normal" {...muted}>
|
||||
Couldn’t load the live catalog ({err}). GET /v1/models requires a bearer token, and this page has none to
|
||||
send — so read it with your own key instead:
|
||||
</Text>
|
||||
<Figure>
|
||||
<Pre>
|
||||
<code>{`curl -s ${ENDPOINT} \\\n -H "Authorization: Bearer $HANZO_API_KEY"`}</code>
|
||||
</Pre>
|
||||
</Figure>
|
||||
<Text render="a" href="/docs/api-keys" fontSize={14} color="$color12" textDecorationLine="underline">
|
||||
Mint a key →
|
||||
</Text>
|
||||
</YStack>
|
||||
);
|
||||
if (!cat)
|
||||
return (
|
||||
<div className="my-4 space-y-2" aria-busy>
|
||||
<YStack gap={8} aria-busy>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-12 animate-pulse rounded-md bg-fd-muted/60" />
|
||||
<YStack key={i} height={48} rounded="$3" bg="$hover" />
|
||||
))}
|
||||
</div>
|
||||
</YStack>
|
||||
);
|
||||
|
||||
const s = cat.summary ?? {};
|
||||
return (
|
||||
<div className="not-prose my-6">
|
||||
{/* Summary + search */}
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-fd-muted-foreground">
|
||||
<span className="font-medium text-fd-foreground">{s.totalModels ?? cat.data.length} models</span>
|
||||
{s.zenModels ? <span>{s.zenModels} Zen</span> : null}
|
||||
{s.ensoModels ? <span>{s.ensoModels} Enso</span> : null}
|
||||
{cat.updated ? <span>updated {new Date(cat.updated).toLocaleDateString()}</span> : null}
|
||||
</div>
|
||||
<label className="relative flex items-center">
|
||||
<Search className="pointer-events-none absolute left-2.5 size-4 text-fd-muted-foreground" />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Filter models…"
|
||||
className="w-full rounded-md border border-fd-border bg-fd-background py-1.5 pl-8 pr-3 text-sm outline-none focus:border-fd-primary sm:w-64"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<YStack gap={24}>
|
||||
<XStack gap={12} justify="space-between" items="center" flexWrap="wrap">
|
||||
<XStack gap={16} flexWrap="wrap" items="center">
|
||||
<Text fontSize={14} fontWeight="500" color="$color12">
|
||||
{s.totalModels ?? cat.data.length} models
|
||||
</Text>
|
||||
{s.zenModels ? <Text fontSize={14} {...muted}>{s.zenModels} Zen</Text> : null}
|
||||
{s.ensoModels ? <Text fontSize={14} {...muted}>{s.ensoModels} Enso</Text> : null}
|
||||
{cat.updated ? (
|
||||
<Text fontSize={14} {...muted}>
|
||||
updated {DAY.format(new Date(cat.updated))}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
<Field value={q} onChange={setQ} placeholder="Filter models…" width={260} />
|
||||
</XStack>
|
||||
|
||||
{groups.map((f) => {
|
||||
const isProvider = f.id.startsWith('provider-');
|
||||
const Icon = FAMILY_ICON[f.icon ?? ''] ?? Box;
|
||||
return (
|
||||
<section key={f.id} className="mb-8">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
{isProvider ? <ProviderChip name={f.name} /> : <Icon className="size-4 text-fd-primary" />}
|
||||
<h3 className="m-0 text-base font-semibold text-fd-foreground">{f.name}</h3>
|
||||
<span className="text-xs text-fd-muted-foreground">({f.resolved.length})</span>
|
||||
</div>
|
||||
{f.description ? <p className="mb-2 mt-0 text-sm text-fd-muted-foreground">{f.description}</p> : null}
|
||||
<div className="overflow-x-auto rounded-lg border border-fd-border">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-fd-muted-foreground">
|
||||
<th className="py-2 pr-4 text-left font-medium">Model</th>
|
||||
<th className="py-2 px-3 text-right font-medium">Context</th>
|
||||
<th className="py-2 px-3 text-right font-medium">Input /1M</th>
|
||||
<th className="py-2 px-3 text-right font-medium">Output /1M</th>
|
||||
<th className="py-2 pl-3 text-right font-medium">Capabilities</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{f.resolved.map((m) => (
|
||||
<ModelRow key={m.id} m={m} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<YStack key={f.id} render="section" gap={8}>
|
||||
<XStack gap={8} items="center">
|
||||
{isProvider ? <ProviderChip name={f.name} /> : <Icon size={16} color="$color12" />}
|
||||
<Text render="h3" fontSize={16} fontWeight="600" color="$color12">
|
||||
{f.name}
|
||||
</Text>
|
||||
<Text fontSize={12} {...muted}>
|
||||
({f.resolved.length})
|
||||
</Text>
|
||||
</XStack>
|
||||
{f.description ? (
|
||||
<Text fontSize={14} whiteSpace="normal" {...muted}>
|
||||
{f.description}
|
||||
</Text>
|
||||
) : null}
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Model</Th>
|
||||
<Th align="right">Context</Th>
|
||||
<Th align="right">Input /1M</Th>
|
||||
<Th align="right">Output /1M</Th>
|
||||
<Th align="right">Capabilities</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{f.resolved.map((m) => (
|
||||
<ModelRow key={m.id} m={m} />
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</YStack>
|
||||
);
|
||||
})}
|
||||
{!groups.length ? <p className="text-sm text-fd-muted-foreground">No models match “{q}”.</p> : null}
|
||||
</div>
|
||||
{!groups.length ? (
|
||||
<Text fontSize={14} {...muted}>
|
||||
No models match “{q}”.
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
// Fixed locale and zone, so every reader sees the same date for the same data.
|
||||
const DAY = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' });
|
||||
|
||||
export default ModelsCatalog;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Text, YStack } from '@hanzo/gui';
|
||||
import { Grid } from '@hanzo/ui/grid';
|
||||
import { muted } from '@/lib/ink';
|
||||
|
||||
export interface Post {
|
||||
url: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Formatted on the server, so both renders print the same string. */
|
||||
date: string;
|
||||
}
|
||||
|
||||
/** The blog's index: a heading and the posts, newest first. */
|
||||
export function Posts({ posts }: { posts: Post[] }) {
|
||||
return (
|
||||
<YStack render="main" width="100%" maxW={1120} self="center" px={40} py={48} gap={28} $max-md={{ px: 20, py: 28 }}>
|
||||
<YStack gap={8} pb={24} borderBottomWidth={1} borderColor="$borderColor">
|
||||
<Text render="h1" fontSize={32} lineHeight={40} fontWeight="600" letterSpacing={-0.6} color="$color12">
|
||||
Blog
|
||||
</Text>
|
||||
<Text fontSize={17} {...muted}>
|
||||
Announcements and updates from Hanzo.
|
||||
</Text>
|
||||
</YStack>
|
||||
<Grid columns={{ min: 260, max: 3 }} gap={12}>
|
||||
{posts.map((post) => (
|
||||
<YStack
|
||||
key={post.url}
|
||||
render={<Link href={post.url} prefetch={false} />}
|
||||
gap={6}
|
||||
p={18}
|
||||
rounded="$5"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$panel"
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<Text fontSize={15} lineHeight={22} fontWeight="600" color="$color12" whiteSpace="normal">
|
||||
{post.title}
|
||||
</Text>
|
||||
{post.description ? (
|
||||
<Text fontSize={14} lineHeight={21} whiteSpace="normal" {...muted}>
|
||||
{post.description}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text mt="auto" pt={12} fontSize={12} {...muted}>
|
||||
{post.date}
|
||||
</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</Grid>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ServerCodeBlock } from '@hanzo/docs-base-ui/components/codeblock.rsc';
|
||||
|
||||
export function Customization() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3 rounded-xl border bg-fd-card text-fd-card-foreground not-prose">
|
||||
<p className="font-medium text-sm">Install via Hanzo Docs CLI</p>
|
||||
<p className="text-fd-muted-foreground text-sm">
|
||||
For advanced customization that supported options cannot suffice.
|
||||
</p>
|
||||
<ServerCodeBlock code="npx @hanzo/docs-cli@latest customise" lang="bash" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
'use client';
|
||||
import { DynamicCodeBlock } from '@hanzo/docs-base-ui/components/dynamic-codeblock';
|
||||
import { shikiConfig } from '@/lib/shiki';
|
||||
import { useState } from 'react';
|
||||
import { bundledLanguages } from 'shiki';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@hanzo/docs-base-ui/components/ui/select';
|
||||
|
||||
export default function Example() {
|
||||
const [lang, setLang] = useState('js');
|
||||
const [code, setCode] = useState('console.log("This is pre-rendered")');
|
||||
|
||||
return (
|
||||
<div className="prose flex flex-col gap-4 rounded-lg bg-fd-background p-4">
|
||||
<div className="not-prose flex flex-col gap-2 rounded-lg bg-fd-secondary text-fd-secondary-foreground p-2">
|
||||
<Select
|
||||
value={lang}
|
||||
onValueChange={(next) => {
|
||||
if (next) setLang(next);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-fit min-w-[140px] h-8 text-xs bg-transparent border-fd-border/50">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
{Object.keys(bundledLanguages).map((l) => (
|
||||
<SelectItem value={l} key={l} className="text-xs">
|
||||
{l}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<textarea
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="bg-transparent px-4 py-2 text-sm focus-visible:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<DynamicCodeBlock
|
||||
lang={lang}
|
||||
code={code}
|
||||
options={shikiConfig}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import { Home } from 'lucide-react';
|
||||
import { Heading } from '@hanzo/docs-base-ui/components/heading';
|
||||
import { Card } from '@hanzo/docs-base-ui/components/card';
|
||||
import { Callout } from '@hanzo/docs-base-ui/components/callout';
|
||||
import { Accordion, Accordions } from '@hanzo/docs-base-ui/components/accordion';
|
||||
import { Tab, Tabs } from '@hanzo/docs-base-ui/components/tabs';
|
||||
import { Step, Steps } from '@hanzo/docs-base-ui/components/steps';
|
||||
import { TypeTable } from '@hanzo/docs-base-ui/components/type-table';
|
||||
import { type ReactNode } from 'react';
|
||||
import { Wrapper } from './wrapper';
|
||||
import { GithubInfo } from '@hanzo/docs-base-ui/components/github-info';
|
||||
import { owner, repo } from '@/lib/github';
|
||||
import {
|
||||
Banner,
|
||||
DynamicCodeBlock,
|
||||
File,
|
||||
Files,
|
||||
Folder,
|
||||
ImageZoom,
|
||||
InlineTOC,
|
||||
} from '@/components/preview/lazy';
|
||||
import BannerImage from '@/public/banner.png';
|
||||
import { GraphView } from '@/components/preview/lazy';
|
||||
import { buildGraph } from '@/lib/build-graph';
|
||||
|
||||
export function heading(): ReactNode {
|
||||
return (
|
||||
<Wrapper>
|
||||
<div className="rounded-lg bg-fd-background p-4 prose-no-margin">
|
||||
<Heading id="preview" as="h3">
|
||||
Hello World
|
||||
</Heading>
|
||||
<Heading id="preview" as="h3">
|
||||
Hello <code>World</code> Everyone!
|
||||
</Heading>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function card(): ReactNode {
|
||||
return (
|
||||
<Wrapper>
|
||||
<div className="rounded-lg bg-fd-background">
|
||||
<Card
|
||||
href="#"
|
||||
icon={<Home />}
|
||||
title="Hello World"
|
||||
description="Learn More about Caching and Revalidation"
|
||||
/>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function tabs(): ReactNode {
|
||||
return (
|
||||
<Wrapper>
|
||||
<div className="prose-no-margin">
|
||||
<Tabs groupId="language" persist items={['Javascript', 'Rust', 'Typescript']}>
|
||||
<Tab value="Javascript">Hello World in Javascript</Tab>
|
||||
<Tab value="Rust">Hello World in Rust</Tab>
|
||||
<Tab value="Typescript">Also works if items are not the same</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Tabs groupId="language" persist items={['Javascript', 'Rust']}>
|
||||
<Tab value="Javascript">
|
||||
Value is shared! Try refresh and see if the value is persisted
|
||||
</Tab>
|
||||
<Tab value="Rust">Value is shared! Try refresh and see if the value is persisted</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function typeTable(): ReactNode {
|
||||
return (
|
||||
<Wrapper>
|
||||
<div className="rounded-xl bg-fd-background">
|
||||
<TypeTable
|
||||
type={{
|
||||
percentage: {
|
||||
description: 'The percentage of scroll position to display the roll button',
|
||||
type: 'number',
|
||||
default: '0.2',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function zoomImage(): ReactNode {
|
||||
return (
|
||||
<Wrapper>
|
||||
<ImageZoom
|
||||
alt="banner"
|
||||
src={BannerImage}
|
||||
className="!my-0 rounded-xl bg-fd-background"
|
||||
priority
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function accordion(): ReactNode {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Accordions>
|
||||
<Accordion id="what-is-hanzo-docs" title="What is Hanzo Docs?">
|
||||
A framework for building documentations
|
||||
</Accordion>
|
||||
<Accordion id="ux" title="What do we love?">
|
||||
We love websites with a good user experience
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function callout(): ReactNode {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Callout title="Title">Hello World</Callout>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function files(): ReactNode {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Files>
|
||||
<Folder name="app" defaultOpen>
|
||||
<Folder name="[id]" defaultOpen>
|
||||
<File name="page.tsx" />
|
||||
</Folder>
|
||||
<File name="layout.tsx" />
|
||||
<File name="page.tsx" />
|
||||
<File name="global.css" />
|
||||
</Folder>
|
||||
<Folder name="components">
|
||||
<File name="button.tsx" />
|
||||
<File name="tabs.tsx" />
|
||||
<File name="dialog.tsx" />
|
||||
<Folder name="empty" />
|
||||
</Folder>
|
||||
<File name="package.json" />
|
||||
</Files>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function inlineTOC(): ReactNode {
|
||||
return (
|
||||
<Wrapper>
|
||||
<InlineTOC
|
||||
items={[
|
||||
{ title: 'Welcome', url: '#welcome', depth: 2 },
|
||||
{ title: 'Getting Started', url: '#getting-started', depth: 3 },
|
||||
{ title: 'Usage', url: '#usage', depth: 3 },
|
||||
{ title: 'Styling', url: '#styling', depth: 3 },
|
||||
{ title: 'Reference', url: '#reference', depth: 2 },
|
||||
{ title: 'Components', url: '#components', depth: 3 },
|
||||
{ title: 'APIs', url: '#api', depth: 3 },
|
||||
{ title: 'Credits', url: '#credits', depth: 2 },
|
||||
]}
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function steps(): ReactNode {
|
||||
return (
|
||||
<Wrapper>
|
||||
<div className="rounded-xl bg-fd-background p-3">
|
||||
<Steps>
|
||||
<Step>
|
||||
<h4>Buy Coffee</h4>
|
||||
<p>Some text here</p>
|
||||
</Step>
|
||||
<Step>
|
||||
<h4>Go to Office Some text here</h4>
|
||||
<p>Some text here</p>
|
||||
</Step>
|
||||
<Step>
|
||||
<h4>Have a meeting Some text here</h4>
|
||||
<p>Some text here</p>
|
||||
</Step>
|
||||
</Steps>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function dynamicCodeBlock() {
|
||||
return (
|
||||
<Wrapper>
|
||||
<DynamicCodeBlock />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function banner(): ReactNode {
|
||||
return (
|
||||
<Wrapper>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Banner className="z-0" changeLayout={false}>
|
||||
Be careful, Hanzo Docs v99 has released
|
||||
</Banner>
|
||||
|
||||
<Banner id="test-rainbow" className="z-0" variant="rainbow" changeLayout={false}>
|
||||
Using the <code>rainbow</code> variant
|
||||
</Banner>
|
||||
|
||||
<Banner
|
||||
id="test"
|
||||
className="z-0"
|
||||
variant="rainbow"
|
||||
rainbowColors={[
|
||||
'rgba(255,100,0, 0.5)',
|
||||
'rgba(255,100,0, 0.5)',
|
||||
'transparent',
|
||||
'rgba(255,100,0, 0.5)',
|
||||
'transparent',
|
||||
'rgba(255,100,0, 0.5)',
|
||||
'transparent',
|
||||
]}
|
||||
changeLayout={false}
|
||||
>
|
||||
customize the <code>rainbow</code> variant
|
||||
</Banner>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function githubInfo() {
|
||||
return (
|
||||
<Wrapper>
|
||||
<GithubInfo
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
token={process.env.GITHUB_TOKEN}
|
||||
className="not-prose bg-fd-card"
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export async function graphView() {
|
||||
return <GraphView graph={await buildGraph()} />;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ServerCodeBlock } from '@hanzo/docs-base-ui/components/codeblock.rsc';
|
||||
|
||||
export function Installation({ name }: { name: string }) {
|
||||
return (
|
||||
<div className="p-3 border rounded-xl bg-fd-card text-fd-card-foreground my-6 text-sm not-prose">
|
||||
<p className="font-medium">Install to your codebase</p>
|
||||
<p className="mt-1 mb-4 text-fd-muted-foreground">Easier customization & control.</p>
|
||||
|
||||
<ServerCodeBlock code={`npx @hanzo/docs-cli@latest add ${name}`} lang="bash" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
'use client';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
export const DynamicCodeBlock = dynamic(() => import('./dynamic-codeblock'));
|
||||
export const Banner = dynamic(() =>
|
||||
import('@hanzo/docs-base-ui/components/banner').then((res) => res.Banner),
|
||||
);
|
||||
export const InlineTOC = dynamic(() =>
|
||||
import('@hanzo/docs-base-ui/components/inline-toc').then((res) => res.InlineTOC),
|
||||
);
|
||||
|
||||
export const File = dynamic(() => import('@hanzo/docs-base-ui/components/files').then((res) => res.File));
|
||||
export const Files = dynamic(() => import('@hanzo/docs-base-ui/components/files').then((res) => res.Files));
|
||||
export const Folder = dynamic(() =>
|
||||
import('@hanzo/docs-base-ui/components/files').then((res) => res.Folder),
|
||||
);
|
||||
|
||||
export const ImageZoom = dynamic(() =>
|
||||
import('@hanzo/docs-base-ui/components/image-zoom').then((res) => res.ImageZoom),
|
||||
);
|
||||
|
||||
export const GraphView = dynamic(() =>
|
||||
import('@/components/graph-view').then((res) => res.GraphView),
|
||||
);
|
||||
|
||||
// Local 'use client' component: a static import into the server-rendered
|
||||
// getMDXComponents (mdx.tsx) resolves to $undefined in this static-export MDX
|
||||
// pipeline (no client boundary). Wrapping in dynamic() forces a proper lazy
|
||||
// client island that hydrates — same reason GraphView above does it.
|
||||
export const ModelsCatalog = dynamic(() =>
|
||||
import('@/components/models-catalog').then((res) => res.ModelsCatalog),
|
||||
);
|
||||
|
||||
// Unified connectors + MCP registry catalog (client-fetches the MCP registry).
|
||||
export const ConnectorsCatalog = dynamic(() =>
|
||||
import('@/components/connectors-catalog').then((res) => res.ConnectorsCatalog),
|
||||
);
|
||||
|
||||
export const InstallCatalog = dynamic(() =>
|
||||
import('@/components/install-catalog').then((res) => res.InstallCatalog),
|
||||
);
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export function Wrapper(props: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'rounded-lg bg-radial-[at_bottom] from-blue-500/20 p-4 border bg-origin-border border-fd-primary/10 prose-no-margin dark:bg-black/20',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,11 @@
|
||||
import Link from 'next/link';
|
||||
'use client';
|
||||
|
||||
// The landing page's unit of navigation: one DOMAIN, not one product.
|
||||
//
|
||||
// A card grid treats ten domains as ten equal tiles, which is exactly the shape
|
||||
// that tells a reader nothing — every tile is the same size, so the page has no
|
||||
// opinion about where to start, and the reader has to read all ten to find the
|
||||
// one they came for. This is the other arrangement: a handful of sections, each
|
||||
// one wide enough to say what the domain IS, show the single line of code that
|
||||
// proves it, and then list its products inline for anyone who already knows the
|
||||
// name they want.
|
||||
//
|
||||
// So there are three depths on one page, and a reader stops at whichever matches
|
||||
// what they already know:
|
||||
//
|
||||
// the sentence — "what is this domain for", for someone who does not know
|
||||
// the snippet — the shortest true call, for someone who wants to try it
|
||||
// the link row — every product in the domain, for someone who knows the name
|
||||
//
|
||||
// Monochrome by construction. Hanzo's tokens carry no hue (--color-brand is
|
||||
// hsl(0,0%,96%) on hsl(0,0%,4%)), so the only emphasis available is WEIGHT,
|
||||
// BORDER and SPACE. That is a constraint worth keeping honest rather than
|
||||
// working around: nothing here introduces a colour to mean "important", because
|
||||
// a page that needs colour to establish hierarchy has not established it.
|
||||
import Link from 'next/link';
|
||||
import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { Text, XStack, YStack } from '@hanzo/gui';
|
||||
import { Figure, Pre } from '@/components/mdx/code';
|
||||
import { Small, flow } from '@/components/mdx/prose';
|
||||
import { muted } from '@/lib/ink';
|
||||
|
||||
/** One product inside a domain: the name a reader would search for, and its page. */
|
||||
export interface ProductLink {
|
||||
@@ -29,25 +13,12 @@ export interface ProductLink {
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface ProductSectionProps {
|
||||
/** The domain's name — the thing a reader is scanning for. */
|
||||
title: string;
|
||||
/** Where the domain's own overview lives. The title links here. */
|
||||
href: string;
|
||||
/** One sentence. What this domain is for, in the reader's terms, not ours. */
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* The shortest true call into this domain — one line, actually runnable.
|
||||
* Optional: a domain with no honest one-liner should show nothing rather
|
||||
* than a decorative fragment that does not run.
|
||||
*/
|
||||
snippet?: string;
|
||||
/** The next step, phrased as the thing the reader would do. */
|
||||
action?: { label: string; href: string };
|
||||
/** Every product in the domain, inline. */
|
||||
links?: ProductLink[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One domain on the docs home: what it is on the left (its name, one sentence,
|
||||
* the next step), how to touch it on the right (the shortest true call, every
|
||||
* product in it). Stacked on a phone. Hairlines between sections are the
|
||||
* layout, so no section guesses its own margin.
|
||||
*/
|
||||
export function ProductSection({
|
||||
title,
|
||||
href,
|
||||
@@ -55,81 +26,80 @@ export function ProductSection({
|
||||
snippet,
|
||||
action,
|
||||
links,
|
||||
}: ProductSectionProps) {
|
||||
first = false,
|
||||
}: {
|
||||
/** Set by ProductSections on the first one, which starts flush with no rule. */
|
||||
first?: boolean;
|
||||
title: string;
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
/** The shortest true call into this domain — one runnable line, or nothing. */
|
||||
snippet?: string;
|
||||
action?: { label: string; href: string };
|
||||
links?: ProductLink[];
|
||||
}) {
|
||||
return (
|
||||
<section className="not-prose border-t border-fd-border py-10 first:border-t-0 first:pt-0">
|
||||
<div className="grid gap-x-12 gap-y-6 md:grid-cols-[minmax(0,22rem)_minmax(0,1fr)]">
|
||||
{/* Left rail: what it is. Narrow on purpose — a domain that needs a
|
||||
paragraph to introduce itself is really two domains. */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight text-fd-foreground">
|
||||
<Link href={href} className="hover:underline underline-offset-4">
|
||||
{title}
|
||||
</Link>
|
||||
</h3>
|
||||
{/* A div, not a p: MDX hands multi-line children over already
|
||||
wrapped in a <p>, and a <p> inside a <p> is invalid HTML the
|
||||
browser splits apart, so React failed to hydrate /docs. */}
|
||||
<div className="mt-2 text-sm leading-relaxed text-fd-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
{action ? (
|
||||
<Link
|
||||
href={action.href}
|
||||
className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-fd-foreground hover:underline underline-offset-4"
|
||||
>
|
||||
{action.label}
|
||||
{/* Marks the link as forward motion without spending a colour on it. */}
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<XStack
|
||||
render="section"
|
||||
gap={40}
|
||||
pt={first ? 0 : 32}
|
||||
pb={32}
|
||||
borderTopWidth={first ? 0 : 1}
|
||||
borderColor="$borderColor"
|
||||
$max-md={{ flexDirection: 'column', gap: 18 }}
|
||||
>
|
||||
<YStack width={300} shrink={0} gap={8} $max-md={{ width: '100%' }}>
|
||||
<Text render={<Link href={href} prefetch={false} />} fontSize={18} lineHeight={26} fontWeight="600" color="$color12" hoverStyle={{ textDecorationLine: 'underline' }}>
|
||||
{title}
|
||||
</Text>
|
||||
{/* A div: MDX hands multi-line children over wrapped in a <p>. */}
|
||||
<YStack gap={8}>
|
||||
<Small value>{flow(children, 'small')}</Small>
|
||||
</YStack>
|
||||
{action ? (
|
||||
<Text render={<Link href={action.href} prefetch={false} />} fontSize="$3" fontWeight="500" color="$color12" hoverStyle={{ textDecorationLine: 'underline' }}>
|
||||
{action.label} →
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
{/* Right rail: how to touch it. */}
|
||||
<div className="min-w-0">
|
||||
{snippet ? (
|
||||
<pre className="overflow-x-auto rounded-lg border border-fd-border bg-fd-card px-4 py-3 text-[13px] leading-relaxed text-fd-foreground">
|
||||
<YStack flex={1} minW={0} gap={16}>
|
||||
{snippet ? (
|
||||
<Figure>
|
||||
<Pre>
|
||||
<code>{snippet}</code>
|
||||
</pre>
|
||||
) : null}
|
||||
|
||||
{links?.length ? (
|
||||
<ul className="mt-4 flex flex-wrap items-center gap-x-1 gap-y-2">
|
||||
{links.map((l, i) => (
|
||||
<li key={l.href} className="flex items-center">
|
||||
{/* A separator BETWEEN items, never trailing — and aria-hidden,
|
||||
because a screen reader announcing "dot" between twenty
|
||||
product names is noise, and the <li> boundary already
|
||||
carries the separation. */}
|
||||
{i > 0 ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mx-2 text-fd-muted-foreground/50"
|
||||
>
|
||||
·
|
||||
</span>
|
||||
) : null}
|
||||
<Link
|
||||
href={l.href}
|
||||
className="text-sm text-fd-muted-foreground transition-colors hover:text-fd-foreground"
|
||||
>
|
||||
{l.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Pre>
|
||||
</Figure>
|
||||
) : null}
|
||||
{links?.length ? (
|
||||
<XStack render="ul" flexWrap="wrap" items="center" rowGap={8} style={{ listStyleType: 'none' }}>
|
||||
{links.map((l, i) => (
|
||||
<XStack key={l.href} render="li" items="center">
|
||||
{i > 0 ? (
|
||||
<Text aria-hidden mx={8} color="$color8">
|
||||
·
|
||||
</Text>
|
||||
) : null}
|
||||
<Text render={<Link href={l.href} prefetch={false} />} fontSize="$3" {...muted} hoverStyle={{ color: '$color12' }}>
|
||||
{l.name}
|
||||
</Text>
|
||||
</XStack>
|
||||
))}
|
||||
</XStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the sections so the hairlines between them are the layout, rather than
|
||||
* each section guessing its own margin. `first:border-t-0` on the child means
|
||||
* the group can start flush under a heading.
|
||||
*/
|
||||
export function ProductSections({ children }: { children: React.ReactNode }) {
|
||||
return <div className="not-prose my-10">{children}</div>;
|
||||
/** The sections, one after another; the first starts flush under its heading. */
|
||||
export function ProductSections({ children }: { children: ReactNode }) {
|
||||
let at = 0;
|
||||
return (
|
||||
<YStack>
|
||||
{Children.map(children, (child) =>
|
||||
isValidElement(child) ? cloneElement(child as ReactElement<{ first?: boolean }>, { first: at++ === 0 }) : child,
|
||||
)}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@hanzo/docs-base-ui/components/ui/select';
|
||||
|
||||
import { SECTIONS, type DocSection, type SectionGroup } from '@/generated/sections';
|
||||
|
||||
/** Flatten all items for route matching */
|
||||
const ALL_ITEMS = SECTIONS.flatMap((g) => g.items);
|
||||
|
||||
/** The value held when the path matches no section. A select needs a value, so
|
||||
* "everything" is a sentinel rather than null. */
|
||||
const ALL = '__all__';
|
||||
|
||||
/** value -> label, the whole vocabulary of this switcher. Base UI resolves the
|
||||
* trigger's text from `items`; given no map it prints the value itself, which
|
||||
* is how every docs page came to show `__all__` where the section name goes. */
|
||||
const LABELS: Record<string, string> = {
|
||||
[ALL]: 'All capabilities',
|
||||
...Object.fromEntries(ALL_ITEMS.map((item) => [item.route, item.name])),
|
||||
};
|
||||
|
||||
/** Deduplicate by route — keep first occurrence */
|
||||
const DEDUPED_SECTIONS: SectionGroup[] = (() => {
|
||||
const seen = new Set<string>();
|
||||
return SECTIONS.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => {
|
||||
if (seen.has(item.route)) return false;
|
||||
seen.add(item.route);
|
||||
return true;
|
||||
}),
|
||||
})).filter((g) => g.items.length > 0);
|
||||
})();
|
||||
|
||||
function findCurrentSection(pathname: string): DocSection | null {
|
||||
let best: DocSection | null = null;
|
||||
for (const item of ALL_ITEMS) {
|
||||
if (pathname.startsWith(item.route)) {
|
||||
if (!best || item.route.length > best.route.length) {
|
||||
best = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function ProjectSwitcher() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const current = findCurrentSection(pathname ?? '');
|
||||
const value = current?.route ?? ALL;
|
||||
|
||||
// No eyebrow above the trigger: it read "Docs" inside the docs sidebar, over a
|
||||
// control that now names the section itself.
|
||||
return (
|
||||
<Select
|
||||
items={LABELS}
|
||||
value={value}
|
||||
onValueChange={(next) => {
|
||||
if (next && next !== ALL) router.push(next);
|
||||
else if (next === ALL) router.push('/docs/openapi');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label="Section switcher">
|
||||
<LayoutGrid className="h-3.5 w-3.5 shrink-0 text-fd-muted-foreground mr-1.5" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-80">
|
||||
<SelectItem value={ALL}>{LABELS[ALL]}</SelectItem>
|
||||
{DEDUPED_SECTIONS.map((group, idx) => (
|
||||
<SelectGroup key={group.label}>
|
||||
{idx > 0 && <SelectSeparator />}
|
||||
<SelectLabel>{group.label}</SelectLabel>
|
||||
{group.items.map((item) => (
|
||||
<SelectItem key={item.route} value={item.route}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { Text, XStack, YStack } from '@hanzo/gui';
|
||||
import { Grid } from '@hanzo/ui/grid';
|
||||
import { PROVIDER_ICONS } from '@/components/provider-icons';
|
||||
import { muted } from '@/lib/ink';
|
||||
|
||||
// The providers, as marks rather than a list of words.
|
||||
//
|
||||
// A reader scanning for "can I use Claude here" finds a logo in a glance and a
|
||||
// noun in a second. Names alone make them read seven lines to answer one question.
|
||||
// These are the real brand marks from hanzoai/icons (our MIT fork of lobe-icons),
|
||||
// already vendored in this app for the models catalogue — no CDN, no runtime dep.
|
||||
//
|
||||
// Ordered deliberately: Zen first because the open weights are ours, then the
|
||||
// frontier labs, then the rest of the open families. Not alphabetical — the order
|
||||
// is the argument.
|
||||
const PROVIDERS = [
|
||||
{ key: 'hanzo', name: 'Zen', note: 'Open weights' },
|
||||
{ key: 'openai', name: 'OpenAI', note: 'GPT' },
|
||||
@@ -23,35 +16,35 @@ const PROVIDERS = [
|
||||
{ key: 'mistral', name: 'Mistral', note: 'Open' },
|
||||
] as const;
|
||||
|
||||
function Mark({ svg }: { svg: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-flex size-6 items-center justify-center text-fd-foreground [&>svg]:size-full"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The model families one key reaches, as their marks: four across, two on a phone. */
|
||||
export function ProviderStrip() {
|
||||
return (
|
||||
<div className="not-prose my-8 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-fd-border bg-fd-border sm:grid-cols-4">
|
||||
<YStack rounded="$5" borderWidth={1} borderColor="$borderColor" bg="$borderColor" overflow="hidden">
|
||||
<Grid columns={{ min: 150, max: 4 }} gap={1}>
|
||||
{PROVIDERS.map(({ key, name, note }) => {
|
||||
const svg = PROVIDER_ICONS[key];
|
||||
if (!svg) return null;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-2 bg-fd-background p-4 transition-colors hover:bg-fd-accent/40"
|
||||
>
|
||||
<Mark svg={svg} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-fd-foreground">{name}</div>
|
||||
<div className="text-xs text-fd-muted-foreground">{note}</div>
|
||||
</div>
|
||||
</div>
|
||||
<YStack key={key} gap={8} p={16} bg="$background" hoverStyle={{ bg: '$hover' }}>
|
||||
<XStack
|
||||
aria-hidden
|
||||
width={24}
|
||||
height={24}
|
||||
color="$color12"
|
||||
dangerouslySetInnerHTML={{ __html: svg.replace('<svg', '<svg width="100%" height="100%"') }}
|
||||
/>
|
||||
<YStack>
|
||||
<Text fontSize="$3" fontWeight="500" color="$color12">
|
||||
{name}
|
||||
</Text>
|
||||
<Text fontSize="$1" {...muted}>
|
||||
{note}
|
||||
</Text>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Grid>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Text, XStack, YStack } from '@hanzo/gui';
|
||||
import { Moon, PanelLeft, Search, Sun, X } from '@hanzogui/lucide-icons-2';
|
||||
import { useThemeSetting } from '@hanzogui/next-theme';
|
||||
import { Sheet, SheetContent, SheetTitle } from '@hanzo/ui';
|
||||
import { useSearchContext } from '@hanzo/docs-base-ui/contexts/search';
|
||||
import { useSearch } from '@/components/search';
|
||||
import { Account } from '@/components/account';
|
||||
import { Tool } from '@/components/action';
|
||||
import { Brand } from '@/components/brand';
|
||||
@@ -133,7 +133,7 @@ function Panel({ drawer = false, close }: { drawer?: boolean; close: React.React
|
||||
* the dialog mounted.
|
||||
*/
|
||||
function Find({ drawer }: { drawer: boolean }) {
|
||||
const { setOpenSearch } = useSearchContext();
|
||||
const search = useSearch();
|
||||
const { setOpen } = useShell();
|
||||
|
||||
return (
|
||||
@@ -142,7 +142,7 @@ function Find({ drawer }: { drawer: boolean }) {
|
||||
type="button"
|
||||
onPress={() => {
|
||||
if (drawer) setOpen(false);
|
||||
setOpenSearch(true);
|
||||
search.setOpen(true);
|
||||
}}
|
||||
height={36}
|
||||
px={10}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { source } from '@/lib/source';
|
||||
import type { Graph } from '@/components/graph-view';
|
||||
|
||||
export function buildGraph(): Graph {
|
||||
const pages = source.getPages();
|
||||
const graph: Graph = { links: [], nodes: [] };
|
||||
|
||||
for (const page of pages) {
|
||||
graph.nodes.push({
|
||||
id: page.url,
|
||||
url: page.url,
|
||||
text: page.data.title,
|
||||
description: page.data.description,
|
||||
});
|
||||
|
||||
const { extractedReferences = [] } = page.data;
|
||||
for (const ref of extractedReferences) {
|
||||
const refPage = source.getPageByHref(ref.href);
|
||||
if (!refPage) continue;
|
||||
|
||||
graph.links.push({
|
||||
source: page.url,
|
||||
target: refPage.page.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { type CompileOptions, type Registry } from '@hanzo/docs-cli/build';
|
||||
import * as radixUi from '../../../../packages/radix-ui/registry';
|
||||
import * as baseUi from '../../../../packages/base-ui/registry';
|
||||
import * as sanity from '../../../../packages/sanity/registry';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const baseDir = path.join(import.meta.dirname, '../../');
|
||||
|
||||
export const compileOptions: Partial<CompileOptions> = {
|
||||
onUnknownFile(absolutePath) {
|
||||
const filePath = path.relative(baseDir, absolutePath);
|
||||
|
||||
// source object is external
|
||||
if (filePath.startsWith('lib/source/')) return false;
|
||||
|
||||
// Hanzo platform infra — the gateway client config (lib/hanzo/client) and
|
||||
// the client-side chat hook (lib/hanzo/use-chat) are provided by the host
|
||||
// app, not vendored into installable components (same as lib/source above).
|
||||
if (filePath.startsWith('lib/hanzo/')) return false;
|
||||
},
|
||||
onParseReference(ref) {
|
||||
if (ref.type === 'unknown' && ref.specifier === 'hast') {
|
||||
return {
|
||||
type: 'dependency',
|
||||
dep: '@types/hast',
|
||||
specifier: 'hast',
|
||||
};
|
||||
}
|
||||
|
||||
if (ref.type === 'file') {
|
||||
let file = path.relative(baseDir, ref.file);
|
||||
|
||||
if (file === 'lib/cn.ts') {
|
||||
return {
|
||||
type: 'file',
|
||||
file: path.join(radixUi.registry.dir, 'utils/cn.ts'),
|
||||
};
|
||||
}
|
||||
|
||||
file = path.relative(radixUi.registry.dir, ref.file);
|
||||
if (file.startsWith('contexts/') || file.startsWith('utils/use-')) {
|
||||
return {
|
||||
dep: '@hanzo/docs-ui',
|
||||
type: 'dependency',
|
||||
specifier: `@hanzo/docs-ui/${removeExtname(file)}`,
|
||||
};
|
||||
}
|
||||
|
||||
file = path.relative(baseUi.registry.dir, ref.file);
|
||||
if (file.startsWith('contexts/') || file.startsWith('utils/use-')) {
|
||||
return {
|
||||
dep: '@hanzo/docs-base-ui',
|
||||
type: 'dependency',
|
||||
specifier: `@hanzo/docs-base-ui/${removeExtname(file)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// map dep imports to actual components
|
||||
if (ref.type === 'dependency' && ref.dep === '@hanzo/docs-ui') {
|
||||
const match = /@hanzo\/docs-ui\/components\/ui\/(.*)/.exec(ref.specifier);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
type: 'file',
|
||||
file: path.join(radixUi.registry.dir, `components/ui/${match[1]}.tsx`),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return ref;
|
||||
},
|
||||
};
|
||||
|
||||
export const registry: Registry = {
|
||||
dir: baseDir,
|
||||
name: 'Hanzo Docs',
|
||||
subRegistries: [radixUi.registry, baseUi.registry, sanity.registry],
|
||||
|
||||
components: [
|
||||
{
|
||||
name: 'layouts/docs-min',
|
||||
description: 'Replace Docs Layout (Minimal)',
|
||||
files: [
|
||||
{
|
||||
type: 'layout',
|
||||
path: 'components/registry/layout/docs-min.tsx',
|
||||
target: '<dir>/docs/index.tsx',
|
||||
},
|
||||
{
|
||||
type: 'layout',
|
||||
path: 'components/registry/layout/page-min.tsx',
|
||||
target: '<dir>/docs/page.tsx',
|
||||
},
|
||||
],
|
||||
unlisted: true,
|
||||
},
|
||||
{
|
||||
name: 'graph-view',
|
||||
description: 'A graph to display relationships of all pages',
|
||||
files: [
|
||||
{
|
||||
type: 'components',
|
||||
path: 'components/graph-view.tsx',
|
||||
},
|
||||
{
|
||||
type: 'lib',
|
||||
path: 'components/registry/build-graph.ts',
|
||||
target: 'lib/build-graph.ts',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'feedback',
|
||||
title: 'Feedback',
|
||||
description: 'Component to send user feedbacks about the docs',
|
||||
files: [
|
||||
{
|
||||
type: 'components',
|
||||
path: 'components/feedback/client.tsx',
|
||||
target: '<dir>/feedback/client.tsx',
|
||||
},
|
||||
{
|
||||
type: 'components',
|
||||
path: 'components/feedback/schema.ts',
|
||||
target: '<dir>/feedback/schema.ts',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ai/shared',
|
||||
unlisted: true,
|
||||
files: [
|
||||
{
|
||||
type: 'components',
|
||||
path: 'components/ai-sdk/search.tsx',
|
||||
target: '<dir>/ai/search.tsx',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ai/openrouter',
|
||||
title: 'AI Chat (AI SDK)',
|
||||
description: 'Ask AI dialog for your docs, default using OpenRouter',
|
||||
files: [
|
||||
{
|
||||
type: 'route-handler',
|
||||
route: 'api/chat',
|
||||
path: 'lib/openrouter/route.ts',
|
||||
},
|
||||
],
|
||||
dependencies: {
|
||||
flexsearch: '^0.8.212',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ai/llmgateway',
|
||||
title: 'AI Chat (LLMGateway)',
|
||||
description: 'Ask AI dialog for your docs, using LLMGateway',
|
||||
files: [
|
||||
{
|
||||
type: 'route-handler',
|
||||
route: 'api/chat',
|
||||
path: 'lib/llmgateway/route.ts',
|
||||
},
|
||||
],
|
||||
dependencies: {
|
||||
flexsearch: '^0.8.212',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'markdown',
|
||||
unlisted: true,
|
||||
files: [
|
||||
{
|
||||
type: 'components',
|
||||
path: 'components/markdown.tsx',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ai/hanzo',
|
||||
title: 'AI Chat (Next.js + Hanzo AI)',
|
||||
description: 'Ask AI dialog for your docs, powered by Hanzo Cloud',
|
||||
files: [
|
||||
{
|
||||
type: 'components',
|
||||
path: 'components/ai/search.tsx',
|
||||
target: '<dir>/ai/search.tsx',
|
||||
},
|
||||
{
|
||||
type: 'components',
|
||||
path: 'components/ai/markdown.tsx',
|
||||
target: '<dir>/ai/markdown.tsx',
|
||||
},
|
||||
{
|
||||
type: 'route',
|
||||
path: 'app/api/chat/route.ts',
|
||||
target: 'app/api/chat/route.ts',
|
||||
},
|
||||
{
|
||||
type: 'route-handler',
|
||||
route: 'api/ai',
|
||||
path: 'lib/ai/route.ts',
|
||||
},
|
||||
{
|
||||
type: 'lib',
|
||||
path: 'lib/ai/qa-schema.ts',
|
||||
target: '<dir>/ai/qa-schema.ts',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'og/mono',
|
||||
description: 'Open graph image generation - mono style',
|
||||
files: [
|
||||
{
|
||||
type: 'lib',
|
||||
path: 'lib/og/mono.tsx',
|
||||
target: '<dir>/og/mono.tsx',
|
||||
},
|
||||
{
|
||||
type: 'lib',
|
||||
path: 'lib/og/JetBrainsMono-Bold.ttf',
|
||||
target: '<dir>/og/JetBrainsMono-Bold.ttf',
|
||||
},
|
||||
{
|
||||
type: 'lib',
|
||||
path: 'lib/og/JetBrainsMono-Regular.ttf',
|
||||
target: '<dir>/og/JetBrainsMono-Regular.ttf',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
dependencies: {
|
||||
'@hanzo/docs-core': null,
|
||||
'@hanzo/docs-ui': null,
|
||||
'lucide-react': null,
|
||||
next: null,
|
||||
react: null,
|
||||
},
|
||||
};
|
||||
|
||||
function removeExtname(file: string) {
|
||||
return file.slice(0, -path.extname(file).length);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
'use client';
|
||||
import type * as PageTree from '@hanzo/docs-core/page-tree';
|
||||
import { type ComponentProps, createContext, type ReactNode, use, useMemo, useState } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { TreeContextProvider, useTreeContext } from '@hanzo/docs-base-ui/contexts/tree';
|
||||
import Link from '@hanzo/docs-core/link';
|
||||
import { useSearchContext } from '@hanzo/docs-base-ui/contexts/search';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { usePathname } from '@hanzo/docs-core/framework';
|
||||
|
||||
interface SidebarContext {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContext | null>(null);
|
||||
|
||||
export interface DocsLayoutProps {
|
||||
tree: PageTree.Root;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DocsLayout({ tree, children }: DocsLayoutProps) {
|
||||
return (
|
||||
<TreeContextProvider tree={tree}>
|
||||
<SidebarProvider>
|
||||
<header className="sticky top-0 bg-fd-background h-14 z-20">
|
||||
<nav className="flex flex-row items-center gap-2 size-full px-4">
|
||||
<Link href="/" className="font-medium mr-auto">
|
||||
My Docs
|
||||
</Link>
|
||||
|
||||
<SearchToggle />
|
||||
<NavbarSidebarTrigger className="md:hidden" />
|
||||
</nav>
|
||||
</header>
|
||||
<main id="nd-docs-layout" className="flex flex-1 flex-row [--fd-nav-height:56px]">
|
||||
<Sidebar />
|
||||
{children}
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</TreeContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<SidebarContext
|
||||
value={useMemo(
|
||||
() => ({
|
||||
open,
|
||||
setOpen,
|
||||
}),
|
||||
[open],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SidebarContext>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchToggle(props: ComponentProps<'button'>) {
|
||||
const { enabled, setOpenSearch } = useSearchContext();
|
||||
if (!enabled) return;
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn('text-sm', props.className)}
|
||||
onClick={() => setOpenSearch(true)}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NavbarSidebarTrigger(props: ComponentProps<'button'>) {
|
||||
const { open, setOpen } = use(SidebarContext)!;
|
||||
|
||||
return (
|
||||
<button {...props} className={cn('text-sm', props.className)} onClick={() => setOpen(!open)}>
|
||||
Sidebar
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const { root } = useTreeContext();
|
||||
const { open } = use(SidebarContext)!;
|
||||
|
||||
const children = useMemo(() => {
|
||||
function renderItems(items: PageTree.Node[]) {
|
||||
return items.map((item) => (
|
||||
<SidebarItem key={item.$id} item={item}>
|
||||
{item.type === 'folder' ? renderItems(item.children) : null}
|
||||
</SidebarItem>
|
||||
));
|
||||
}
|
||||
|
||||
return renderItems(root.children);
|
||||
}, [root]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed flex flex-col shrink-0 p-4 top-14 z-20 text-sm overflow-auto md:sticky md:h-[calc(100dvh-56px)] md:w-[300px]',
|
||||
'max-md:inset-x-0 max-md:bottom-0 max-md:bg-fd-background',
|
||||
!open && 'max-md:invisible',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const linkVariants = cva(
|
||||
'flex items-center gap-2 w-full py-1.5 rounded-lg text-fd-foreground/80 [&_svg]:size-4',
|
||||
{
|
||||
variants: {
|
||||
active: {
|
||||
true: 'text-fd-primary font-medium',
|
||||
false: 'hover:text-fd-accent-foreground',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function SidebarItem({ item, children }: { item: PageTree.Node; children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (item.type === 'page') {
|
||||
return (
|
||||
<Link
|
||||
href={item.url}
|
||||
className={linkVariants({
|
||||
active: pathname === item.url,
|
||||
})}
|
||||
>
|
||||
{item.icon}
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'separator') {
|
||||
return (
|
||||
<p className="text-fd-muted-foreground mt-6 mb-2 first:mt-0">
|
||||
{item.icon}
|
||||
{item.name}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{item.index ? (
|
||||
<Link
|
||||
className={linkVariants({
|
||||
active: pathname === item.index.url,
|
||||
})}
|
||||
href={item.index.url}
|
||||
>
|
||||
{item.index.icon}
|
||||
{item.index.name}
|
||||
</Link>
|
||||
) : (
|
||||
<p className={cn(linkVariants(), 'text-start')}>
|
||||
{item.icon}
|
||||
{item.name}
|
||||
</p>
|
||||
)}
|
||||
<div className="pl-4 border-l flex flex-col">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
'use client';
|
||||
import { type ComponentProps, type ReactNode, useMemo } from 'react';
|
||||
import { AnchorProvider, type TOCItemType, useActiveAnchors } from '@hanzo/docs-core/toc';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useTreeContext } from '@hanzo/docs-base-ui/contexts/tree';
|
||||
import { Link, usePathname } from '@hanzo/docs-core/framework';
|
||||
import type * as PageTree from '@hanzo/docs-core/page-tree';
|
||||
|
||||
export interface DocsPageProps {
|
||||
toc?: TOCItemType[];
|
||||
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DocsPage({ toc = [], ...props }: DocsPageProps) {
|
||||
return (
|
||||
<AnchorProvider toc={toc}>
|
||||
<main className="flex w-full min-w-0 flex-col">
|
||||
<article className="flex flex-1 flex-col w-full max-w-[860px] gap-6 px-4 py-8 md:px-6 md:mx-auto">
|
||||
{props.children}
|
||||
<Footer />
|
||||
</article>
|
||||
</main>
|
||||
{toc.length > 0 && (
|
||||
<div className="sticky top-(--fd-nav-height) w-[286px] shrink-0 h-[calc(100dvh-var(--fd-nav-height))] p-4 overflow-auto max-xl:hidden">
|
||||
<p className="text-sm text-fd-muted-foreground mb-2">On this page</p>
|
||||
<div className="flex flex-col">
|
||||
{toc.map((item) => (
|
||||
<TocItem key={item.url} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnchorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsBody(props: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div {...props} className={cn('prose', props.className)}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsDescription(props: ComponentProps<'p'>) {
|
||||
// don't render if no description provided
|
||||
if (props.children === undefined) return null;
|
||||
|
||||
return (
|
||||
<p {...props} className={cn('mb-8 text-lg text-fd-muted-foreground', props.className)}>
|
||||
{props.children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsTitle(props: ComponentProps<'h1'>) {
|
||||
return (
|
||||
<h1 {...props} className={cn('text-3xl font-semibold', props.className)}>
|
||||
{props.children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
function TocItem({ item }: { item: TOCItemType }) {
|
||||
const isActive = useActiveAnchors().includes(item.url.slice(1));
|
||||
|
||||
return (
|
||||
<a
|
||||
href={item.url}
|
||||
className={cn('text-sm text-fd-foreground/80 py-1', isActive && 'text-fd-primary')}
|
||||
style={{
|
||||
paddingLeft: Math.max(0, item.depth - 2) * 16,
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
const { root } = useTreeContext();
|
||||
const pathname = usePathname();
|
||||
const flatten = useMemo(() => {
|
||||
const result: PageTree.Item[] = [];
|
||||
|
||||
function scan(items: PageTree.Node[]) {
|
||||
for (const item of items) {
|
||||
if (item.type === 'page') result.push(item);
|
||||
else if (item.type === 'folder') {
|
||||
if (item.index) result.push(item.index);
|
||||
scan(item.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scan(root.children);
|
||||
return result;
|
||||
}, [root]);
|
||||
|
||||
const { previous, next } = useMemo(() => {
|
||||
const idx = flatten.findIndex((item) => item.url === pathname);
|
||||
|
||||
if (idx === -1) return {};
|
||||
return {
|
||||
previous: flatten[idx - 1],
|
||||
next: flatten[idx + 1],
|
||||
};
|
||||
}, [flatten, pathname]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-between gap-2 items-center font-medium">
|
||||
{previous ? <Link href={previous.url}>{previous.name}</Link> : null}
|
||||
{next ? <Link href={next.url}>{next.name}</Link> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { createContext, use, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Search: one dialog, opened from the rail's field or with ⌘K / Ctrl+K from
|
||||
* anywhere. The dialog and its index client load the first time it opens, so a
|
||||
* reader who never searches never downloads either.
|
||||
*/
|
||||
interface Search {
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const Context = createContext<Search>({ open: false, setOpen: () => undefined });
|
||||
|
||||
export function useSearch(): Search {
|
||||
return use(Context);
|
||||
}
|
||||
|
||||
const Finder = dynamic(() => import('@/components/finder'), { ssr: false });
|
||||
|
||||
export function SearchProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [wanted, setWanted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const key = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setWanted(true);
|
||||
setOpen((v) => !v);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', key);
|
||||
return () => window.removeEventListener('keydown', key);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
open,
|
||||
setOpen: (v: boolean) => {
|
||||
if (v) setWanted(true);
|
||||
setOpen(v);
|
||||
},
|
||||
}),
|
||||
[open],
|
||||
);
|
||||
|
||||
return (
|
||||
<Context value={value}>
|
||||
{children}
|
||||
{wanted && <Finder open={open} onOpenChange={setOpen} />}
|
||||
</Context>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Spinner, Text, YStack } from '@hanzo/gui';
|
||||
import { muted } from '@/lib/ink';
|
||||
|
||||
/**
|
||||
* A whole-screen word from the sign-in routes: a spinner while IAM answers, or
|
||||
* what went wrong and the one thing to do about it.
|
||||
*/
|
||||
export function Status({ title, note, action }: { title?: string; note: string; action?: ReactNode }) {
|
||||
return (
|
||||
<YStack minH="100dvh" items="center" justify="center" gap={10} px={20} bg="$background">
|
||||
{title ? (
|
||||
<Text fontSize={18} fontWeight="600" color="$color12">
|
||||
{title}
|
||||
</Text>
|
||||
) : (
|
||||
<Spinner size="small" color="$color12" />
|
||||
)}
|
||||
<Text fontSize="$3" text="center" {...muted}>
|
||||
{note}
|
||||
</Text>
|
||||
{action}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ description: The chat app on Hanzo Cloud — enso and Zen models, MCP tools, age
|
||||
index: true
|
||||
---
|
||||
|
||||
import { LayoutGrid } from 'lucide-react'
|
||||
|
||||
# Hanzo Chat
|
||||
|
||||
@@ -154,7 +153,7 @@ chat route into the rest of `/v1`.
|
||||
## In this section
|
||||
|
||||
<Cards>
|
||||
<Card icon={<LayoutGrid />} title="Hanzo Dev" href="/docs/dev" description="AI-powered coding assistant for your terminal. Install, configure, and operate Hanzo Dev." />
|
||||
<Card icon={<LayoutGrid />} title="Integrations" href="/docs/guides/integrations" description="Connect blob storage, Slack, Mixpanel, and Insights to your project." />
|
||||
<Card icon={<LayoutGrid />} title="Integrations & Extensions" href="/docs/apps" description="Install and connect Hanzo across your browser, editor, desktop, docs, design tools, and business apps — one…" />
|
||||
<Card icon="LayoutGrid" title="Hanzo Dev" href="/docs/dev" description="AI-powered coding assistant for your terminal. Install, configure, and operate Hanzo Dev." />
|
||||
<Card icon="LayoutGrid" title="Integrations" href="/docs/guides/integrations" description="Connect blob storage, Slack, Mixpanel, and Insights to your project." />
|
||||
<Card icon="LayoutGrid" title="Integrations & Extensions" href="/docs/apps" description="Install and connect Hanzo across your browser, editor, desktop, docs, design tools, and business apps — one…" />
|
||||
</Cards>
|
||||
|
||||
@@ -3,7 +3,6 @@ title: User Guide
|
||||
description: The CLI tool that automates setups and installs components.
|
||||
---
|
||||
|
||||
import { Terminal } from 'lucide-react'
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -93,9 +92,9 @@ export default (
|
||||
## In this section
|
||||
|
||||
<Cards>
|
||||
<Card icon={<Terminal />} title="SDKs" href="/docs/sdks" description="Official Hanzo SDKs for every language — Python, TypeScript, Go, Rust, C++, Swift, Kotlin. Two lines per…" />
|
||||
<Card icon={<Terminal />} title="ZAP Protocol" href="/docs/zap" description="Zero-Copy App Proto — High-performance Cap'n Proto RPC for AI agent communication. 10-100x faster than MCP…" />
|
||||
<Card icon={<Terminal />} title="Hanzo Skills Reference" href="/docs/openapi/skills" description="Comprehensive reference documentation for all 49 Hanzo skills — deep-dive guides for AI agents and developers." />
|
||||
<Card icon={<Terminal />} title="Usage" href="/docs/usage" description="One usage plane for every AI account you have — rate limits, credits, and spend across Codex, Claude, Hanzo…" />
|
||||
<Card icon={<Terminal />} title="Organizations" href="/docs/openapi/iam" description="Multi-org setup, resource scoping, team management, and white-label login for Hanzo organizations." />
|
||||
<Card icon="Terminal" title="SDKs" href="/docs/sdks" description="Official Hanzo SDKs for every language — Python, TypeScript, Go, Rust, C++, Swift, Kotlin. Two lines per…" />
|
||||
<Card icon="Terminal" title="ZAP Protocol" href="/docs/zap" description="Zero-Copy App Proto — High-performance Cap'n Proto RPC for AI agent communication. 10-100x faster than MCP…" />
|
||||
<Card icon="Terminal" title="Hanzo Skills Reference" href="/docs/openapi/skills" description="Comprehensive reference documentation for all 49 Hanzo skills — deep-dive guides for AI agents and developers." />
|
||||
<Card icon="Terminal" title="Usage" href="/docs/usage" description="One usage plane for every AI account you have — rate limits, credits, and spend across Codex, Claude, Hanzo…" />
|
||||
<Card icon="Terminal" title="Organizations" href="/docs/openapi/iam" description="Multi-org setup, resource scoping, team management, and white-label login for Hanzo organizations." />
|
||||
</Cards>
|
||||
|
||||
@@ -4,7 +4,6 @@ title: Build a game
|
||||
description: The shortest path from nothing to something you can play — in the browser, from your terminal, or against the raw API.
|
||||
---
|
||||
|
||||
import { Rocket, Code, Package, Sparkles } from 'lucide-react'
|
||||
|
||||
# Build a game
|
||||
|
||||
@@ -128,17 +127,17 @@ somewhere shared to put the state, and something to tell each tab when it change
|
||||
which is the point at which a toy starts using the platform properly:
|
||||
|
||||
<Cards>
|
||||
<Card icon={<Sparkles />} title="Keep the scores" href="/docs/openapi/kv" description="A key/value store for the leaderboard — read and written from the browser." />
|
||||
<Card icon={<Rocket />} title="Push the moves" href="/docs/openapi/pubsub" description="Publish each move, subscribe in the other tab. No polling loop to write." />
|
||||
<Card icon={<Code />} title="Know who is playing" href="/docs/openapi/iam" description="Sign-in, so a high score belongs to a person instead of a browser." />
|
||||
<Card icon={<Package />} title="Put it somewhere" href="/docs/openapi/deploy" description="Ship the finished thing to a URL you can send to someone." />
|
||||
<Card icon="Sparkles" title="Keep the scores" href="/docs/openapi/kv" description="A key/value store for the leaderboard — read and written from the browser." />
|
||||
<Card icon="Rocket" title="Push the moves" href="/docs/openapi/pubsub" description="Publish each move, subscribe in the other tab. No polling loop to write." />
|
||||
<Card icon="Code" title="Know who is playing" href="/docs/openapi/iam" description="Sign-in, so a high score belongs to a person instead of a browser." />
|
||||
<Card icon="Package" title="Put it somewhere" href="/docs/openapi/deploy" description="Ship the finished thing to a URL you can send to someone." />
|
||||
</Cards>
|
||||
|
||||
## Then take it further
|
||||
|
||||
<Cards>
|
||||
<Card icon={<Rocket />} title="Get Started" href="/docs/quickstart" description="Account, API key, first call — the five-minute version." />
|
||||
<Card icon={<Code />} title="API Reference" href="/docs/openapi" description="Every endpoint, generated from OpenAPI." />
|
||||
<Card icon={<Package />} title="SDKs" href="/docs/sdks" description="Python, TypeScript, Go, Rust — one contract, many bindings." />
|
||||
<Card icon={<Sparkles />} title="MCP" href="/docs/mcp" description="Give the model real tools — files, HTTP, your own functions." />
|
||||
<Card icon="Rocket" title="Get Started" href="/docs/quickstart" description="Account, API key, first call — the five-minute version." />
|
||||
<Card icon="Code" title="API Reference" href="/docs/openapi" description="Every endpoint, generated from OpenAPI." />
|
||||
<Card icon="Package" title="SDKs" href="/docs/sdks" description="Python, TypeScript, Go, Rust — one contract, many bindings." />
|
||||
<Card icon="Sparkles" title="MCP" href="/docs/mcp" description="Give the model real tools — files, HTTP, your own functions." />
|
||||
</Cards>
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { ProvideLinksToolSchema } from '@/lib/ai/qa-schema';
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import { $routeHandler } from 'fuma-cli/macros/route-handler';
|
||||
import { convertToModelMessages, streamText, type UIMessage } from 'ai';
|
||||
|
||||
export type HanzoUIMessage = UIMessage<
|
||||
never,
|
||||
{
|
||||
client: {
|
||||
location: string;
|
||||
};
|
||||
}
|
||||
>;
|
||||
|
||||
// NATIVE Hanzo AI — replaces the retired third-party hosted RAG. Everything
|
||||
// runs on our own stack: the `rag-api` native index for retrieval + the Hanzo
|
||||
// gateway (api.hanzo.ai, OpenAI-compatible) for generation. No third-party AI.
|
||||
const hanzo = createOpenAICompatible({
|
||||
name: 'hanzo',
|
||||
apiKey: process.env.HANZO_API_KEY,
|
||||
baseURL: process.env.HANZO_AI_BASE_URL ?? 'https://api.hanzo.ai/v1',
|
||||
});
|
||||
|
||||
// The native RAG index (rag-api). `/query` returns the top-k indexed doc chunks
|
||||
// (page_content + metadata) — we ground the answer on these and cite their URLs.
|
||||
const RAG_URL = process.env.HANZO_RAG_URL ?? 'https://api.hanzo.ai/v1';
|
||||
const RAG_ENTITY = process.env.HANZO_RAG_ENTITY ?? 'docs.hanzo.ai';
|
||||
|
||||
interface RagChunk {
|
||||
page_content: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function retrieve(
|
||||
query: string,
|
||||
): Promise<{ context: string; links: { url: string; title?: string }[] }> {
|
||||
try {
|
||||
const res = await fetch(`${RAG_URL}/query`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${process.env.HANZO_API_KEY ?? ''}`,
|
||||
},
|
||||
body: JSON.stringify({ query, k: 8, entity_id: RAG_ENTITY }),
|
||||
});
|
||||
if (!res.ok) return { context: '', links: [] };
|
||||
const chunks = (await res.json()) as RagChunk[];
|
||||
if (!Array.isArray(chunks)) return { context: '', links: [] };
|
||||
const links = chunks
|
||||
.map((c) => ({
|
||||
url: String(c.metadata?.url ?? c.metadata?.source ?? ''),
|
||||
title: (c.metadata?.title as string | undefined) ?? undefined,
|
||||
}))
|
||||
.filter((l) => l.url);
|
||||
const context = chunks
|
||||
.map((c, i) => `[${i + 1}] ${(c.metadata?.title as string) ?? ''}\n${c.page_content}`)
|
||||
.join('\n\n');
|
||||
return { context, links };
|
||||
} catch {
|
||||
return { context: '', links: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export const handler = $routeHandler(
|
||||
{
|
||||
methods: ['POST'],
|
||||
params: [],
|
||||
},
|
||||
async (req) => {
|
||||
const reqJson = await req.json();
|
||||
|
||||
// Ground the answer on the native index: retrieve for the latest user turn.
|
||||
const msgs = (reqJson.messages ?? []) as HanzoUIMessage[];
|
||||
const lastUser = [...msgs].reverse().find((m) => m.role === 'user');
|
||||
const question = (lastUser?.parts ?? [])
|
||||
.map((p) => ((p as { type?: string; text?: string }).type === 'text' ? (p as { text?: string }).text ?? '' : ''))
|
||||
.join(' ')
|
||||
.trim();
|
||||
const { context } = question ? await retrieve(question) : { context: '' };
|
||||
|
||||
const result = streamText({
|
||||
model: hanzo(process.env.HANZO_AI_MODEL ?? 'enso'),
|
||||
system:
|
||||
'You are the Hanzo documentation assistant. Answer using ONLY the Hanzo ' +
|
||||
'documentation context below. Cite every source you use by calling the ' +
|
||||
'provideLinks tool with its URL. If the answer is not in the context, say ' +
|
||||
'so plainly and point to the closest relevant section.\n\n' +
|
||||
(context
|
||||
? `--- Hanzo docs context ---\n${context}\n--- end context ---`
|
||||
: '(no matching docs were found in the native index for this query)'),
|
||||
tools: {
|
||||
provideLinks: {
|
||||
inputSchema: ProvideLinksToolSchema,
|
||||
},
|
||||
},
|
||||
messages: await convertToModelMessages<HanzoUIMessage>(reqJson.messages, {
|
||||
ignoreIncompleteToolCalls: true,
|
||||
convertDataPart(part) {
|
||||
if (part.type === 'data-client')
|
||||
return {
|
||||
type: 'text',
|
||||
text: `[Client Context: ${JSON.stringify(part.data)}]`,
|
||||
};
|
||||
},
|
||||
}),
|
||||
toolChoice: 'auto',
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse();
|
||||
},
|
||||
);
|
||||
@@ -1,32 +0,0 @@
|
||||
import { source } from '@/lib/source';
|
||||
import type { Graph } from '@/components/graph-view';
|
||||
|
||||
export async function buildGraph(): Promise<Graph> {
|
||||
const graph: Graph = { links: [], nodes: [] };
|
||||
|
||||
await Promise.all(
|
||||
source.getPages().map(async (page) => {
|
||||
if (page.type === 'openapi') return;
|
||||
|
||||
graph.nodes.push({
|
||||
id: page.url,
|
||||
url: page.url,
|
||||
text: page.data.title,
|
||||
description: page.data.description,
|
||||
});
|
||||
|
||||
const { extractedReferences = [] } = await page.data.load();
|
||||
for (const ref of extractedReferences) {
|
||||
const refPage = source.getPageByHref(ref.href);
|
||||
if (!refPage) continue;
|
||||
|
||||
graph.links.push({
|
||||
source: page.url,
|
||||
target: refPage.page.url,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return graph;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { twMerge as cn } from 'tailwind-merge';
|
||||
@@ -1,168 +0,0 @@
|
||||
import { App, Octokit } from 'octokit';
|
||||
import {
|
||||
blockFeedback,
|
||||
BlockFeedback,
|
||||
pageFeedback,
|
||||
type ActionResponse,
|
||||
type PageFeedback,
|
||||
} from '@/components/feedback/schema';
|
||||
|
||||
export const repo = 'docs';
|
||||
export const owner = 'hanzoai';
|
||||
export const DocsCategory = 'Docs Feedback';
|
||||
|
||||
let instance: Octokit | undefined;
|
||||
|
||||
async function getOctokit(): Promise<Octokit> {
|
||||
if (instance) return instance;
|
||||
const appId = process.env.GITHUB_APP_ID;
|
||||
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY;
|
||||
|
||||
if (!appId || !privateKey) {
|
||||
throw new Error('No GitHub keys provided for Github app, docs feedback feature will not work.');
|
||||
}
|
||||
|
||||
const app = new App({
|
||||
appId,
|
||||
privateKey,
|
||||
});
|
||||
|
||||
const { data } = await app.octokit.request('GET /repos/{owner}/{repo}/installation', {
|
||||
owner,
|
||||
repo,
|
||||
headers: {
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
});
|
||||
|
||||
instance = await app.getInstallationOctokit(data.id);
|
||||
return instance;
|
||||
}
|
||||
|
||||
interface RepositoryInfo {
|
||||
id: string;
|
||||
discussionCategories: {
|
||||
nodes: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
let cachedDestination: RepositoryInfo | undefined;
|
||||
async function getFeedbackDestination() {
|
||||
if (cachedDestination) return cachedDestination;
|
||||
const octokit = await getOctokit();
|
||||
|
||||
const {
|
||||
repository,
|
||||
}: {
|
||||
repository: RepositoryInfo;
|
||||
} = await octokit.graphql(`
|
||||
query {
|
||||
repository(owner: "${owner}", name: "${repo}") {
|
||||
id
|
||||
discussionCategories(first: 25) {
|
||||
nodes { id name }
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
return (cachedDestination = repository);
|
||||
}
|
||||
|
||||
// Note: Server Actions are disabled for static export
|
||||
// These functions are stubbed out and return mock responses
|
||||
// To enable feedback, deploy with a server (non-static export) and uncomment 'use server' directives
|
||||
|
||||
export async function onPageFeedbackAction(feedback: PageFeedback): Promise<ActionResponse> {
|
||||
// 'use server'; // Disabled for static export
|
||||
if (typeof window !== 'undefined') {
|
||||
// Client-side: return mock response
|
||||
console.log('Feedback submitted (static export - no server):', feedback);
|
||||
return { githubUrl: '#' };
|
||||
}
|
||||
feedback = pageFeedback.parse(feedback);
|
||||
const url = new URL(feedback.url);
|
||||
|
||||
return createDiscussionThread(
|
||||
url.pathname,
|
||||
`[${feedback.opinion}] ${feedback.message}\n\n> Forwarded from user feedback.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function onBlockFeedbackAction(feedback: BlockFeedback): Promise<ActionResponse> {
|
||||
// 'use server'; // Disabled for static export
|
||||
if (typeof window !== 'undefined') {
|
||||
// Client-side: return mock response
|
||||
console.log('Block feedback submitted (static export - no server):', feedback);
|
||||
return { githubUrl: '#' };
|
||||
}
|
||||
feedback = blockFeedback.parse(feedback);
|
||||
const url = new URL(feedback.url);
|
||||
url.hash = feedback.blockId;
|
||||
|
||||
return createDiscussionThread(
|
||||
url.pathname,
|
||||
`> ${feedback.blockBody}\n\n${feedback.message}\n\n> [Forwarded from user feedback](${url.href}).`,
|
||||
);
|
||||
}
|
||||
|
||||
async function createDiscussionThread(pageId: string, body: string) {
|
||||
const octokit = await getOctokit();
|
||||
const destination = await getFeedbackDestination();
|
||||
const category = destination.discussionCategories.nodes.find(
|
||||
(category) => category.name === DocsCategory,
|
||||
);
|
||||
|
||||
if (!category) throw new Error(`Please create a "${DocsCategory}" category in GitHub Discussion`);
|
||||
|
||||
const title = `Feedback for ${pageId}`;
|
||||
const queryResult: {
|
||||
search: {
|
||||
nodes: { id: string; title: string; url: string }[];
|
||||
};
|
||||
} = await octokit.graphql(`
|
||||
query {
|
||||
search(type: DISCUSSION, query: ${JSON.stringify(`"${title}" in:title repo:${owner}/${repo} author:@me`)}, first: 10) {
|
||||
nodes {
|
||||
... on Discussion { id, title, url }
|
||||
}
|
||||
}
|
||||
}`);
|
||||
|
||||
const discussion = queryResult.search.nodes.find((item) => item.title === title);
|
||||
|
||||
if (discussion) {
|
||||
const result: {
|
||||
addDiscussionComment: {
|
||||
comment: { id: string; url: string };
|
||||
};
|
||||
} = await octokit.graphql(`
|
||||
mutation {
|
||||
addDiscussionComment(input: { body: ${JSON.stringify(body)}, discussionId: "${discussion.id}" }) {
|
||||
comment { id, url }
|
||||
}
|
||||
}`);
|
||||
|
||||
return {
|
||||
githubUrl: result.addDiscussionComment.comment.url,
|
||||
};
|
||||
} else {
|
||||
const result: {
|
||||
createDiscussion: {
|
||||
discussion: { id: string; url: string };
|
||||
};
|
||||
} = await octokit.graphql(`
|
||||
mutation {
|
||||
createDiscussion(input: { repositoryId: "${destination.id}", categoryId: "${category.id}", body: ${JSON.stringify(body)}, title: ${JSON.stringify(title)} }) {
|
||||
discussion { id, url }
|
||||
}
|
||||
}`);
|
||||
|
||||
return {
|
||||
githubUrl: result.createDiscussion.discussion.url,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
export const searchBackend =
|
||||
(process.env.NEXT_PUBLIC_HANZO_SEARCH_BACKEND as 'cloud' | 'meilisearch' | undefined) ??
|
||||
'meilisearch';
|
||||
|
||||
export const searchEndpoint =
|
||||
process.env.NEXT_PUBLIC_HANZO_SEARCH_ENDPOINT ??
|
||||
(searchBackend === 'meilisearch'
|
||||
? 'https://search.hanzo.ai'
|
||||
: 'https://api.hanzo.ai/v1/search-docs');
|
||||
|
||||
export const searchIndex =
|
||||
process.env.NEXT_PUBLIC_HANZO_SEARCH_INDEX ?? 'app-docs-hanzo-ai-docs';
|
||||
|
||||
export const indexEndpoint =
|
||||
process.env.HANZO_SEARCH_INDEX_ENDPOINT ??
|
||||
'https://api.hanzo.ai/v1/index-docs';
|
||||
|
||||
// Gateway BASE the docs chat widget streams from, CLIENT-SIDE. The @hanzo/ai
|
||||
// client appends `/v1/chat/completions` itself (never an `/api/` prefix), so
|
||||
// this is the gateway ROOT — not a full path. Replaces the retired server-route
|
||||
// RAG proxy (the dead `/v1/chat-docs` name): docs deploys as a static export
|
||||
// with no server, so the browser calls the real gateway directly.
|
||||
export const chatEndpoint =
|
||||
process.env.NEXT_PUBLIC_HANZO_CHAT_ENDPOINT ?? 'https://api.hanzo.ai';
|
||||
|
||||
// Publishable key (pk-*) for the hanzo-docs org, scoped to chat. Ships in the
|
||||
// client bundle BY DESIGN (NEXT_PUBLIC) — never a server secret. Answering is
|
||||
// balance-gated: the hanzo-docs org wallet must be funded for the gateway to bill.
|
||||
export const chatKey = process.env.NEXT_PUBLIC_HANZO_CHAT_KEY ?? '';
|
||||
|
||||
// Generation model on the gateway. `enso` is the Hanzo docs assistant model.
|
||||
export const chatModel = process.env.NEXT_PUBLIC_HANZO_CHAT_MODEL ?? 'enso';
|
||||
|
||||
export const publishableKey =
|
||||
process.env.NEXT_PUBLIC_HANZO_SEARCH_KEY ??
|
||||
(searchBackend === 'meilisearch'
|
||||
? '2d99c3ab7551b807c9b8c132f663eba7e27e765a511907d9a566799497c7fd42'
|
||||
: 'pk-hanzo-docs-search-2026');
|
||||
|
||||
export const adminKey =
|
||||
process.env.HANZO_SEARCH_ADMIN_KEY ?? '';
|
||||
@@ -1,248 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { createAiClient, type ChatCompletionMessage, type Tool } from '@hanzo/ai';
|
||||
import type { z } from 'zod';
|
||||
import type { ProvideLinksToolSchema } from '@/lib/ai/qa-schema';
|
||||
import { chatEndpoint, chatKey, chatModel } from '@/lib/hanzo/client';
|
||||
|
||||
// Client-side chat for the docs AISearch widget. docs.hanzo.ai deploys as a
|
||||
// STATIC export (hanzoai/static) with no server, so the retired `/api/chat`
|
||||
// route can never run in prod. This hook streams straight from the Hanzo
|
||||
// gateway (`POST {chatEndpoint}/v1/chat/completions`, model `enso`) using our
|
||||
// own headless SDK (@hanzo/ai `createAiClient`) with a PUBLISHABLE key — never a
|
||||
// server secret. It presents the exact surface the widget already consumes
|
||||
// (messages/status/sendMessage/regenerate/stop/setMessages), so the bespoke
|
||||
// provideLinks/sources UI is untouched.
|
||||
|
||||
export type ChatStatus = 'ready' | 'submitted' | 'streaming' | 'error';
|
||||
|
||||
// Reuse the ONE links contract the UI already renders (from the tool schema).
|
||||
type Links = z.infer<typeof ProvideLinksToolSchema>['links'];
|
||||
|
||||
export type ChatPart =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'tool-provideLinks'; input: { links: Links } }
|
||||
| { type: 'data-client'; data: { location: string } };
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
parts: ChatPart[];
|
||||
}
|
||||
|
||||
export interface HanzoChat {
|
||||
messages: ChatMessage[];
|
||||
status: ChatStatus;
|
||||
sendMessage: (message: { role: 'user'; parts: ChatPart[] }) => void;
|
||||
regenerate: () => void;
|
||||
stop: () => void;
|
||||
setMessages: (messages: ChatMessage[]) => void;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT =
|
||||
'You are Hanzo Docs AI, the assistant for the Hanzo AI Cloud documentation. ' +
|
||||
'Answer questions about Hanzo products, APIs, SDKs, and self-hosting concisely ' +
|
||||
'and accurately. Cite the documentation pages you draw on by calling the ' +
|
||||
'provideLinks tool with their URLs. If you are unsure, say so plainly and ' +
|
||||
'point to the closest relevant section — never invent APIs, flags, or endpoints.';
|
||||
|
||||
// The model-facing contract for citations. Kept as a clean JSON Schema (the zod
|
||||
// `ProvideLinksToolSchema` is deliberately loose because it PARSES untrusted
|
||||
// model output; this is what we ASK the model to produce). Shape matches the
|
||||
// source cards the widget renders (label / url / title).
|
||||
const provideLinksTool: Tool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'provideLinks',
|
||||
description:
|
||||
'Cite the Hanzo documentation pages used to answer. Call once with every ' +
|
||||
'source URL referenced in the answer.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
links: {
|
||||
type: 'array',
|
||||
description: 'The documentation pages cited in the answer.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string', description: 'Footnote label, e.g. "1".' },
|
||||
url: { type: 'string', description: 'Absolute URL of the doc page.' },
|
||||
title: { type: 'string', description: 'Human-readable page title.' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['links'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function partsToText(parts: ChatPart[]): string {
|
||||
return parts
|
||||
.map((p) =>
|
||||
p.type === 'text'
|
||||
? p.text
|
||||
: p.type === 'data-client'
|
||||
? `[Current page: ${p.data.location}]\n\n`
|
||||
: '',
|
||||
)
|
||||
.join('')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function toCompletionMessages(history: ChatMessage[]): ChatCompletionMessage[] {
|
||||
const turns: ChatCompletionMessage[] = history
|
||||
.filter((m) => m.role !== 'system')
|
||||
.map((m) => ({ role: m.role as 'user' | 'assistant', content: partsToText(m.parts) }))
|
||||
.filter((m) => typeof m.content === 'string' && m.content.length > 0);
|
||||
return [{ role: 'system', content: SYSTEM_PROMPT }, ...turns];
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
const nextId = () => `msg-${Date.now()}-${seq++}`;
|
||||
|
||||
export function useHanzoChat(): HanzoChat {
|
||||
const [messages, setMessagesState] = useState<ChatMessage[]>([]);
|
||||
const [status, setStatus] = useState<ChatStatus>('ready');
|
||||
const messagesRef = useRef<ChatMessage[]>([]);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const commit = useCallback((next: ChatMessage[]) => {
|
||||
messagesRef.current = next;
|
||||
setMessagesState(next);
|
||||
}, []);
|
||||
|
||||
// One client for the widget's lifetime. Missing key → null (fail secure: we
|
||||
// surface a config message rather than firing an unauthenticated request).
|
||||
const client = useMemo(
|
||||
() => (chatKey ? createAiClient({ baseUrl: chatEndpoint, token: chatKey }) : null),
|
||||
[],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
async (history: ChatMessage[]) => {
|
||||
if (!client) {
|
||||
commit([
|
||||
...history,
|
||||
{
|
||||
id: nextId(),
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'AI chat is not configured yet. Set `NEXT_PUBLIC_HANZO_CHAT_KEY` to a Hanzo publishable key.',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
setStatus('ready');
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setStatus('submitted');
|
||||
|
||||
const assistantId = nextId();
|
||||
let body = '';
|
||||
const toolArgs = new Map<number, { name: string; args: string }>();
|
||||
|
||||
const render = () => {
|
||||
const parts: ChatPart[] = [];
|
||||
if (body) parts.push({ type: 'text', text: body });
|
||||
for (const { name, args } of toolArgs.values()) {
|
||||
if (name !== 'provideLinks') continue;
|
||||
try {
|
||||
const parsed = JSON.parse(args) as { links: Links };
|
||||
if (parsed?.links) parts.push({ type: 'tool-provideLinks', input: { links: parsed.links } });
|
||||
} catch {
|
||||
// Arguments still streaming (incomplete JSON) — render once valid.
|
||||
}
|
||||
}
|
||||
commit([...history, { id: assistantId, role: 'assistant', parts }]);
|
||||
};
|
||||
|
||||
// Seed an empty assistant turn so the panel shows activity immediately.
|
||||
commit([...history, { id: assistantId, role: 'assistant', parts: [] }]);
|
||||
|
||||
try {
|
||||
const stream = await client.chat.completions.create(
|
||||
{
|
||||
model: chatModel,
|
||||
messages: toCompletionMessages(history),
|
||||
tools: [provideLinksTool],
|
||||
tool_choice: 'auto',
|
||||
stream: true,
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
setStatus('streaming');
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta;
|
||||
if (!delta) continue;
|
||||
if (delta.content) body += delta.content;
|
||||
for (const call of delta.tool_calls ?? []) {
|
||||
const cur = toolArgs.get(call.index) ?? { name: '', args: '' };
|
||||
if (call.function?.name) cur.name = call.function.name;
|
||||
if (call.function?.arguments) cur.args += call.function.arguments;
|
||||
toolArgs.set(call.index, cur);
|
||||
}
|
||||
render();
|
||||
}
|
||||
render();
|
||||
setStatus('ready');
|
||||
} catch {
|
||||
if (controller.signal.aborted) {
|
||||
setStatus('ready');
|
||||
return;
|
||||
}
|
||||
// Fail closed: never leak gateway internals to the client.
|
||||
commit([
|
||||
...history,
|
||||
{
|
||||
id: assistantId,
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text: 'Chat is temporarily unavailable. Please try again in a moment.' }],
|
||||
},
|
||||
]);
|
||||
setStatus('error');
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
}
|
||||
},
|
||||
[client, commit],
|
||||
);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(message: { role: 'user'; parts: ChatPart[] }) => {
|
||||
const next = [...messagesRef.current, { id: nextId(), role: 'user' as const, parts: message.parts }];
|
||||
commit(next);
|
||||
void run(next);
|
||||
},
|
||||
[commit, run],
|
||||
);
|
||||
|
||||
const regenerate = useCallback(() => {
|
||||
const msgs = messagesRef.current;
|
||||
let lastUser = -1;
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
if (msgs[i].role === 'user') {
|
||||
lastUser = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastUser === -1) return;
|
||||
const history = msgs.slice(0, lastUser + 1);
|
||||
commit(history);
|
||||
void run(history);
|
||||
}, [commit, run]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
return { messages, status, sendMessage, regenerate, stop, setMessages: commit };
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { createLLMGateway } from '@llmgateway/ai-sdk-provider';
|
||||
import { convertToModelMessages, stepCountIs, streamText, tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { source } from '@/lib/source';
|
||||
import { Document, type DocumentData } from 'flexsearch';
|
||||
import { $routeHandler } from 'fuma-cli/macros/route-handler';
|
||||
import type { ChatUIMessage, SearchTool } from '@/components/ai-sdk/search';
|
||||
|
||||
interface CustomDocument extends DocumentData {
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const searchServer = createSearchServer();
|
||||
|
||||
async function createSearchServer() {
|
||||
const search = new Document<CustomDocument>({
|
||||
document: {
|
||||
id: 'url',
|
||||
index: ['title', 'description', 'content'],
|
||||
store: true,
|
||||
},
|
||||
});
|
||||
|
||||
const docs = await chunkedAll(
|
||||
source.getPages().map(async (page) => {
|
||||
if (!('getText' in page.data)) return null;
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
url: page.url,
|
||||
content: await page.data.getText('processed'),
|
||||
} as CustomDocument;
|
||||
}),
|
||||
);
|
||||
|
||||
for (const doc of docs) {
|
||||
if (doc) search.add(doc);
|
||||
}
|
||||
|
||||
return search;
|
||||
}
|
||||
|
||||
async function chunkedAll<O>(promises: Promise<O>[]): Promise<O[]> {
|
||||
const SIZE = 50;
|
||||
const out: O[] = [];
|
||||
for (let i = 0; i < promises.length; i += SIZE) {
|
||||
out.push(...(await Promise.all(promises.slice(i, i + SIZE))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const llmgateway = createLLMGateway({
|
||||
apiKey: process.env.LLM_GATEWAY_API_KEY,
|
||||
});
|
||||
|
||||
/** System prompt, you can update it to provide more specific information */
|
||||
const systemPrompt = [
|
||||
'You are an AI assistant for a documentation site.',
|
||||
'Use the `search` tool to retrieve relevant docs context before answering when needed.',
|
||||
'The `search` tool returns raw JSON results from documentation. Use those results to ground your answer and cite sources as markdown links using the document `url` field when available.',
|
||||
'If you cannot find the answer in search results, say you do not know and suggest a better search query.',
|
||||
].join('\n');
|
||||
|
||||
export const handler = $routeHandler(
|
||||
{
|
||||
methods: ['POST'],
|
||||
params: [],
|
||||
},
|
||||
async (req) => {
|
||||
const reqJson = await req.json();
|
||||
const modelId = process.env.LLM_GATEWAY_MODEL ?? 'anthropic/claude-3.5-sonnet';
|
||||
|
||||
const result = streamText({
|
||||
model: llmgateway.chat(modelId as never),
|
||||
stopWhen: stepCountIs(5),
|
||||
tools: {
|
||||
search: searchTool,
|
||||
},
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...(await convertToModelMessages<ChatUIMessage>(reqJson.messages ?? [], {
|
||||
convertDataPart(part) {
|
||||
if (part.type === 'data-client')
|
||||
return {
|
||||
type: 'text',
|
||||
text: `[Client Context: ${JSON.stringify(part.data)}]`,
|
||||
};
|
||||
},
|
||||
})),
|
||||
],
|
||||
toolChoice: 'auto',
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse();
|
||||
},
|
||||
);
|
||||
|
||||
const searchTool = tool({
|
||||
description: 'Search the docs content and return raw JSON results.',
|
||||
inputSchema: z.object({
|
||||
query: z.string(),
|
||||
limit: z.number().int().min(1).max(100).default(10),
|
||||
}),
|
||||
async execute({ query, limit }) {
|
||||
const search = await searchServer;
|
||||
return await search.searchAsync(query, { limit, merge: true, enrich: true });
|
||||
},
|
||||
}) satisfies SearchTool;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { createOpenAPI } from '@hanzo/docs/openapi/server';
|
||||
import fs from 'node:fs';
|
||||
import { DOCUMENT } from '@/scripts/openapi-doc';
|
||||
|
||||
const isExport = process.env.NEXT_EXPORT === '1';
|
||||
// ONE document. The interactive loader reads exactly the file the static
|
||||
// reference is generated from, so dev and the export never disagree — which
|
||||
// means it reads the path through `openapi-doc`, the one module that resolves
|
||||
// it, rather than spelling a filename the lock is free to change.
|
||||
const document = DOCUMENT;
|
||||
|
||||
// Load the document when it is present (fetched by sync-openapi.sh). During
|
||||
// static export the generated API pages contain slugs with dots and deeply
|
||||
// nested paths that break Next.js prerendering / file-copy, so we skip them
|
||||
// entirely in that mode and serve the generated MDX instead.
|
||||
const specFiles = !isExport && fs.existsSync(document) ? [document] : [];
|
||||
|
||||
export const hasSpecs = specFiles.length > 0;
|
||||
|
||||
export const openapi = hasSpecs
|
||||
? createOpenAPI({ input: specFiles, proxyUrl: '/api/proxy' })
|
||||
: (null as any);
|
||||
@@ -1,110 +0,0 @@
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||
import { convertToModelMessages, stepCountIs, streamText, tool, type UIMessage } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { source } from '@/lib/source';
|
||||
import { Document, type DocumentData } from 'flexsearch';
|
||||
import { $routeHandler } from 'fuma-cli/macros/route-handler';
|
||||
import { ChatUIMessage, SearchTool } from '@/components/ai-sdk/search';
|
||||
|
||||
interface CustomDocument extends DocumentData {
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
}
|
||||
const searchServer = createSearchServer();
|
||||
|
||||
async function createSearchServer() {
|
||||
const search = new Document<CustomDocument>({
|
||||
document: {
|
||||
id: 'url',
|
||||
index: ['title', 'description', 'content'],
|
||||
store: true,
|
||||
},
|
||||
});
|
||||
|
||||
const docs = await chunkedAll(
|
||||
source.getPages().map(async (page) => {
|
||||
if (!('getText' in page.data)) return null;
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
url: page.url,
|
||||
content: await page.data.getText('processed'),
|
||||
} as CustomDocument;
|
||||
}),
|
||||
);
|
||||
|
||||
for (const doc of docs) {
|
||||
if (doc) search.add(doc);
|
||||
}
|
||||
|
||||
return search;
|
||||
}
|
||||
|
||||
async function chunkedAll<O>(promises: Promise<O>[]): Promise<O[]> {
|
||||
const SIZE = 50;
|
||||
const out: O[] = [];
|
||||
for (let i = 0; i < promises.length; i += SIZE) {
|
||||
out.push(...(await Promise.all(promises.slice(i, i + SIZE))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey: process.env.OPENROUTER_API_KEY,
|
||||
});
|
||||
|
||||
/** System prompt, you can update it to provide more specific information */
|
||||
const systemPrompt = [
|
||||
'You are an AI assistant for a documentation site.',
|
||||
'Use the `search` tool to retrieve relevant docs context before answering when needed.',
|
||||
'The `search` tool returns raw JSON results from documentation. Use those results to ground your answer and cite sources as markdown links using the document `url` field when available.',
|
||||
'If you cannot find the answer in search results, say you do not know and suggest a better search query.',
|
||||
].join('\n');
|
||||
|
||||
export const handler = $routeHandler(
|
||||
{
|
||||
methods: ['POST'],
|
||||
params: [],
|
||||
},
|
||||
async (req) => {
|
||||
const reqJson = await req.json();
|
||||
|
||||
const result = streamText({
|
||||
model: openrouter.chat(process.env.OPENROUTER_MODEL ?? 'anthropic/claude-3.5-sonnet'),
|
||||
stopWhen: stepCountIs(5),
|
||||
tools: {
|
||||
search: searchTool,
|
||||
},
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...(await convertToModelMessages<ChatUIMessage>(reqJson.messages ?? [], {
|
||||
convertDataPart(part) {
|
||||
if (part.type === 'data-client')
|
||||
return {
|
||||
type: 'text',
|
||||
text: `[Client Context: ${JSON.stringify(part.data)}]`,
|
||||
};
|
||||
},
|
||||
})),
|
||||
],
|
||||
toolChoice: 'auto',
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse();
|
||||
},
|
||||
);
|
||||
|
||||
const searchTool = tool({
|
||||
description: 'Search the docs content and return raw JSON results.',
|
||||
inputSchema: z.object({
|
||||
query: z.string(),
|
||||
limit: z.number().int().min(1).max(100).default(10),
|
||||
}),
|
||||
async execute({ query, limit }) {
|
||||
const search = await searchServer;
|
||||
return await search.searchAsync(query, { limit, merge: true, enrich: true });
|
||||
},
|
||||
}) satisfies SearchTool;
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type LoaderPlugin, loader } from '@hanzo/docs-core/source';
|
||||
import { blog as blogPosts, docs } from 'collections/server';
|
||||
import { createSource } from '@hanzo/docs-mdx/runtime/server';
|
||||
import { lucideIconsPlugin } from '@hanzo/docs-core/source/lucide-icons';
|
||||
import { inlineCode } from '@/lib/inline-code';
|
||||
|
||||
// ONE REFERENCE.
|
||||
@@ -29,10 +28,9 @@ export const source = loader(
|
||||
},
|
||||
{
|
||||
baseUrl: '/docs',
|
||||
plugins: [
|
||||
pageTreeCodeTitles(),
|
||||
lucideIconsPlugin(),
|
||||
],
|
||||
// No icon plugin: the rail draws no icons, and each one it resolved was an
|
||||
// SVG element tree serialised into every page's copy of the tree.
|
||||
plugins: [pageTreeCodeTitles()],
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"react-force-graph-2d": "catalog:",
|
||||
"react-native-web": "catalog:",
|
||||
"rehype-katex": "catalog:",
|
||||
"rehype-raw": "catalog:",
|
||||
"remark": "catalog:",
|
||||
"remark-gfm": "catalog:",
|
||||
"remark-math": "catalog:",
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { compile, writeRegistry } from '@hanzo/docs-cli/build';
|
||||
import { compileOptions, registry } from '@/components/registry/index.js';
|
||||
|
||||
export async function buildRegistry() {
|
||||
const all = await compile({
|
||||
root: registry,
|
||||
...compileOptions,
|
||||
});
|
||||
await writeRegistry(all, {
|
||||
dir: 'public/registry',
|
||||
});
|
||||
}
|
||||
@@ -269,7 +269,6 @@ function render(p: Pricing): string {
|
||||
)}`,
|
||||
'---',
|
||||
'',
|
||||
"import { Sparkle, Boxes, Key, Code } from 'lucide-react'",
|
||||
'',
|
||||
'# Pricing',
|
||||
'',
|
||||
@@ -432,10 +431,10 @@ function render(p: Pricing): string {
|
||||
'## Next',
|
||||
'',
|
||||
'<Cards>',
|
||||
' <Card icon={<Boxes />} title="Models" href="/docs/models" description="The live catalogue — every model id and its current rate." />',
|
||||
' <Card icon={<Sparkle />} title="Enso" href="/docs/models/enso" description="How the router picks a model, and the enso-* tiers." />',
|
||||
' <Card icon={<Key />} title="API keys" href="/docs/api-keys" description="Mint the key these requests are billed against." />',
|
||||
' <Card icon={<Code />} title="Pricing API" href="/docs/openapi/pricing" description="Every pricing endpoint, generated from the spec." />',
|
||||
' <Card icon="Boxes" title="Models" href="/docs/models" description="The live catalogue — every model id and its current rate." />',
|
||||
' <Card icon="Sparkle" title="Enso" href="/docs/models/enso" description="How the router picks a model, and the enso-* tiers." />',
|
||||
' <Card icon="Key" title="API keys" href="/docs/api-keys" description="Mint the key these requests are billed against." />',
|
||||
' <Card icon="Code" title="Pricing API" href="/docs/openapi/pricing" description="Every pricing endpoint, generated from the spec." />',
|
||||
'</Cards>',
|
||||
'',
|
||||
`*Per-token rates and context windows read from \`/v1/models\`. Everything` +
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { buildRegistry } from '@/scripts/build-registry';
|
||||
import { genOpenapiPages } from './gen-openapi-pages';
|
||||
import { genFlowPages } from './gen-flow-pages';
|
||||
import { genMcpPages } from './gen-mcp-pages';
|
||||
@@ -58,7 +57,6 @@ async function main() {
|
||||
// reads /v1/pricing, so it neither needs nor blocks the three above.
|
||||
await genPricingPage();
|
||||
|
||||
await buildRegistry();
|
||||
// Ported project docs are NOT fetched here. They are a committed snapshot
|
||||
// (content/docs/projects/, tracked), refreshed on purpose by
|
||||
// `pnpm --filter docs sync:projects` and read in a diff before it ships. A
|
||||
|
||||
@@ -181,7 +181,7 @@ export const blog = defineCollections({
|
||||
id: 'package-manager',
|
||||
},
|
||||
},
|
||||
remarkPlugins: isLint ? [remarkElementIds] : [remarkSteps, remarkParts],
|
||||
remarkPlugins: isLint ? [remarkElementIds] : [remarkTitle, remarkSteps, remarkParts],
|
||||
})(environment);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { AnalyticsProvider, usePageview } from '@hanzo/event/react';
|
||||
|
||||
/** The client, for a page that sends an event of its own. */
|
||||
export { useAnalytics } from '@hanzo/event/react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** The ONE Hanzo Cloud telemetry front door — POST api.hanzo.ai/v1/event. Cloud
|
||||
* fans the one batched stream out to the web (analytics), product (insights) and
|
||||
@@ -36,9 +40,10 @@ function Pageview() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Telemetry for a Hanzo docs site — mount once in the root layout:
|
||||
* Telemetry for a Hanzo docs site — mount once in the root layout, around the
|
||||
* app when a page sends events of its own (`useAnalytics` from @hanzo/event/react):
|
||||
*
|
||||
* <Analytics product="zen-docs" />
|
||||
* <Analytics product="zen-docs">{app}</Analytics>
|
||||
*
|
||||
* The provider owns the ONE @hanzo/event client: it fires the first pageview,
|
||||
* registers auto error capture (window.onerror + unhandledrejection), and flushes
|
||||
@@ -47,12 +52,13 @@ function Pageview() {
|
||||
* here is docs-specific except the default host and consent gate — `product` is
|
||||
* the only knob, because Cloud derives everything else server-side.
|
||||
*/
|
||||
export function Analytics({ product }: { product: string }) {
|
||||
export function Analytics({ product, children }: { product: string; children?: ReactNode }) {
|
||||
return (
|
||||
<AnalyticsProvider
|
||||
config={{ product, host: HOST, ingestKey: INGEST_KEY, enabled: consented() }}
|
||||
>
|
||||
<Pageview />
|
||||
{children}
|
||||
</AnalyticsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+51
-48
@@ -1464,6 +1464,9 @@ importers:
|
||||
rehype-katex:
|
||||
specifier: 'catalog:'
|
||||
version: 7.0.1
|
||||
rehype-raw:
|
||||
specifier: 'catalog:'
|
||||
version: 7.0.0
|
||||
remark:
|
||||
specifier: 'catalog:'
|
||||
version: 15.0.1([email protected])
|
||||
@@ -21973,7 +21976,7 @@ snapshots:
|
||||
|
||||
'@astrojs/[email protected]':
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
'@types/mdast': 4.0.4
|
||||
js-yaml: 4.1.1
|
||||
picomatch: 4.0.4
|
||||
@@ -31355,7 +31358,7 @@ snapshots:
|
||||
'@shikijs/engine-oniguruma': 1.17.7
|
||||
'@shikijs/types': 1.17.7
|
||||
'@shikijs/vscode-textmate': 9.3.1
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/[email protected]':
|
||||
@@ -31363,7 +31366,7 @@ snapshots:
|
||||
'@shikijs/primitive': 4.1.0
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/[email protected]':
|
||||
@@ -31419,7 +31422,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
'@shikijs/[email protected]':
|
||||
dependencies:
|
||||
@@ -31465,12 +31468,12 @@ snapshots:
|
||||
'@shikijs/[email protected]':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 9.3.1
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
'@shikijs/[email protected]':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
'@shikijs/[email protected]':
|
||||
dependencies:
|
||||
@@ -35436,12 +35439,12 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
hast-util-is-element: 3.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
hast-util-embedded: 3.0.0
|
||||
hast-util-minify-whitespace: 1.0.1
|
||||
hast-util-phrasing: 3.0.1
|
||||
@@ -35451,20 +35454,20 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
hastscript: 9.0.1
|
||||
web-namespaces: 2.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
hast-util-from-dom: 5.0.1
|
||||
hast-util-from-html: 2.0.3
|
||||
unist-util-remove-position: 5.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
devlop: 1.1.0
|
||||
hast-util-from-parse5: 8.0.3
|
||||
parse5: 7.3.0
|
||||
@@ -35473,7 +35476,7 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
'@types/unist': 3.0.3
|
||||
devlop: 1.1.0
|
||||
hastscript: 9.0.1
|
||||
@@ -35484,19 +35487,19 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
hast-util-embedded: 3.0.0
|
||||
hast-util-is-element: 3.0.0
|
||||
hast-util-whitespace: 3.0.0
|
||||
@@ -35504,11 +35507,11 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
hast-util-embedded: 3.0.0
|
||||
hast-util-has-property: 3.0.0
|
||||
hast-util-is-body-ok-link: 3.0.1
|
||||
@@ -35516,9 +35519,9 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
'@types/unist': 3.0.3
|
||||
'@ungap/structured-clone': 1.3.1
|
||||
'@ungap/structured-clone': 1.4.0
|
||||
hast-util-from-parse5: 8.0.3
|
||||
hast-util-to-parse5: 8.0.1
|
||||
html-void-elements: 3.0.0
|
||||
@@ -35532,8 +35535,8 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@ungap/structured-clone': 1.3.1
|
||||
'@types/hast': 3.0.5
|
||||
'@ungap/structured-clone': 1.4.0
|
||||
unist-util-position: 5.0.0
|
||||
|
||||
[email protected]:
|
||||
@@ -35580,7 +35583,7 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
'@types/unist': 3.0.3
|
||||
ccount: 2.0.1
|
||||
comma-separated-tokens: 2.0.3
|
||||
@@ -35634,7 +35637,7 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
comma-separated-tokens: 2.0.3
|
||||
devlop: 1.1.0
|
||||
property-information: 7.1.0
|
||||
@@ -35648,18 +35651,18 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
'@types/unist': 3.0.3
|
||||
hast-util-is-element: 3.0.0
|
||||
unist-util-find-after: 5.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
comma-separated-tokens: 2.0.3
|
||||
hast-util-parse-selector: 4.0.0
|
||||
property-information: 7.1.0
|
||||
@@ -36404,7 +36407,7 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
devlop: 1.1.0
|
||||
highlight.js: 11.11.1
|
||||
|
||||
@@ -36663,7 +36666,7 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
longest-streak: 3.1.0
|
||||
@@ -36675,7 +36678,7 @@ snapshots:
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
longest-streak: 3.1.0
|
||||
@@ -36688,7 +36691,7 @@ snapshots:
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
mdast-util-from-markdown: 2.0.3
|
||||
@@ -36699,7 +36702,7 @@ snapshots:
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
mdast-util-from-markdown: 2.0.3([email protected])
|
||||
@@ -36710,7 +36713,7 @@ snapshots:
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
'@types/mdast': 4.0.4
|
||||
'@types/unist': 3.0.3
|
||||
ccount: 2.0.1
|
||||
@@ -36727,7 +36730,7 @@ snapshots:
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
'@types/mdast': 4.0.4
|
||||
'@types/unist': 3.0.3
|
||||
ccount: 2.0.1
|
||||
@@ -36764,7 +36767,7 @@ snapshots:
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
mdast-util-from-markdown: 2.0.3
|
||||
@@ -36775,7 +36778,7 @@ snapshots:
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
mdast-util-from-markdown: 2.0.3([email protected])
|
||||
@@ -39012,7 +39015,7 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
'@types/prismjs': 1.26.6
|
||||
hastscript: 9.0.1
|
||||
parse-entities: 4.0.2
|
||||
@@ -39072,8 +39075,8 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@ungap/structured-clone': 1.3.1
|
||||
'@types/hast': 3.0.5
|
||||
'@ungap/structured-clone': 1.4.0
|
||||
hast-util-is-element: 3.0.0
|
||||
is-absolute-url: 4.0.1
|
||||
space-separated-tokens: 2.0.2
|
||||
@@ -39081,7 +39084,7 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
hast-util-format: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
@@ -39096,20 +39099,20 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
hast-util-from-html: 2.0.3
|
||||
unified: 11.0.5
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
hast-util-raw: 9.1.0
|
||||
vfile: 6.0.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
hast-util-to-estree: 3.1.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -39117,14 +39120,14 @@ snapshots:
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
hast-util-to-estree: 3.1.3([email protected])
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
hast-util-sanitize: 5.0.2
|
||||
|
||||
[email protected]:
|
||||
@@ -39135,7 +39138,7 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
rehype-parse: 9.0.1
|
||||
rehype-stringify: 10.0.1
|
||||
unified: 11.0.5
|
||||
@@ -39868,7 +39871,7 @@ snapshots:
|
||||
'@shikijs/engine-oniguruma': 1.17.7
|
||||
'@shikijs/types': 1.17.7
|
||||
'@shikijs/vscode-textmate': 9.3.1
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -39879,7 +39882,7 @@ snapshots:
|
||||
'@shikijs/themes': 4.1.0
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user