├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── app ├── api │ ├── complaints │ │ └── create │ │ │ └── route.ts │ ├── list_files │ │ └── route.ts │ ├── orders │ │ └── [order_id] │ │ │ ├── cancel │ │ │ └── route.ts │ │ │ ├── create_refund │ │ │ └── route.ts │ │ │ ├── create_return │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ └── send_replacement │ │ │ └── route.ts │ ├── tickets │ │ └── create │ │ │ └── route.ts │ ├── turn_response │ │ └── route.ts │ ├── users │ │ └── [user_id] │ │ │ ├── order_history │ │ │ └── route.ts │ │ │ ├── reset_password │ │ │ └── route.ts │ │ │ └── update_info │ │ │ └── route.ts │ ├── vector_stores │ │ ├── add_file │ │ │ └── route.ts │ │ ├── create_store │ │ │ └── route.ts │ │ ├── get_file │ │ │ └── route.ts │ │ ├── list_files │ │ │ └── route.ts │ │ ├── retrieve_store │ │ │ └── route.ts │ │ └── upload_file │ │ │ └── route.ts │ └── vouchers │ │ └── create │ │ └── route.ts ├── faq │ └── page.tsx ├── fonts │ ├── GeistMonoVF.woff │ └── GeistVF.woff ├── globals.css ├── init_vs │ └── page.tsx ├── kb │ └── page.tsx ├── layout.tsx └── page.tsx ├── components.json ├── components ├── Action.tsx ├── AgentView.tsx ├── Annotations.tsx ├── Chat.tsx ├── ContextPanel.tsx ├── CustomerDetails.tsx ├── ListArticles.tsx ├── Message.tsx ├── RecommendedActions.tsx ├── RelevantArticles.tsx ├── ToolCall.tsx ├── UserView.tsx └── ui │ ├── button.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── input.tsx │ ├── label.tsx │ ├── switch.tsx │ └── textarea.tsx ├── config ├── constants.ts ├── demoData.ts ├── functions.ts └── tools-list.ts ├── eslint.config.mjs ├── lib ├── assistant.ts ├── tools │ ├── tools-handling.ts │ └── tools.ts └── utils.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── faq │ ├── account_recovery.md │ ├── cancel_order.md │ ├── change_password.md │ ├── damaged_product.md │ ├── delivery.md │ ├── help_chat.md │ ├── payment_methods.md │ ├── product_exchange.md │ ├── refunds.md │ ├── returns.md │ └── update_info.md ├── knowledge_base │ ├── account_recovery.md │ ├── cancel_order.md │ ├── change_password.md │ ├── damaged_product.md │ ├── delivery_issues.md │ ├── handle_complaints.md │ ├── interactions_guidelines.md │ ├── product_exchange.md │ ├── refunds.md │ ├── returns.md │ └── update_info.md ├── openai_logo.svg └── screenshot.jpg ├── stores ├── useConversationStore.ts └── useDataStore.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | !.env.example 36 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | next-env.d.ts 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 OpenAI 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the “Software”), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | 21 | --- 22 | 23 | Geist and Geist Mono Fonts 24 | 25 | Copyright (c) 2023 Vercel, in collaboration with basement.studio 26 | 27 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 28 | This license is copied below, and is also available with a FAQ at: 29 | http://scripts.sil.org/OFL 30 | 31 | ----------------------------------------------------------- 32 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 33 | ----------------------------------------------------------- 34 | 35 | PREAMBLE 36 | The goals of the Open Font License (OFL) are to stimulate worldwide 37 | development of collaborative font projects, to support the font creation 38 | efforts of academic and linguistic communities, and to provide a free and 39 | open framework in which fonts may be shared and improved in partnership 40 | with others. 41 | 42 | The OFL allows the licensed fonts to be used, studied, modified and 43 | redistributed freely as long as they are not sold by themselves. The 44 | fonts, including any derivative works, can be bundled, embedded, 45 | redistributed and/or sold with any software provided that any reserved 46 | names are not used by derivative works. The fonts and derivatives, 47 | however, cannot be released under any other type of license. The 48 | requirement for fonts to remain under this license does not apply 49 | to any document created using the fonts or their derivatives. 50 | 51 | DEFINITIONS 52 | "Font Software" refers to the set of files released by the Copyright 53 | Holder(s) under this license and clearly marked as such. This may 54 | include source files, build scripts and documentation. 55 | 56 | "Reserved Font Name" refers to any names specified as such after the 57 | copyright statement(s). 58 | 59 | "Original Version" refers to the collection of Font Software components as 60 | distributed by the Copyright Holder(s). 61 | 62 | "Modified Version" refers to any derivative made by adding to, deleting, 63 | or substituting -- in part or in whole -- any of the components of the 64 | Original Version, by changing formats or by porting the Font Software to a 65 | new environment. 66 | 67 | "Author" refers to any designer, engineer, programmer, technical 68 | writer or other person who contributed to the Font Software. 69 | 70 | PERMISSION AND CONDITIONS 71 | Permission is hereby granted, free of charge, to any person obtaining 72 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 73 | redistribute, and sell modified and unmodified copies of the Font 74 | Software, subject to the following conditions: 75 | 76 | 1) Neither the Font Software nor any of its individual components, 77 | in Original or Modified Versions, may be sold by itself. 78 | 79 | 2) Original or Modified Versions of the Font Software may be bundled, 80 | redistributed and/or sold with any software, provided that each copy 81 | contains the above copyright notice and this license. These can be 82 | included either as stand-alone text files, human-readable headers or 83 | in the appropriate machine-readable metadata fields within text or 84 | binary files as long as those fields can be easily viewed by the user. 85 | 86 | 3) No Modified Version of the Font Software may use the Reserved Font 87 | Name(s) unless explicit written permission is granted by the corresponding 88 | Copyright Holder. This restriction only applies to the primary font name as 89 | presented to the users. 90 | 91 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 92 | Software shall not be used to promote, endorse or advertise any 93 | Modified Version, except to acknowledge the contribution(s) of the 94 | Copyright Holder(s) and the Author(s) or with their explicit written 95 | permission. 96 | 97 | 5) The Font Software, modified or unmodified, in part or in whole, 98 | must be distributed entirely under this license, and must not be 99 | distributed under any other license. The requirement for fonts to 100 | remain under this license does not apply to any document created 101 | using the Font Software. 102 | 103 | TERMINATION 104 | This license becomes null and void if any of the above conditions are 105 | not met. 106 | 107 | DISCLAIMER 108 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 109 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 110 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 111 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 112 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 113 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 114 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 115 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 116 | OTHER DEALINGS IN THE FONT SOFTWARE. 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Customer Support Agent with Human in the Loop Demo 2 | 3 | [![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) 4 | ![NextJS](https://img.shields.io/badge/Built_with-NextJS-blue) 5 | ![OpenAI API](https://img.shields.io/badge/Powered_by-OpenAI_API-orange) 6 | 7 | This repository contains a NextJS demo app of a Customer Service with a Human in the loop (HITL) use case built on top of the [Responses API](https://platform.openai.com/docs/api-reference/responses). 8 | It leverages the [file search](https://platform.openai.com/docs/guides/tools-file-search) built-in tool and implements 2 views of a chat interface: one for the customer, and one for the human agent. 9 | 10 | This demo is an example flow where a human agent would be assisted by an AI agent to answer customer questions, while staying in control of sensitive actions. 11 | 12 | ![screenshot](./public/screenshot.jpg) 13 | 14 | Features: 15 | 16 | - Multi-turn conversation handling 17 | - File search tool 18 | - Vector store creation & file upload for use with the file search 19 | - Knowledge base display 20 | - Function calling 21 | - Streaming suggested responses 22 | - Suggested actions to execute tool calls 23 | - Auto-execution of tool calls for non-sensitive actions 24 | 25 | Feel free to customize this demo to suit your specific use case. 26 | 27 | ## How to use 28 | 29 | 1. **Set up the OpenAI API:** 30 | 31 | - If you're new to the OpenAI API, [sign up for an account](https://platform.openai.com/signup). 32 | - Follow the [Quickstart](https://platform.openai.com/docs/quickstart) to retrieve your API key. 33 | 34 | 2. **Clone the Repository:** 35 | 36 | ```bash 37 | git clone https://github.com/openai/openai-support-agent-demo.git 38 | ``` 39 | 40 | 3. **Set the OpenAI API key:** 41 | 42 | 2 options: 43 | 44 | - Set the `OPENAI_API_KEY` environment variable [globally in your system](https://platform.openai.com/docs/libraries#create-and-export-an-api-key) 45 | - Set the `OPENAI_API_KEY` environment variable in the project: Create a `.env` file at the root of the project and add the following line (see `.env.example` for reference): 46 | 47 | ```bash 48 | OPENAI_API_KEY= 49 | ``` 50 | 51 | 4. **Install dependencies:** 52 | 53 | Run in the project root: 54 | 55 | ```bash 56 | npm install 57 | ``` 58 | 59 | 5. **Run the app:** 60 | 61 | ```bash 62 | npm run dev 63 | ``` 64 | 65 | The app will be available at [`http://localhost:3000`](http://localhost:3000). 66 | 67 | 6. **Initialize the vector store:** 68 | 69 | Go to [`/init_vs`](http://localhost:3000/init_vs) to create a vector store and initialize it with the knowledge base. Once you have created the vector store, update `config/constants.ts` with your own vector store ID. 70 | 71 | ## Demo Flow 72 | 73 | To try out the demo, you can ask questions that will trigger a file search. 74 | 75 | Example questions: 76 | 77 | - What is the return policy? 78 | - How do I return a product? 79 | - How can I cancel an order? 80 | 81 | When an answer is generated, it will be displayed as a suggested response for the customer support representative. 82 | In the agent view, you can edit the message or send it as is. 83 | 84 | You can also click on the "Relevant articles" to see the corresponding articles in the knowledge base or FAQ. 85 | 86 | You can then continue the conversation as the user. 87 | 88 | You can ask for help to trigger actions. 89 | 90 | Example questions: 91 | 92 | - Help me cancel order ORD1001 => Should suggest the `cancel_order` action 93 | - Help me reset my password => Should suggest the `reset_password` action 94 | - Give me a list of my past orders => Should trigger the execution of `get_order_history` 95 | 96 | ### End-to-end demo flow 97 | 98 | 1. Ask as the user "How can I cancel my order?" 99 | 2. Confirm the suggested response 100 | 3. Ask as the user "Help me cancel order ORD1001" 101 | 4. Confirm the suggested response 102 | 5. Confirm the suggested action to cancel the order 103 | 6. Confirm the suggested response 104 | 105 | ### Limitations 106 | 107 | Note that the functions that are executed are just placeholders and are not actually modifying any data, so the actions will not have any effect. For example, calling `cancel_order` won't change the status of the order. 108 | 109 | ## Customization 110 | 111 | To customize this demo you can: 112 | 113 | - Edit prompts, initial message and model in `config/constants.ts` 114 | - Edit available functions in `config/tools-list.ts` 115 | - Edit functions logic in `config/functions.ts` 116 | - (optional) Edit the demo data in `config/demoData.ts` 117 | 118 | You can also customize the endpoints in the `/api` folder to call your own backend or external services. 119 | 120 | If you want to use this code repository as a starting point for your own project in production, please note that this demo is not production-ready and that you would need to implement safety measures such as input guardrails, user authentication, etc. 121 | 122 | ## Contributing 123 | 124 | You are welcome to open issues or submit PRs to improve this app, however, please note that we may not review all suggestions. 125 | 126 | ## License 127 | 128 | This project is licensed under the MIT License. See the LICENSE file for details. 129 | -------------------------------------------------------------------------------- /app/api/complaints/create/route.ts: -------------------------------------------------------------------------------- 1 | export async function POST(request: Request) { 2 | try { 3 | const { user_id, type, details, order_id } = await request.json(); 4 | // Simulate complaint creation. 5 | return new Response( 6 | JSON.stringify({ 7 | message: `Complaint created for user ${user_id}`, 8 | type, 9 | details, 10 | order_id, 11 | }), 12 | { status: 200 } 13 | ); 14 | } catch (error) { 15 | console.error("Error creating complaint:", error); 16 | return new Response("Error creating complaint", { status: 500 }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/api/list_files/route.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "fs"; 2 | import path from "path"; 3 | 4 | export async function GET(request: Request) { 5 | try { 6 | const { searchParams } = new URL(request.url); 7 | const folder = searchParams.get("folder"); 8 | const faqDir = path.join(process.cwd(), `public/${folder}`); // path to the faq directory 9 | const filesList = await fs.readdir(faqDir); 10 | console.log(`Files found in folder ${folder}:`, filesList); 11 | return new Response(JSON.stringify(filesList), { status: 200 }); 12 | } catch (error) { 13 | console.error("Error fetching files:", error); 14 | return new Response("Error fetching files", { status: 500 }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/api/orders/[order_id]/cancel/route.ts: -------------------------------------------------------------------------------- 1 | export async function POST( 2 | request: Request, 3 | context: { params: { order_id: string } } 4 | ) { 5 | try { 6 | const { params } = context; 7 | const { order_id } = await params; 8 | // Simulate order cancellation 9 | return new Response( 10 | JSON.stringify({ message: `Order ${order_id} cancelled successfully` }), 11 | { status: 200 } 12 | ); 13 | } catch (error) { 14 | console.error("Error cancelling order:", error); 15 | return new Response("Error cancelling order", { status: 500 }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/api/orders/[order_id]/create_refund/route.ts: -------------------------------------------------------------------------------- 1 | export async function POST( 2 | request: Request, 3 | { params }: { params: { order_id: string } } 4 | ) { 5 | try { 6 | const { order_id } = params; 7 | const { amount, reason } = await request.json(); 8 | // Simulate refund creation 9 | return new Response( 10 | JSON.stringify({ 11 | message: `Refund of $${amount} for order ${order_id} is processing`, 12 | reason, 13 | }), 14 | { status: 200 } 15 | ); 16 | } catch (error) { 17 | console.error("Error creating refund:", error); 18 | return new Response("Error creating refund", { status: 500 }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/api/orders/[order_id]/create_return/route.ts: -------------------------------------------------------------------------------- 1 | export async function POST( 2 | request: Request, 3 | { params }: { params: { order_id: string } } 4 | ) { 5 | try { 6 | const { order_id } = params; 7 | const { product_ids } = await request.json(); 8 | // Simulate return initiation 9 | return new Response( 10 | JSON.stringify({ 11 | message: `Return initiated for order ${order_id}`, 12 | product_ids, 13 | }), 14 | { status: 200 } 15 | ); 16 | } catch (error) { 17 | console.error("Error creating return:", error); 18 | return new Response("Error creating return", { status: 500 }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/api/orders/[order_id]/route.ts: -------------------------------------------------------------------------------- 1 | import { DEMO_ORDERS } from "@/config/demoData"; 2 | 3 | export async function GET( 4 | request: Request, 5 | { params }: { params: { order_id: string } } 6 | ) { 7 | try { 8 | const { order_id } = params; 9 | const order = DEMO_ORDERS.find((order) => order.id === order_id); 10 | if (!order) { 11 | return new Response(JSON.stringify({ error: "Order not found" }), { 12 | status: 404, 13 | }); 14 | } 15 | return new Response(JSON.stringify(order), { status: 200 }); 16 | } catch (error) { 17 | console.error("Error retrieving order:", error); 18 | return new Response("Error retrieving order", { status: 500 }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/api/orders/[order_id]/send_replacement/route.ts: -------------------------------------------------------------------------------- 1 | export async function POST( 2 | request: Request, 3 | { params }: { params: { order_id: string } } 4 | ) { 5 | try { 6 | const { order_id } = params; 7 | const { product_id } = await request.json(); 8 | // Simulate sending a replacement 9 | return new Response( 10 | JSON.stringify({ 11 | message: `Replacement for product ${product_id} in order ${order_id} sent successfully`, 12 | }), 13 | { status: 200 } 14 | ); 15 | } catch (error) { 16 | console.error("Error sending replacement:", error); 17 | return new Response("Error sending replacement", { status: 500 }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/api/tickets/create/route.ts: -------------------------------------------------------------------------------- 1 | export async function POST(request: Request) { 2 | try { 3 | const { user_id, type, details, order_id } = await request.json(); 4 | // Simulate ticket creation 5 | return new Response( 6 | JSON.stringify({ 7 | message: `Ticket created for user ${user_id}`, 8 | type, 9 | details, 10 | order_id, 11 | }), 12 | { status: 200 } 13 | ); 14 | } catch (error) { 15 | console.error("Error creating ticket:", error); 16 | return new Response("Error creating ticket", { status: 500 }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/api/turn_response/route.ts: -------------------------------------------------------------------------------- 1 | import { MODEL } from "@/config/constants"; 2 | import { NextResponse } from "next/server"; 3 | import OpenAI from "openai"; 4 | 5 | export async function POST(request: Request) { 6 | try { 7 | const { messages, tools } = await request.json(); 8 | console.log("Received messages:", messages); 9 | 10 | const openai = new OpenAI(); 11 | 12 | const events = await openai.responses.create({ 13 | model: MODEL, 14 | input: messages, 15 | tools, 16 | stream: true, 17 | include: ["file_search_call.results"], 18 | parallel_tool_calls: false, 19 | }); 20 | 21 | // Create a ReadableStream that emits SSE data 22 | const stream = new ReadableStream({ 23 | async start(controller) { 24 | try { 25 | for await (const event of events) { 26 | // Sending all events to the client 27 | const data = JSON.stringify({ 28 | event: event.type, 29 | data: event, 30 | }); 31 | controller.enqueue(`data: ${data}\n\n`); 32 | } 33 | // End of stream 34 | controller.close(); 35 | } catch (error) { 36 | console.error("Error in streaming loop:", error); 37 | controller.error(error); 38 | } 39 | }, 40 | }); 41 | 42 | // Return the ReadableStream as SSE 43 | return new Response(stream, { 44 | headers: { 45 | "Content-Type": "text/event-stream", 46 | "Cache-Control": "no-cache", 47 | Connection: "keep-alive", 48 | }, 49 | }); 50 | } catch (error) { 51 | console.error("Error in POST handler:", error); 52 | return NextResponse.json( 53 | { 54 | error: error instanceof Error ? error.message : "Unknown error", 55 | }, 56 | { status: 500 } 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/api/users/[user_id]/order_history/route.ts: -------------------------------------------------------------------------------- 1 | import { USER_INFO } from "@/config/demoData"; 2 | 3 | export async function GET( 4 | request: Request, 5 | { params }: { params: { user_id: string } } 6 | ) { 7 | try { 8 | const { user_id } = params; 9 | console.log("Retrieving order history for user:", user_id); 10 | return new Response(JSON.stringify(USER_INFO.order_history), { 11 | status: 200, 12 | }); 13 | } catch (error) { 14 | console.error("Error retrieving order history:", error); 15 | return new Response("Error retrieving order history", { status: 500 }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/api/users/[user_id]/reset_password/route.ts: -------------------------------------------------------------------------------- 1 | export async function POST( 2 | request: Request, 3 | { params }: { params: { user_id: string } } 4 | ) { 5 | try { 6 | const { user_id } = params; 7 | // Simulate sending a reset password email 8 | return new Response( 9 | JSON.stringify({ 10 | message: `Password reset email sent to user ${user_id}`, 11 | }), 12 | { status: 200 } 13 | ); 14 | } catch (error) { 15 | console.error("Error resetting password:", error); 16 | return new Response("Error resetting password", { status: 500 }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/api/users/[user_id]/update_info/route.ts: -------------------------------------------------------------------------------- 1 | export async function POST( 2 | request: Request, 3 | { params }: { params: { user_id: string } } 4 | ) { 5 | try { 6 | const { user_id } = params; 7 | const { info } = await request.json(); 8 | // Simulate updating user information 9 | return new Response( 10 | JSON.stringify({ 11 | message: `User ${user_id} info updated`, 12 | updatedField: info.field, 13 | newValue: info.value, 14 | }), 15 | { status: 200 } 16 | ); 17 | } catch (error) { 18 | console.error("Error updating user info:", error); 19 | return new Response("Error updating user info", { status: 500 }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/api/vector_stores/add_file/route.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | 3 | const openai = new OpenAI(); 4 | 5 | export async function POST(request: Request) { 6 | const { vectorStoreId, fileId, attributes } = await request.json(); 7 | console.log( 8 | `Adding file ${fileId} with attributes ${JSON.stringify(attributes)}` 9 | ); 10 | try { 11 | const vectorStore = await openai.vectorStores.files.create(vectorStoreId, { 12 | file_id: fileId, 13 | attributes, 14 | }); 15 | return new Response(JSON.stringify(vectorStore), { status: 200 }); 16 | } catch (error) { 17 | console.error("Error adding file:", error); 18 | return new Response("Error adding file", { status: 500 }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/api/vector_stores/create_store/route.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | 3 | const openai = new OpenAI(); 4 | 5 | export async function POST(request: Request) { 6 | const { name } = await request.json(); 7 | try { 8 | const vectorStore = await openai.vectorStores.create({ 9 | name, 10 | }); 11 | console.log("Vector store created:", vectorStore); 12 | return new Response(JSON.stringify(vectorStore), { status: 200 }); 13 | } catch (error) { 14 | console.error("Error creating vector store:", error); 15 | return new Response("Error creating vector store", { status: 500 }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/api/vector_stores/get_file/route.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | 3 | const openai = new OpenAI(); 4 | 5 | export async function GET(request: Request) { 6 | const { searchParams } = new URL(request.url); 7 | const vectorStoreId = searchParams.get("vectorStoreId") ?? ""; 8 | const fileId = searchParams.get("fileId") ?? ""; 9 | try { 10 | const fileContent = await openai.vectorStores.files.retrieve( 11 | vectorStoreId, 12 | fileId 13 | ); 14 | return new Response(JSON.stringify(fileContent), { status: 200 }); 15 | } catch (error) { 16 | console.error("Error retrieving file:", error); 17 | return new Response("Error retrieving file", { status: 500 }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/api/vector_stores/list_files/route.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | 3 | const openai = new OpenAI(); 4 | 5 | export async function GET(request: Request) { 6 | const { searchParams } = new URL(request.url); 7 | const vectorStoreId = searchParams.get("vectorStoreId"); 8 | 9 | try { 10 | const vectorStore = await openai.vectorStores.files.list( 11 | vectorStoreId || "" 12 | ); 13 | return new Response(JSON.stringify(vectorStore), { status: 200 }); 14 | } catch (error) { 15 | console.error("Error fetching files:", error); 16 | return new Response("Error fetching files", { status: 500 }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/api/vector_stores/retrieve_store/route.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | 3 | const openai = new OpenAI(); 4 | 5 | export async function GET(request: Request) { 6 | const { searchParams } = new URL(request.url); 7 | const vectorStoreId = searchParams.get("vector_store_id"); 8 | try { 9 | const vectorStore = await openai.vectorStores.retrieve( 10 | vectorStoreId || "" 11 | ); 12 | return new Response(JSON.stringify(vectorStore), { status: 200 }); 13 | } catch (error) { 14 | console.error("Error fetching vector store:", error); 15 | return new Response("Error fetching vector store", { status: 500 }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/api/vector_stores/upload_file/route.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | const openai = new OpenAI(); 3 | import fs from "fs"; 4 | import path from "path"; 5 | export async function POST(request: Request) { 6 | const { filePath } = await request.json(); 7 | 8 | try { 9 | const workingDir = process.cwd(); 10 | const fileContent = fs.createReadStream(path.join(workingDir, filePath)); 11 | const file = await openai.files.create({ 12 | file: fileContent, 13 | purpose: "assistants", 14 | }); 15 | 16 | return new Response(JSON.stringify(file), { status: 200 }); 17 | } catch (error) { 18 | console.error("Error uploading file:", error); 19 | return new Response("Error uploading file", { status: 500 }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/api/vouchers/create/route.ts: -------------------------------------------------------------------------------- 1 | export async function POST(request: Request) { 2 | try { 3 | const { user_id, amount, reason } = await request.json(); 4 | // Simulate voucher issuance 5 | return new Response( 6 | JSON.stringify({ 7 | message: `Voucher of $${amount} issued to user ${user_id} for: ${reason}`, 8 | }), 9 | { status: 200 } 10 | ); 11 | } catch (error) { 12 | console.error("Error issuing voucher:", error); 13 | return new Response("Error issuing voucher", { status: 500 }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/faq/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import ListArticles from "@/components/ListArticles"; 3 | 4 | // Page showing all FAQ articles in /faq folder 5 | export default function KnowledgeBasePage() { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/openai-support-agent-demo/9141e75fc347f2c3617a76e709e76fdec1f54714/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/openai-support-agent-demo/9141e75fc347f2c3617a76e709e76fdec1f54714/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | 9 | --foreground: 224 71.4% 4.1%; 10 | 11 | --card: 0 0% 100%; 12 | 13 | --card-foreground: 224 71.4% 4.1%; 14 | 15 | --popover: 0 0% 100%; 16 | 17 | --popover-foreground: 224 71.4% 4.1%; 18 | 19 | --primary: 220.9 39.3% 11%; 20 | 21 | --primary-foreground: 210 20% 98%; 22 | 23 | --secondary: 220 14.3% 95.9%; 24 | 25 | --secondary-foreground: 220.9 39.3% 11%; 26 | 27 | --muted: 220 14.3% 95.9%; 28 | 29 | --muted-foreground: 220 8.9% 46.1%; 30 | 31 | --accent: 0 0% 98%; 32 | 33 | --accent-foreground: 220.9 39.3% 11%; 34 | 35 | --destructive: 0 84.2% 60.2%; 36 | 37 | --destructive-foreground: 210 20% 98%; 38 | 39 | --border: 220 13% 91%; 40 | 41 | --input: 220 13% 91%; 42 | 43 | --ring: 224 71.4% 4.1%; 44 | 45 | --chart-1: 12 76% 61%; 46 | 47 | --chart-2: 173 58% 39%; 48 | 49 | --chart-3: 197 37% 24%; 50 | 51 | --chart-4: 43 74% 66%; 52 | 53 | --chart-5: 27 87% 67%; 54 | 55 | --radius: 0.5rem; 56 | } 57 | .dark { 58 | --background: 224 71.4% 4.1%; 59 | 60 | --foreground: 210 20% 98%; 61 | 62 | --card: 224 71.4% 4.1%; 63 | 64 | --card-foreground: 210 20% 98%; 65 | 66 | --popover: 224 71.4% 4.1%; 67 | 68 | --popover-foreground: 210 20% 98%; 69 | 70 | --primary: 210 20% 98%; 71 | 72 | --primary-foreground: 220.9 39.3% 11%; 73 | 74 | --secondary: 215 27.9% 16.9%; 75 | 76 | --secondary-foreground: 210 20% 98%; 77 | 78 | --muted: 215 27.9% 16.9%; 79 | 80 | --muted-foreground: 217.9 10.6% 64.9%; 81 | 82 | --accent: 215 27.9% 16.9%; 83 | 84 | --accent-foreground: 210 20% 98%; 85 | 86 | --destructive: 0 62.8% 30.6%; 87 | 88 | --destructive-foreground: 210 20% 98%; 89 | 90 | --border: 215 27.9% 16.9%; 91 | 92 | --input: 215 27.9% 16.9%; 93 | 94 | --ring: 216 12.2% 83.9%; 95 | 96 | --chart-1: 220 70% 50%; 97 | 98 | --chart-2: 160 60% 45%; 99 | 100 | --chart-3: 30 80% 55%; 101 | 102 | --chart-4: 280 65% 60%; 103 | 104 | --chart-5: 340 75% 55%; 105 | } 106 | } 107 | 108 | @layer base { 109 | * { 110 | @apply border-border outline-ring/50; 111 | } 112 | body { 113 | @apply bg-background text-foreground; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /app/init_vs/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { KB_FOLDERS } from "@/config/demoData"; 3 | import { Copy } from "lucide-react"; 4 | import { useState } from "react"; 5 | 6 | interface KBFile { 7 | type: string; 8 | filename: string; 9 | filepath: string; 10 | } 11 | 12 | export default function InitVS() { 13 | const [loading, setLoading] = useState(false); 14 | const [vectorStoreId, setVectorStoreId] = useState(null); 15 | const [status, setStatus] = useState(""); 16 | const [error, setError] = useState(null); 17 | const [success, setSuccess] = useState(false); 18 | 19 | const copyToClipboard = (text: string) => { 20 | navigator.clipboard.writeText(text); 21 | }; 22 | 23 | const handleInitialize = async () => { 24 | setLoading(true); 25 | setSuccess(false); 26 | setStatus("Creating vector store..."); 27 | const response = await fetch("/api/vector_stores/create_store", { 28 | method: "POST", 29 | body: JSON.stringify({ name: "CS Knowledge Base" }), 30 | }); 31 | if (response.status === 200) { 32 | const vs = await response.json(); 33 | setVectorStoreId(vs.id); 34 | setStatus("Fetching files..."); 35 | const filesList: KBFile[] = []; 36 | for (const folder of KB_FOLDERS) { 37 | const folderFiles = await fetch( 38 | `/api/list_files?folder=${folder}` 39 | ).then((res) => res.json()); 40 | console.log(`Files found in folder ${folder}:`, folderFiles); 41 | filesList.push( 42 | ...folderFiles.map((file: string) => ({ 43 | type: folder, 44 | filename: file.split(".")[0], 45 | filepath: `/public/${folder}/${file}`, 46 | })) 47 | ); 48 | } 49 | setStatus(`Uploading ${filesList.length} files to vector store...`); 50 | for (const file of filesList) { 51 | console.log(`Uploading file ${file.filepath}...`); 52 | const response = await fetch("/api/vector_stores/upload_file", { 53 | method: "POST", 54 | body: JSON.stringify({ filePath: file.filepath }), 55 | }); 56 | if (response.status === 200) { 57 | const fileData = await response.json(); 58 | const fileId = fileData.id; 59 | const attributes = { 60 | type: file.type, 61 | filename: file.filename, 62 | filepath: file.filepath, 63 | }; 64 | const addFileResponse = await fetch("/api/vector_stores/add_file", { 65 | method: "POST", 66 | body: JSON.stringify({ vectorStoreId: vs.id, fileId, attributes }), 67 | }); 68 | if (addFileResponse.status === 200) { 69 | setStatus(`Uploaded ${file.type}/${file.filename}`); 70 | } else { 71 | setError(`Failed to add file ${file.filename} to vector store`); 72 | } 73 | } else { 74 | setError(`Failed to upload file ${file.filename} to vector store`); 75 | } 76 | } 77 | setStatus("Uploaded all files to vector store."); 78 | setLoading(false); 79 | setSuccess(true); 80 | } else { 81 | setError("Failed to create vector store"); 82 | setLoading(false); 83 | } 84 | }; 85 | 86 | return ( 87 |
88 |
89 |
Initialize the Vector Store
90 |
91 |

92 | For this demo to work, you need to load knowledge base content into 93 | a vector store. This vector store will be used by the File Search 94 | tool to search for relevant content that can help answer the user 95 | query. 96 |

97 |

98 | When you click on the button below, the content contained in the{" "} 99 | 100 | /public/knowledge_base 101 | {" "} 102 | and{" "} 103 | 104 | /public/faq 105 | {" "} 106 | folders will be loaded into a vector store. 107 |

108 |

109 | Feel free to update these articles with your own content. If you 110 | change the folder names, make sure to update the reference to the 111 | folders below. After you make changes, you can re-initialize the 112 | vector store to load the new content. 113 |

114 |

115 | Once the content is loaded, you will see the newly created vector 116 | store's ID below. You can then configure the vector store ID in{" "} 117 | 118 | /config/constants.ts 119 | {" "} 120 | to use with the File Search tool. 121 |

122 |
123 |
124 | {!loading ? ( 125 |
129 | Initialize vector store 130 |
131 | ) : ( 132 |
{status}
133 | )} 134 |
135 |
136 |
137 | {error &&
{error}
} 138 | {success && !error && ( 139 |
140 | Knowledge base updated successfully. 141 |
142 |
Vector Store ID:
143 |
144 | {vectorStoreId ?? ""} 145 |
146 | copyToClipboard(vectorStoreId ?? "")} 148 | size={16} 149 | className="cursor-pointer text-zinc-400 hover:text-zinc-600 transition-all duration-100" 150 | /> 151 |
152 |
153 | )} 154 |
155 |
156 | ); 157 | } 158 | -------------------------------------------------------------------------------- /app/kb/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import ListArticles from "@/components/ListArticles"; 3 | 4 | // Page showing all knowledge base articles in /knowledge_base folder 5 | export default function KnowledgeBasePage() { 6 | return ( 7 |
8 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import localFont from "next/font/local"; 3 | import "./globals.css"; 4 | 5 | const geistSans = localFont({ 6 | src: "./fonts/GeistVF.woff", 7 | variable: "--font-geist-sans", 8 | weight: "100 900", 9 | }); 10 | const geistMono = localFont({ 11 | src: "./fonts/GeistMonoVF.woff", 12 | variable: "--font-geist-mono", 13 | weight: "100 900", 14 | }); 15 | 16 | export const metadata: Metadata = { 17 | title: "Customer Service Demo", 18 | description: "Customer Service Demo with support agent and user view", 19 | icons: { 20 | icon: "/openai_logo.svg", 21 | }, 22 | }; 23 | 24 | export default function RootLayout({ 25 | children, 26 | }: Readonly<{ 27 | children: React.ReactNode; 28 | }>) { 29 | return ( 30 | 31 | 34 |
35 |
{children}
36 |
37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useState } from "react"; 3 | import AgentView from "@/components/AgentView"; 4 | import UserView from "@/components/UserView"; 5 | import { Switch } from "@/components/ui/switch"; 6 | 7 | export default function Main() { 8 | const [isAgentView, setIsAgentView] = useState(false); 9 | 10 | return ( 11 |
12 | {/* Small screens */} 13 |
14 |
19 | Customer View 20 |
21 | 27 |
32 | Agent View 33 |
34 |
35 | 36 |
37 | {/* Left column */} 38 |
44 |
45 | Customer View 46 |
47 | 48 |
49 | 50 | {/* Right column */} 51 |
57 |
58 | Support Representative View 59 |
60 | 61 |
62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "stone", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /components/Action.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | Dialog, 4 | DialogContent, 5 | DialogDescription, 6 | DialogFooter, 7 | DialogHeader, 8 | DialogTitle, 9 | DialogTrigger, 10 | } from "@/components/ui/dialog"; 11 | import { Input } from "@/components/ui/input"; 12 | import { Label } from "@/components/ui/label"; 13 | import { handleTool, ToolName } from "@/lib/tools/tools-handling"; 14 | import { useState } from "react"; 15 | import { Loader2 } from "lucide-react"; 16 | import useConversationStore from "@/stores/useConversationStore"; 17 | import { processMessages } from "@/lib/assistant"; 18 | export default function Action({ 19 | name, 20 | functionName, 21 | parameters, 22 | }: { 23 | name: string; 24 | functionName: ToolName; 25 | parameters: Record; 26 | }) { 27 | const [loading, setLoading] = useState(false); 28 | const [open, setOpen] = useState(false); 29 | const addConversationItem = useConversationStore( 30 | (s) => s.addConversationItem 31 | ); 32 | const removeRecommendedAction = useConversationStore( 33 | (s) => s.removeRecommendedAction 34 | ); 35 | const handleConfirm = async () => { 36 | setLoading(true); 37 | const result = await handleTool(functionName, parameters, "execute"); 38 | if (result.result) { 39 | console.log("Executed function", functionName, parameters, result); 40 | // Add to conversation as a new assistant message 41 | addConversationItem({ 42 | role: "assistant", 43 | content: JSON.stringify(result), 44 | }); 45 | await processMessages(); 46 | } else { 47 | console.log("Error executing function", functionName, parameters, result); 48 | } 49 | setLoading(false); 50 | // Close dialog and remove suggested action 51 | setOpen(false); 52 | removeRecommendedAction(functionName); 53 | }; 54 | 55 | return ( 56 | 57 | 58 |
59 | {name} 60 |
61 |
62 | 63 | 64 | {name} 65 | 66 | Confirm the action to take to resolve the customer query 67 | 68 | {functionName} 69 | 70 |
71 | {Object.entries(parameters).map(([key, value]) => ( 72 |
73 | 76 | 77 |
78 | ))} 79 |
80 | 81 | 88 | 89 |
90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /components/AgentView.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import useConversationStore from "@/stores/useConversationStore"; 4 | import { Item } from "@/lib/assistant"; 5 | import ContextPanel from "./ContextPanel"; 6 | import { 7 | Drawer, 8 | DrawerContent, 9 | DrawerHeader, 10 | DrawerTitle, 11 | DrawerTrigger, 12 | } from "./ui/drawer"; 13 | import { Info } from "lucide-react"; 14 | import Chat from "./Chat"; 15 | 16 | export default function AgentView() { 17 | const { chatMessages, addConversationItem, addChatMessage } = 18 | useConversationStore(); 19 | 20 | const handleSendMessage = async (message: string) => { 21 | if (!message.trim()) return; 22 | 23 | const agentItem: Item = { 24 | type: "message", 25 | role: "agent", 26 | content: [{ type: "input_text", text: message.trim() }], 27 | }; 28 | const agentMessage: any = { 29 | role: "assistant", 30 | content: message.trim(), 31 | }; 32 | 33 | addConversationItem(agentMessage); 34 | addChatMessage(agentItem); 35 | }; 36 | 37 | return ( 38 |
39 |
40 | 45 |
46 | 47 |
48 | 49 |
50 | 51 | 52 | 58 | 59 | 60 | 61 | Case Context 62 | 63 |
64 | 65 |
66 |
67 |
68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /components/Annotations.tsx: -------------------------------------------------------------------------------- 1 | export type Annotation = { 2 | type: "file_citation"; 3 | fileId?: string; 4 | title?: string; 5 | filename?: string; 6 | index?: number; 7 | }; 8 | 9 | const Annotations = ({ annotations }: { annotations: Annotation[] }) => { 10 | const uniqueAnnotations = annotations.reduce( 11 | (acc: Annotation[], annotation) => { 12 | if ( 13 | !acc.some( 14 | (a: Annotation) => 15 | a.type === annotation.type && 16 | annotation.type === "file_citation" && 17 | a.fileId === annotation.fileId 18 | ) 19 | ) { 20 | acc.push(annotation); 21 | } 22 | return acc; 23 | }, 24 | [] 25 | ); 26 | 27 | return ( 28 |
29 | {uniqueAnnotations.map((annotation: Annotation, index: number) => ( 30 | 34 | {annotation.filename} 35 | 36 | ))} 37 |
38 | ); 39 | }; 40 | 41 | export default Annotations; 42 | -------------------------------------------------------------------------------- /components/Chat.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useCallback, useEffect, useRef, useState } from "react"; 4 | import ToolCall from "./ToolCall"; 5 | import Message from "./Message"; 6 | import Annotations from "./Annotations"; 7 | import type { Item, ChatMessage } from "@/lib/assistant"; 8 | import useConversationStore from "@/stores/useConversationStore"; 9 | import { SendIcon, PencilIcon } from "lucide-react"; 10 | 11 | interface ChatProps { 12 | items: Item[]; 13 | view: "user" | "agent"; 14 | onSendMessage: (message: string) => void; 15 | } 16 | 17 | function TypingIndicatorDot({ 18 | delay, 19 | color, 20 | }: { 21 | delay: string; 22 | color: string; 23 | }) { 24 | return ( 25 | 29 | ); 30 | } 31 | 32 | function TypingIndicator({ sender }: { sender: "user" | "agent" }) { 33 | const color = sender === "user" ? "bg-zinc-900" : "bg-white"; 34 | return ( 35 |
40 |
47 | 48 | 49 | 50 |
51 |
52 | ); 53 | } 54 | 55 | export default function Chat({ items, view, onSendMessage }: ChatProps) { 56 | const itemsEndRef = useRef(null); 57 | 58 | const [inputMessageText, setInputMessageText] = useState(""); 59 | const [isComposing, setIsComposing] = useState(false); 60 | const [isFocused, setIsFocused] = useState(false); 61 | 62 | const setUserTyping = useConversationStore((s) => s.setUserTyping); 63 | const setAgentTyping = useConversationStore((s) => s.setAgentTyping); 64 | const userTyping = useConversationStore((s) => s.userTyping); 65 | const agentTyping = useConversationStore((s) => s.agentTyping); 66 | 67 | const suggestedMessage = useConversationStore( 68 | (s) => s.suggestedMessage 69 | ) as ChatMessage | null; 70 | const setSuggestedMessage = useConversationStore( 71 | (s) => s.setSuggestedMessage 72 | ); 73 | const suggestedMessageDone = useConversationStore( 74 | (s) => s.suggestedMessageDone 75 | ); 76 | const composerText = useConversationStore((s) => s.composerText); 77 | const setComposerText = useConversationStore((s) => s.setComposerText); 78 | 79 | useEffect(() => { 80 | itemsEndRef.current?.scrollIntoView({ behavior: "instant" }); 81 | }, [items, suggestedMessage]); 82 | 83 | useEffect(() => { 84 | const typing = 85 | isComposing || 86 | (isFocused && 87 | (view === "agent" ? composerText.length : inputMessageText.length) > 0); 88 | 89 | if (view === "user") { 90 | setUserTyping(typing); 91 | } else { 92 | setAgentTyping(typing); 93 | } 94 | }, [ 95 | isComposing, 96 | isFocused, 97 | inputMessageText, 98 | composerText, 99 | view, 100 | setUserTyping, 101 | setAgentTyping, 102 | ]); 103 | 104 | const handleSendMessage = useCallback(() => { 105 | if (view === "agent") { 106 | if (!composerText.trim()) return; 107 | onSendMessage(composerText); 108 | setComposerText(""); 109 | } else { 110 | if (!inputMessageText.trim()) return; 111 | onSendMessage(inputMessageText); 112 | setInputMessageText(""); 113 | } 114 | }, [view, composerText, inputMessageText, onSendMessage, setComposerText]); 115 | 116 | const handleKeyDown = useCallback( 117 | (e: React.KeyboardEvent) => { 118 | if (e.key === "Enter" && !e.shiftKey && !isComposing) { 119 | e.preventDefault(); 120 | handleSendMessage(); 121 | } 122 | }, 123 | [handleSendMessage, isComposing] 124 | ); 125 | 126 | const handleSendNow = useCallback(() => { 127 | if (!suggestedMessage) return; 128 | const text = suggestedMessage.content[0]?.text ?? ""; 129 | onSendMessage(text); 130 | setSuggestedMessage(null); 131 | setAgentTyping(false); 132 | }, [suggestedMessage, onSendMessage, setSuggestedMessage, setAgentTyping]); 133 | 134 | const handleEdit = useCallback(() => { 135 | if (!suggestedMessage) return; 136 | const text = suggestedMessage.content[0]?.text ?? ""; 137 | setComposerText(text); 138 | setSuggestedMessage(null); 139 | setAgentTyping(false); 140 | }, [suggestedMessage, setComposerText, setSuggestedMessage, setAgentTyping]); 141 | 142 | return ( 143 |
144 | {/* Messages */} 145 |
146 | {items.map((item, idx) => ( 147 | 148 | {item.type === "tool_call" && view === "agent" ? ( 149 | 150 | ) : item.type === "message" ? ( 151 |
152 | 153 | {view === "agent" && 154 | item.content[0]?.annotations && 155 | item.content[0]?.annotations?.length > 0 && ( 156 | 157 | )} 158 |
159 | ) : null} 160 |
161 | ))} 162 | 163 | {/* Suggested message + actions */} 164 | {view === "agent" && suggestedMessage && ( 165 |
166 | 167 | 168 | {suggestedMessageDone ? ( 169 |
170 |
171 |
172 |
176 | 177 | Send now 178 |
179 |
183 | 184 | Edit 185 |
186 |
187 |
188 |
189 | ) : null} 190 |
191 | )} 192 | 193 | {/* Typing indicators */} 194 | {view === "user" && agentTyping && } 195 | {view === "agent" && userTyping && } 196 | 197 |
198 |
199 | 200 | {/* Input */} 201 |
202 |
203 |
204 |
205 |
206 |
207 |