From 9943bcc3e6da033af600acb22657a6866036b6d9 Mon Sep 17 00:00:00 2001 From: Jack Bridger Date: Mon, 13 Oct 2025 12:26:42 +0100 Subject: [PATCH 1/5] WIP --- app/api/agent/route.ts | 61 ++++++++++++++++++++++++++++++++---------- cloudflare-env.d.ts | 1 + wrangler.jsonc | 9 ++++++- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/app/api/agent/route.ts b/app/api/agent/route.ts index e04960c..e4c729e 100644 --- a/app/api/agent/route.ts +++ b/app/api/agent/route.ts @@ -6,6 +6,7 @@ import z from 'zod'; import { streamResponse, verifySignature } from '@layercode/node-server-sdk'; import { prettyPrintMsgs } from '@/app/utils/msgs'; import config from '@/layercode.config.json'; +import { getCloudflareContext } from '@opennextjs/cloudflare'; export type MessageWithTurnId = ModelMessage & { turn_id: string }; type WebhookRequest = { @@ -25,10 +26,40 @@ const SYSTEM_PROMPT = config.prompt; const WELCOME_MESSAGE = config.welcome_message; const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY! }); +const inMemoryConversations = {} as Record; -// In production we recommend fast datastore like Redis or Cloudflare D1 for storing conversation history -// Here we use a simple in-memory object for demo purposes -const conversations = {} as Record; +const conversationKey = (conversationId: string) => `conversation:${conversationId}`; + +const loadConversation = async (kv: KVNamespace, conversationId: string) => { + const existing = await kv.get(conversationKey(conversationId), { type: 'json' }); + console.log('existing....'); + console.log(existing); + + return existing ?? []; +}; + +const persistConversation = (kv: KVNamespace, conversationId: string, messages: MessageWithTurnId[]) => kv.put(conversationKey(conversationId), JSON.stringify(messages)); + +const getConversationStore = () => { + try { + const { env } = getCloudflareContext(); + const kv = env.MESSAGES_KV; + if (!kv) throw new Error('MESSAGES_KV binding is not configured.'); + return { + load: (conversationId: string) => loadConversation(kv, conversationId), + persist: (conversationId: string, messages: MessageWithTurnId[]) => persistConversation(kv, conversationId, messages) + }; + } catch (error) { + if (process.env.NODE_ENV === 'production') throw error; + console.warn('MESSAGES_KV binding unavailable in this environment – falling back to in-memory storage. Data will reset on restart.', error); + return { + load: async (conversationId: string) => inMemoryConversations[conversationId] ?? [], + persist: async (conversationId: string, messages: MessageWithTurnId[]) => { + inMemoryConversations[conversationId] = messages; + } + }; + } +}; export const POST = async (request: Request) => { const requestBody = (await request.json()) as WebhookRequest; @@ -46,20 +77,20 @@ export const POST = async (request: Request) => { const { conversation_id, text: userText, turn_id, type, interruption_context } = requestBody; - // If this is a new conversation, create a new array to hold messages - if (!conversations[conversation_id]) { - conversations[conversation_id] = []; - } + const store = getConversationStore(); + const conversation = await store.load(conversation_id); // Immediately store the user message received - conversations[conversation_id].push({ role: 'user', turn_id, content: userText }); + conversation.push({ role: 'user', turn_id, content: userText }); + await store.persist(conversation_id, conversation); switch (type) { case 'session.start': // A new session/call has started. If you want to send a welcome message (have the agent speak first), return that here. return streamResponse(requestBody, async ({ stream }) => { // Save the welcome message to the conversation history - conversations[conversation_id].push({ role: 'assistant', turn_id, content: WELCOME_MESSAGE }); + conversation.push({ role: 'assistant', turn_id, content: WELCOME_MESSAGE }); + await store.persist(conversation_id, conversation); // Send the welcome message to be spoken stream.tts(WELCOME_MESSAGE); stream.end(); @@ -68,7 +99,8 @@ export const POST = async (request: Request) => { // The user has spoken and the transcript has been received. Call our LLM and genereate a response. // Before generating a response, we store a placeholder assistant msg in the history. This is so that if the agent response is interrupted (which is common for voice agents), before we have the chance to save our agent's response, our conversation history will still follow the correct user-assistant turn order. - const assistantResposneIdx = conversations[conversation_id].push({ role: 'assistant', turn_id, content: '' }); + const assistantResposneIdx = conversation.push({ role: 'assistant', turn_id, content: '' }); + await store.persist(conversation_id, conversation); return streamResponse(requestBody, async ({ stream }) => { const weather = tool({ description: 'Get the weather in a location', @@ -89,7 +121,7 @@ export const POST = async (request: Request) => { const { textStream } = streamText({ model: openai('gpt-4o-mini'), system: SYSTEM_PROMPT, - messages: conversations[conversation_id], // The user message has already been added to the conversation array earlier, so the LLM will be responding to that. + messages: conversation, // The user message has already been added to the conversation array earlier, so the LLM will be responding to that. tools: { weather }, toolChoice: 'auto', stopWhen: stepCountIs(10), @@ -97,13 +129,14 @@ export const POST = async (request: Request) => { // The assistant has finished generating the full response text. Now we update our conversation history with the additional messages generated. For a simple LLM generated single agent response, there will be one additional message. If you add some tools, and allow multi-step agent mode, there could be multiple additional messages which all need to be added to the conversation history. // First, we remove the placeholder assistant message we added earlier, as we will be replacing it with the actual generated messages. - conversations[conversation_id].splice(assistantResposneIdx - 1, 1); + conversation.splice(assistantResposneIdx - 1, 1); // Push the new messages returned from the LLM into the conversation history, adding the Layercode turn_id to each message. - conversations[conversation_id].push(...response.messages.map((m) => ({ ...m, turn_id }))); + conversation.push(...response.messages.map((m) => ({ ...m, turn_id }))); + await store.persist(conversation_id, conversation); console.log('--- final message history ---'); - prettyPrintMsgs(conversations[conversation_id]); + prettyPrintMsgs(conversation); stream.end(); // Tell Layercode we are done responding } diff --git a/cloudflare-env.d.ts b/cloudflare-env.d.ts index f5b5248..28622fb 100644 --- a/cloudflare-env.d.ts +++ b/cloudflare-env.d.ts @@ -5,6 +5,7 @@ declare namespace Cloudflare { interface Env { NEXTJS_ENV: string; ASSETS: Fetcher; + MESSAGES_KV: KVNamespace; } } interface CloudflareEnv extends Cloudflare.Env {} diff --git a/wrangler.jsonc b/wrangler.jsonc index 9ba23df..f79fa44 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -14,7 +14,14 @@ }, "observability": { "enabled": true - } + }, + "kv_namespaces": [ + { + "binding": "MESSAGES_KV", + "id": "38fc52f237e84999af586dfce7ec7514", + "preview_id": "5e9a64b4d598473fab5b5b4e62e64769" + } + ] /** * Smart Placement * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement From 201b78927fb240857ac546b3c8cd36112fb729a9 Mon Sep 17 00:00:00 2001 From: Damien Tanner Date: Mon, 13 Oct 2025 13:20:58 +0100 Subject: [PATCH 2/5] Converastion storage --- README.md | 21 +++++------------ app/api/agent/route.ts | 36 +--------------------------- app/api/authorize/route.ts | 4 +++- app/utils/conversationStorage.ts | 40 ++++++++++++++++++++++++++++++++ wrangler.jsonc | 9 +------ 5 files changed, 51 insertions(+), 59 deletions(-) create mode 100644 app/utils/conversationStorage.ts diff --git a/README.md b/README.md index e215bc4..26218ac 100644 --- a/README.md +++ b/README.md @@ -16,21 +16,12 @@ bun dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## Deploy on Cloudflare -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. +When you're ready to deploy to production, you can follow these steps: https://docs.layercode.com/how-tos/deploy-nextjs-to-cloudflare -## Learn More +So that user conversation history is correctly stored, you must configure Cloudflare KV store. Run the following, and ensure your wrangler.jsonc is updated with the correct KV binding info. -To learn more about Next.js, take a look at the following resources: - -- [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. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -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. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +```bash +npx wrangler kv namespace create MESSAGES_KV +``` diff --git a/app/api/agent/route.ts b/app/api/agent/route.ts index e4c729e..4c3ebed 100644 --- a/app/api/agent/route.ts +++ b/app/api/agent/route.ts @@ -5,8 +5,8 @@ import { streamText, ModelMessage, tool, stepCountIs } from 'ai'; import z from 'zod'; import { streamResponse, verifySignature } from '@layercode/node-server-sdk'; import { prettyPrintMsgs } from '@/app/utils/msgs'; +import { getConversationStore } from '@/app/utils/conversationStorage'; import config from '@/layercode.config.json'; -import { getCloudflareContext } from '@opennextjs/cloudflare'; export type MessageWithTurnId = ModelMessage & { turn_id: string }; type WebhookRequest = { @@ -26,40 +26,6 @@ const SYSTEM_PROMPT = config.prompt; const WELCOME_MESSAGE = config.welcome_message; const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY! }); -const inMemoryConversations = {} as Record; - -const conversationKey = (conversationId: string) => `conversation:${conversationId}`; - -const loadConversation = async (kv: KVNamespace, conversationId: string) => { - const existing = await kv.get(conversationKey(conversationId), { type: 'json' }); - console.log('existing....'); - console.log(existing); - - return existing ?? []; -}; - -const persistConversation = (kv: KVNamespace, conversationId: string, messages: MessageWithTurnId[]) => kv.put(conversationKey(conversationId), JSON.stringify(messages)); - -const getConversationStore = () => { - try { - const { env } = getCloudflareContext(); - const kv = env.MESSAGES_KV; - if (!kv) throw new Error('MESSAGES_KV binding is not configured.'); - return { - load: (conversationId: string) => loadConversation(kv, conversationId), - persist: (conversationId: string, messages: MessageWithTurnId[]) => persistConversation(kv, conversationId, messages) - }; - } catch (error) { - if (process.env.NODE_ENV === 'production') throw error; - console.warn('MESSAGES_KV binding unavailable in this environment – falling back to in-memory storage. Data will reset on restart.', error); - return { - load: async (conversationId: string) => inMemoryConversations[conversationId] ?? [], - persist: async (conversationId: string, messages: MessageWithTurnId[]) => { - inMemoryConversations[conversationId] = messages; - } - }; - } -}; export const POST = async (request: Request) => { const requestBody = (await request.json()) as WebhookRequest; diff --git a/app/api/authorize/route.ts b/app/api/authorize/route.ts index c288214..0d821af 100644 --- a/app/api/authorize/route.ts +++ b/app/api/authorize/route.ts @@ -22,5 +22,7 @@ export const POST = async (request: Request) => { const text = await response.text(); return NextResponse.json({ error: text || response.statusText }, { status: response.status }); } - return NextResponse.json(await response.json()); + const responseData = await response.json(); + console.log('Authorize response data:', responseData); + return NextResponse.json(responseData); }; diff --git a/app/utils/conversationStorage.ts b/app/utils/conversationStorage.ts new file mode 100644 index 0000000..ed10419 --- /dev/null +++ b/app/utils/conversationStorage.ts @@ -0,0 +1,40 @@ +import { getCloudflareContext } from '@opennextjs/cloudflare'; +import { MessageWithTurnId } from '../api/agent/route'; + +export const inMemoryConversations = {} as Record; + +export const conversationKey = (conversationId: string) => `conversation:${conversationId}`; + +export const loadConversation = async (kv: KVNamespace, conversationId: string) => { + const existing = await kv.get(conversationKey(conversationId), { type: 'json' }); + + return existing ?? []; +}; + +export const persistConversation = (kv: KVNamespace, conversationId: string, messages: MessageWithTurnId[]) => { + console.log(`Persisting conversation ${conversationId} with ${messages.length} messages.`); + console.log(JSON.stringify(messages, null, 2)); + kv.put(conversationKey(conversationId), JSON.stringify(messages)); +}; + +export const getConversationStore = () => { + try { + const { env } = getCloudflareContext(); + const kv = env.MESSAGES_KV; + if (!kv) throw new Error('MESSAGES_KV binding is not configured.'); + console.log('Using MESSAGE_KV for conversation storage.'); + return { + load: (conversationId: string) => loadConversation(kv, conversationId), + persist: (conversationId: string, messages: MessageWithTurnId[]) => persistConversation(kv, conversationId, messages) + }; + } catch (error) { + if (process.env.NEXTJS_ENV === 'production') throw error; + console.warn('MESSAGES_KV binding unavailable in this environment – falling back to in-memory storage. Data will reset on restart.'); + return { + load: async (conversationId: string) => inMemoryConversations[conversationId] ?? [], + persist: async (conversationId: string, messages: MessageWithTurnId[]) => { + inMemoryConversations[conversationId] = messages; + } + }; + } +}; diff --git a/wrangler.jsonc b/wrangler.jsonc index f79fa44..9ba23df 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -14,14 +14,7 @@ }, "observability": { "enabled": true - }, - "kv_namespaces": [ - { - "binding": "MESSAGES_KV", - "id": "38fc52f237e84999af586dfce7ec7514", - "preview_id": "5e9a64b4d598473fab5b5b4e62e64769" - } - ] + } /** * Smart Placement * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement From 1c06a312394bd03dbd153c5567c093871390250c Mon Sep 17 00:00:00 2001 From: Damien Tanner Date: Mon, 13 Oct 2025 13:21:11 +0100 Subject: [PATCH 3/5] Update conversationStorage.ts --- app/utils/conversationStorage.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/utils/conversationStorage.ts b/app/utils/conversationStorage.ts index ed10419..94b9428 100644 --- a/app/utils/conversationStorage.ts +++ b/app/utils/conversationStorage.ts @@ -29,7 +29,9 @@ export const getConversationStore = () => { }; } catch (error) { if (process.env.NEXTJS_ENV === 'production') throw error; - console.warn('MESSAGES_KV binding unavailable in this environment – falling back to in-memory storage. Data will reset on restart.'); + console.warn( + 'MESSAGES_KV binding unavailable in this environment – falling back to in-memory storage. Data will reset on restart. Run `npx wrangler kv namespace create MESSAGES_KV` to create the KV namespace for conversation storage.' + ); return { load: async (conversationId: string) => inMemoryConversations[conversationId] ?? [], persist: async (conversationId: string, messages: MessageWithTurnId[]) => { From e5e8c87cc47aff9eb00cfb34a96b5d4de9513168 Mon Sep 17 00:00:00 2001 From: Damien Tanner Date: Mon, 13 Oct 2025 13:35:27 +0100 Subject: [PATCH 4/5] Update VoiceAgent.tsx --- app/ui/VoiceAgent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ui/VoiceAgent.tsx b/app/ui/VoiceAgent.tsx index 3f1bf3e..8cc2096 100644 --- a/app/ui/VoiceAgent.tsx +++ b/app/ui/VoiceAgent.tsx @@ -34,7 +34,7 @@ export default function VoiceAgent() { onMuteStateChange(isMuted) { setMessages((prev) => [...prev, { role: 'data', text: `MIC → ${isMuted ? 'muted' : 'unmuted'}`, ts: Date.now() }]); }, - onConnect: (connectData) => { + onConnect: (connectData: { conversationId: string | null; config?: { transcription?: { trigger?: string } } }) => { setIsPushToTalk(connectData.config?.transcription.trigger === 'push_to_talk'); }, onMessage: (data: any) => { From b526f2dc054af8ddf478f4f0800530bc00464c75 Mon Sep 17 00:00:00 2001 From: Damien Tanner Date: Mon, 13 Oct 2025 13:36:40 +0100 Subject: [PATCH 5/5] Update VoiceAgent.tsx --- app/ui/VoiceAgent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ui/VoiceAgent.tsx b/app/ui/VoiceAgent.tsx index 8cc2096..f7c7452 100644 --- a/app/ui/VoiceAgent.tsx +++ b/app/ui/VoiceAgent.tsx @@ -35,7 +35,7 @@ export default function VoiceAgent() { setMessages((prev) => [...prev, { role: 'data', text: `MIC → ${isMuted ? 'muted' : 'unmuted'}`, ts: Date.now() }]); }, onConnect: (connectData: { conversationId: string | null; config?: { transcription?: { trigger?: string } } }) => { - setIsPushToTalk(connectData.config?.transcription.trigger === 'push_to_talk'); + setIsPushToTalk(connectData.config?.transcription?.trigger === 'push_to_talk'); }, onMessage: (data: any) => { console.log(data);