This commit is contained in:
annie
2024-06-28 16:06:33 -07:00
parent b1f859fe91
commit 331f6633ca
29 changed files with 1239 additions and 0 deletions

3
.env Normal file
View File

@@ -0,0 +1,3 @@
HEYGEN_API_KEY=your Heygen API key
OPENAI_API_KEY=your OpenAI API key
NEXT_PUBLIC_OPENAI_API_KEY=your OpenAI API key

20
.eslintignore Normal file
View File

@@ -0,0 +1,20 @@
.now/*
*.css
.changeset
dist
esm/*
public/*
tests/*
scripts/*
*.config.js
.DS_Store
node_modules
coverage
.next
build
!.commitlintrc.cjs
!.lintstagedrc.cjs
!jest.config.js
!plopfile.js
!react-shim.js
!tsup.config.ts

92
.eslintrc.json Normal file
View File

@@ -0,0 +1,92 @@
{
"$schema": "https://json.schemastore.org/eslintrc.json",
"env": {
"browser": false,
"es2021": true,
"node": true
},
"extends": [
"plugin:react/recommended",
"plugin:prettier/recommended",
"plugin:react-hooks/recommended",
"plugin:jsx-a11y/recommended"
],
"plugins": ["react", "unused-imports", "import", "@typescript-eslint", "jsx-a11y", "prettier"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
"no-console": "warn",
"react/prop-types": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "off",
"jsx-a11y/click-events-have-key-events": "warn",
"jsx-a11y/interactive-supports-focus": "warn",
"prettier/prettier": "warn",
"no-unused-vars": "off",
"unused-imports/no-unused-vars": "off",
"unused-imports/no-unused-imports": "warn",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"args": "after-used",
"ignoreRestSiblings": false,
"argsIgnorePattern": "^_.*?$"
}
],
"import/order": [
"warn",
{
"groups": [
"type",
"builtin",
"object",
"external",
"internal",
"parent",
"sibling",
"index"
],
"pathGroups": [
{
"pattern": "~/**",
"group": "external",
"position": "after"
}
],
"newlines-between": "always"
}
],
"react/self-closing-comp": "warn",
"react/jsx-sort-props": [
"warn",
{
"callbacksLast": true,
"shorthandFirst": true,
"noSortAlphabetically": false,
"reservedFirst": true
}
],
"padding-line-between-statements": [
"warn",
{"blankLine": "always", "prev": "*", "next": "return"},
{"blankLine": "always", "prev": ["const", "let", "var"], "next": "*"},
{
"blankLine": "any",
"prev": ["const", "let", "var"],
"next": ["const", "let", "var"]
}
]
}
}

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
package-lock=false

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}

53
README.md Normal file
View File

@@ -0,0 +1,53 @@
# Next.js & NextUI Template
This is a template for creating applications using Next.js 14 (app directory) and NextUI (v2).
[Try it on CodeSandbox](https://githubbox.com/nextui-org/next-app-template)
## Technologies Used
- [Next.js 14](https://nextjs.org/docs/getting-started)
- [NextUI v2](https://nextui.org/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Tailwind Variants](https://tailwind-variants.org)
- [TypeScript](https://www.typescriptlang.org/)
- [Framer Motion](https://www.framer.com/motion/)
- [next-themes](https://github.com/pacocoursey/next-themes)
## How to Use
### Use the template with create-next-app
To create a new project based on this template using `create-next-app`, run the following command:
```bash
npx create-next-app -e https://github.com/nextui-org/next-app-template
```
### Install dependencies
You can use one of them `npm`, `yarn`, `pnpm`, `bun`, Example using `npm`:
```bash
npm install
```
### Run the development server
```bash
npm run dev
```
### Setup pnpm (optional)
If you are using `pnpm`, you need to add the following code to your `.npmrc` file:
```bash
public-hoist-pattern[]=*@nextui-org/*
```
After modifying the `.npmrc` file, you need to run `pnpm install` again to ensure that the dependencies are installed correctly.
## License
Licensed under the [MIT license](https://github.com/nextui-org/next-app-template/blob/main/LICENSE).

16
app/api/chat/route.ts Normal file
View File

@@ -0,0 +1,16 @@
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
// Allow streaming responses up to 30 seconds
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = await streamText({
model: openai("gpt-4-turbo"),
messages,
});
return result.toAIStreamResponse();
}

View File

@@ -0,0 +1,30 @@
const HEYGEN_API_KEY = process.env.HEYGEN_API_KEY;
export async function POST() {
try {
if (!HEYGEN_API_KEY) {
throw new Error("API key is missing from .env");
}
const res = await fetch(
"https://api.heygen.com/v1/streaming.create_token",
{
method: "POST",
headers: {
"x-api-key": HEYGEN_API_KEY,
},
}
);
const data = await res.json();
return new Response(data.data.token, {
status: 200,
});
} catch (error) {
console.error("Error retrieving access token:", error);
return new Response("Failed to retrieve access token", {
status: 500,
});
}
}

31
app/error.tsx Normal file
View File

@@ -0,0 +1,31 @@
"use client";
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
useEffect(() => {
// Log the error to an error reporting service
/* eslint-disable no-console */
console.error(error);
}, [error]);
return (
<div>
<h2>Something went wrong!</h2>
<button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
Try again
</button>
</div>
);
}

59
app/layout.tsx Normal file
View File

@@ -0,0 +1,59 @@
import "@/styles/globals.css";
import clsx from "clsx";
import { Metadata, Viewport } from "next";
import { Providers } from "./providers";
import { Fira_Code as FontMono, Inter as FontSans } from "next/font/google";
import NavBar from "@/components/NavBar";
const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
});
const fontMono = FontMono({
subsets: ["latin"],
variable: "--font-geist-mono",
});
export const metadata: Metadata = {
title: {
default: "HeyGen Streaming Avatar SDK Demo",
template: `%s - HeyGen Streaming Avatar SDK Demo`,
},
icons: {
icon: "/heygen-logo.png",
},
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "black" },
],
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
suppressHydrationWarning
lang="en"
className={`${fontSans.variable} ${fontMono.variable} font-sans`}
>
<head />
<body className={clsx("min-h-screen bg-background antialiased")}>
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
<main className="relative flex flex-col h-screen">
<NavBar />
{children}
</main>
</Providers>
</body>
</html>
);
}

37
app/page.tsx Normal file
View File

@@ -0,0 +1,37 @@
"use client";
import NavBar from "@/components/NavBar";
import StreamingAvatar from "@/components/StreamingAvatar";
import StreamingAvatarCode from "@/components/StreamingAvatarCode";
import { Tab, Tabs } from "@nextui-org/react";
export default function App() {
const tabs = [
{
id: "demo",
label: "Demo",
content: <StreamingAvatar />,
},
{
id: "code",
label: "Code",
content: <StreamingAvatarCode />,
},
];
return (
<div className="w-screen h-screen flex flex-col">
<div className="w-[900px] flex flex-col items-start justify-start gap-5 mx-auto pt-6 pb-20">
<div className="w-full">
<Tabs items={tabs}>
{(items) => (
<Tab key={items.id} title={items.label}>
{items.content}
</Tab>
)}
</Tabs>
</div>
</div>
</div>
);
}

22
app/providers.tsx Normal file
View File

@@ -0,0 +1,22 @@
"use client";
import * as React from "react";
import { NextUIProvider } from "@nextui-org/system";
import { useRouter } from "next/navigation";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { ThemeProviderProps } from "next-themes/dist/types";
export interface ProvidersProps {
children: React.ReactNode;
themeProps?: ThemeProviderProps;
}
export function Providers({ children, themeProps }: ProvidersProps) {
const router = useRouter();
return (
<NextUIProvider navigate={router.push}>
<NextThemesProvider {...themeProps}>{children}</NextThemesProvider>
</NextUIProvider>
);
}

81
components/Icons.tsx Normal file
View File

@@ -0,0 +1,81 @@
export function HeyGenLogo() {
return <img src="/heygen-logo.png" className="h-8" alt="HeyGen Logo" />;
}
type IconSvgProps = {
size?: number;
width?: number;
height?: number;
className?: string;
};
export function GithubIcon({
size = 24,
width,
height,
...props
}: IconSvgProps) {
return (
<svg
height={size || height}
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
clipRule="evenodd"
d="M12.026 2c-5.509 0-9.974 4.465-9.974 9.974 0 4.406 2.857 8.145 6.821 9.465.499.09.679-.217.679-.481 0-.237-.008-.865-.011-1.696-2.775.602-3.361-1.338-3.361-1.338-.452-1.152-1.107-1.459-1.107-1.459-.905-.619.069-.605.069-.605 1.002.07 1.527 1.028 1.527 1.028.89 1.524 2.336 1.084 2.902.829.091-.645.351-1.085.635-1.334-2.214-.251-4.542-1.107-4.542-4.93 0-1.087.389-1.979 1.024-2.675-.101-.253-.446-1.268.099-2.64 0 0 .837-.269 2.742 1.021a9.582 9.582 0 0 1 2.496-.336 9.554 9.554 0 0 1 2.496.336c1.906-1.291 2.742-1.021 2.742-1.021.545 1.372.203 2.387.099 2.64.64.696 1.024 1.587 1.024 2.675 0 3.833-2.33 4.675-4.552 4.922.355.308.675.916.675 1.846 0 1.334-.012 2.41-.012 2.737 0 .267.178.577.687.479C19.146 20.115 22 16.379 22 11.974 22 6.465 17.535 2 12.026 2z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
);
}
export function MoonFilledIcon({
size = 24,
width,
height,
...props
}: IconSvgProps) {
return (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M21.53 15.93c-.16-.27-.61-.69-1.73-.49a8.46 8.46 0 01-1.88.13 8.409 8.409 0 01-5.91-2.82 8.068 8.068 0 01-1.44-8.66c.44-1.01.13-1.54-.09-1.76s-.77-.55-1.83-.11a10.318 10.318 0 00-6.32 10.21 10.475 10.475 0 007.04 8.99 10 10 0 002.89.55c.16.01.32.02.48.02a10.5 10.5 0 008.47-4.27c.67-.93.49-1.519.32-1.79z"
fill="currentColor"
/>
</svg>
);
}
export function SunFilledIcon({
size = 24,
width,
height,
...props
}: IconSvgProps) {
return (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<g fill="currentColor">
<path d="M19 12a7 7 0 11-7-7 7 7 0 017 7z" />
<path d="M12 22.96a.969.969 0 01-1-.96v-.08a1 1 0 012 0 1.038 1.038 0 01-1 1.04zm7.14-2.82a1.024 1.024 0 01-.71-.29l-.13-.13a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.984.984 0 01-.7.29zm-14.28 0a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a1 1 0 01-.7.29zM22 13h-.08a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zM2.08 13H2a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zm16.93-7.01a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a.984.984 0 01-.7.29zm-14.02 0a1.024 1.024 0 01-.71-.29l-.13-.14a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.97.97 0 01-.7.3zM12 3.04a.969.969 0 01-1-.96V2a1 1 0 012 0 1.038 1.038 0 01-1 1.04z" />
</g>
</svg>
);
}

46
components/NavBar.tsx Normal file
View File

@@ -0,0 +1,46 @@
import {
Link,
Navbar,
NavbarBrand,
NavbarContent,
NavbarItem,
} from "@nextui-org/react";
import { GithubIcon, HeyGenLogo } from "./Icons";
import { ThemeSwitch } from "./ThemeSwitch";
export default function NavBar() {
return (
<Navbar>
<NavbarBrand>
<Link isExternal aria-label="HeyGen" href="https://app.heygen.com/">
<HeyGenLogo />
</Link>
<div className="bg-gradient-to-br from-sky-300 to-indigo-500 bg-clip-text ml-4">
<p className="text-xl font-semibold text-transparent">
HeyGen Streaming Avatar SDK NextJS Demo
</p>
</div>
</NavbarBrand>
<NavbarContent justify="center">
<NavbarItem className="flex flex-row items-center gap-10">
<Link
color="foreground"
href="https://docs.heygen.com/reference/new-session-copy"
>
API Docs
</Link>
<Link
isExternal
aria-label="Github"
href="https://github.com/HeyGen-Official/StreamingAvatarSDK"
className="flex flex-row justify-center gap-2 text-foreground"
>
<GithubIcon className="text-default-500" />
SDK Github
</Link>
<ThemeSwitch />
</NavbarItem>
</NavbarContent>
</Navbar>
);
}

View File

@@ -0,0 +1,332 @@
import {
Configuration,
NewSessionData,
StreamingAvatarApi,
} from "@heygen/streaming-avatar";
import {
Button,
Card,
CardBody,
CardFooter,
Divider,
Input,
Spinner,
Tooltip,
} from "@nextui-org/react";
import { useChat } from "ai/react";
import OpenAI from "openai";
import { useEffect, useRef, useState } from "react";
import StreamingAvatarTextInput from "./StreamingAvatarTextInput";
import {
Camera,
Microphone,
MicrophoneSlash,
MicrophoneStage,
Record,
} from "@phosphor-icons/react";
import clsx from "clsx";
const openai = new OpenAI({
apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY,
dangerouslyAllowBrowser: true,
});
export default function StreamingAvatar() {
const [loading, setLoading] = useState(false);
const [stream, setStream] = useState<MediaStream>();
const [debug, setDebug] = useState<string>();
const [avatarId, setAvatarId] = useState<string>("");
const [voiceId, setVoiceId] = useState<string>("");
const [data, setData] = useState<NewSessionData>();
const [text, setText] = useState<string>("");
const [initialized, setInitialized] = useState(false); // Track initialization
const [recording, setRecording] = useState(false); // Track recording state
const mediaStream = useRef<HTMLVideoElement>(null);
const avatar = useRef<StreamingAvatarApi | null>(null);
const mediaRecorder = useRef<MediaRecorder | null>(null);
const audioChunks = useRef<Blob[]>([]);
const { input, setInput, isLoading, handleSubmit } = useChat({
onFinish: async (message) => {
console.log("ChatGPT Response:", message);
if (!initialized || !avatar.current) {
setDebug("Avatar API not initialized");
return;
}
//send the ChatGPT response to the Streaming Avatar
await avatar.current
.speak({
taskRequest: { text: message.content, sessionId: data?.sessionId },
})
.catch((e) => {
setDebug(e.message);
});
},
});
async function fetchAccessToken() {
try {
const response = await fetch("/api/get-access-token", {
method: "POST",
});
const token = await response.text();
console.log("Access Token:", token); // Log the token to verify
return token;
} catch (error) {
console.error("Error fetching access token:", error);
return "";
}
}
async function start() {
setLoading(true);
await updateToken();
if (!avatar.current) {
setDebug("Avatar API is not initialized");
return;
}
try {
const res = await avatar.current.createStartAvatar(
{
newSessionRequest: {
quality: "low",
avatarName: avatarId,
voice: { voiceId: voiceId },
},
},
setDebug
);
setData(res);
setStream(avatar.current.mediaStream);
setLoading(false);
} catch (error) {
console.error("Error starting avatar session:", error);
}
}
async function updateToken() {
const newToken = await fetchAccessToken();
console.log("Updating Access Token:", newToken); // Log token for debugging
avatar.current = new StreamingAvatarApi(
new Configuration({ accessToken: newToken })
);
const startTalkCallback = (e: any) => {
console.log("Avatar started talking", e);
};
const stopTalkCallback = (e: any) => {
console.log("Avatar stopped talking", e);
};
console.log("Adding event handlers:", avatar.current);
avatar.current.addEventHandler("avatar_start_talking", startTalkCallback);
avatar.current.addEventHandler("avatar_stop_talking", stopTalkCallback);
setInitialized(true);
}
async function stop() {
if (!initialized || !avatar.current) {
setDebug("Avatar API not initialized");
return;
}
await avatar.current.stopAvatar(
{ stopSessionRequest: { sessionId: data?.sessionId } },
setDebug
);
}
async function handleSpeak() {
if (!initialized || !avatar.current) {
setDebug("Avatar API not initialized");
return;
}
await avatar.current
.speak({ taskRequest: { text: text, sessionId: data?.sessionId } })
.catch((e) => {
setDebug(e.message);
});
}
useEffect(() => {
async function init() {
const newToken = await fetchAccessToken();
console.log("Initializing with Access Token:", newToken); // Log token for debugging
avatar.current = new StreamingAvatarApi(
new Configuration({ accessToken: newToken, jitterBuffer: 200 })
);
setInitialized(true); // Set initialized to true
}
init();
return () => {
stop();
};
}, []);
useEffect(() => {
if (stream && mediaStream.current) {
mediaStream.current.srcObject = stream;
mediaStream.current.onloadedmetadata = () => {
mediaStream.current!.play();
setDebug("Playing");
};
}
}, [mediaStream, stream]);
function startRecording() {
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => {
mediaRecorder.current = new MediaRecorder(stream);
mediaRecorder.current.ondataavailable = (event) => {
audioChunks.current.push(event.data);
};
mediaRecorder.current.onstop = () => {
const audioBlob = new Blob(audioChunks.current, {
type: "audio/wav",
});
audioChunks.current = [];
transcribeAudio(audioBlob);
};
mediaRecorder.current.start();
setRecording(true);
})
.catch((error) => {
console.error("Error accessing microphone:", error);
});
}
function stopRecording() {
if (mediaRecorder.current) {
mediaRecorder.current.stop();
setRecording(false);
}
}
async function transcribeAudio(audioBlob: Blob) {
try {
// Convert Blob to File
const audioFile = new File([audioBlob], "recording.wav", {
type: "audio/wav",
});
const response = await openai.audio.transcriptions.create({
model: "whisper-1",
file: audioFile,
});
const transcription = response.text;
console.log("Transcription: ", transcription);
setInput(transcription);
} catch (error) {
console.error("Error transcribing audio:", error);
}
}
return (
<div className="w-full flex flex-col gap-4">
<Card>
<CardBody className="h-[500px] flex flex-col justify-center items-center">
{stream ? (
<div className="h-[500px] w-[900px] justify-center items-center flex rounded-lg overflow-hidden">
<video
ref={mediaStream}
autoPlay
playsInline
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
>
<track kind="captions" />
</video>
</div>
) : !loading ? (
<div className="h-full justify-center items-center flex flex-col gap-4 w-96 self-center">
<Input
value={avatarId}
onChange={(e) => setAvatarId(e.target.value)}
placeholder="Custom Avatar ID (optional)"
/>
<Input
value={voiceId}
onChange={(e) => setVoiceId(e.target.value)}
placeholder="Custom Voice ID (optional)"
/>
<Button
size="md"
onClick={start}
className="bg-gradient-to-tr from-indigo-500 to-indigo-300 w-full text-white"
variant="shadow"
>
Start session
</Button>
</div>
) : (
<Spinner size="lg" color="default" />
)}
</CardBody>
<Divider />
<CardFooter className="flex flex-col gap-3">
<StreamingAvatarTextInput
label="Repeat"
placeholder="Type something for the avatar to repeat"
input={text}
onSubmit={handleSpeak}
setInput={setText}
disabled={!stream}
/>
<StreamingAvatarTextInput
label="Chat"
placeholder="Chat with the avatar (uses ChatGPT)"
input={input}
onSubmit={() => {
if (!input) {
setDebug("Please enter text to send to ChatGPT");
return;
}
handleSubmit();
}}
setInput={setInput}
endContent={
<Tooltip
content={!recording ? "Start recording" : "Stop recording"}
>
<Button
onClick={!recording ? startRecording : stopRecording}
isDisabled={!stream}
isIconOnly
className={clsx(
"mr-4 text-white",
!recording
? "bg-gradient-to-tr from-indigo-500 to-indigo-300"
: ""
)}
size="sm"
variant="shadow"
>
{!recording ? (
<Microphone size={20} />
) : (
<>
<div className="absolute h-full w-full bg-gradient-to-tr from-indigo-500 to-indigo-300 animate-pulse -z-10"></div>
<MicrophoneStage size={20} />
</>
)}
</Button>
</Tooltip>
}
disabled={!stream}
/>
</CardFooter>
</Card>
<p className="font-mono text-right">
<span className="font-bold">Console:</span>
<br />
{debug}
</p>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { Card, CardBody } from "@nextui-org/react";
import { langs } from "@uiw/codemirror-extensions-langs";
import ReactCodeMirror from "@uiw/react-codemirror";
export default function StreamingAvatarCode() {
return (
<div className="w-full flex flex-col gap-2">
<p>This SDK supports the following behavior:</p>
<ul>
<li>
<div className="flex flex-row gap-2">
<p className="text-indigo-400 font-semibold">Start:</p> Start the
streaming avatar session
</div>
</li>
<li>
<div className="flex flex-row gap-2">
<p className="text-indigo-400 font-semibold">Close:</p> Close the
streaming avatar session
</div>
</li>
<li>
<div className="flex flex-row gap-2">
<p className="text-indigo-400 font-semibold">Speak:</p> Repeat the
input
</div>
</li>
</ul>
<Card>
<CardBody>
<ReactCodeMirror
editable={false}
extensions={[langs.typescript()]}
height="700px"
theme="dark"
value={TEXT}
/>
</CardBody>
</Card>
</div>
);
}
const TEXT = `
export default function App() {
// Media stream used by the video player to display the avatar
const [stream, setStream] = useState<MediaStream> ();
const mediaStream = useRef<HTMLVideoElement>(null);
// Instantiate the streaming avatar api using your access token
const avatar = useRef(new StreamingAvatarApi(
new Configuration({accessToken: '<REPLACE_WITH_ACCESS_TOKEN>'})
));
// State holding streaming avatar session data
const [sessionData, setSessionData] = useState<NewSessionData>();
// Function to start the streaming avatar session
async function start(){
const res = await avatar.current.createStartAvatar(
{ newSessionRequest:
// Define the session variables during creation
{ quality: "medium", // low, medium, high
avatarName: <REPLACE_WITH_AVATAR_ID>,
voice:{voiceId: <REPLACE_WITH_VOICE_ID>}
}
});
setSessionData(res);
}
// Function to stop the streaming avatar session
async function stop(){
await avatar.current.stopAvatar({stopSessionRequest: {sessionId: sessionData?.sessionId}});
}
// Function which passes in text to the avatar to repeat
async function handleSpeak(){
await avatar.current.speak({taskRequest: {text: <TEXT_TO_SAY>, sessionId: sessionData?.sessionId}}).catch((e) => {
});
}
useEffect(()=>{
// Handles the display of the streaming avatar
if(stream && mediaStream.current){
mediaStream.current.srcObject = stream;
mediaStream.current.onloadedmetadata = () => {
mediaStream.current!.play();
}
}
}, [mediaStream, stream])
return (
<div className="w-full">
<video playsInline autoPlay width={500} ref={mediaStream}/>
</div>
}`;

View File

@@ -0,0 +1,67 @@
import { Input, Tooltip } from "@nextui-org/react";
import { Airplane, ArrowRight, PaperPlaneRight } from "@phosphor-icons/react";
import clsx from "clsx";
interface StreamingAvatarTextInputProps {
label: string;
placeholder: string;
input: string;
onSubmit: () => void;
setInput: (value: string) => void;
endContent?: React.ReactNode;
disabled?: boolean;
}
export default function StreamingAvatarTextInput({
label,
placeholder,
input,
onSubmit,
setInput,
endContent,
disabled = false,
}: StreamingAvatarTextInputProps) {
function handleSubmit() {
if (input.trim() === "") {
return;
}
onSubmit();
setInput("");
}
return (
<Input
endContent={
<div className="flex flex-row items-center h-full">
{endContent}
<Tooltip content="Send message">
<button
type="submit"
className="focus:outline-none"
onClick={handleSubmit}
>
<PaperPlaneRight
className={clsx(
"text-indigo-300 hover:text-indigo-200",
disabled && "opacity-50"
)}
size={24}
/>
</button>
</Tooltip>
</div>
}
label={label}
placeholder={placeholder}
size="sm"
value={input}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSubmit();
}
}}
onValueChange={setInput}
isDisabled={disabled}
/>
);
}

View File

@@ -0,0 +1,80 @@
"use client";
import { FC } from "react";
import { VisuallyHidden } from "@react-aria/visually-hidden";
import { SwitchProps, useSwitch } from "@nextui-org/switch";
import { useTheme } from "next-themes";
import { useIsSSR } from "@react-aria/ssr";
import clsx from "clsx";
import { MoonFilledIcon, SunFilledIcon } from "./Icons";
export interface ThemeSwitchProps {
className?: string;
classNames?: SwitchProps["classNames"];
}
export const ThemeSwitch: FC<ThemeSwitchProps> = ({
className,
classNames,
}) => {
const { theme, setTheme } = useTheme();
const isSSR = useIsSSR();
const onChange = () => {
theme === "light" ? setTheme("dark") : setTheme("light");
};
const {
Component,
slots,
isSelected,
getBaseProps,
getInputProps,
getWrapperProps,
} = useSwitch({
isSelected: theme === "light" || isSSR,
"aria-label": `Switch to ${theme === "light" || isSSR ? "dark" : "light"} mode`,
onChange,
});
return (
<Component
{...getBaseProps({
className: clsx(
"px-px transition-opacity hover:opacity-80 cursor-pointer",
className,
classNames?.base
),
})}
>
<VisuallyHidden>
<input {...getInputProps()} />
</VisuallyHidden>
<div
{...getWrapperProps()}
className={slots.wrapper({
class: clsx(
[
"w-auto h-auto",
"bg-transparent",
"rounded-lg",
"flex items-center justify-center",
"group-data-[selected=true]:bg-transparent",
"!text-default-500",
"pt-px",
"px-0",
"mx-0",
],
classNames?.wrapper
),
})}
>
{!isSelected || isSSR ? (
<SunFilledIcon size={24} />
) : (
<MoonFilledIcon size={24} />
)}
</div>
</Component>
);
};

4
next.config.js Normal file
View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

64
package.json Normal file
View File

@@ -0,0 +1,64 @@
{
"name": "next-app-template",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev -p 3234",
"build": "next build",
"start": "next start",
"lint": "eslint . --ext .ts,.tsx -c .eslintrc.json --fix"
},
"dependencies": {
"@ai-sdk/openai": "^0.0.34",
"@heygen/streaming-avatar": "^1.0.10",
"@nextui-org/button": "2.0.34",
"@nextui-org/code": "2.0.29",
"@nextui-org/input": "2.2.2",
"@nextui-org/kbd": "2.0.30",
"@nextui-org/link": "2.0.32",
"@nextui-org/listbox": "2.1.21",
"@nextui-org/navbar": "2.0.33",
"@nextui-org/react": "^2.4.2",
"@nextui-org/snippet": "2.0.38",
"@nextui-org/switch": "2.0.31",
"@nextui-org/system": "2.2.1",
"@nextui-org/theme": "2.2.5",
"@phosphor-icons/react": "^2.1.5",
"@react-aria/ssr": "3.9.4",
"@react-aria/visually-hidden": "3.8.12",
"@uiw/codemirror-extensions-langs": "^4.22.1",
"@uiw/react-codemirror": "^4.22.1",
"ai": "^3.2.15",
"clsx": "2.1.1",
"framer-motion": "~11.1.1",
"intl-messageformat": "^10.5.0",
"next": "14.2.4",
"next-themes": "^0.2.1",
"openai": "^4.52.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "20.5.7",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "7.2.0",
"@typescript-eslint/parser": "7.2.0",
"autoprefixer": "10.4.19",
"eslint": "^8.57.0",
"eslint-config-next": "14.2.1",
"eslint-config-prettier": "^8.2.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.23.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-unused-imports": "^3.2.0",
"postcss": "8.4.38",
"tailwind-variants": "0.1.20",
"tailwindcss": "3.4.3",
"typescript": "5.0.4"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
public/heygen-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

9
styles/globals.css Normal file
View File

@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
li {
list-style-type: square;
margin-left: 30px;
padding: 2px;
}

20
tailwind.config.js Normal file
View File

@@ -0,0 +1,20 @@
import {nextui} from '@nextui-org/theme'
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}'
],
theme: {
extend: {
fontFamily: {
sans: ["var(--font-sans)"],
mono: ["var(--font-geist-mono)"],
},
},
},
darkMode: "class",
plugins: [nextui()],
}

28
tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}