Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ jobs:
bun run build
bunx publint

# The docs demos are the styled reference people copy, so they must
# compile against the built package with no app aliases in scope. Runs
# after the build above, which produces the dist this typechecks against.
- name: Demo portability gate
run: bunx tsc -p tsconfig.demos.json

# The chat API route reads OPENAI_API_KEY at request time only; a dummy
# value keeps the production build self-contained.
- name: App build
Expand Down
59 changes: 36 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,49 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# intentface-chat

## Getting Started
Monorepo for **[@intentface/chat](https://www.npmjs.com/package/@intentface/chat)** — headless chat UI
primitives for React — and the documentation site and playground at
[intentface.dev](https://intentface.dev).

First, run the development server:
The package ships behavior, state, and wire formats with no styling of its own: a contenteditable
composer with commands and chips, a thread with scroll auto-follow, message part segmentation, tool-call
timelines. This is the Base UI model applied to chat — the package owns behavior, you own every class.

```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
## Layout

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
| Path | What it is |
| --- | --- |
| `packages/chat` | The published package. Namespace exports per primitive, built per-module by `tsc`. |
| `content/docs` | Documentation pages (MDX, via fumadocs). |
| `components/docs` | Docs-site chrome: previews, code blocks, tables. |
| `components/ai`, `components/ui` | The playground's own styled layer. **App-private** — it uses design tokens, Motion, and local icons, and is not published or supported for copying. |
| `app/(chat)` | The playground chat. |
| `app/docs` | The documentation site. |

You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## Development

This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
```bash
bun install # also generates .source via fumadocs-mdx (postinstall)
bun dev # http://localhost:3000
bun run lint # biome check
bunx tsc --noEmit # typecheck
bun run build # production build
```

## Learn More
The playground chat needs `.env.local` with `OPENAI_API_KEY`. The docs site renders without it.

To learn more about Next.js, take a look at the following resources:
Package tests and build:

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
```bash
cd packages/chat
bun test
bun run build
```

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Contributing

## Deploy on Vercel
Conventions, architecture, and patterns live in [AGENTS.md](./AGENTS.md) — read it before opening a PR.
Changesets gate releases: run `bunx changeset` for any user-facing change to `packages/chat`.

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
## License

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
MIT
2 changes: 1 addition & 1 deletion app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const SYSTEM_PROMPT = `You are the assistant in the Intentface Chat playground
- Use the current date and time context when answering time-sensitive questions.

## Library Questions
When the user asks about @intentface/chat — its primitives (composer, thread, message, chip, steps, reasoning, attachments), installation, theming, state, or how this playground is built:
When the user asks about @intentface/chat — its primitives (composer, thread, message, chip, steps, reasoning, attachments), installation, styling, state, or how this playground is built:
1. Call listDocsPages to see the documentation index
2. Read the relevant pages with readDocsPage before answering
3. For implementation internals ("how does X work under the hood"), read the code with readSourceFile — each docs page's source field names its component under packages/chat/src
Expand Down
10 changes: 6 additions & 4 deletions app/docs-markdown/[...slug]/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { expandDemos } from "@/lib/docs/expand-demos";

// Serves a doc page's raw .mdx as text/plain so "View as Markdown" opens the
// source in-browser (and pastes cleanly into an LLM). The slug maps directly to
// the file under content/docs, mirroring the fumadocs page route.
// Serves a doc page's .mdx as text/plain so "View as Markdown" opens the source
// in-browser (and pastes cleanly into an LLM). The slug maps directly to the
// file under content/docs, mirroring the fumadocs page route. Demos are inlined
// as code blocks so a reader that can't run the page still gets the source.
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = await params;

Expand All @@ -15,7 +17,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu
const filePath = path.join(process.cwd(), "content", "docs", `${slug.join("/")}.mdx`);

try {
const source = await readFile(filePath, "utf8");
const source = await expandDemos(await readFile(filePath, "utf8"));
return new Response(source, {
headers: { "content-type": "text/plain; charset=utf-8" },
});
Expand Down
75 changes: 75 additions & 0 deletions app/llms.txt/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { ReactNode } from "react";
import { getPage, source } from "@/lib/docs/source";

const SITE = "https://intentface.dev";

const SUMMARY =
"Headless chat UI primitives for React — the behavior, state, and wire formats for building AI chat interfaces, with no styling of their own. Each documentation page below is served as plain markdown with every demo's source inlined.";

type TreeNode = {
type: string;
name?: ReactNode;
url?: string;
children?: TreeNode[];
};

// Page tree names are ReactNode; ours come from frontmatter, so they're strings.
const asText = (value: ReactNode): string => (typeof value === "string" ? value : "");

// "/docs/primitives/composer" -> "primitives/composer"; "/docs" -> "index"
const slugOf = (url: string) => url.replace(/^\/docs\/?/, "") || "index";

const lineFor = (url: string) => {
const slug = slugOf(url);
const page = getPage(slug === "index" ? [] : slug.split("/"));
if (!page) return null;
const description = page.data.description ? `: ${page.data.description}` : "";
return `- [${page.data.title}](${SITE}/docs-markdown/${slug})${description}`;
};

// Groups follow the page tree, so the order matches the sidebar.
const sectionsOf = (nodes: TreeNode[]) => {
const loose: string[] = [];
const groups: { title: string; lines: string[] }[] = [];

for (const node of nodes) {
if (node.type === "page" && node.url) {
const line = lineFor(node.url);
if (line) loose.push(line);
}
if (node.type === "folder") {
const lines = (node.children ?? [])
.filter((child) => child.type === "page" && child.url)
.map((child) => lineFor(child.url as string))
.filter((line): line is string => line !== null);
if (lines.length) groups.push({ title: asText(node.name) || "Pages", lines });
}
}

return { loose, groups };
};

// llms.txt — a flat, ordered index so an agent can find the docs without
// crawling rendered HTML. Pairs with /docs-markdown/<slug>, which serves each
// page as markdown with demo source expanded inline.
export const dynamic = "force-static";

export const GET = () => {
const { loose, groups } = sectionsOf(source.pageTree.children as TreeNode[]);

const body = [
"# @intentface/chat",
"",
`> ${SUMMARY}`,
"",
"## Documentation",
"",
...loose,
...groups.flatMap(({ title, lines }) => ["", `## ${title}`, "", ...lines]),
"",
].join("\n");

return new Response(body, {
headers: { "content-type": "text/plain; charset=utf-8" },
});
};
33 changes: 0 additions & 33 deletions components/docs/component-preview.tsx

This file was deleted.

24 changes: 24 additions & 0 deletions components/docs/demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { ReactNode } from "react";
import { CodeBlock } from "./code-block";
import { ComponentPreviewFrame } from "./component-preview-frame";

type DemoProps = {
component: ReactNode;
/** Path under content/docs, e.g. "primitives/composer/demos/basic.tsx". */
file: string;
};

// Server component: renders a colocated demo and shows that same file's source,
// read from disk at build time — the file that runs is the file displayed.
export const Demo = async ({ component, file }: DemoProps) => {
const source = await readFile(path.join(process.cwd(), "content", "docs", file), "utf8");

return (
<ComponentPreviewFrame
preview={component}
code={<CodeBlock code={source.trimEnd()} lang="tsx" />}
/>
);
};
58 changes: 58 additions & 0 deletions components/docs/installation-block-tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"use client";

import type { ReactNode } from "react";
import { useState } from "react";
import { CheckMarkMediumIcon } from "@/components/icons/check-mark-medium";
import { CopyIcon } from "@/components/icons/copy";
import { useCopy } from "@/hooks/use-copy";
import { cn } from "@/lib/utils";

export type InstallationEntry = {
manager: string;
command: string;
/** Pre-highlighted command, rendered on the server. */
code: ReactNode;
};

type InstallationBlockTabsProps = {
entries: InstallationEntry[];
};

// Client shell for the package-manager tabs. The server highlights every
// command up front; this only picks which one shows and owns the copy button.
export const InstallationBlockTabs = ({ entries }: InstallationBlockTabsProps) => {
const [manager, setManager] = useState(entries[0].manager);
const { copy, copied } = useCopy();
const current = entries.find((entry) => entry.manager === manager) ?? entries[0];

return (
<div className="not-prose my-6 overflow-hidden rounded-lg border border-primary-border">
<div className="flex items-center gap-1 border-primary-border border-b bg-primary-bg p-1.5">
{entries.map((entry) => (
<button
key={entry.manager}
type="button"
onClick={() => setManager(entry.manager)}
className={cn(
"cursor-pointer rounded-full border px-3 py-1 font-mono text-sm transition-colors",
entry.manager === manager
? "border-secondary-border bg-secondary-bg text-ink-primary shadow-xs"
: "border-transparent text-ink-tertiary hover:bg-primary-bg-hover hover:text-ink-secondary",
)}
>
{entry.manager}
</button>
))}
<button
type="button"
onClick={() => copy(current.command)}
aria-label={`Copy: ${current.command}`}
className="ml-auto cursor-pointer rounded-md p-1.5 text-ink-tertiary transition-colors hover:bg-primary-bg-hover hover:text-ink-secondary"
>
{copied === current.command ? <CheckMarkMediumIcon /> : <CopyIcon />}
</button>
</div>
<div className="[&_pre]:rounded-none [&_pre]:border-0">{current.code}</div>
</div>
);
};
18 changes: 18 additions & 0 deletions components/docs/installation-block.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { PACKAGE_MANAGERS } from "@/lib/docs/package-managers";
import { CodeBlock } from "./code-block";
import { InstallationBlockTabs } from "./installation-block-tabs";

type InstallationBlockProps = {
packageName: string;
};

// Server component: highlights one install command per package manager and
// hands them all to the client shell, which shows the selected one.
export const InstallationBlock = ({ packageName }: InstallationBlockProps) => (
<InstallationBlockTabs
entries={PACKAGE_MANAGERS.map(({ manager, install }) => {
const command = `${install} ${packageName}`;
return { manager, command, code: <CodeBlock code={command} lang="bash" /> };
})}
/>
);
Loading
Loading