diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore new file mode 100644 index 00000000..65822348 --- /dev/null +++ b/apps/docs/.gitignore @@ -0,0 +1,2 @@ +# Local Netlify folder +.netlify diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 0e4f8a5b..c6389d7a 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -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: { diff --git a/apps/docs/package.json b/apps/docs/package.json index 7d2d30bf..7ecb3202 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -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", diff --git a/apps/docs/src/components/VoiceAgentIsland.tsx b/apps/docs/src/components/VoiceAgentIsland.tsx new file mode 100644 index 00000000..150f22bc --- /dev/null +++ b/apps/docs/src/components/VoiceAgentIsland.tsx @@ -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 { + await fetch('/docs/api/session', { method: 'POST' }); +} + +/* ── 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 { + 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 && ( + + )} + + {/* Agent panel */} + + + + + ); +} diff --git a/apps/docs/src/layouts/DocsLayout.astro b/apps/docs/src/layouts/DocsLayout.astro index ef5d4506..85469e23 100644 --- a/apps/docs/src/layouts/DocsLayout.astro +++ b/apps/docs/src/layouts/DocsLayout.astro @@ -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 { @@ -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 = { + 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 = { + guides: '2', + reference: '2', + advanced: '1.5', + examples: '1', + api: '0.3', +}; +const weight = pagefindWeight[topSegment] ?? '1'; --- @@ -155,13 +178,16 @@ const currentPath = Astro.url.pathname; Skip to content - +
+ + + / @@ -183,6 +209,8 @@ const currentPath = Astro.url.pathname;
+ +