|
| 1 | +// Dedicated runtime for the /demos/voice cell. |
| 2 | +// |
| 3 | +// Goals |
| 4 | +// ----- |
| 5 | +// 1. Advertise `audioFileTranscriptionEnabled: true` on `/info` so the chat |
| 6 | +// composer renders the mic button. |
| 7 | +// 2. Handle `POST /transcribe` by invoking an OpenAI-backed |
| 8 | +// `TranscriptionServiceOpenAI` (from `@copilotkit/voice`), so recorded |
| 9 | +// audio is transcribed and the transcript auto-sends. |
| 10 | +// 3. Return a deterministic 4xx when `OPENAI_API_KEY` is not configured, |
| 11 | +// instead of an opaque 5xx. The V2 runtime's `handleTranscribe` maps |
| 12 | +// error messages containing "api key" or "unauthorized" to |
| 13 | +// `AUTH_FAILED → HTTP 401`, so throwing with that message funnels the |
| 14 | +// missing-key case into the intended 4xx path. |
| 15 | +// |
| 16 | +// Implementation |
| 17 | +// -------------- |
| 18 | +// Wires the **V2** `CopilotRuntime` directly (from `@copilotkit/runtime/v2`) |
| 19 | +// because the V1 wrapper in `@copilotkit/runtime` drops the |
| 20 | +// `transcriptionService` option on the floor (see the TODO on the V1 |
| 21 | +// constructor). V2 URL-routes on `/info`, `/agent/:id/run`, `/transcribe`, |
| 22 | +// etc., so the route file lives at `[[...slug]]/route.ts` to catch all |
| 23 | +// sub-paths under `/api/copilotkit-voice`. |
| 24 | + |
| 25 | +import type { NextRequest } from "next/server"; |
| 26 | +import { |
| 27 | + CopilotRuntime, |
| 28 | + TranscriptionService, |
| 29 | + createCopilotRuntimeHandler, |
| 30 | +} from "@copilotkit/runtime/v2"; |
| 31 | +import type { TranscribeFileOptions } from "@copilotkit/runtime/v2"; |
| 32 | +import { LangGraphAgent } from "@copilotkit/runtime/langgraph"; |
| 33 | +import { TranscriptionServiceOpenAI } from "@copilotkit/voice"; |
| 34 | +import OpenAI from "openai"; |
| 35 | + |
| 36 | +const LANGGRAPH_URL = |
| 37 | + process.env.AGENT_URL || |
| 38 | + process.env.LANGGRAPH_DEPLOYMENT_URL || |
| 39 | + "http://localhost:8123"; |
| 40 | + |
| 41 | +const voiceDemoAgent = new LangGraphAgent({ |
| 42 | + deploymentUrl: `${LANGGRAPH_URL}/`, |
| 43 | + graphId: "sample_agent", |
| 44 | +}); |
| 45 | + |
| 46 | +/** |
| 47 | + * Transcription service wrapper that reports a clean, typed auth error when |
| 48 | + * OPENAI_API_KEY is not configured. When the key is present we delegate to |
| 49 | + * the real OpenAI-backed service; any upstream Whisper error keeps its |
| 50 | + * natural categorization. |
| 51 | + */ |
| 52 | +class GuardedOpenAITranscriptionService extends TranscriptionService { |
| 53 | + private delegate: TranscriptionServiceOpenAI | null; |
| 54 | + |
| 55 | + constructor() { |
| 56 | + super(); |
| 57 | + const apiKey = process.env.OPENAI_API_KEY; |
| 58 | + this.delegate = apiKey |
| 59 | + ? new TranscriptionServiceOpenAI({ openai: new OpenAI({ apiKey }) }) |
| 60 | + : null; |
| 61 | + } |
| 62 | + |
| 63 | + async transcribeFile(options: TranscribeFileOptions): Promise<string> { |
| 64 | + if (!this.delegate) { |
| 65 | + // "api key" substring → handleTranscribe maps to AUTH_FAILED → 401. |
| 66 | + throw new Error( |
| 67 | + "OPENAI_API_KEY not configured for this deployment (api key missing). " + |
| 68 | + "Set OPENAI_API_KEY to enable voice transcription.", |
| 69 | + ); |
| 70 | + } |
| 71 | + return this.delegate.transcribeFile(options); |
| 72 | + } |
| 73 | +} |
| 74 | + |
| 75 | +// Cache the runtime + handler across invocations so the transcription service |
| 76 | +// is constructed once per Node process instead of per request. The guarded |
| 77 | +// service reads OPENAI_API_KEY lazily in its transcribeFile call path, so |
| 78 | +// deferring construction past module load is not required for cold-start |
| 79 | +// safety under missing-key conditions. |
| 80 | +let cachedHandler: ((req: Request) => Promise<Response>) | null = null; |
| 81 | +function getHandler(): (req: Request) => Promise<Response> { |
| 82 | + if (cachedHandler) return cachedHandler; |
| 83 | + |
| 84 | + const runtime = new CopilotRuntime({ |
| 85 | + // @ts-ignore -- Published CopilotRuntime agents type wraps Record in |
| 86 | + // MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in |
| 87 | + // source, pending release. |
| 88 | + agents: { |
| 89 | + // The page mounts <CopilotKit agent="voice-demo">; resolve that to |
| 90 | + // the neutral sample_agent graph. |
| 91 | + "voice-demo": voiceDemoAgent, |
| 92 | + // useAgent() with no args defaults to "default"; alias so any internal |
| 93 | + // default-agent lookups resolve against the same graph. |
| 94 | + default: voiceDemoAgent, |
| 95 | + }, |
| 96 | + transcriptionService: new GuardedOpenAITranscriptionService(), |
| 97 | + }); |
| 98 | + |
| 99 | + cachedHandler = createCopilotRuntimeHandler({ |
| 100 | + runtime, |
| 101 | + basePath: "/api/copilotkit-voice", |
| 102 | + }); |
| 103 | + return cachedHandler; |
| 104 | +} |
| 105 | + |
| 106 | +// Next.js App Router bindings. This file lives at |
| 107 | +// `src/app/api/copilotkit-voice/[[...slug]]/route.ts` — the catchall slug |
| 108 | +// pattern forwards every sub-path (`/info`, `/agent/:id/run`, |
| 109 | +// `/transcribe`, ...) to the V2 handler so its URL router can dispatch. |
| 110 | +export const POST = (req: NextRequest) => getHandler()(req); |
| 111 | +export const GET = (req: NextRequest) => getHandler()(req); |
| 112 | +export const PUT = (req: NextRequest) => getHandler()(req); |
| 113 | +export const DELETE = (req: NextRequest) => getHandler()(req); |
0 commit comments