Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
2d496e3
feat(docs): add Netlify adapter with static output and SSR opt-in
lukeocodes Mar 22, 2026
647cb57
feat(ui): add voice agent panel components and useVoiceAgent hook
lukeocodes Mar 22, 2026
19c117f
feat(docs): add serverless API routes for voice agent
lukeocodes Mar 22, 2026
6964fa0
feat(docs): integrate voice agent island with Pagefind search tool
lukeocodes Mar 22, 2026
2baa744
feat(ui): add Pagefind search to docs navbar with cmd+K shortcut
lukeocodes Mar 22, 2026
82989aa
chore: trigger deploy preview retry
lukeocodes Mar 22, 2026
9261975
fix(docs): use template literal for pagefind import to avoid Vite res…
lukeocodes Mar 22, 2026
d1106e4
fix(ui): remove agent re-export from main barrel to fix cross-site bu…
lukeocodes Mar 22, 2026
101b39d
fix(docs): mark SDK as external so docs build without SDK dist
lukeocodes Mar 22, 2026
feee3eb
fix(ui): load pagefind-ui.js via script tag instead of dynamic import
lukeocodes Mar 23, 2026
dd71c01
fix(ui): check window.PagefindUI first before script tag fallback
lukeocodes Mar 23, 2026
7d60576
style(docs): theme pagefind search results to match design system
lukeocodes Mar 23, 2026
76b5686
style(docs): force mark highlight and link color overrides for pagefind
lukeocodes Mar 23, 2026
8f22d4d
style(docs): refine pagefind search spacing, padding, and edges
lukeocodes Mar 23, 2026
8767b60
fix(docs): restore pagefind search icon and clear button positioning
lukeocodes Mar 23, 2026
2498ef7
feat(docs): add pagefind section filters for search results
lukeocodes Mar 23, 2026
f39a474
style(docs): remove arrow from pagefind sub-result links
lukeocodes Mar 23, 2026
df8de16
style(docs): widen search modal and deprioritize API reference results
lukeocodes Mar 23, 2026
e9dd222
fix(docs): resolve voice agent SDK import failure
lukeocodes Mar 23, 2026
6449f16
chore: retrigger deploy preview with API keys
lukeocodes Mar 23, 2026
d4fef89
fix(ui): add audio encoding params to DeepgramFlux STT config
lukeocodes Mar 23, 2026
a222bfe
fix(ui): use 'token' auth type for Deepgram WebSocket providers
lukeocodes Mar 23, 2026
f828bcd
fix(ui): fetch fresh Deepgram JWT before each WebSocket connection
lukeocodes Mar 23, 2026
20fb9a2
feat: support async apiKey factory for short-lived tokens
lukeocodes Mar 23, 2026
84777ef
fix(ui): use bearer auth type for Deepgram JWT tokens
lukeocodes Mar 23, 2026
7158cf6
fix: move await out of Promise executor in DeepgramAgent
lukeocodes Mar 23, 2026
296c05c
fix: prevent duplicate WebSocket connections in Deepgram providers
lukeocodes Mar 23, 2026
f64be03
fix(ui): disable capture pause for full-duplex barge-in support
lukeocodes Mar 23, 2026
f648f4c
feat(ui): switch voice agent LLM to claude-haiku-4-5
lukeocodes Mar 23, 2026
a9b0912
feat: auto keep-alive for Deepgram WebSockets and inactivity teardown
lukeocodes Mar 23, 2026
380a952
fix: remove keep-alive timer from DeepgramFlux (V2 uses native ping/p…
lukeocodes Mar 23, 2026
8658b17
fix(docs): always initialize voice agent on panel open, even with his…
lukeocodes Mar 23, 2026
f9d0e73
fix(ui): reconnect STT pipeline when unmuting microphone
lukeocodes Mar 23, 2026
97de487
fix(docs): use import.meta.env fallback for API keys in dev
lukeocodes Mar 23, 2026
1c1bfa5
feat: include provider class name in error messages
lukeocodes Mar 23, 2026
62070d4
fix: prevent overlapping audio playback on rapid barge-in
lukeocodes Mar 24, 2026
0cc8c96
fix(ui): reset all UI state before dispose on inactivity teardown
lukeocodes Mar 24, 2026
4a6adf0
fix(ui): defer localStorage read to useEffect to prevent hydration mi…
lukeocodes Mar 24, 2026
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
2 changes: 2 additions & 0 deletions apps/docs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Local Netlify folder
.netlify
3 changes: 3 additions & 0 deletions apps/docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import pagefind from 'astro-pagefind';
import llmsTxt from '@4hse/astro-llms-txt';
import playformInline from '@playform/inline';
import compress from '@playform/compress';
import netlify from '@astrojs/netlify';
import { remarkBaseUrl } from './src/lib/remark-base-url.mjs';
import { remarkBrandName } from './src/lib/remark-brand-name.mjs';

// https://astro.build/config
export default defineConfig({
output: 'static',
adapter: netlify(),
site: process.env.CV_WEB_URL || 'http://localhost:4321',
base: '/docs',
markdown: {
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
},
"dependencies": {
"@4hse/astro-llms-txt": "^1.0.4",
"@astrojs/netlify": "^6.6.5",
"@astrojs/react": "^4.2.1",
"@astrojs/sitemap": "^3.7.0",
"@lukeocodes/composite-voice": "workspace:*",
"@lukeocodes/composite-voice-ui": "workspace:*",
"@playform/compress": "^0.2.1",
"@playform/inline": "^0.1.2",
Expand Down
200 changes: 200 additions & 0 deletions apps/docs/src/components/VoiceAgentIsland.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* React island that mounts the voice agent panel in the Astro docs site.
*
* Handles session creation, token fetching, and bridges the UI components
* with the CompositeVoice pipeline. Provides a `search_docs` tool so the
* agent can query the Pagefind index and give grounded answers.
*/

import { useState, useCallback } from 'react';
import {
AgentPanel,
ChatPanel,
useVoiceAgent,
} from '@lukeocodes/composite-voice-ui/agent';
import type {
AgentToolDefinition,
AgentToolCall,
AgentToolResult,
} from '@lukeocodes/composite-voice-ui/agent';

/* ── Credentials ─────────────────────────────── */

/** Fetch a Deepgram JWT from the serverless endpoint. */
async function getToken(): Promise<{ token: string; expiresIn: number }> {
const res = await fetch('/docs/api/deepgram-token');
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Token request failed: ${res.status}`);
}
const data = await res.json();
return { token: data.token, expiresIn: data.expiresIn };
}

/** Create a session cookie (required before token requests). */
async function ensureSession(): Promise<void> {
await fetch('/docs/api/session', { method: 'POST' });
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensureSession() ignores the response status. Because fetch doesn’t reject on non-2xx, this can set sessionReady even if the session endpoint failed and no cookie was set, leading to confusing downstream 401s. Check res.ok and surface an error when session creation fails.

Suggested change
await fetch('/docs/api/session', { method: 'POST' });
const res = await fetch('/docs/api/session', { method: 'POST' });
if (!res.ok) {
let message = `Session request failed: ${res.status}`;
try {
const body = await res.json();
if (body && typeof body.error === 'string') {
message = body.error;
}
} catch {
// Ignore JSON parsing errors and fall back to default message
}
throw new Error(message);
}

Copilot uses AI. Check for mistakes.
}

/* ── Pagefind search tool ────────────────────── */

const SEARCH_TOOL: AgentToolDefinition = {
name: 'search_docs',
description:
'Search the CompositeVoice SDK documentation. Returns relevant page titles, URLs, and excerpts. Use this when the user asks about SDK features, configuration, providers, events, examples, or API reference.',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query to find relevant documentation pages',
},
},
required: ['query'],
},
};

/** Cached Pagefind instance. */
let pagefindInstance: {
search: (query: string) => Promise<{
results: Array<{
data: () => Promise<{
url: string;
excerpt: string;
meta: { title?: string };
content: string;
}>;
}>;
}>;
} | null = null;

const PAGEFIND_BASE = '/docs';

async function getPagefind() {
if (pagefindInstance) return pagefindInstance;
const mod = await import(/* @vite-ignore */ `${PAGEFIND_BASE}/pagefind/pagefind.js`);
if (mod.init) await mod.init();
pagefindInstance = mod;
return mod;
}

async function handleToolCall(toolCall: AgentToolCall): Promise<AgentToolResult> {
if (toolCall.name === 'search_docs') {
try {
const pagefind = await getPagefind();
const query = String(toolCall.arguments.query ?? '');
const { results } = await pagefind.search(query);

// Load data for top 5 results
const entries = await Promise.all(
results.slice(0, 5).map(async (r) => {
const data = await r.data();
return {
title: data.meta?.title ?? 'Untitled',
url: data.url,
excerpt: data.excerpt.replace(/<\/?mark>/g, ''),
content: data.content.slice(0, 300),
};
}),
);

return {
toolCallId: toolCall.id,
content: JSON.stringify(entries.length > 0 ? entries : { message: 'No results found' }),
};
} catch {
return {
toolCallId: toolCall.id,
content: JSON.stringify({ error: 'Search unavailable — index not built yet' }),
isError: true,
};
}
}

return {
toolCallId: toolCall.id,
content: JSON.stringify({ error: `Unknown tool: ${toolCall.name}` }),
isError: true,
};
}

/* ── Component ───────────────────────────────── */

export default function VoiceAgentIsland() {
const [isOpen, setIsOpen] = useState(false);
const [sessionReady, setSessionReady] = useState(false);

const [state, actions] = useVoiceAgent({
getToken,
anthropicProxyUrl: '/docs/api/proxy/anthropic',
model: 'claude-haiku-4-5',
maxTokens: 1024,
voice: 'aura-2-thalia-en',
systemPrompt: `You are a helpful voice assistant for the CompositeVoice SDK documentation site.
Answer questions about the SDK concisely and conversationally.
When discussing code, keep examples short and focused.
You can help with: provider setup, configuration, pipeline architecture,
proxy setup, event handling, conversation history, tool use, and custom providers.
You have a search_docs tool — use it to find relevant documentation before answering technical questions.
When you use search results, naturally weave the information into your response.
Respond in plain text for voice — no markdown formatting, no bullet points, no code blocks unless specifically asked for code.`,
tools: {
definitions: [SEARCH_TOOL],
onToolCall: handleToolCall,
},
});

const handleOpen = useCallback(async () => {
setIsOpen(true);

if (!sessionReady) {
await ensureSession();
setSessionReady(true);
}

if (state.status === 'idle') {
await actions.initialize();
await actions.startListening();
}
}, [sessionReady, state.status, state.messages.length, actions]);

const handleClose = useCallback(() => {
setIsOpen(false);
actions.stopListening();
}, [actions]);

return (
<>
{/* FAB trigger button */}
{!isOpen && (
<button
onClick={handleOpen}
className="fixed bottom-6 right-6 z-[9998] flex items-center gap-2 rounded-full bg-primary-600 px-4 py-3 text-sm font-medium text-on-filled shadow-lg transition-all hover:bg-primary-500 hover:shadow-xl active:scale-95"
aria-label="Open voice assistant"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
>
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
Ask AI
</button>
)}

{/* Agent panel */}
<AgentPanel isOpen={isOpen} onClose={handleClose}>
<ChatPanel state={state} actions={actions} onClose={handleClose} />
</AgentPanel>
</>
);
}
30 changes: 29 additions & 1 deletion apps/docs/src/layouts/DocsLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Breadcrumbs } from "astro-breadcrumbs";
import { Navbar, Sidebar, Footer } from "@lukeocodes/composite-voice-ui";
import type { SidebarItem } from "@lukeocodes/composite-voice-ui";
import { nav, isNavFolder, isNavSection } from "../lib/nav";
import VoiceAgentIsland from "../components/VoiceAgentIsland";
import pkg from "../../../../package.json";

interface Props {
Expand Down Expand Up @@ -69,6 +70,28 @@ const sections: SidebarItem[] = nav.map((item) => {
});

const currentPath = Astro.url.pathname;

/* Derive pagefind section filter from the URL path */
const pathWithoutBase = currentPath.replace(/^\/docs\/?/, '');
const topSegment = pathWithoutBase.split('/')[0];
const sectionMap: Record<string, string> = {
guides: 'Guides',
reference: 'Reference',
advanced: 'Advanced',
api: 'API Reference',
examples: 'Examples',
};
const pagefindSection = sectionMap[topSegment] ?? 'Overview';

/* Rank guides/reference/advanced higher than API reference in search */
const pagefindWeight: Record<string, string> = {
guides: '2',
reference: '2',
advanced: '1.5',
examples: '1',
api: '0.3',
};
const weight = pagefindWeight[topSegment] ?? '1';
---

<html lang="en">
Expand Down Expand Up @@ -155,13 +178,16 @@ const currentPath = Astro.url.pathname;
Skip to content
</a>

<Navbar client:load sites={sites} version={pkg.version} hasSidebar />
<Navbar client:load sites={sites} version={pkg.version} hasSidebar showSearch searchBasePath="/docs" />

<div class="flex min-h-screen pt-14">
<Sidebar client:load sections={sections} currentPath={currentPath} ariaLabel="Documentation sections" />

<main id="main-content" class="flex-1 min-w-0 md:ml-64">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
<meta data-pagefind-filter="section[content]" content={pagefindSection} />
<meta data-pagefind-weight={weight} />

<Breadcrumbs indexText="Docs" linkTextFormat="capitalized">
<span slot="separator">/</span>
</Breadcrumbs>
Expand All @@ -183,6 +209,8 @@ const currentPath = Astro.url.pathname;
<Footer client:load sites={sites} className="mt-auto" />
</main>
</div>
<VoiceAgentIsland client:idle />

<script is:inline>
// Inject copy-to-clipboard buttons on Prism code blocks
document.querySelectorAll('pre[class*="language-"]').forEach(function(pre) {
Expand Down
78 changes: 78 additions & 0 deletions apps/docs/src/pages/api/_session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Session management for API routes.
*
* Uses an HMAC-signed session cookie. The /api/session endpoint creates the
* cookie; other API routes validate it via validateSession().
*
* The session ID is a random value signed with a server-side secret. This
* prevents external scripts from calling the token endpoint directly — they
* would need to first load a page that creates the session.
*/

export const SESSION_COOKIE = 'cv_agent_session';
const SESSION_MAX_AGE = 3600; // 1 hour

/** Server secret for HMAC signing. Falls back to a build-time random value. */
const SECRET = process.env.SESSION_SECRET || crypto.randomUUID();
Comment on lines +15 to +16
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SECRET falls back to crypto.randomUUID(). In a serverless environment this will differ between cold starts/instances, so a session cookie created by /api/session will intermittently fail validation in /api/deepgram-token and /api/proxy/*. Require process.env.SESSION_SECRET (fail fast if missing) so all functions share the same signing key.

Suggested change
/** Server secret for HMAC signing. Falls back to a build-time random value. */
const SECRET = process.env.SESSION_SECRET || crypto.randomUUID();
/** Server secret for HMAC signing. Must be provided via SESSION_SECRET. */
const SECRET = (() => {
const secret = process.env.SESSION_SECRET;
if (!secret) {
throw new Error('SESSION_SECRET environment variable is required for session signing');
}
return secret;
})();

Copilot uses AI. Check for mistakes.

/**
* Create a signed session value: `id.signature`
*/
export function createSignedSession(): { value: string; id: string } {
const id = crypto.randomUUID();
const signature = signValue(id);
return { value: `${id}.${signature}`, id };
}

/**
* Validate a session cookie from a request. Returns the session ID if valid.
*/
export function validateSession(request: Request): string | null {
const cookieHeader = request.headers.get('cookie');
if (!cookieHeader) return null;

const cookies = parseCookies(cookieHeader);
const sessionValue = cookies[SESSION_COOKIE];
if (!sessionValue) return null;

const dotIndex = sessionValue.lastIndexOf('.');
if (dotIndex === -1) return null;

const id = sessionValue.slice(0, dotIndex);
const signature = sessionValue.slice(dotIndex + 1);

if (signValue(id) !== signature) return null;

return id;
}

/**
* Build a Set-Cookie header value for the session.
*/
export function buildSessionCookie(signedValue: string): string {
return `${SESSION_COOKIE}=${signedValue}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_MAX_AGE}; Secure`;
}
Comment on lines +52 to +54
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildSessionCookie always appends Secure. On http://localhost dev this prevents the browser from storing/sending the cookie, so /api/deepgram-token and /api/proxy/* will 401 even after calling /api/session. Consider conditionally adding Secure only when running over HTTPS (or based on NODE_ENV / request URL). Also consider scoping Path to /docs to reduce cookie exposure.

Copilot uses AI. Check for mistakes.

// ─── Helpers ──────────────────────────────────────────────────────────

function signValue(value: string): string {
// Simple HMAC-like signature using Web Crypto SubtleCrypto is async,
// so we use a sync hash approach with a keyed prefix instead.
// This is sufficient for session validation (not cryptographic security).
let hash = 0;
Comment on lines +58 to +62
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

signValue uses a simple non-cryptographic hash, but the session cookie is acting as an auth gate for token minting / Anthropic proxy access. This signature is forgeable and enables abuse of the protected endpoints. Use a real HMAC (e.g., HMAC-SHA256 via crypto.subtle or Node createHmac) and compare signatures in constant time.

Copilot uses AI. Check for mistakes.
const input = `${SECRET}:${value}`;
for (let i = 0; i < input.length; i++) {
const char = input.charCodeAt(i);
hash = ((hash << 5) - hash + char) | 0;
}
return Math.abs(hash).toString(36);
}

function parseCookies(header: string): Record<string, string> {
const cookies: Record<string, string> = {};
for (const pair of header.split(';')) {
const [key, ...rest] = pair.trim().split('=');
if (key) cookies[key.trim()] = rest.join('=').trim();
}
return cookies;
}
Loading
Loading