Upload 53 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +3 -0
- .gitignore +158 -0
- Dockerfile +52 -3
- env.example +9 -0
- frontend/.eslintrc.json +3 -0
- frontend/.gitignore +36 -0
- frontend/README.md +36 -0
- frontend/app/favicon.ico +0 -0
- frontend/app/globals.css +109 -0
- frontend/app/layout.tsx +46 -0
- frontend/app/opengraph-image.png +3 -0
- frontend/app/page.tsx +16 -0
- frontend/app/twitter-image.png +3 -0
- frontend/app/utils.ts +6 -0
- frontend/components.json +17 -0
- frontend/components/App.tsx +81 -0
- frontend/components/AudioIndicator/index.tsx +69 -0
- frontend/components/DevicePicker/index.tsx +139 -0
- frontend/components/MicToggle/index.tsx +34 -0
- frontend/components/Setup.tsx +94 -0
- frontend/components/Story.tsx +79 -0
- frontend/components/StoryTranscript/StoryTranscript.module.css +42 -0
- frontend/components/StoryTranscript/index.tsx +55 -0
- frontend/components/UserInputIndicator/UserInputIndicator.module.css +82 -0
- frontend/components/UserInputIndicator/index.tsx +48 -0
- frontend/components/VideoTile/VideoTile.module.css +34 -0
- frontend/components/VideoTile/index.tsx +27 -0
- frontend/components/WaveText/WaveText.module.css +68 -0
- frontend/components/WaveText/index.tsx +23 -0
- frontend/components/ui/button.tsx +56 -0
- frontend/components/ui/select.tsx +160 -0
- frontend/components/ui/typewriter.tsx +61 -0
- frontend/env.example +1 -0
- frontend/next.config.mjs +15 -0
- frontend/package.json +38 -0
- frontend/postcss.config.js +6 -0
- frontend/public/alpha-mask.gif +0 -0
- frontend/public/bg.jpg +0 -0
- frontend/tailwind.config.ts +86 -0
- frontend/tsconfig.json +40 -0
- frontend/yarn.lock +0 -0
- image.png +3 -0
- requirements.txt +6 -0
- src/assets/book1.png +0 -0
- src/assets/book2.png +0 -0
- src/assets/ding.wav +0 -0
- src/assets/listening.wav +0 -0
- src/assets/talking.wav +0 -0
- src/bot.py +161 -0
- src/bot_runner.py +233 -0
.gitattributes
CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
36 |
+
frontend/app/opengraph-image.png filter=lfs diff=lfs merge=lfs -text
|
37 |
+
frontend/app/twitter-image.png filter=lfs diff=lfs merge=lfs -text
|
38 |
+
image.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
node_modules/
|
2 |
+
.idea/
|
3 |
+
|
4 |
+
# Byte-compiled / optimized / DLL files
|
5 |
+
__pycache__/
|
6 |
+
*.py[cod]
|
7 |
+
*$py.class
|
8 |
+
.DS_Store
|
9 |
+
|
10 |
+
# C extensions
|
11 |
+
*.so
|
12 |
+
|
13 |
+
# Distribution / packaging
|
14 |
+
.Python
|
15 |
+
build/
|
16 |
+
develop-eggs/
|
17 |
+
dist/
|
18 |
+
downloads/
|
19 |
+
eggs/
|
20 |
+
.eggs/
|
21 |
+
lib/
|
22 |
+
lib64/
|
23 |
+
parts/
|
24 |
+
sdist/
|
25 |
+
var/
|
26 |
+
wheels/
|
27 |
+
share/python-wheels/
|
28 |
+
*.egg-info/
|
29 |
+
.installed.cfg
|
30 |
+
*.egg
|
31 |
+
MANIFEST
|
32 |
+
|
33 |
+
# PyInstaller
|
34 |
+
# Usually these files are written by a python script from a template
|
35 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
36 |
+
*.manifest
|
37 |
+
*.spec
|
38 |
+
|
39 |
+
# Installer logs
|
40 |
+
pip-log.txt
|
41 |
+
pip-delete-this-directory.txt
|
42 |
+
|
43 |
+
# Unit test / coverage reports
|
44 |
+
htmlcov/
|
45 |
+
.tox/
|
46 |
+
.nox/
|
47 |
+
.coverage
|
48 |
+
.coverage.*
|
49 |
+
.cache
|
50 |
+
nosetests.xml
|
51 |
+
coverage.xml
|
52 |
+
*.cover
|
53 |
+
*.py,cover
|
54 |
+
.hypothesis/
|
55 |
+
.pytest_cache/
|
56 |
+
cover/
|
57 |
+
|
58 |
+
# Translations
|
59 |
+
*.mo
|
60 |
+
*.pot
|
61 |
+
|
62 |
+
# Django stuff:
|
63 |
+
*.log
|
64 |
+
local_settings.py
|
65 |
+
db.sqlite3
|
66 |
+
db.sqlite3-journal
|
67 |
+
|
68 |
+
# Flask stuff:
|
69 |
+
instance/
|
70 |
+
.webassets-cache
|
71 |
+
|
72 |
+
# Scrapy stuff:
|
73 |
+
.scrapy
|
74 |
+
|
75 |
+
# Sphinx documentation
|
76 |
+
docs/_build/
|
77 |
+
|
78 |
+
# PyBuilder
|
79 |
+
.pybuilder/
|
80 |
+
target/
|
81 |
+
|
82 |
+
# Jupyter Notebook
|
83 |
+
.ipynb_checkpoints
|
84 |
+
|
85 |
+
# IPython
|
86 |
+
profile_default/
|
87 |
+
ipython_config.py
|
88 |
+
|
89 |
+
# pyenv
|
90 |
+
# For a library or package, you might want to ignore these files since the code is
|
91 |
+
# intended to run in multiple environments; otherwise, check them in:
|
92 |
+
# .python-version
|
93 |
+
|
94 |
+
# pipenv
|
95 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
96 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
97 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
98 |
+
# install all needed dependencies.
|
99 |
+
#Pipfile.lock
|
100 |
+
|
101 |
+
# poetry
|
102 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
103 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
104 |
+
# commonly ignored for libraries.
|
105 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
106 |
+
#poetry.lock
|
107 |
+
|
108 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
109 |
+
__pypackages__/
|
110 |
+
|
111 |
+
# Celery stuff
|
112 |
+
celerybeat-schedule
|
113 |
+
celerybeat.pid
|
114 |
+
|
115 |
+
# SageMath parsed files
|
116 |
+
*.sage.py
|
117 |
+
|
118 |
+
# Environments
|
119 |
+
.env
|
120 |
+
.venv
|
121 |
+
env/
|
122 |
+
venv/
|
123 |
+
ENV/
|
124 |
+
env.bak/
|
125 |
+
venv.bak/
|
126 |
+
|
127 |
+
# Spyder project settings
|
128 |
+
.spyderproject
|
129 |
+
.spyproject
|
130 |
+
|
131 |
+
# Rope project settings
|
132 |
+
.ropeproject
|
133 |
+
|
134 |
+
# mkdocs documentation
|
135 |
+
/site
|
136 |
+
|
137 |
+
# mypy
|
138 |
+
.mypy_cache/
|
139 |
+
.dmypy.json
|
140 |
+
dmypy.json
|
141 |
+
|
142 |
+
# Pyre type checker
|
143 |
+
.pyre/
|
144 |
+
|
145 |
+
# pytype static type analyzer
|
146 |
+
.pytype/
|
147 |
+
|
148 |
+
# Cython debug symbols
|
149 |
+
cython_debug/
|
150 |
+
|
151 |
+
# PyCharm
|
152 |
+
# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can
|
153 |
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
154 |
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
155 |
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
156 |
+
#.idea/
|
157 |
+
|
158 |
+
read.html
|
Dockerfile
CHANGED
@@ -1,5 +1,54 @@
|
|
1 |
-
|
2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
3 |
ENV FAST_API_PORT=7860
|
4 |
EXPOSE 7860
|
5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FROM python:3.11-slim-bookworm
|
2 |
+
|
3 |
+
ARG DEBIAN_FRONTEND=noninteractive
|
4 |
+
ARG USE_PERSISTENT_DATA
|
5 |
+
ENV PYTHONUNBUFFERED=1
|
6 |
+
ENV NODE_MAJOR=20
|
7 |
+
|
8 |
+
# Expose FastAPI port
|
9 |
ENV FAST_API_PORT=7860
|
10 |
EXPOSE 7860
|
11 |
+
|
12 |
+
# Install system dependencies
|
13 |
+
RUN apt-get update && apt-get install --no-install-recommends -y \
|
14 |
+
build-essential \
|
15 |
+
git \
|
16 |
+
ffmpeg \
|
17 |
+
google-perftools \
|
18 |
+
ca-certificates curl gnupg \
|
19 |
+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
20 |
+
|
21 |
+
# Install Node.js
|
22 |
+
RUN mkdir -p /etc/apt/keyrings
|
23 |
+
RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
24 |
+
RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list > /dev/null
|
25 |
+
RUN apt-get update && apt-get install nodejs -y
|
26 |
+
|
27 |
+
# Set up a new user named "user" with user ID 1000
|
28 |
+
RUN useradd -m -u 1000 user
|
29 |
+
|
30 |
+
# Set home to the user's home directory
|
31 |
+
ENV HOME=/home/user \
|
32 |
+
PATH=/home/user/.local/bin:$PATH \
|
33 |
+
PYTHONPATH=$HOME/app \
|
34 |
+
PYTHONUNBUFFERED=1
|
35 |
+
|
36 |
+
# Switch to the "user" user
|
37 |
+
USER user
|
38 |
+
|
39 |
+
# Set the working directory to the user's home directory
|
40 |
+
WORKDIR $HOME/app
|
41 |
+
|
42 |
+
# Install Python dependencies
|
43 |
+
COPY ./requirements.txt requirements.txt
|
44 |
+
RUN pip3 install --no-cache-dir --upgrade -r requirements.txt
|
45 |
+
|
46 |
+
# Copy everything else
|
47 |
+
COPY --chown=user ./src/ src/
|
48 |
+
|
49 |
+
# Copy frontend app and build
|
50 |
+
COPY --chown=user ./frontend/ frontend/
|
51 |
+
RUN cd frontend && npm install && npm run build
|
52 |
+
|
53 |
+
# Start the FastAPI server
|
54 |
+
CMD python3 src/bot_runner.py --port ${FAST_API_PORT}
|
env.example
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
DAILY_API_KEY=
|
2 |
+
DAILY_SAMPLE_ROOM_URL=
|
3 |
+
ELEVENLABS_API_KEY=
|
4 |
+
ELEVENLABS_VOICE_ID=
|
5 |
+
FAL_KEY=
|
6 |
+
OPENAI_API_KEY=
|
7 |
+
|
8 |
+
ENV= # dev | production
|
9 |
+
RUN_AS_VM= # Set this if you want to run bots on process (not launch a new VM)
|
frontend/.eslintrc.json
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"extends": "next/core-web-vitals"
|
3 |
+
}
|
frontend/.gitignore
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
2 |
+
|
3 |
+
# dependencies
|
4 |
+
/node_modules
|
5 |
+
/.pnp
|
6 |
+
.pnp.js
|
7 |
+
.yarn/install-state.gz
|
8 |
+
|
9 |
+
# testing
|
10 |
+
/coverage
|
11 |
+
|
12 |
+
# next.js
|
13 |
+
/.next/
|
14 |
+
/out/
|
15 |
+
|
16 |
+
# production
|
17 |
+
/build
|
18 |
+
|
19 |
+
# misc
|
20 |
+
.DS_Store
|
21 |
+
*.pem
|
22 |
+
|
23 |
+
# debug
|
24 |
+
npm-debug.log*
|
25 |
+
yarn-debug.log*
|
26 |
+
yarn-error.log*
|
27 |
+
|
28 |
+
# local env files
|
29 |
+
.env*.local
|
30 |
+
|
31 |
+
# vercel
|
32 |
+
.vercel
|
33 |
+
|
34 |
+
# typescript
|
35 |
+
*.tsbuildinfo
|
36 |
+
next-env.d.ts
|
frontend/README.md
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
2 |
+
|
3 |
+
## Getting Started
|
4 |
+
|
5 |
+
First, run the development server:
|
6 |
+
|
7 |
+
```bash
|
8 |
+
npm run dev
|
9 |
+
# or
|
10 |
+
yarn dev
|
11 |
+
# or
|
12 |
+
pnpm dev
|
13 |
+
# or
|
14 |
+
bun dev
|
15 |
+
```
|
16 |
+
|
17 |
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
18 |
+
|
19 |
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
20 |
+
|
21 |
+
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
22 |
+
|
23 |
+
## Learn More
|
24 |
+
|
25 |
+
To learn more about Next.js, take a look at the following resources:
|
26 |
+
|
27 |
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
28 |
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
29 |
+
|
30 |
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
31 |
+
|
32 |
+
## Deploy on Vercel
|
33 |
+
|
34 |
+
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.
|
35 |
+
|
36 |
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
frontend/app/favicon.ico
ADDED
frontend/app/globals.css
ADDED
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
@tailwind base;
|
2 |
+
@tailwind components;
|
3 |
+
@tailwind utilities;
|
4 |
+
|
5 |
+
@layer base {
|
6 |
+
:root {
|
7 |
+
--background: 0 0% 100%;
|
8 |
+
--foreground: 224 71.4% 4.1%;
|
9 |
+
|
10 |
+
--card: 0 0% 100%;
|
11 |
+
--card-foreground: 224 71.4% 4.1%;
|
12 |
+
|
13 |
+
--popover: 0 0% 100%;
|
14 |
+
--popover-foreground: 224 71.4% 4.1%;
|
15 |
+
|
16 |
+
--primary: 220.9 39.3% 11%;
|
17 |
+
--primary-foreground: 210 20% 98%;
|
18 |
+
|
19 |
+
--secondary: 220 14.3% 95.9%;
|
20 |
+
--secondary-foreground: 220.9 39.3% 11%;
|
21 |
+
|
22 |
+
--muted: 220 14.3% 95.9%;
|
23 |
+
--muted-foreground: 220 8.9% 46.1%;
|
24 |
+
|
25 |
+
--accent: 220 14.3% 95.9%;
|
26 |
+
--accent-foreground: 220.9 39.3% 11%;
|
27 |
+
|
28 |
+
--destructive: 0 84.2% 60.2%;
|
29 |
+
--destructive-foreground: 210 20% 98%;
|
30 |
+
|
31 |
+
--border: 220 13% 91%;
|
32 |
+
--input: 220 13% 91%;
|
33 |
+
--ring: 224 71.4% 4.1%;
|
34 |
+
|
35 |
+
--radius: 0.5rem;
|
36 |
+
}
|
37 |
+
|
38 |
+
.dark {
|
39 |
+
--background: 224 71.4% 4.1%;
|
40 |
+
--foreground: 210 20% 98%;
|
41 |
+
|
42 |
+
--card: 224 71.4% 4.1%;
|
43 |
+
--card-foreground: 210 20% 98%;
|
44 |
+
|
45 |
+
--popover: 224 71.4% 4.1%;
|
46 |
+
--popover-foreground: 210 20% 98%;
|
47 |
+
|
48 |
+
--primary: 210 20% 98%;
|
49 |
+
--primary-foreground: 220.9 39.3% 11%;
|
50 |
+
|
51 |
+
--secondary: 215 27.9% 16.9%;
|
52 |
+
--secondary-foreground: 210 20% 98%;
|
53 |
+
|
54 |
+
--muted: 215 27.9% 16.9%;
|
55 |
+
--muted-foreground: 217.9 10.6% 64.9%;
|
56 |
+
|
57 |
+
--accent: 215 27.9% 16.9%;
|
58 |
+
--accent-foreground: 210 20% 98%;
|
59 |
+
|
60 |
+
--destructive: 0 62.8% 30.6%;
|
61 |
+
--destructive-foreground: 210 20% 98%;
|
62 |
+
|
63 |
+
--border: 215 27.9% 16.9%;
|
64 |
+
--input: 215 27.9% 16.9%;
|
65 |
+
--ring: 216 12.2% 83.9%;
|
66 |
+
}
|
67 |
+
}
|
68 |
+
|
69 |
+
@layer base {
|
70 |
+
* {
|
71 |
+
@apply border-border;
|
72 |
+
}
|
73 |
+
body {
|
74 |
+
@apply bg-background text-foreground;
|
75 |
+
}
|
76 |
+
}
|
77 |
+
|
78 |
+
body{
|
79 |
+
background: url("/bg.jpg") no-repeat center center;
|
80 |
+
background-size: cover;
|
81 |
+
}
|
82 |
+
|
83 |
+
.cardShadow{
|
84 |
+
box-shadow: 0px 124px 35px 0px rgba(0, 0, 0, 0.00), 0px 79px 32px 0px rgba(0, 0, 0, 0.01), 0px 45px 27px 0px rgba(0, 0, 0, 0.05), 0px 20px 20px 0px rgba(0, 0, 0, 0.09), 0px 5px 11px 0px rgba(0, 0, 0, 0.10);
|
85 |
+
}
|
86 |
+
|
87 |
+
@keyframes fadeInSlideUp {
|
88 |
+
0% {
|
89 |
+
opacity: 0;
|
90 |
+
transform: translateY(50px);
|
91 |
+
}
|
92 |
+
100% {
|
93 |
+
opacity: 1;
|
94 |
+
transform: translateY(0);
|
95 |
+
}
|
96 |
+
}
|
97 |
+
|
98 |
+
.fade-in {
|
99 |
+
animation: fadeIn 1s ease-out;
|
100 |
+
}
|
101 |
+
|
102 |
+
@keyframes fadeIn {
|
103 |
+
0% {
|
104 |
+
opacity: 0;
|
105 |
+
}
|
106 |
+
100% {
|
107 |
+
opacity: 1;
|
108 |
+
}
|
109 |
+
}
|
frontend/app/layout.tsx
ADDED
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react";
|
2 |
+
|
3 |
+
import "./globals.css";
|
4 |
+
import type { Metadata } from "next";
|
5 |
+
import { Space_Grotesk, Space_Mono } from "next/font/google";
|
6 |
+
|
7 |
+
import { cn } from "@/app/utils";
|
8 |
+
|
9 |
+
// Font
|
10 |
+
const sans = Space_Grotesk({
|
11 |
+
subsets: ["latin"],
|
12 |
+
weight: ["400", "500", "600"],
|
13 |
+
variable: "--font-sans",
|
14 |
+
});
|
15 |
+
|
16 |
+
const mono = Space_Mono({
|
17 |
+
subsets: ["latin"],
|
18 |
+
weight: ["400", "700"],
|
19 |
+
variable: "--font-mono",
|
20 |
+
});
|
21 |
+
|
22 |
+
export const metadata: Metadata = {
|
23 |
+
title: "Storytelling Chatbot - Daily AI",
|
24 |
+
description: "Built with git.new/ai",
|
25 |
+
metadataBase: new URL(process.env.SITE_URL || "http://localhost:3000"),
|
26 |
+
};
|
27 |
+
|
28 |
+
export default function RootLayout({
|
29 |
+
children,
|
30 |
+
}: Readonly<{
|
31 |
+
children: React.ReactNode;
|
32 |
+
}>) {
|
33 |
+
return (
|
34 |
+
<html lang="en">
|
35 |
+
<body
|
36 |
+
className={cn(
|
37 |
+
"min-h-screen bg-background font-sans antialiased flex flex-col",
|
38 |
+
sans.variable,
|
39 |
+
mono.variable
|
40 |
+
)}
|
41 |
+
>
|
42 |
+
<main className="flex flex-1">{children}</main>
|
43 |
+
</body>
|
44 |
+
</html>
|
45 |
+
);
|
46 |
+
}
|
frontend/app/opengraph-image.png
ADDED
Git LFS Details
|
frontend/app/page.tsx
ADDED
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import React from "react";
|
4 |
+
import { DailyProvider, useCallObject } from "@daily-co/daily-react";
|
5 |
+
|
6 |
+
import App from "../components/App";
|
7 |
+
|
8 |
+
export default function Home() {
|
9 |
+
const callObject = useCallObject({});
|
10 |
+
|
11 |
+
return (
|
12 |
+
<DailyProvider callObject={callObject}>
|
13 |
+
<App />
|
14 |
+
</DailyProvider>
|
15 |
+
);
|
16 |
+
}
|
frontend/app/twitter-image.png
ADDED
Git LFS Details
|
frontend/app/utils.ts
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { type ClassValue, clsx } from "clsx"
|
2 |
+
import { twMerge } from "tailwind-merge"
|
3 |
+
|
4 |
+
export function cn(...inputs: ClassValue[]) {
|
5 |
+
return twMerge(clsx(inputs))
|
6 |
+
}
|
frontend/components.json
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
3 |
+
"style": "default",
|
4 |
+
"rsc": true,
|
5 |
+
"tsx": true,
|
6 |
+
"tailwind": {
|
7 |
+
"config": "tailwind.config.ts",
|
8 |
+
"css": "app/globals.css",
|
9 |
+
"baseColor": "gray",
|
10 |
+
"cssVariables": true,
|
11 |
+
"prefix": ""
|
12 |
+
},
|
13 |
+
"aliases": {
|
14 |
+
"components": "@/components",
|
15 |
+
"utils": "@/app"
|
16 |
+
}
|
17 |
+
}
|
frontend/components/App.tsx
ADDED
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import React, { useState } from "react";
|
4 |
+
|
5 |
+
import { useDaily } from "@daily-co/daily-react";
|
6 |
+
import Setup from "./Setup";
|
7 |
+
import Story from "./Story";
|
8 |
+
|
9 |
+
type State =
|
10 |
+
| "idle"
|
11 |
+
| "connecting"
|
12 |
+
| "connected"
|
13 |
+
| "started"
|
14 |
+
| "finished"
|
15 |
+
| "error";
|
16 |
+
|
17 |
+
export default function Call() {
|
18 |
+
const daily = useDaily();
|
19 |
+
|
20 |
+
const [state, setState] = useState<State>("idle");
|
21 |
+
const [room, setRoom] = useState<string | null>(null);
|
22 |
+
|
23 |
+
async function start() {
|
24 |
+
setState("connecting");
|
25 |
+
|
26 |
+
if (!daily) return;
|
27 |
+
|
28 |
+
// Create a new room for the story session
|
29 |
+
try {
|
30 |
+
const response = await fetch("/start_bot", {
|
31 |
+
method: "POST",
|
32 |
+
headers: {
|
33 |
+
"Content-Type": "application/json",
|
34 |
+
},
|
35 |
+
});
|
36 |
+
|
37 |
+
const { room_url, token } = await response.json();
|
38 |
+
|
39 |
+
// Keep a reference to the room url for later
|
40 |
+
setRoom(room_url);
|
41 |
+
|
42 |
+
// Join the WebRTC session
|
43 |
+
await daily.join({
|
44 |
+
url: room_url,
|
45 |
+
token,
|
46 |
+
videoSource: false,
|
47 |
+
startAudioOff: true,
|
48 |
+
});
|
49 |
+
|
50 |
+
setState("connected");
|
51 |
+
|
52 |
+
// Disable local audio, the bot will say hello first
|
53 |
+
daily.setLocalAudio(false);
|
54 |
+
|
55 |
+
setState("started");
|
56 |
+
} catch (error) {
|
57 |
+
setState("error");
|
58 |
+
}
|
59 |
+
}
|
60 |
+
|
61 |
+
async function leave() {
|
62 |
+
await daily?.leave();
|
63 |
+
setState("finished");
|
64 |
+
}
|
65 |
+
|
66 |
+
if (state === "error") {
|
67 |
+
return (
|
68 |
+
<div className="flex items-center mx-auto">
|
69 |
+
<p className="text-red-500 font-semibold bg-white px-4 py-2 shadow-xl rounded-lg">
|
70 |
+
This demo is currently at capacity. Please try again later.
|
71 |
+
</p>
|
72 |
+
</div>
|
73 |
+
);
|
74 |
+
}
|
75 |
+
|
76 |
+
if (state === "started") {
|
77 |
+
return <Story handleLeave={() => leave()} />;
|
78 |
+
}
|
79 |
+
|
80 |
+
return <Setup handleStart={() => start()} />;
|
81 |
+
}
|
frontend/components/AudioIndicator/index.tsx
ADDED
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
useAudioLevel,
|
3 |
+
useAudioTrack,
|
4 |
+
useLocalSessionId,
|
5 |
+
} from "@daily-co/daily-react";
|
6 |
+
import { useCallback, useRef } from "react";
|
7 |
+
|
8 |
+
export const AudioIndicator: React.FC = () => {
|
9 |
+
const localSessionId = useLocalSessionId();
|
10 |
+
const audioTrack = useAudioTrack(localSessionId);
|
11 |
+
const volRef = useRef<HTMLDivElement>(null);
|
12 |
+
|
13 |
+
useAudioLevel(
|
14 |
+
audioTrack?.persistentTrack,
|
15 |
+
useCallback((volume) => {
|
16 |
+
// this volume number will be between 0 and 1
|
17 |
+
// give it a minimum scale of 0.15 to not completely disappear 👻
|
18 |
+
if (volRef.current) {
|
19 |
+
const v = volume * 1.75;
|
20 |
+
volRef.current.style.transform = `scale(${Math.max(0.1, v)})`;
|
21 |
+
}
|
22 |
+
}, [])
|
23 |
+
);
|
24 |
+
|
25 |
+
// Your audio track's audio volume visualized in a small circle,
|
26 |
+
// whose size changes depending on the volume level
|
27 |
+
return (
|
28 |
+
<>
|
29 |
+
<div className="vol bg-teal-700" ref={volRef} />
|
30 |
+
<style jsx>{`
|
31 |
+
.vol {
|
32 |
+
position: absolute;
|
33 |
+
overflow: hidden;
|
34 |
+
inset: 0px;
|
35 |
+
z-index: 0;
|
36 |
+
border-radius: 999px;
|
37 |
+
transition: all 0.1s ease;
|
38 |
+
transform: scale(0);
|
39 |
+
}
|
40 |
+
`}</style>
|
41 |
+
</>
|
42 |
+
);
|
43 |
+
};
|
44 |
+
|
45 |
+
export const AudioIndicatorBar: React.FC = () => {
|
46 |
+
const localSessionId = useLocalSessionId();
|
47 |
+
const audioTrack = useAudioTrack(localSessionId);
|
48 |
+
|
49 |
+
const volRef = useRef<HTMLDivElement>(null);
|
50 |
+
|
51 |
+
useAudioLevel(
|
52 |
+
audioTrack?.persistentTrack,
|
53 |
+
useCallback((volume) => {
|
54 |
+
if (volRef.current)
|
55 |
+
volRef.current.style.width = Math.max(2, volume * 100) + "%";
|
56 |
+
}, [])
|
57 |
+
);
|
58 |
+
|
59 |
+
return (
|
60 |
+
<div className="flex-1 bg-gray-200 h-[8px] rounded-full overflow-hidden">
|
61 |
+
<div
|
62 |
+
className="bg-green-500 h-[8px] w-[0] rounded-full transition-all duration-100 ease"
|
63 |
+
ref={volRef}
|
64 |
+
/>
|
65 |
+
</div>
|
66 |
+
);
|
67 |
+
};
|
68 |
+
|
69 |
+
export default AudioIndicator;
|
frontend/components/DevicePicker/index.tsx
ADDED
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import { useEffect } from "react";
|
4 |
+
import { DailyMeetingState } from "@daily-co/daily-js";
|
5 |
+
import { useDaily, useDevices } from "@daily-co/daily-react";
|
6 |
+
import {
|
7 |
+
Select,
|
8 |
+
SelectContent,
|
9 |
+
SelectItem,
|
10 |
+
SelectTrigger,
|
11 |
+
SelectValue,
|
12 |
+
} from "@/components/ui/select";
|
13 |
+
import { IconMicrophone, IconDeviceSpeaker } from "@tabler/icons-react";
|
14 |
+
import { AudioIndicatorBar } from "../AudioIndicator";
|
15 |
+
|
16 |
+
interface Props {}
|
17 |
+
|
18 |
+
export default function DevicePicker({}: Props) {
|
19 |
+
const daily = useDaily();
|
20 |
+
const {
|
21 |
+
currentMic,
|
22 |
+
hasMicError,
|
23 |
+
micState,
|
24 |
+
microphones,
|
25 |
+
setMicrophone,
|
26 |
+
currentSpeaker,
|
27 |
+
speakers,
|
28 |
+
setSpeaker,
|
29 |
+
} = useDevices();
|
30 |
+
|
31 |
+
const handleMicrophoneChange = (value: string) => {
|
32 |
+
setMicrophone(value);
|
33 |
+
};
|
34 |
+
|
35 |
+
const handleSpeakerChange = (value: string) => {
|
36 |
+
setSpeaker(value);
|
37 |
+
};
|
38 |
+
|
39 |
+
useEffect(() => {
|
40 |
+
if (microphones.length > 0 || !daily || daily.isDestroyed()) return;
|
41 |
+
const meetingState = daily.meetingState();
|
42 |
+
const meetingStatesBeforeJoin: DailyMeetingState[] = [
|
43 |
+
"new",
|
44 |
+
"loading",
|
45 |
+
"loaded",
|
46 |
+
];
|
47 |
+
if (meetingStatesBeforeJoin.includes(meetingState)) {
|
48 |
+
daily.startCamera({ startVideoOff: true, startAudioOff: false });
|
49 |
+
}
|
50 |
+
}, [daily, microphones]);
|
51 |
+
|
52 |
+
return (
|
53 |
+
<div className="flex flex-col gap-5">
|
54 |
+
<section>
|
55 |
+
<label className="uppercase text-sm tracking-wider text-gray-500">
|
56 |
+
Microphone:
|
57 |
+
</label>
|
58 |
+
<div className="flex flex-row gap-4 items-center mt-2">
|
59 |
+
<IconMicrophone size={24} />
|
60 |
+
<div className="flex flex-col flex-1 gap-3">
|
61 |
+
<Select onValueChange={handleMicrophoneChange}>
|
62 |
+
<SelectTrigger className="">
|
63 |
+
<SelectValue
|
64 |
+
placeholder={
|
65 |
+
hasMicError ? "error" : currentMic?.device?.label
|
66 |
+
}
|
67 |
+
/>
|
68 |
+
</SelectTrigger>
|
69 |
+
<SelectContent>
|
70 |
+
{hasMicError && (
|
71 |
+
<option value="error" disabled>
|
72 |
+
No microphone access.
|
73 |
+
</option>
|
74 |
+
)}
|
75 |
+
|
76 |
+
{microphones.map((m) => (
|
77 |
+
<SelectItem key={m.device.deviceId} value={m.device.deviceId}>
|
78 |
+
{m.device.label}
|
79 |
+
</SelectItem>
|
80 |
+
))}
|
81 |
+
</SelectContent>
|
82 |
+
</Select>
|
83 |
+
<AudioIndicatorBar />
|
84 |
+
</div>
|
85 |
+
</div>
|
86 |
+
</section>
|
87 |
+
|
88 |
+
<section>
|
89 |
+
<label className="uppercase text-sm tracking-wider text-gray-500">
|
90 |
+
Speakers:
|
91 |
+
</label>
|
92 |
+
<div className="flex flex-row gap-4 items-center mt-2">
|
93 |
+
<IconDeviceSpeaker size={24} />
|
94 |
+
<Select onValueChange={handleSpeakerChange}>
|
95 |
+
<SelectTrigger className="">
|
96 |
+
<SelectValue placeholder={currentSpeaker?.device?.label} />
|
97 |
+
</SelectTrigger>
|
98 |
+
<SelectContent>
|
99 |
+
{speakers.map((m) => (
|
100 |
+
<SelectItem key={m.device.deviceId} value={m.device.deviceId}>
|
101 |
+
{m.device.label}
|
102 |
+
</SelectItem>
|
103 |
+
))}
|
104 |
+
</SelectContent>
|
105 |
+
</Select>
|
106 |
+
</div>
|
107 |
+
</section>
|
108 |
+
{hasMicError && (
|
109 |
+
<div className="error">
|
110 |
+
{micState === "blocked" ? (
|
111 |
+
<p className="text-red-500">
|
112 |
+
Please check your browser and system permissions. Make sure that
|
113 |
+
this app is allowed to access your microphone.
|
114 |
+
</p>
|
115 |
+
) : micState === "in-use" ? (
|
116 |
+
<p className="text-red-500">
|
117 |
+
Your microphone is being used by another app. Please close any
|
118 |
+
other apps using your microphone and restart this app.
|
119 |
+
</p>
|
120 |
+
) : micState === "not-found" ? (
|
121 |
+
<p className="text-red-500">
|
122 |
+
No microphone seems to be connected. Please connect a microphone.
|
123 |
+
</p>
|
124 |
+
) : micState === "not-supported" ? (
|
125 |
+
<p className="text-red-500">
|
126 |
+
This app is not supported on your device. Please update your
|
127 |
+
software or use a different device.
|
128 |
+
</p>
|
129 |
+
) : (
|
130 |
+
<p className="text-red-500">
|
131 |
+
There seems to be an issue accessing your microphone. Try
|
132 |
+
restarting the app or consult a system administrator.
|
133 |
+
</p>
|
134 |
+
)}
|
135 |
+
</div>
|
136 |
+
)}
|
137 |
+
</div>
|
138 |
+
);
|
139 |
+
}
|
frontend/components/MicToggle/index.tsx
ADDED
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
useDaily,
|
3 |
+
useLocalSessionId,
|
4 |
+
useMediaTrack,
|
5 |
+
} from "@daily-co/daily-react";
|
6 |
+
import { useCallback } from "react";
|
7 |
+
import { IconMicrophone, IconMicrophoneOff } from "@tabler/icons-react";
|
8 |
+
|
9 |
+
export const MicToggle: React.FC = () => {
|
10 |
+
const daily = useDaily();
|
11 |
+
const localSessionId = useLocalSessionId();
|
12 |
+
const audioTrack = useMediaTrack(localSessionId, "audio");
|
13 |
+
const isMicMuted =
|
14 |
+
audioTrack.state === "blocked" || audioTrack.state === "off";
|
15 |
+
|
16 |
+
const handleClick = useCallback(() => {
|
17 |
+
if (!daily) return;
|
18 |
+
daily.setLocalAudio(isMicMuted);
|
19 |
+
}, [daily, isMicMuted]);
|
20 |
+
|
21 |
+
const text = isMicMuted ? (
|
22 |
+
<IconMicrophone size={21} />
|
23 |
+
) : (
|
24 |
+
<IconMicrophoneOff size={21} />
|
25 |
+
);
|
26 |
+
|
27 |
+
return (
|
28 |
+
<button className="MicToggle UIButton" onClick={handleClick}>
|
29 |
+
{text}
|
30 |
+
</button>
|
31 |
+
);
|
32 |
+
};
|
33 |
+
|
34 |
+
export default MicToggle;
|
frontend/components/Setup.tsx
ADDED
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react";
|
2 |
+
import { Button } from "@/components/ui/button";
|
3 |
+
import DevicePicker from "@/components/DevicePicker";
|
4 |
+
import { IconAlertCircle, IconEar, IconLoader2 } from "@tabler/icons-react";
|
5 |
+
|
6 |
+
type SetupProps = {
|
7 |
+
handleStart: () => void;
|
8 |
+
};
|
9 |
+
|
10 |
+
const buttonLabel = {
|
11 |
+
intro: "Next",
|
12 |
+
setup: "Let's begin!",
|
13 |
+
loading: "Joining...",
|
14 |
+
};
|
15 |
+
export const Setup: React.FC<SetupProps> = ({ handleStart }) => {
|
16 |
+
const [state, setState] = React.useState<"intro" | "setup" | "loading">(
|
17 |
+
"intro"
|
18 |
+
);
|
19 |
+
|
20 |
+
return (
|
21 |
+
<div className="w-full flex flex-col items-center justify-between">
|
22 |
+
<div className="bg-white rounded-3xl cardAnim cardShadow p-9 max-w-screen-sm mx-auto outline outline-[5px] outline-gray-600/10 my-auto">
|
23 |
+
<div className="flex flex-col gap-6">
|
24 |
+
<h1 className="text-4xl font-bold text-pretty tracking-tighter mb-4">
|
25 |
+
Welcome to <span className="text-sky-500">Storytime</span>
|
26 |
+
</h1>
|
27 |
+
{state === "intro" ? (
|
28 |
+
<>
|
29 |
+
<p className="text-gray-600 leading-relaxed text-pretty">
|
30 |
+
This app demos a voice-controlled storytelling chatbot. It will
|
31 |
+
start with the bot asking you what kind of story you'd like
|
32 |
+
to hear (e.g. a fairy tale, a mystery, etc.). After each scene,
|
33 |
+
the bot will pause to ask for your input. Direct the story any
|
34 |
+
way you choose!
|
35 |
+
</p>
|
36 |
+
<p className="flex flex-row gap-2 text-gray-600 font-medium">
|
37 |
+
<IconEar size={24} /> For best results, try in a quiet
|
38 |
+
environment!
|
39 |
+
</p>
|
40 |
+
<p className="flex flex-row gap-2 text-gray-600 font-medium text-red-500">
|
41 |
+
<IconAlertCircle size={24} /> This demo expires after 5 minutes.
|
42 |
+
</p>
|
43 |
+
</>
|
44 |
+
) : (
|
45 |
+
<>
|
46 |
+
<p className="text-gray-600 leading-relaxed text-pretty">
|
47 |
+
Since you'll be talking to Storybot, we need to make sure
|
48 |
+
it can hear you! Please configure your microphone and speakers
|
49 |
+
below.
|
50 |
+
</p>
|
51 |
+
<DevicePicker />
|
52 |
+
</>
|
53 |
+
)}
|
54 |
+
<hr className="border-gray-150 my-2" />
|
55 |
+
|
56 |
+
<Button
|
57 |
+
size="lg"
|
58 |
+
disabled={state === "loading"}
|
59 |
+
onClick={() => {
|
60 |
+
if (state === "intro") {
|
61 |
+
setState("setup");
|
62 |
+
} else {
|
63 |
+
setState("loading");
|
64 |
+
handleStart();
|
65 |
+
}
|
66 |
+
}}
|
67 |
+
>
|
68 |
+
{state === "loading" && (
|
69 |
+
<IconLoader2
|
70 |
+
size={21}
|
71 |
+
stroke={2}
|
72 |
+
className="mr-2 h-4 w-4 animate-spin"
|
73 |
+
/>
|
74 |
+
)}
|
75 |
+
{buttonLabel[state]}
|
76 |
+
</Button>
|
77 |
+
</div>
|
78 |
+
</div>
|
79 |
+
<footer className="flex-0 text-center font-mono text-sm text-gray-100 py-6">
|
80 |
+
<span className="bg-gray-800/70 px-3 py-1 rounded-md">
|
81 |
+
Created with{" "}
|
82 |
+
<a
|
83 |
+
href="https://git.new/ai"
|
84 |
+
className="text-violet-300 underline decoration-violet-400 hover:text-violet-100"
|
85 |
+
>
|
86 |
+
git.new/ai
|
87 |
+
</a>
|
88 |
+
</span>
|
89 |
+
</footer>
|
90 |
+
</div>
|
91 |
+
);
|
92 |
+
};
|
93 |
+
|
94 |
+
export default Setup;
|
frontend/components/Story.tsx
ADDED
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState } from "react";
|
2 |
+
import {
|
3 |
+
useDaily,
|
4 |
+
useParticipantIds,
|
5 |
+
useAppMessage,
|
6 |
+
DailyAudio,
|
7 |
+
} from "@daily-co/daily-react";
|
8 |
+
import { IconLogout, IconLoader2 } from "@tabler/icons-react";
|
9 |
+
|
10 |
+
import VideoTile from "@/components/VideoTile";
|
11 |
+
import { Button } from "@/components/ui/button";
|
12 |
+
import UserInputIndicator from "@/components/UserInputIndicator";
|
13 |
+
import WaveText from "@/components/WaveText";
|
14 |
+
|
15 |
+
interface StoryProps {
|
16 |
+
handleLeave: () => void;
|
17 |
+
}
|
18 |
+
|
19 |
+
const Story: React.FC<StoryProps> = ({ handleLeave }) => {
|
20 |
+
const daily = useDaily();
|
21 |
+
const participantIds = useParticipantIds({ filter: "remote" });
|
22 |
+
const [storyState, setStoryState] = useState<"user" | "assistant">(
|
23 |
+
"assistant"
|
24 |
+
);
|
25 |
+
|
26 |
+
useAppMessage({
|
27 |
+
onAppMessage: (e) => {
|
28 |
+
if (!daily || !e.data?.cue) return;
|
29 |
+
|
30 |
+
// Determine the UI state from the cue sent by the bot
|
31 |
+
if (e.data?.cue === "user_turn") {
|
32 |
+
// Delay enabling local mic input to avoid feedback from LLM
|
33 |
+
setTimeout(() => daily.setLocalAudio(true), 500);
|
34 |
+
setStoryState("user");
|
35 |
+
} else {
|
36 |
+
daily.setLocalAudio(false);
|
37 |
+
setStoryState("assistant");
|
38 |
+
}
|
39 |
+
},
|
40 |
+
});
|
41 |
+
|
42 |
+
return (
|
43 |
+
<div className="w-full flex flex-col flex-1 self-stretch">
|
44 |
+
{/* Absolute elements */}
|
45 |
+
<div className="absolute top-20 w-full text-center z-50">
|
46 |
+
<WaveText active={storyState === "user"} />
|
47 |
+
</div>
|
48 |
+
<header className="flex absolute top-0 w-full z-50 p-6 justify-end">
|
49 |
+
<Button variant="secondary" onClick={() => handleLeave()}>
|
50 |
+
<IconLogout size={21} className="mr-2" />
|
51 |
+
Exit
|
52 |
+
</Button>
|
53 |
+
</header>
|
54 |
+
<div className="absolute inset-0 bg-gray-800 bg-opacity-90 z-10 fade-in"></div>
|
55 |
+
|
56 |
+
{/* Static elements */}
|
57 |
+
<div className="relative z-20 flex-1 flex items-center justify-center">
|
58 |
+
{participantIds.length >= 1 ? (
|
59 |
+
<VideoTile
|
60 |
+
sessionId={participantIds[0]}
|
61 |
+
inactive={storyState === "user"}
|
62 |
+
/>
|
63 |
+
) : (
|
64 |
+
<span className="p-3 rounded-full bg-gray-900/60 animate-pulse">
|
65 |
+
<IconLoader2
|
66 |
+
size={42}
|
67 |
+
stroke={2}
|
68 |
+
className="animate-spin text-white z-20 self-center"
|
69 |
+
/>
|
70 |
+
</span>
|
71 |
+
)}
|
72 |
+
<DailyAudio />
|
73 |
+
</div>
|
74 |
+
<UserInputIndicator active={storyState === "user"} />
|
75 |
+
</div>
|
76 |
+
);
|
77 |
+
};
|
78 |
+
|
79 |
+
export default Story;
|
frontend/components/StoryTranscript/StoryTranscript.module.css
ADDED
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.container{
|
2 |
+
position: absolute;
|
3 |
+
color:white;
|
4 |
+
z-index: 50;
|
5 |
+
margin: 0 auto;
|
6 |
+
display: flex;
|
7 |
+
flex-direction: column;
|
8 |
+
@apply gap-4 inset-6;
|
9 |
+
align-items: center;
|
10 |
+
justify-content: end;
|
11 |
+
text-align: center;
|
12 |
+
}
|
13 |
+
|
14 |
+
.transcript{
|
15 |
+
@apply font-semibold;
|
16 |
+
}
|
17 |
+
|
18 |
+
.transcript span{
|
19 |
+
box-decoration-break: clone;
|
20 |
+
@apply bg-gray-900/80 rounded-md px-4 py-2;
|
21 |
+
}
|
22 |
+
|
23 |
+
.sentence{
|
24 |
+
opacity: 1;
|
25 |
+
animation: fadeOut 2.5s linear forwards;
|
26 |
+
animation-delay: 1s;
|
27 |
+
}
|
28 |
+
@keyframes fadeOut {
|
29 |
+
0% {
|
30 |
+
opacity: 1;
|
31 |
+
transform: scale(1);
|
32 |
+
}
|
33 |
+
20% {
|
34 |
+
transform: scale(1);
|
35 |
+
filter: blur(0);
|
36 |
+
}
|
37 |
+
100% {
|
38 |
+
transform: scale(0.8) translateY(-50%);
|
39 |
+
filter: blur(25px);
|
40 |
+
opacity: 0;
|
41 |
+
}
|
42 |
+
}
|
frontend/components/StoryTranscript/index.tsx
ADDED
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import React, { useEffect, useRef, useState } from "react";
|
4 |
+
import { useAppMessage } from "@daily-co/daily-react";
|
5 |
+
import { DailyEventObjectAppMessage } from "@daily-co/daily-js";
|
6 |
+
|
7 |
+
import styles from "./StoryTranscript.module.css";
|
8 |
+
|
9 |
+
export default function StoryTranscript() {
|
10 |
+
const [partialText, setPartialText] = useState<string>("");
|
11 |
+
const [sentences, setSentences] = useState<string[]>([]);
|
12 |
+
const intervalRef = useRef<any | null>(null);
|
13 |
+
|
14 |
+
useEffect(() => {
|
15 |
+
clearInterval(intervalRef.current);
|
16 |
+
|
17 |
+
intervalRef.current = setInterval(() => {
|
18 |
+
if (sentences.length > 2) {
|
19 |
+
setSentences((s) => s.slice(1));
|
20 |
+
}
|
21 |
+
}, 2500);
|
22 |
+
|
23 |
+
return () => clearInterval(intervalRef.current);
|
24 |
+
}, [sentences]);
|
25 |
+
|
26 |
+
useAppMessage({
|
27 |
+
onAppMessage: (e: DailyEventObjectAppMessage<any>) => {
|
28 |
+
if (e.fromId && e.fromId === "transcription") {
|
29 |
+
// Check for LLM transcripts only
|
30 |
+
if (e.data.user_id !== "") {
|
31 |
+
setPartialText(e.data.text);
|
32 |
+
if (e.data.is_final) {
|
33 |
+
setPartialText("");
|
34 |
+
setSentences((s) => [...s, e.data.text]);
|
35 |
+
}
|
36 |
+
}
|
37 |
+
}
|
38 |
+
},
|
39 |
+
});
|
40 |
+
|
41 |
+
return (
|
42 |
+
<div className={styles.container}>
|
43 |
+
{sentences.map((sentence, index) => (
|
44 |
+
<p key={index} className={`${styles.transcript} ${styles.sentence}`}>
|
45 |
+
<span>{sentence}</span>
|
46 |
+
</p>
|
47 |
+
))}
|
48 |
+
{partialText && (
|
49 |
+
<p className={`${styles.transcript}`}>
|
50 |
+
<span>{partialText}</span>
|
51 |
+
</p>
|
52 |
+
)}
|
53 |
+
</div>
|
54 |
+
);
|
55 |
+
}
|
frontend/components/UserInputIndicator/UserInputIndicator.module.css
ADDED
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.panel{
|
2 |
+
width: 100%;
|
3 |
+
pointer-events: none;
|
4 |
+
color: #ffffff;
|
5 |
+
text-align: center;
|
6 |
+
position: relative;
|
7 |
+
@apply pb-8;
|
8 |
+
}
|
9 |
+
|
10 |
+
.panel::after{
|
11 |
+
content: "";
|
12 |
+
position: absolute;
|
13 |
+
inset: 0px;
|
14 |
+
z-index: 10;
|
15 |
+
opacity: 0;
|
16 |
+
transition: all 0.3s ease;
|
17 |
+
@apply bg-gradient-to-t from-gray-950 to-transparent;
|
18 |
+
}
|
19 |
+
|
20 |
+
.active::after{ opacity: 1;}
|
21 |
+
|
22 |
+
.micIcon{
|
23 |
+
position: relative;
|
24 |
+
width: 120px;
|
25 |
+
height: 120px;
|
26 |
+
border-radius: 120px;
|
27 |
+
border: 6px solid;
|
28 |
+
outline: 6px solid;
|
29 |
+
display: flex;
|
30 |
+
justify-content: center;
|
31 |
+
align-items: center;
|
32 |
+
margin: 0 auto;
|
33 |
+
z-index: 20;
|
34 |
+
transition: all 0.5s ease;
|
35 |
+
@apply bg-gray-700/60 border-gray-600 outline-gray-900/20;
|
36 |
+
|
37 |
+
}
|
38 |
+
|
39 |
+
.micIcon svg{
|
40 |
+
position: relative;
|
41 |
+
z-index: 20;
|
42 |
+
opacity: 0.25;
|
43 |
+
transition: opacity 0.5s ease;
|
44 |
+
}
|
45 |
+
|
46 |
+
|
47 |
+
@keyframes pulse {
|
48 |
+
0% {
|
49 |
+
outline-width: 6px;
|
50 |
+
@apply outline-teal-500/10;
|
51 |
+
}
|
52 |
+
50% {
|
53 |
+
outline-width: 24px;
|
54 |
+
@apply outline-teal-500/50;
|
55 |
+
}
|
56 |
+
100% {
|
57 |
+
outline-width: 6px;
|
58 |
+
@apply outline-teal-500/10;
|
59 |
+
}
|
60 |
+
}
|
61 |
+
|
62 |
+
.micIconActive{
|
63 |
+
@apply bg-teal-950 border-teal-500 outline-teal-500/20;
|
64 |
+
animation: pulse 2s infinite ease-in-out;
|
65 |
+
}
|
66 |
+
|
67 |
+
.micIconActive svg{
|
68 |
+
opacity: 1;
|
69 |
+
}
|
70 |
+
|
71 |
+
.transcript{
|
72 |
+
flex: 0;
|
73 |
+
align-self: center;
|
74 |
+
opacity: 0.25;
|
75 |
+
transition: opacity 1s ease;
|
76 |
+
transition-delay: 2.5s;
|
77 |
+
@apply bg-gray-900/90 font-medium py-1 px-2 rounded-sm mt-4;
|
78 |
+
}
|
79 |
+
|
80 |
+
.active .transcript{
|
81 |
+
opacity: 1;
|
82 |
+
}
|
frontend/components/UserInputIndicator/index.tsx
ADDED
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useEffect } from "react";
|
2 |
+
|
3 |
+
import { useAppMessage } from "@daily-co/daily-react";
|
4 |
+
import { DailyEventObjectAppMessage } from "@daily-co/daily-js";
|
5 |
+
import styles from "./UserInputIndicator.module.css";
|
6 |
+
import { IconMicrophone } from "@tabler/icons-react";
|
7 |
+
import { TypewriterEffect } from "../ui/typewriter";
|
8 |
+
import AudioIndicator from "../AudioIndicator";
|
9 |
+
|
10 |
+
interface Props {
|
11 |
+
active: boolean;
|
12 |
+
}
|
13 |
+
|
14 |
+
export default function UserInputIndicator({ active }: Props) {
|
15 |
+
const [transcription, setTranscription] = useState<string[]>([]);
|
16 |
+
|
17 |
+
useAppMessage({
|
18 |
+
onAppMessage: (e: DailyEventObjectAppMessage<any>) => {
|
19 |
+
if (e.fromId && e.fromId === "transcription") {
|
20 |
+
if (e.data.user_id === "" && e.data.is_final) {
|
21 |
+
setTranscription((t) => [...t, ...e.data.text.split(" ")]);
|
22 |
+
}
|
23 |
+
}
|
24 |
+
},
|
25 |
+
});
|
26 |
+
|
27 |
+
useEffect(() => {
|
28 |
+
if (active) return;
|
29 |
+
const t = setTimeout(() => setTranscription([]), 4000);
|
30 |
+
return () => clearTimeout(t);
|
31 |
+
}, [active]);
|
32 |
+
|
33 |
+
return (
|
34 |
+
<div className={`${styles.panel} ${active ? styles.active : ""}`}>
|
35 |
+
<div className="relative z-20 flex flex-col">
|
36 |
+
<div
|
37 |
+
className={`${styles.micIcon} ${active ? styles.micIconActive : ""}`}
|
38 |
+
>
|
39 |
+
<IconMicrophone size={42} />
|
40 |
+
{active && <AudioIndicator />}
|
41 |
+
</div>
|
42 |
+
<footer className={styles.transcript}>
|
43 |
+
<TypewriterEffect words={transcription} />
|
44 |
+
</footer>
|
45 |
+
</div>
|
46 |
+
</div>
|
47 |
+
);
|
48 |
+
}
|
frontend/components/VideoTile/VideoTile.module.css
ADDED
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.container{
|
2 |
+
position: relative;
|
3 |
+
animation: fadeIn 3s ease;
|
4 |
+
transition: all 3s ease-out;
|
5 |
+
}
|
6 |
+
|
7 |
+
.videoTile{
|
8 |
+
@apply bg-gray-950;
|
9 |
+
width: 560px;
|
10 |
+
height: 560px;
|
11 |
+
mask-image: url('/alpha-mask.gif');
|
12 |
+
mask-size: cover;
|
13 |
+
mask-repeat: no-repeat;
|
14 |
+
margin:0 auto;
|
15 |
+
z-index: 10;
|
16 |
+
position: relative;
|
17 |
+
}
|
18 |
+
|
19 |
+
.inactive{
|
20 |
+
opacity: 0.7;
|
21 |
+
filter:blur(3px);
|
22 |
+
transform: scale(0.95)
|
23 |
+
}
|
24 |
+
|
25 |
+
@keyframes fadeIn {
|
26 |
+
0% {
|
27 |
+
filter: blur(100px);
|
28 |
+
opacity: 0;
|
29 |
+
}
|
30 |
+
100% {
|
31 |
+
filter: blur(0px);
|
32 |
+
opacity: 1;
|
33 |
+
}
|
34 |
+
}
|
frontend/components/VideoTile/index.tsx
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react";
|
2 |
+
import styles from "./VideoTile.module.css";
|
3 |
+
import { DailyVideo } from "@daily-co/daily-react";
|
4 |
+
import StoryTranscript from "@/components/StoryTranscript";
|
5 |
+
|
6 |
+
interface Props {
|
7 |
+
sessionId: string;
|
8 |
+
inactive: boolean;
|
9 |
+
}
|
10 |
+
|
11 |
+
const VideoTile = ({ sessionId, inactive }: Props) => {
|
12 |
+
return (
|
13 |
+
<div className={`${styles.container} ${inactive ? styles.inactive : ""} `}>
|
14 |
+
<StoryTranscript />
|
15 |
+
|
16 |
+
<div className={styles.videoTile}>
|
17 |
+
<DailyVideo
|
18 |
+
sessionId={sessionId}
|
19 |
+
type={"video"}
|
20 |
+
className="aspect-square"
|
21 |
+
/>
|
22 |
+
</div>
|
23 |
+
</div>
|
24 |
+
);
|
25 |
+
};
|
26 |
+
|
27 |
+
export default VideoTile;
|
frontend/components/WaveText/WaveText.module.css
ADDED
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.waveText{
|
2 |
+
color: white;
|
3 |
+
text-shadow: 3px 3px 0px rgba(0,0,0,0.5);
|
4 |
+
opacity: 0;
|
5 |
+
transition: opacity 2s ease;
|
6 |
+
position: relative;
|
7 |
+
@apply text-4xl font-bold;
|
8 |
+
}
|
9 |
+
|
10 |
+
.active{
|
11 |
+
opacity: 1;
|
12 |
+
}
|
13 |
+
|
14 |
+
@keyframes jump {
|
15 |
+
0% {
|
16 |
+
opacity: 0.5;
|
17 |
+
transform:translateY(0px)
|
18 |
+
}
|
19 |
+
50% {
|
20 |
+
opacity: 1;
|
21 |
+
transform:translateY(-30px);
|
22 |
+
}
|
23 |
+
100% {
|
24 |
+
opacity: 0.55;
|
25 |
+
transform:translateY(0px)
|
26 |
+
}
|
27 |
+
}
|
28 |
+
|
29 |
+
.waveText span{
|
30 |
+
display:inline-block;
|
31 |
+
animation:jump 2s infinite ease-in-out;
|
32 |
+
|
33 |
+
}
|
34 |
+
|
35 |
+
.waveText span:nth-child(1) {
|
36 |
+
animation-delay:0s;
|
37 |
+
}
|
38 |
+
|
39 |
+
.waveText span:nth-child(1) {
|
40 |
+
animation-delay:0.1s;
|
41 |
+
}
|
42 |
+
.waveText span:nth-child(2) {
|
43 |
+
animation-delay:0.2s;
|
44 |
+
}
|
45 |
+
.waveText span:nth-child(3) {
|
46 |
+
animation-delay:0.3s;
|
47 |
+
}
|
48 |
+
.waveText span:nth-child(4) {
|
49 |
+
animation-delay:0.4s;
|
50 |
+
}
|
51 |
+
.waveText span:nth-child(5) {
|
52 |
+
animation-delay:0.5s;
|
53 |
+
}
|
54 |
+
.waveText span:nth-child(6) {
|
55 |
+
animation-delay:0.6s;
|
56 |
+
}
|
57 |
+
.waveText span:nth-child(7) {
|
58 |
+
animation-delay:0.7s;
|
59 |
+
}
|
60 |
+
.waveText span:nth-child(8) {
|
61 |
+
animation-delay:0.8s;
|
62 |
+
}
|
63 |
+
.waveText span:nth-child(9) {
|
64 |
+
animation-delay:0.9s;
|
65 |
+
}
|
66 |
+
.waveText span:nth-child(10) {
|
67 |
+
animation-delay:1s;
|
68 |
+
}
|
frontend/components/WaveText/index.tsx
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react";
|
2 |
+
import styles from "./WaveText.module.css";
|
3 |
+
|
4 |
+
interface Props {
|
5 |
+
active: boolean;
|
6 |
+
}
|
7 |
+
|
8 |
+
export default function WaveText({ active }: Props) {
|
9 |
+
return (
|
10 |
+
<div className={`${styles.waveText} ${active ? styles.active : ""}`}>
|
11 |
+
<span>W</span>
|
12 |
+
<span>h</span>
|
13 |
+
<span>a</span>
|
14 |
+
<span>t</span>
|
15 |
+
<span> </span>
|
16 |
+
<span>n</span>
|
17 |
+
<span>e</span>
|
18 |
+
<span>x</span>
|
19 |
+
<span>t</span>
|
20 |
+
<span>?</span>
|
21 |
+
</div>
|
22 |
+
);
|
23 |
+
}
|
frontend/components/ui/button.tsx
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react";
|
2 |
+
import { Slot } from "@radix-ui/react-slot";
|
3 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
4 |
+
|
5 |
+
import { cn } from "@/app/utils";
|
6 |
+
|
7 |
+
const buttonVariants = cva(
|
8 |
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-md font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
9 |
+
{
|
10 |
+
variants: {
|
11 |
+
variant: {
|
12 |
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
13 |
+
destructive:
|
14 |
+
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
15 |
+
outline:
|
16 |
+
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
17 |
+
secondary:
|
18 |
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
19 |
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
20 |
+
link: "text-primary underline-offset-4 hover:underline",
|
21 |
+
},
|
22 |
+
size: {
|
23 |
+
default: "h-11 rounded-lg px-6 py-2",
|
24 |
+
sm: "h-9 rounded-md px-3",
|
25 |
+
lg: "h-12 rounded-xl px-8",
|
26 |
+
icon: "h-10 w-10",
|
27 |
+
},
|
28 |
+
},
|
29 |
+
defaultVariants: {
|
30 |
+
variant: "default",
|
31 |
+
size: "default",
|
32 |
+
},
|
33 |
+
}
|
34 |
+
);
|
35 |
+
|
36 |
+
export interface ButtonProps
|
37 |
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
38 |
+
VariantProps<typeof buttonVariants> {
|
39 |
+
asChild?: boolean;
|
40 |
+
}
|
41 |
+
|
42 |
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
43 |
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
44 |
+
const Comp = asChild ? Slot : "button";
|
45 |
+
return (
|
46 |
+
<Comp
|
47 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
48 |
+
ref={ref}
|
49 |
+
{...props}
|
50 |
+
/>
|
51 |
+
);
|
52 |
+
}
|
53 |
+
);
|
54 |
+
Button.displayName = "Button";
|
55 |
+
|
56 |
+
export { Button, buttonVariants };
|
frontend/components/ui/select.tsx
ADDED
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import * as React from "react";
|
4 |
+
import * as SelectPrimitive from "@radix-ui/react-select";
|
5 |
+
import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
6 |
+
|
7 |
+
import { cn } from "@/app/utils";
|
8 |
+
|
9 |
+
const Select = SelectPrimitive.Root;
|
10 |
+
|
11 |
+
const SelectGroup = SelectPrimitive.Group;
|
12 |
+
|
13 |
+
const SelectValue = SelectPrimitive.Value;
|
14 |
+
|
15 |
+
const SelectTrigger = React.forwardRef<
|
16 |
+
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
17 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
18 |
+
>(({ className, children, ...props }, ref) => (
|
19 |
+
<SelectPrimitive.Trigger
|
20 |
+
ref={ref}
|
21 |
+
className={cn(
|
22 |
+
"flex gap-3 h-12 w-full items-center justify-between rounded-xl border border-input bg-background px-4 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
23 |
+
className
|
24 |
+
)}
|
25 |
+
{...props}
|
26 |
+
>
|
27 |
+
{children}
|
28 |
+
<SelectPrimitive.Icon asChild>
|
29 |
+
<IconChevronDown className="h-4 w-4 opacity-50" />
|
30 |
+
</SelectPrimitive.Icon>
|
31 |
+
</SelectPrimitive.Trigger>
|
32 |
+
));
|
33 |
+
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
34 |
+
|
35 |
+
const SelectScrollUpButton = React.forwardRef<
|
36 |
+
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
37 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
38 |
+
>(({ className, ...props }, ref) => (
|
39 |
+
<SelectPrimitive.ScrollUpButton
|
40 |
+
ref={ref}
|
41 |
+
className={cn(
|
42 |
+
"flex cursor-default items-center justify-center py-1",
|
43 |
+
className
|
44 |
+
)}
|
45 |
+
{...props}
|
46 |
+
>
|
47 |
+
<IconChevronUp className="h-4 w-4" />
|
48 |
+
</SelectPrimitive.ScrollUpButton>
|
49 |
+
));
|
50 |
+
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
51 |
+
|
52 |
+
const SelectScrollDownButton = React.forwardRef<
|
53 |
+
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
54 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
55 |
+
>(({ className, ...props }, ref) => (
|
56 |
+
<SelectPrimitive.ScrollDownButton
|
57 |
+
ref={ref}
|
58 |
+
className={cn(
|
59 |
+
"flex cursor-default items-center justify-center py-1",
|
60 |
+
className
|
61 |
+
)}
|
62 |
+
{...props}
|
63 |
+
>
|
64 |
+
<IconChevronDown className="h-4 w-4" />
|
65 |
+
</SelectPrimitive.ScrollDownButton>
|
66 |
+
));
|
67 |
+
SelectScrollDownButton.displayName =
|
68 |
+
SelectPrimitive.ScrollDownButton.displayName;
|
69 |
+
|
70 |
+
const SelectContent = React.forwardRef<
|
71 |
+
React.ElementRef<typeof SelectPrimitive.Content>,
|
72 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
73 |
+
>(({ className, children, position = "popper", ...props }, ref) => (
|
74 |
+
<SelectPrimitive.Portal>
|
75 |
+
<SelectPrimitive.Content
|
76 |
+
ref={ref}
|
77 |
+
className={cn(
|
78 |
+
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
79 |
+
position === "popper" &&
|
80 |
+
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
81 |
+
className
|
82 |
+
)}
|
83 |
+
position={position}
|
84 |
+
{...props}
|
85 |
+
>
|
86 |
+
<SelectScrollUpButton />
|
87 |
+
<SelectPrimitive.Viewport
|
88 |
+
className={cn(
|
89 |
+
"p-1",
|
90 |
+
position === "popper" &&
|
91 |
+
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
92 |
+
)}
|
93 |
+
>
|
94 |
+
{children}
|
95 |
+
</SelectPrimitive.Viewport>
|
96 |
+
<SelectScrollDownButton />
|
97 |
+
</SelectPrimitive.Content>
|
98 |
+
</SelectPrimitive.Portal>
|
99 |
+
));
|
100 |
+
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
101 |
+
|
102 |
+
const SelectLabel = React.forwardRef<
|
103 |
+
React.ElementRef<typeof SelectPrimitive.Label>,
|
104 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
105 |
+
>(({ className, ...props }, ref) => (
|
106 |
+
<SelectPrimitive.Label
|
107 |
+
ref={ref}
|
108 |
+
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
109 |
+
{...props}
|
110 |
+
/>
|
111 |
+
));
|
112 |
+
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
113 |
+
|
114 |
+
const SelectItem = React.forwardRef<
|
115 |
+
React.ElementRef<typeof SelectPrimitive.Item>,
|
116 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
117 |
+
>(({ className, children, ...props }, ref) => (
|
118 |
+
<SelectPrimitive.Item
|
119 |
+
ref={ref}
|
120 |
+
className={cn(
|
121 |
+
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
122 |
+
className
|
123 |
+
)}
|
124 |
+
{...props}
|
125 |
+
>
|
126 |
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
127 |
+
<SelectPrimitive.ItemIndicator>
|
128 |
+
<IconCheck className="h-4 w-4" />
|
129 |
+
</SelectPrimitive.ItemIndicator>
|
130 |
+
</span>
|
131 |
+
|
132 |
+
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
133 |
+
</SelectPrimitive.Item>
|
134 |
+
));
|
135 |
+
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
136 |
+
|
137 |
+
const SelectSeparator = React.forwardRef<
|
138 |
+
React.ElementRef<typeof SelectPrimitive.Separator>,
|
139 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
140 |
+
>(({ className, ...props }, ref) => (
|
141 |
+
<SelectPrimitive.Separator
|
142 |
+
ref={ref}
|
143 |
+
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
144 |
+
{...props}
|
145 |
+
/>
|
146 |
+
));
|
147 |
+
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
148 |
+
|
149 |
+
export {
|
150 |
+
Select,
|
151 |
+
SelectGroup,
|
152 |
+
SelectValue,
|
153 |
+
SelectTrigger,
|
154 |
+
SelectContent,
|
155 |
+
SelectLabel,
|
156 |
+
SelectItem,
|
157 |
+
SelectSeparator,
|
158 |
+
SelectScrollUpButton,
|
159 |
+
SelectScrollDownButton,
|
160 |
+
};
|
frontend/components/ui/typewriter.tsx
ADDED
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import { cn } from "@/app/utils";
|
4 |
+
import { motion } from "framer-motion";
|
5 |
+
|
6 |
+
export const TypewriterEffect = ({
|
7 |
+
words,
|
8 |
+
className,
|
9 |
+
}: {
|
10 |
+
words: string[];
|
11 |
+
className?: string;
|
12 |
+
cursorClassName?: string;
|
13 |
+
}) => {
|
14 |
+
const renderWords = () => {
|
15 |
+
return (
|
16 |
+
<div>
|
17 |
+
{words.map((word, idx) => {
|
18 |
+
return (
|
19 |
+
<div key={`word-${idx}`} className="inline-block">
|
20 |
+
{word.split("").map((char, index) => (
|
21 |
+
<span key={`char-${index}`}>{char}</span>
|
22 |
+
))}
|
23 |
+
|
24 |
+
</div>
|
25 |
+
);
|
26 |
+
})}
|
27 |
+
</div>
|
28 |
+
);
|
29 |
+
};
|
30 |
+
|
31 |
+
return (
|
32 |
+
<div className={cn("flex", className)}>
|
33 |
+
{words.length < 1 ? (
|
34 |
+
<span>...</span>
|
35 |
+
) : (
|
36 |
+
<motion.div
|
37 |
+
className="overflow-hidden"
|
38 |
+
initial={{
|
39 |
+
width: "0%",
|
40 |
+
}}
|
41 |
+
whileInView={{
|
42 |
+
width: "fit-content",
|
43 |
+
}}
|
44 |
+
transition={{
|
45 |
+
duration: 0.5,
|
46 |
+
ease: "linear",
|
47 |
+
delay: 1,
|
48 |
+
}}
|
49 |
+
>
|
50 |
+
<div
|
51 |
+
style={{
|
52 |
+
whiteSpace: "nowrap",
|
53 |
+
}}
|
54 |
+
>
|
55 |
+
{renderWords()}
|
56 |
+
</div>
|
57 |
+
</motion.div>
|
58 |
+
)}
|
59 |
+
</div>
|
60 |
+
);
|
61 |
+
};
|
frontend/env.example
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
SITE_URL=
|
frontend/next.config.mjs
ADDED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/** @type {import('next').NextConfig} */
|
2 |
+
const nextConfig = {
|
3 |
+
output: "export",
|
4 |
+
|
5 |
+
async rewrites() {
|
6 |
+
return [
|
7 |
+
{
|
8 |
+
source: "/:path*",
|
9 |
+
destination: "http://localhost:7860/:path*",
|
10 |
+
},
|
11 |
+
];
|
12 |
+
},
|
13 |
+
};
|
14 |
+
|
15 |
+
export default nextConfig;
|
frontend/package.json
ADDED
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "frontend",
|
3 |
+
"version": "0.1.0",
|
4 |
+
"private": true,
|
5 |
+
"scripts": {
|
6 |
+
"dev": "next dev",
|
7 |
+
"build": "next build",
|
8 |
+
"start": "next start",
|
9 |
+
"lint": "next lint"
|
10 |
+
},
|
11 |
+
"dependencies": {
|
12 |
+
"@daily-co/daily-js": "^0.62.0",
|
13 |
+
"@daily-co/daily-react": "^0.18.0",
|
14 |
+
"@radix-ui/react-select": "^2.0.0",
|
15 |
+
"@radix-ui/react-slot": "^1.0.2",
|
16 |
+
"@tabler/icons-react": "^3.1.0",
|
17 |
+
"class-variance-authority": "^0.7.0",
|
18 |
+
"clsx": "^2.1.0",
|
19 |
+
"framer-motion": "^11.0.27",
|
20 |
+
"next": "14.1.4",
|
21 |
+
"react": "^18",
|
22 |
+
"react-dom": "^18",
|
23 |
+
"recoil": "^0.7.7",
|
24 |
+
"tailwind-merge": "^2.2.2",
|
25 |
+
"tailwindcss-animate": "^1.0.7"
|
26 |
+
},
|
27 |
+
"devDependencies": {
|
28 |
+
"@types/node": "^20",
|
29 |
+
"@types/react": "^18",
|
30 |
+
"@types/react-dom": "^18",
|
31 |
+
"autoprefixer": "^10.0.1",
|
32 |
+
"eslint": "^8",
|
33 |
+
"eslint-config-next": "14.1.4",
|
34 |
+
"postcss": "^8",
|
35 |
+
"tailwindcss": "^3.4.3",
|
36 |
+
"typescript": "^5"
|
37 |
+
}
|
38 |
+
}
|
frontend/postcss.config.js
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
module.exports = {
|
2 |
+
plugins: {
|
3 |
+
tailwindcss: {},
|
4 |
+
autoprefixer: {},
|
5 |
+
},
|
6 |
+
};
|
frontend/public/alpha-mask.gif
ADDED
frontend/public/bg.jpg
ADDED
frontend/tailwind.config.ts
ADDED
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { Config } from "tailwindcss";
|
2 |
+
|
3 |
+
import { fontFamily } from "tailwindcss/defaultTheme";
|
4 |
+
|
5 |
+
const config = {
|
6 |
+
darkMode: ["class"],
|
7 |
+
content: [
|
8 |
+
"./pages/**/*.{ts,tsx}",
|
9 |
+
"./components/**/*.{ts,tsx}",
|
10 |
+
"./app/**/*.{ts,tsx}",
|
11 |
+
"./src/**/*.{ts,tsx}",
|
12 |
+
],
|
13 |
+
prefix: "",
|
14 |
+
theme: {
|
15 |
+
container: {
|
16 |
+
center: true,
|
17 |
+
padding: "2rem",
|
18 |
+
screens: {
|
19 |
+
"2xl": "1400px",
|
20 |
+
},
|
21 |
+
},
|
22 |
+
extend: {
|
23 |
+
fontFamily: {
|
24 |
+
sans: ["var(--font-sans)", ...fontFamily.sans],
|
25 |
+
mono: ["var(--font-mono)", ...fontFamily.mono],
|
26 |
+
},
|
27 |
+
colors: {
|
28 |
+
border: "hsl(var(--border))",
|
29 |
+
input: "hsl(var(--input))",
|
30 |
+
ring: "hsl(var(--ring))",
|
31 |
+
background: "hsl(var(--background))",
|
32 |
+
foreground: "hsl(var(--foreground))",
|
33 |
+
primary: {
|
34 |
+
DEFAULT: "hsl(var(--primary))",
|
35 |
+
foreground: "hsl(var(--primary-foreground))",
|
36 |
+
},
|
37 |
+
secondary: {
|
38 |
+
DEFAULT: "hsl(var(--secondary))",
|
39 |
+
foreground: "hsl(var(--secondary-foreground))",
|
40 |
+
},
|
41 |
+
destructive: {
|
42 |
+
DEFAULT: "hsl(var(--destructive))",
|
43 |
+
foreground: "hsl(var(--destructive-foreground))",
|
44 |
+
},
|
45 |
+
muted: {
|
46 |
+
DEFAULT: "hsl(var(--muted))",
|
47 |
+
foreground: "hsl(var(--muted-foreground))",
|
48 |
+
},
|
49 |
+
accent: {
|
50 |
+
DEFAULT: "hsl(var(--accent))",
|
51 |
+
foreground: "hsl(var(--accent-foreground))",
|
52 |
+
},
|
53 |
+
popover: {
|
54 |
+
DEFAULT: "hsl(var(--popover))",
|
55 |
+
foreground: "hsl(var(--popover-foreground))",
|
56 |
+
},
|
57 |
+
card: {
|
58 |
+
DEFAULT: "hsl(var(--card))",
|
59 |
+
foreground: "hsl(var(--card-foreground))",
|
60 |
+
},
|
61 |
+
},
|
62 |
+
borderRadius: {
|
63 |
+
lg: "var(--radius)",
|
64 |
+
md: "calc(var(--radius) - 2px)",
|
65 |
+
sm: "calc(var(--radius) - 4px)",
|
66 |
+
},
|
67 |
+
keyframes: {
|
68 |
+
"accordion-down": {
|
69 |
+
from: { height: "0" },
|
70 |
+
to: { height: "var(--radix-accordion-content-height)" },
|
71 |
+
},
|
72 |
+
"accordion-up": {
|
73 |
+
from: { height: "var(--radix-accordion-content-height)" },
|
74 |
+
to: { height: "0" },
|
75 |
+
},
|
76 |
+
},
|
77 |
+
animation: {
|
78 |
+
"accordion-down": "accordion-down 0.2s ease-out",
|
79 |
+
"accordion-up": "accordion-up 0.2s ease-out",
|
80 |
+
},
|
81 |
+
},
|
82 |
+
},
|
83 |
+
plugins: [require("tailwindcss-animate")],
|
84 |
+
} satisfies Config;
|
85 |
+
|
86 |
+
export default config;
|
frontend/tsconfig.json
ADDED
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"compilerOptions": {
|
3 |
+
"lib": [
|
4 |
+
"dom",
|
5 |
+
"dom.iterable",
|
6 |
+
"esnext"
|
7 |
+
],
|
8 |
+
"allowJs": true,
|
9 |
+
"skipLibCheck": true,
|
10 |
+
"strict": true,
|
11 |
+
"noEmit": true,
|
12 |
+
"esModuleInterop": true,
|
13 |
+
"module": "esnext",
|
14 |
+
"moduleResolution": "bundler",
|
15 |
+
"resolveJsonModule": true,
|
16 |
+
"isolatedModules": true,
|
17 |
+
"jsx": "preserve",
|
18 |
+
"incremental": true,
|
19 |
+
"plugins": [
|
20 |
+
{
|
21 |
+
"name": "next"
|
22 |
+
}
|
23 |
+
],
|
24 |
+
"paths": {
|
25 |
+
"@/*": [
|
26 |
+
"./*"
|
27 |
+
]
|
28 |
+
}
|
29 |
+
},
|
30 |
+
"include": [
|
31 |
+
"next-env.d.ts",
|
32 |
+
"**/*.ts",
|
33 |
+
"**/*.tsx",
|
34 |
+
".next/types/**/*.ts",
|
35 |
+
"build/types/**/*.ts"
|
36 |
+
],
|
37 |
+
"exclude": [
|
38 |
+
"node_modules"
|
39 |
+
]
|
40 |
+
}
|
frontend/yarn.lock
ADDED
The diff for this file is too large to render.
See raw diff
|
|
image.png
ADDED
Git LFS Details
|
requirements.txt
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
async_timeout
|
2 |
+
fastapi
|
3 |
+
uvicorn
|
4 |
+
requests
|
5 |
+
python-dotenv
|
6 |
+
pipecat-ai[daily,openai,fal]
|
src/assets/book1.png
ADDED
src/assets/book2.png
ADDED
src/assets/ding.wav
ADDED
Binary file (16.7 kB). View file
|
|
src/assets/listening.wav
ADDED
Binary file (26.1 kB). View file
|
|
src/assets/talking.wav
ADDED
Binary file (24.4 kB). View file
|
|
src/bot.py
ADDED
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import argparse
|
2 |
+
import asyncio
|
3 |
+
import aiohttp
|
4 |
+
import os
|
5 |
+
import sys
|
6 |
+
|
7 |
+
|
8 |
+
from pipecat.frames.frames import LLMMessagesFrame, StopTaskFrame, EndFrame
|
9 |
+
from pipecat.pipeline.pipeline import Pipeline
|
10 |
+
from pipecat.pipeline.runner import PipelineRunner
|
11 |
+
from pipecat.pipeline.task import PipelineTask
|
12 |
+
from pipecat.processors.aggregators.llm_response import LLMAssistantResponseAggregator, LLMUserResponseAggregator
|
13 |
+
from pipecat.services.elevenlabs import ElevenLabsTTSService
|
14 |
+
from pipecat.services.fal import FalImageGenService
|
15 |
+
from pipecat.services.openai import OpenAILLMService,OpenAITTSService
|
16 |
+
from pipecat.transports.services.daily import DailyParams, DailyTransport, DailyTransportMessageFrame
|
17 |
+
|
18 |
+
from processors import StoryProcessor, StoryImageProcessor
|
19 |
+
from prompts import LLM_BASE_PROMPT, LLM_INTRO_PROMPT, CUE_USER_TURN
|
20 |
+
from utils.helpers import load_sounds, load_images
|
21 |
+
|
22 |
+
from loguru import logger
|
23 |
+
|
24 |
+
from dotenv import load_dotenv
|
25 |
+
load_dotenv(override=True)
|
26 |
+
|
27 |
+
logger.remove(0)
|
28 |
+
logger.add(sys.stderr, level="DEBUG")
|
29 |
+
|
30 |
+
sounds = load_sounds(["listening.wav"])
|
31 |
+
images = load_images(["book1.png", "book2.png"])
|
32 |
+
|
33 |
+
|
34 |
+
async def main(room_url, token=None):
|
35 |
+
async with aiohttp.ClientSession() as session:
|
36 |
+
|
37 |
+
# -------------- Transport --------------- #
|
38 |
+
|
39 |
+
transport = DailyTransport(
|
40 |
+
room_url,
|
41 |
+
token,
|
42 |
+
"Storytelling Bot",
|
43 |
+
DailyParams(
|
44 |
+
audio_out_enabled=True,
|
45 |
+
camera_out_enabled=True,
|
46 |
+
camera_out_width=768,
|
47 |
+
camera_out_height=768,
|
48 |
+
transcription_enabled=True,
|
49 |
+
vad_enabled=True,
|
50 |
+
)
|
51 |
+
)
|
52 |
+
|
53 |
+
logger.debug("Transport created for room:" + room_url)
|
54 |
+
|
55 |
+
# -------------- Services --------------- #
|
56 |
+
|
57 |
+
llm_service = OpenAILLMService(
|
58 |
+
api_key=os.getenv("OPENAI_API_KEY"),
|
59 |
+
model="gpt-3.5-turbo"
|
60 |
+
)
|
61 |
+
|
62 |
+
# tts_service = ElevenLabsTTSService(
|
63 |
+
# aiohttp_session=session,
|
64 |
+
# api_key=os.getenv("ELEVENLABS_API_KEY"),
|
65 |
+
# voice_id=os.getenv("ELEVENLABS_VOICE_ID"),
|
66 |
+
# )
|
67 |
+
tts_service=OpenAITTSService()
|
68 |
+
|
69 |
+
fal_service_params = FalImageGenService.InputParams(
|
70 |
+
image_size={
|
71 |
+
"width": 768,
|
72 |
+
"height": 768
|
73 |
+
}
|
74 |
+
)
|
75 |
+
|
76 |
+
fal_service = FalImageGenService(
|
77 |
+
aiohttp_session=session,
|
78 |
+
model="fal-ai/fast-lightning-sdxl",
|
79 |
+
params=fal_service_params,
|
80 |
+
key=os.getenv("FAL_KEY"),
|
81 |
+
)
|
82 |
+
|
83 |
+
# --------------- Setup ----------------- #
|
84 |
+
|
85 |
+
message_history = [LLM_BASE_PROMPT]
|
86 |
+
story_pages = []
|
87 |
+
|
88 |
+
# We need aggregators to keep track of user and LLM responses
|
89 |
+
llm_responses = LLMAssistantResponseAggregator(message_history)
|
90 |
+
user_responses = LLMUserResponseAggregator(message_history)
|
91 |
+
|
92 |
+
# -------------- Processors ------------- #
|
93 |
+
|
94 |
+
story_processor = StoryProcessor(message_history, story_pages)
|
95 |
+
image_processor = StoryImageProcessor(fal_service)
|
96 |
+
|
97 |
+
# -------------- Story Loop ------------- #
|
98 |
+
|
99 |
+
runner = PipelineRunner()
|
100 |
+
|
101 |
+
# The intro pipeline is used to start
|
102 |
+
# the story (as per LLM_INTRO_PROMPT)
|
103 |
+
intro_pipeline = Pipeline([llm_service, tts_service, transport.output()])
|
104 |
+
|
105 |
+
intro_task = PipelineTask(intro_pipeline)
|
106 |
+
|
107 |
+
logger.debug("Waiting for participant...")
|
108 |
+
|
109 |
+
@transport.event_handler("on_first_participant_joined")
|
110 |
+
async def on_first_participant_joined(transport, participant):
|
111 |
+
logger.debug("Participant joined, storytime commence!")
|
112 |
+
transport.capture_participant_transcription(participant["id"])
|
113 |
+
await intro_task.queue_frames(
|
114 |
+
[
|
115 |
+
images['book1'],
|
116 |
+
LLMMessagesFrame([LLM_INTRO_PROMPT]),
|
117 |
+
DailyTransportMessageFrame(CUE_USER_TURN),
|
118 |
+
sounds["listening"],
|
119 |
+
images['book2'],
|
120 |
+
StopTaskFrame()
|
121 |
+
]
|
122 |
+
)
|
123 |
+
|
124 |
+
# We run the intro pipeline. This will start the transport. The intro
|
125 |
+
# task will exit after StopTaskFrame is processed.
|
126 |
+
await runner.run(intro_task)
|
127 |
+
|
128 |
+
# The main story pipeline is used to continue the story based on user
|
129 |
+
# input.
|
130 |
+
main_pipeline = Pipeline([
|
131 |
+
transport.input(),
|
132 |
+
user_responses,
|
133 |
+
llm_service,
|
134 |
+
story_processor,
|
135 |
+
image_processor,
|
136 |
+
tts_service,
|
137 |
+
transport.output(),
|
138 |
+
llm_responses
|
139 |
+
])
|
140 |
+
|
141 |
+
main_task = PipelineTask(main_pipeline)
|
142 |
+
|
143 |
+
@transport.event_handler("on_participant_left")
|
144 |
+
async def on_participant_left(transport, participant, reason):
|
145 |
+
intro_task.queue_frame(EndFrame())
|
146 |
+
await main_task.queue_frame(EndFrame())
|
147 |
+
|
148 |
+
@transport.event_handler("on_call_state_updated")
|
149 |
+
async def on_call_state_updated(transport, state):
|
150 |
+
if state == "left":
|
151 |
+
await main_task.queue_frame(EndFrame())
|
152 |
+
|
153 |
+
await runner.run(main_task)
|
154 |
+
|
155 |
+
if __name__ == "__main__":
|
156 |
+
parser = argparse.ArgumentParser(description="Daily Storyteller Bot")
|
157 |
+
parser.add_argument("-u", type=str, help="Room URL")
|
158 |
+
parser.add_argument("-t", type=str, help="Token")
|
159 |
+
config = parser.parse_args()
|
160 |
+
|
161 |
+
asyncio.run(main(config.u, config.t))
|
src/bot_runner.py
ADDED
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import argparse
|
3 |
+
import subprocess
|
4 |
+
import requests
|
5 |
+
from pathlib import Path
|
6 |
+
from typing import Optional
|
7 |
+
|
8 |
+
from fastapi import FastAPI, Request, HTTPException
|
9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
10 |
+
from fastapi.staticfiles import StaticFiles
|
11 |
+
from fastapi.responses import FileResponse, JSONResponse
|
12 |
+
|
13 |
+
from pipecat.transports.services.helpers.daily_rest import DailyRESTHelper, DailyRoomObject, DailyRoomProperties, DailyRoomParams
|
14 |
+
|
15 |
+
|
16 |
+
from dotenv import load_dotenv
|
17 |
+
load_dotenv(override=True)
|
18 |
+
|
19 |
+
# ------------ Fast API Config ------------ #
|
20 |
+
|
21 |
+
MAX_SESSION_TIME = 8 * 60 # 5 minutes
|
22 |
+
|
23 |
+
daily_rest_helper = DailyRESTHelper(
|
24 |
+
os.getenv("DAILY_API_KEY", ""),
|
25 |
+
os.getenv("DAILY_API_URL", 'https://api.daily.co/v1'))
|
26 |
+
|
27 |
+
|
28 |
+
app = FastAPI()
|
29 |
+
|
30 |
+
app.add_middleware(
|
31 |
+
CORSMiddleware,
|
32 |
+
allow_origins=["*"],
|
33 |
+
allow_credentials=True,
|
34 |
+
allow_methods=["*"],
|
35 |
+
allow_headers=["*"],
|
36 |
+
)
|
37 |
+
|
38 |
+
# Mount the static directory
|
39 |
+
STATIC_DIR = "frontend/out"
|
40 |
+
|
41 |
+
|
42 |
+
# ------------ Fast API Routes ------------ #
|
43 |
+
|
44 |
+
app.mount("/static", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|
45 |
+
|
46 |
+
|
47 |
+
@app.post("/start_bot")
|
48 |
+
async def start_bot(request: Request) -> JSONResponse:
|
49 |
+
if os.getenv("ENV", "dev") == "production":
|
50 |
+
# Only allow requests from the specified domain
|
51 |
+
host_header = request.headers.get("host")
|
52 |
+
allowed_domains = ["storytelling-chatbot.fly.dev", "www.storytelling-chatbot.fly.dev"]
|
53 |
+
# Check if the Host header matches the allowed domain
|
54 |
+
if host_header not in allowed_domains:
|
55 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
56 |
+
|
57 |
+
try:
|
58 |
+
data = await request.json()
|
59 |
+
# Is this a webhook creation request?
|
60 |
+
if "test" in data:
|
61 |
+
return JSONResponse({"test": True})
|
62 |
+
except Exception as e:
|
63 |
+
pass
|
64 |
+
|
65 |
+
# Use specified room URL, or create a new one if not specified
|
66 |
+
room_url = os.getenv("DAILY_SAMPLE_ROOM_URL", "")
|
67 |
+
|
68 |
+
if not room_url:
|
69 |
+
params = DailyRoomParams(
|
70 |
+
properties=DailyRoomProperties()
|
71 |
+
)
|
72 |
+
try:
|
73 |
+
room: DailyRoomObject = daily_rest_helper.create_room(params=params)
|
74 |
+
except Exception as e:
|
75 |
+
raise HTTPException(
|
76 |
+
status_code=500,
|
77 |
+
detail=f"Unable to provision room {e}")
|
78 |
+
else:
|
79 |
+
# Check passed room URL exists, we should assume that it already has a sip set up
|
80 |
+
try:
|
81 |
+
room: DailyRoomObject = daily_rest_helper.get_room_from_url(room_url)
|
82 |
+
except Exception:
|
83 |
+
raise HTTPException(
|
84 |
+
status_code=500, detail=f"Room not found: {room_url}")
|
85 |
+
|
86 |
+
# Give the agent a token to join the session
|
87 |
+
token = daily_rest_helper.get_token(room.url, MAX_SESSION_TIME)
|
88 |
+
|
89 |
+
if not room or not token:
|
90 |
+
raise HTTPException(
|
91 |
+
status_code=500, detail=f"Failed to get token for room: {room_url}")
|
92 |
+
|
93 |
+
# Launch a new VM, or run as a shell process (not recommended)
|
94 |
+
if os.getenv("RUN_AS_VM", False):
|
95 |
+
try:
|
96 |
+
virtualize_bot(room.url, token)
|
97 |
+
except Exception as e:
|
98 |
+
raise HTTPException(
|
99 |
+
status_code=500, detail=f"Failed to spawn VM: {e}")
|
100 |
+
else:
|
101 |
+
try:
|
102 |
+
subprocess.Popen(
|
103 |
+
[f"python3 -m bot -u {room.url} -t {token}"],
|
104 |
+
shell=True,
|
105 |
+
bufsize=1,
|
106 |
+
cwd=os.path.dirname(os.path.abspath(__file__)))
|
107 |
+
except Exception as e:
|
108 |
+
raise HTTPException(
|
109 |
+
status_code=500, detail=f"Failed to start subprocess: {e}")
|
110 |
+
|
111 |
+
# Grab a token for the user to join with
|
112 |
+
user_token = daily_rest_helper.get_token(room.url, MAX_SESSION_TIME)
|
113 |
+
|
114 |
+
return JSONResponse({
|
115 |
+
"room_url": room.url,
|
116 |
+
"token": user_token,
|
117 |
+
})
|
118 |
+
|
119 |
+
|
120 |
+
@app.get("/{path_name:path}", response_class=FileResponse)
|
121 |
+
async def catch_all(path_name: Optional[str] = ""):
|
122 |
+
if path_name == "":
|
123 |
+
return FileResponse(f"{STATIC_DIR}/index.html")
|
124 |
+
|
125 |
+
file_path = Path(STATIC_DIR) / (path_name or "")
|
126 |
+
|
127 |
+
if file_path.is_file():
|
128 |
+
return file_path
|
129 |
+
|
130 |
+
html_file_path = file_path.with_suffix(".html")
|
131 |
+
if html_file_path.is_file():
|
132 |
+
return FileResponse(html_file_path)
|
133 |
+
|
134 |
+
raise HTTPException(status_code=450, detail="Incorrect API call")
|
135 |
+
|
136 |
+
|
137 |
+
# ------------ Virtualization ------------ #
|
138 |
+
|
139 |
+
def virtualize_bot(room_url: str, token: str):
|
140 |
+
"""
|
141 |
+
This is an example of how to virtualize the bot using Fly.io
|
142 |
+
You can adapt this method to use whichever cloud provider you prefer.
|
143 |
+
"""
|
144 |
+
FLY_API_HOST = os.getenv("FLY_API_HOST", "https://api.machines.dev/v1")
|
145 |
+
FLY_APP_NAME = os.getenv("FLY_APP_NAME", "storytelling-chatbot")
|
146 |
+
FLY_API_KEY = os.getenv("FLY_API_KEY", "")
|
147 |
+
FLY_HEADERS = {
|
148 |
+
'Authorization': f"Bearer {FLY_API_KEY}",
|
149 |
+
'Content-Type': 'application/json'
|
150 |
+
}
|
151 |
+
|
152 |
+
# Use the same image as the bot runner
|
153 |
+
res = requests.get(f"{FLY_API_HOST}/apps/{FLY_APP_NAME}/machines", headers=FLY_HEADERS)
|
154 |
+
if res.status_code != 200:
|
155 |
+
raise Exception(f"Unable to get machine info from Fly: {res.text}")
|
156 |
+
image = res.json()[0]['config']['image']
|
157 |
+
|
158 |
+
# Machine configuration
|
159 |
+
cmd = f"python3 src/bot.py -u {room_url} -t {token}"
|
160 |
+
cmd = cmd.split()
|
161 |
+
worker_props = {
|
162 |
+
"config": {
|
163 |
+
"image": image,
|
164 |
+
"auto_destroy": True,
|
165 |
+
"init": {
|
166 |
+
"cmd": cmd
|
167 |
+
},
|
168 |
+
"restart": {
|
169 |
+
"policy": "no"
|
170 |
+
},
|
171 |
+
"guest": {
|
172 |
+
"cpu_kind": "shared",
|
173 |
+
"cpus": 1,
|
174 |
+
"memory_mb": 512
|
175 |
+
}
|
176 |
+
},
|
177 |
+
|
178 |
+
}
|
179 |
+
|
180 |
+
# Spawn a new machine instance
|
181 |
+
res = requests.post(
|
182 |
+
f"{FLY_API_HOST}/apps/{FLY_APP_NAME}/machines",
|
183 |
+
headers=FLY_HEADERS,
|
184 |
+
json=worker_props)
|
185 |
+
|
186 |
+
if res.status_code != 200:
|
187 |
+
raise Exception(f"Problem starting a bot worker: {res.text}")
|
188 |
+
|
189 |
+
# Wait for the machine to enter the started state
|
190 |
+
vm_id = res.json()['id']
|
191 |
+
|
192 |
+
res = requests.get(
|
193 |
+
f"{FLY_API_HOST}/apps/{FLY_APP_NAME}/machines/{vm_id}/wait?state=started",
|
194 |
+
headers=FLY_HEADERS)
|
195 |
+
|
196 |
+
if res.status_code != 200:
|
197 |
+
raise Exception(f"Bot was unable to enter started state: {res.text}")
|
198 |
+
|
199 |
+
print(f"Machine joined room: {room_url}")
|
200 |
+
|
201 |
+
|
202 |
+
# ------------ Main ------------ #
|
203 |
+
|
204 |
+
if __name__ == "__main__":
|
205 |
+
# Check environment variables
|
206 |
+
required_env_vars = ['OPENAI_API_KEY', 'DAILY_API_KEY',
|
207 |
+
'FAL_KEY', 'OPENAI_BASE_URL']
|
208 |
+
for env_var in required_env_vars:
|
209 |
+
if env_var not in os.environ:
|
210 |
+
raise Exception(f"Missing environment variable: {env_var}.")
|
211 |
+
|
212 |
+
import uvicorn
|
213 |
+
|
214 |
+
default_host = os.getenv("HOST", "0.0.0.0")
|
215 |
+
default_port = int(os.getenv("FAST_API_PORT", "7860"))
|
216 |
+
|
217 |
+
parser = argparse.ArgumentParser(
|
218 |
+
description="Daily Storyteller FastAPI server")
|
219 |
+
parser.add_argument("--host", type=str,
|
220 |
+
default=default_host, help="Host address")
|
221 |
+
parser.add_argument("--port", type=int,
|
222 |
+
default=default_port, help="Port number")
|
223 |
+
parser.add_argument("--reload", action="store_true",
|
224 |
+
help="Reload code on change")
|
225 |
+
|
226 |
+
config = parser.parse_args()
|
227 |
+
|
228 |
+
uvicorn.run(
|
229 |
+
"bot_runner:app",
|
230 |
+
host=config.host,
|
231 |
+
port=config.port,
|
232 |
+
reload=config.reload
|
233 |
+
)
|