seawolf2357 commited on
Commit
d754924
·
verified ·
1 Parent(s): bef1173

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +2182 -0
app.py ADDED
@@ -0,0 +1,2182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import base64
3
+ import json
4
+ from pathlib import Path
5
+ import os
6
+ import numpy as np
7
+ import openai
8
+ from dotenv import load_dotenv
9
+ from fastapi import FastAPI, Request
10
+ from fastapi.responses import HTMLResponse, StreamingResponse
11
+ from fastrtc import (
12
+ AdditionalOutputs,
13
+ AsyncStreamHandler,
14
+ Stream,
15
+ get_twilio_turn_credentials,
16
+ wait_for_item,
17
+ )
18
+ from gradio.utils import get_space
19
+ from openai.types.beta.realtime import ResponseAudioTranscriptDoneEvent
20
+ import httpx
21
+ from typing import Optional, List, Dict
22
+ import gradio as gr
23
+ import io
24
+ from scipy import signal
25
+ import wave
26
+ import aiosqlite
27
+ from langdetect import detect, LangDetectException
28
+ from datetime import datetime
29
+ import uuid
30
+
31
+ load_dotenv()
32
+
33
+ SAMPLE_RATE = 24000
34
+
35
+ # Use Persistent Storage path for Hugging Face Space
36
+ # In HF Spaces, persistent storage is at /data
37
+ if os.path.exists("/data"):
38
+ PERSISTENT_DIR = "/data"
39
+ else:
40
+ PERSISTENT_DIR = "./data"
41
+
42
+ os.makedirs(PERSISTENT_DIR, exist_ok=True)
43
+ DB_PATH = os.path.join(PERSISTENT_DIR, "personal_assistant.db")
44
+ print(f"Using persistent directory: {PERSISTENT_DIR}")
45
+ print(f"Database path: {DB_PATH}")
46
+
47
+ # HTML content embedded as a string
48
+ HTML_CONTENT = """<!DOCTYPE html>
49
+ <html lang="ko">
50
+
51
+ <head>
52
+ <meta charset="UTF-8">
53
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
54
+ <title>Personal AI Assistant</title>
55
+ <style>
56
+ :root {
57
+ --primary-color: #6f42c1;
58
+ --secondary-color: #563d7c;
59
+ --dark-bg: #121212;
60
+ --card-bg: #1e1e1e;
61
+ --text-color: #f8f9fa;
62
+ --border-color: #333;
63
+ --hover-color: #8a5cf6;
64
+ --memory-color: #4a9eff;
65
+ }
66
+ body {
67
+ font-family: "SF Pro Display", -apple-system, BlinkMacSystemFont, sans-serif;
68
+ background-color: var(--dark-bg);
69
+ color: var(--text-color);
70
+ margin: 0;
71
+ padding: 0;
72
+ height: 100vh;
73
+ display: flex;
74
+ flex-direction: column;
75
+ overflow: hidden;
76
+ }
77
+ .container {
78
+ max-width: 1400px;
79
+ margin: 0 auto;
80
+ padding: 20px;
81
+ flex-grow: 1;
82
+ display: flex;
83
+ flex-direction: column;
84
+ width: 100%;
85
+ height: 100vh;
86
+ box-sizing: border-box;
87
+ overflow: hidden;
88
+ }
89
+ .header {
90
+ text-align: center;
91
+ padding: 15px 0;
92
+ border-bottom: 1px solid var(--border-color);
93
+ margin-bottom: 20px;
94
+ flex-shrink: 0;
95
+ background-color: var(--card-bg);
96
+ }
97
+ .main-content {
98
+ display: flex;
99
+ gap: 20px;
100
+ flex-grow: 1;
101
+ min-height: 0;
102
+ overflow: hidden;
103
+ }
104
+ .sidebar {
105
+ width: 350px;
106
+ flex-shrink: 0;
107
+ display: flex;
108
+ flex-direction: column;
109
+ gap: 20px;
110
+ overflow-y: auto;
111
+ max-height: calc(100vh - 120px);
112
+ }
113
+ .chat-section {
114
+ flex-grow: 1;
115
+ display: flex;
116
+ flex-direction: column;
117
+ min-width: 0;
118
+ }
119
+ .logo {
120
+ display: flex;
121
+ align-items: center;
122
+ justify-content: center;
123
+ gap: 10px;
124
+ }
125
+ .logo h1 {
126
+ margin: 0;
127
+ background: linear-gradient(135deg, var(--primary-color), #a78bfa);
128
+ -webkit-background-clip: text;
129
+ background-clip: text;
130
+ color: transparent;
131
+ font-size: 32px;
132
+ letter-spacing: 1px;
133
+ }
134
+ /* Settings section */
135
+ .settings-section {
136
+ background-color: var(--card-bg);
137
+ border-radius: 12px;
138
+ padding: 20px;
139
+ border: 1px solid var(--border-color);
140
+ overflow-y: auto;
141
+ }
142
+ .settings-grid {
143
+ display: flex;
144
+ flex-direction: column;
145
+ gap: 15px;
146
+ margin-bottom: 15px;
147
+ }
148
+ .setting-item {
149
+ display: flex;
150
+ align-items: center;
151
+ justify-content: space-between;
152
+ gap: 10px;
153
+ }
154
+ .setting-label {
155
+ font-size: 14px;
156
+ color: #aaa;
157
+ min-width: 60px;
158
+ }
159
+ /* Toggle switch */
160
+ .toggle-switch {
161
+ position: relative;
162
+ width: 50px;
163
+ height: 26px;
164
+ background-color: #ccc;
165
+ border-radius: 13px;
166
+ cursor: pointer;
167
+ transition: background-color 0.3s;
168
+ }
169
+ .toggle-switch.active {
170
+ background-color: var(--primary-color);
171
+ }
172
+ .toggle-slider {
173
+ position: absolute;
174
+ top: 3px;
175
+ left: 3px;
176
+ width: 20px;
177
+ height: 20px;
178
+ background-color: white;
179
+ border-radius: 50%;
180
+ transition: transform 0.3s;
181
+ }
182
+ .toggle-switch.active .toggle-slider {
183
+ transform: translateX(24px);
184
+ }
185
+ /* Memory section */
186
+ .memory-section {
187
+ background-color: var(--card-bg);
188
+ border-radius: 12px;
189
+ padding: 20px;
190
+ border: 1px solid var(--border-color);
191
+ max-height: 300px;
192
+ overflow-y: auto;
193
+ }
194
+ .memory-item {
195
+ padding: 10px;
196
+ margin-bottom: 10px;
197
+ background: linear-gradient(135deg, rgba(74, 158, 255, 0.1), rgba(111, 66, 193, 0.1));
198
+ border-radius: 6px;
199
+ border-left: 3px solid var(--memory-color);
200
+ }
201
+ .memory-category {
202
+ font-size: 12px;
203
+ color: var(--memory-color);
204
+ font-weight: bold;
205
+ text-transform: uppercase;
206
+ margin-bottom: 5px;
207
+ }
208
+ .memory-content {
209
+ font-size: 14px;
210
+ color: var(--text-color);
211
+ }
212
+ /* History section */
213
+ .history-section {
214
+ background-color: var(--card-bg);
215
+ border-radius: 12px;
216
+ padding: 20px;
217
+ border: 1px solid var(--border-color);
218
+ max-height: 200px;
219
+ overflow-y: auto;
220
+ }
221
+ .history-item {
222
+ padding: 10px;
223
+ margin-bottom: 10px;
224
+ background-color: var(--dark-bg);
225
+ border-radius: 6px;
226
+ cursor: pointer;
227
+ transition: background-color 0.2s;
228
+ }
229
+ .history-item:hover {
230
+ background-color: var(--hover-color);
231
+ }
232
+ .history-date {
233
+ font-size: 12px;
234
+ color: #888;
235
+ }
236
+ .history-preview {
237
+ font-size: 14px;
238
+ margin-top: 5px;
239
+ overflow: hidden;
240
+ text-overflow: ellipsis;
241
+ white-space: nowrap;
242
+ }
243
+ /* Text inputs */
244
+ .text-input-section {
245
+ margin-top: 15px;
246
+ }
247
+ input[type="text"], textarea {
248
+ width: 100%;
249
+ background-color: var(--dark-bg);
250
+ color: var(--text-color);
251
+ border: 1px solid var(--border-color);
252
+ padding: 10px;
253
+ border-radius: 6px;
254
+ font-size: 14px;
255
+ box-sizing: border-box;
256
+ margin-top: 5px;
257
+ }
258
+ input[type="text"]:focus, textarea:focus {
259
+ outline: none;
260
+ border-color: var(--primary-color);
261
+ }
262
+ textarea {
263
+ resize: vertical;
264
+ min-height: 80px;
265
+ }
266
+ .chat-container {
267
+ border-radius: 12px;
268
+ background-color: var(--card-bg);
269
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
270
+ padding: 20px;
271
+ flex-grow: 1;
272
+ display: flex;
273
+ flex-direction: column;
274
+ border: 1px solid var(--border-color);
275
+ overflow: hidden;
276
+ min-height: 0;
277
+ height: 100%;
278
+ }
279
+ .chat-messages {
280
+ flex-grow: 1;
281
+ overflow-y: auto;
282
+ padding: 15px;
283
+ scrollbar-width: thin;
284
+ scrollbar-color: var(--primary-color) var(--card-bg);
285
+ min-height: 0;
286
+ max-height: calc(100vh - 250px);
287
+ }
288
+ .chat-messages::-webkit-scrollbar {
289
+ width: 6px;
290
+ }
291
+ .chat-messages::-webkit-scrollbar-thumb {
292
+ background-color: var(--primary-color);
293
+ border-radius: 6px;
294
+ }
295
+ .message {
296
+ margin-bottom: 15px;
297
+ padding: 12px 16px;
298
+ border-radius: 8px;
299
+ font-size: 15px;
300
+ line-height: 1.5;
301
+ position: relative;
302
+ max-width: 85%;
303
+ animation: fade-in 0.3s ease-out;
304
+ word-wrap: break-word;
305
+ }
306
+ @keyframes fade-in {
307
+ from {
308
+ opacity: 0;
309
+ transform: translateY(10px);
310
+ }
311
+ to {
312
+ opacity: 1;
313
+ transform: translateY(0);
314
+ }
315
+ }
316
+ .message.user {
317
+ background: linear-gradient(135deg, #2c3e50, #34495e);
318
+ margin-left: auto;
319
+ border-bottom-right-radius: 2px;
320
+ }
321
+ .message.assistant {
322
+ background: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
323
+ margin-right: auto;
324
+ border-bottom-left-radius: 2px;
325
+ }
326
+ .message.search-result {
327
+ background: linear-gradient(135deg, #1a5a3e, #2e7d32);
328
+ font-size: 14px;
329
+ padding: 10px;
330
+ margin-bottom: 10px;
331
+ }
332
+ .message.memory-update {
333
+ background: linear-gradient(135deg, rgba(74, 158, 255, 0.2), rgba(111, 66, 193, 0.2));
334
+ font-size: 13px;
335
+ padding: 8px 12px;
336
+ margin-bottom: 10px;
337
+ border-left: 3px solid var(--memory-color);
338
+ }
339
+ .language-info {
340
+ font-size: 12px;
341
+ color: #888;
342
+ margin-left: 5px;
343
+ }
344
+ .controls {
345
+ text-align: center;
346
+ margin-top: auto;
347
+ display: flex;
348
+ justify-content: center;
349
+ gap: 10px;
350
+ flex-shrink: 0;
351
+ padding-top: 20px;
352
+ }
353
+ /* Responsive design */
354
+ @media (max-width: 1024px) {
355
+ .sidebar {
356
+ width: 300px;
357
+ }
358
+ }
359
+ @media (max-width: 768px) {
360
+ .main-content {
361
+ flex-direction: column;
362
+ }
363
+ .sidebar {
364
+ width: 100%;
365
+ margin-bottom: 20px;
366
+ }
367
+ .chat-section {
368
+ height: 400px;
369
+ }
370
+ }
371
+ button {
372
+ background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
373
+ color: white;
374
+ border: none;
375
+ padding: 14px 28px;
376
+ font-family: inherit;
377
+ font-size: 16px;
378
+ cursor: pointer;
379
+ transition: all 0.3s;
380
+ text-transform: uppercase;
381
+ letter-spacing: 1px;
382
+ border-radius: 50px;
383
+ display: flex;
384
+ align-items: center;
385
+ justify-content: center;
386
+ gap: 10px;
387
+ box-shadow: 0 4px 10px rgba(111, 66, 193, 0.3);
388
+ }
389
+ button:hover {
390
+ transform: translateY(-2px);
391
+ box-shadow: 0 6px 15px rgba(111, 66, 193, 0.5);
392
+ background: linear-gradient(135deg, var(--hover-color), var(--primary-color));
393
+ }
394
+ button:active {
395
+ transform: translateY(1px);
396
+ }
397
+ #send-button {
398
+ background: linear-gradient(135deg, #2ecc71, #27ae60);
399
+ padding: 10px 20px;
400
+ font-size: 14px;
401
+ flex-shrink: 0;
402
+ }
403
+ #send-button:hover {
404
+ background: linear-gradient(135deg, #27ae60, #229954);
405
+ }
406
+ #end-session-button {
407
+ background: linear-gradient(135deg, #4a9eff, #3a7ed8);
408
+ padding: 8px 16px;
409
+ font-size: 13px;
410
+ }
411
+ #end-session-button:hover {
412
+ background: linear-gradient(135deg, #3a7ed8, #2a5eb8);
413
+ }
414
+ #audio-output {
415
+ display: none;
416
+ }
417
+ .icon-with-spinner {
418
+ display: flex;
419
+ align-items: center;
420
+ justify-content: center;
421
+ gap: 12px;
422
+ min-width: 180px;
423
+ }
424
+ .spinner {
425
+ width: 20px;
426
+ height: 20px;
427
+ border: 2px solid #ffffff;
428
+ border-top-color: transparent;
429
+ border-radius: 50%;
430
+ animation: spin 1s linear infinite;
431
+ flex-shrink: 0;
432
+ }
433
+ @keyframes spin {
434
+ to {
435
+ transform: rotate(360deg);
436
+ }
437
+ }
438
+ .audio-visualizer {
439
+ display: flex;
440
+ align-items: center;
441
+ justify-content: center;
442
+ gap: 5px;
443
+ min-width: 80px;
444
+ height: 25px;
445
+ }
446
+ .visualizer-bar {
447
+ width: 4px;
448
+ height: 100%;
449
+ background-color: rgba(255, 255, 255, 0.7);
450
+ border-radius: 2px;
451
+ transform-origin: bottom;
452
+ transform: scaleY(0.1);
453
+ transition: transform 0.1s ease;
454
+ }
455
+ .toast {
456
+ position: fixed;
457
+ top: 20px;
458
+ left: 50%;
459
+ transform: translateX(-50%);
460
+ padding: 16px 24px;
461
+ border-radius: 8px;
462
+ font-size: 14px;
463
+ z-index: 1000;
464
+ display: none;
465
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
466
+ }
467
+ .toast.error {
468
+ background-color: #f44336;
469
+ color: white;
470
+ }
471
+ .toast.warning {
472
+ background-color: #ff9800;
473
+ color: white;
474
+ }
475
+ .toast.success {
476
+ background-color: #4caf50;
477
+ color: white;
478
+ }
479
+ .status-indicator {
480
+ display: inline-flex;
481
+ align-items: center;
482
+ margin-top: 10px;
483
+ font-size: 14px;
484
+ color: #aaa;
485
+ }
486
+ .status-dot {
487
+ width: 8px;
488
+ height: 8px;
489
+ border-radius: 50%;
490
+ margin-right: 8px;
491
+ }
492
+ .status-dot.connected {
493
+ background-color: #4caf50;
494
+ }
495
+ .status-dot.disconnected {
496
+ background-color: #f44336;
497
+ }
498
+ .status-dot.connecting {
499
+ background-color: #ff9800;
500
+ animation: pulse 1.5s infinite;
501
+ }
502
+ @keyframes pulse {
503
+ 0% {
504
+ opacity: 0.6;
505
+ }
506
+ 50% {
507
+ opacity: 1;
508
+ }
509
+ 100% {
510
+ opacity: 0.6;
511
+ }
512
+ }
513
+ .user-avatar {
514
+ width: 40px;
515
+ height: 40px;
516
+ background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
517
+ border-radius: 50%;
518
+ display: flex;
519
+ align-items: center;
520
+ justify-content: center;
521
+ font-size: 20px;
522
+ font-weight: bold;
523
+ color: white;
524
+ }
525
+ </style>
526
+ </head>
527
+
528
+ <body>
529
+ <div id="error-toast" class="toast"></div>
530
+ <div class="container">
531
+ <div class="header">
532
+ <div class="logo">
533
+ <div class="user-avatar" id="user-avatar">👤</div>
534
+ <h1>Personal AI Assistant</h1>
535
+ </div>
536
+ <div class="status-indicator">
537
+ <div id="status-dot" class="status-dot disconnected"></div>
538
+ <span id="status-text">연결 대기 중</span>
539
+ </div>
540
+ </div>
541
+
542
+ <div class="main-content">
543
+ <div class="sidebar">
544
+ <div class="settings-section">
545
+ <h3 style="margin: 0 0 15px 0; color: var(--primary-color);">설정</h3>
546
+ <div class="settings-grid">
547
+ <div class="setting-item">
548
+ <span class="setting-label">웹 검색</span>
549
+ <div id="search-toggle" class="toggle-switch">
550
+ <div class="toggle-slider"></div>
551
+ </div>
552
+ </div>
553
+ </div>
554
+ <div class="text-input-section">
555
+ <label for="user-name" class="setting-label">사용자 이름:</label>
556
+ <input type="text" id="user-name" placeholder="이름을 입력하세요..." />
557
+ </div>
558
+ </div>
559
+
560
+ <div class="memory-section">
561
+ <h3 style="margin: 0 0 15px 0; color: var(--memory-color);">기억된 정보</h3>
562
+ <div id="memory-list"></div>
563
+ </div>
564
+
565
+ <div class="history-section">
566
+ <h3 style="margin: 0 0 15px 0; color: var(--primary-color);">대화 기록</h3>
567
+ <div id="history-list"></div>
568
+ </div>
569
+
570
+ <div class="controls">
571
+ <button id="start-button">대화 시작</button>
572
+ <button id="end-session-button" style="display: none;">기억 업데이트</button>
573
+ </div>
574
+ </div>
575
+
576
+ <div class="chat-section">
577
+ <div class="chat-container">
578
+ <h3 style="margin: 0 0 15px 0; color: var(--primary-color);">대화</h3>
579
+ <div class="chat-messages" id="chat-messages"></div>
580
+ <div class="text-input-section" style="margin-top: 10px;">
581
+ <div style="display: flex; gap: 10px;">
582
+ <input type="text" id="text-input" placeholder="텍스트 메시지를 입력하세요..." style="flex-grow: 1;" />
583
+ <button id="send-button" style="display: none;">전송</button>
584
+ </div>
585
+ </div>
586
+ </div>
587
+ </div>
588
+ </div>
589
+ </div>
590
+ <audio id="audio-output"></audio>
591
+
592
+ <script>
593
+ let peerConnection;
594
+ let webrtc_id;
595
+ let webSearchEnabled = false;
596
+ let currentSessionId = null;
597
+ let userName = localStorage.getItem('userName') || '';
598
+ let userMemories = {};
599
+ const audioOutput = document.getElementById('audio-output');
600
+ const startButton = document.getElementById('start-button');
601
+ const endSessionButton = document.getElementById('end-session-button');
602
+ const sendButton = document.getElementById('send-button');
603
+ const chatMessages = document.getElementById('chat-messages');
604
+ const statusDot = document.getElementById('status-dot');
605
+ const statusText = document.getElementById('status-text');
606
+ const searchToggle = document.getElementById('search-toggle');
607
+ const textInput = document.getElementById('text-input');
608
+ const historyList = document.getElementById('history-list');
609
+ const memoryList = document.getElementById('memory-list');
610
+ const userNameInput = document.getElementById('user-name');
611
+ const userAvatar = document.getElementById('user-avatar');
612
+ let audioLevel = 0;
613
+ let animationFrame;
614
+ let audioContext, analyser, audioSource;
615
+ let dataChannel = null;
616
+ let isVoiceActive = false;
617
+
618
+ // Initialize user name
619
+ userNameInput.value = userName;
620
+ if (userName) {
621
+ userAvatar.textContent = userName.charAt(0).toUpperCase();
622
+ }
623
+
624
+ userNameInput.addEventListener('input', () => {
625
+ userName = userNameInput.value;
626
+ localStorage.setItem('userName', userName);
627
+ if (userName) {
628
+ userAvatar.textContent = userName.charAt(0).toUpperCase();
629
+ } else {
630
+ userAvatar.textContent = '👤';
631
+ }
632
+ });
633
+
634
+ // Start new session
635
+ async function startNewSession() {
636
+ const response = await fetch('/session/new', { method: 'POST' });
637
+ const data = await response.json();
638
+ currentSessionId = data.session_id;
639
+ console.log('New session started:', currentSessionId);
640
+ loadHistory();
641
+ loadMemories();
642
+ }
643
+
644
+ // Load memories
645
+ async function loadMemories() {
646
+ try {
647
+ const response = await fetch('/memory/all');
648
+ const memories = await response.json();
649
+
650
+ userMemories = {};
651
+ memoryList.innerHTML = '';
652
+
653
+ memories.forEach(memory => {
654
+ if (!userMemories[memory.category]) {
655
+ userMemories[memory.category] = [];
656
+ }
657
+ userMemories[memory.category].push(memory.content);
658
+
659
+ const item = document.createElement('div');
660
+ item.className = 'memory-item';
661
+ item.innerHTML = `
662
+ <div class="memory-category">${memory.category}</div>
663
+ <div class="memory-content">${memory.content}</div>
664
+ `;
665
+ memoryList.appendChild(item);
666
+ });
667
+
668
+ console.log('Loaded memories:', userMemories);
669
+ } catch (error) {
670
+ console.error('Failed to load memories:', error);
671
+ }
672
+ }
673
+
674
+ // End session and update memories
675
+ async function endSession() {
676
+ if (!currentSessionId) return;
677
+
678
+ try {
679
+ addMessage('memory-update', '대화 내용을 분석하여 기억을 업데이트하고 있습니다...');
680
+
681
+ const response = await fetch('/session/end', {
682
+ method: 'POST',
683
+ headers: { 'Content-Type': 'application/json' },
684
+ body: JSON.stringify({ session_id: currentSessionId })
685
+ });
686
+
687
+ const result = await response.json();
688
+ if (result.status === 'ok') {
689
+ showToast('기억이 성공적으로 업데이트되었습니다.', 'success');
690
+ loadMemories();
691
+ startNewSession();
692
+ }
693
+ } catch (error) {
694
+ console.error('Failed to end session:', error);
695
+ showError('기억 업데이트 중 오류가 발생했습니다.');
696
+ }
697
+ }
698
+
699
+ // Load conversation history
700
+ async function loadHistory() {
701
+ try {
702
+ const response = await fetch('/history/recent');
703
+ const conversations = await response.json();
704
+
705
+ historyList.innerHTML = '';
706
+ conversations.forEach(conv => {
707
+ const item = document.createElement('div');
708
+ item.className = 'history-item';
709
+ item.innerHTML = `
710
+ <div class="history-date">${new Date(conv.created_at).toLocaleString()}</div>
711
+ <div class="history-preview">${conv.summary || '대화 시작'}</div>
712
+ `;
713
+ item.onclick = () => loadConversation(conv.id);
714
+ historyList.appendChild(item);
715
+ });
716
+ } catch (error) {
717
+ console.error('Failed to load history:', error);
718
+ }
719
+ }
720
+
721
+ // Load specific conversation
722
+ async function loadConversation(sessionId) {
723
+ try {
724
+ const response = await fetch(`/history/${sessionId}`);
725
+ const messages = await response.json();
726
+
727
+ chatMessages.innerHTML = '';
728
+ messages.forEach(msg => {
729
+ addMessage(msg.role, msg.content, false);
730
+ });
731
+ } catch (error) {
732
+ console.error('Failed to load conversation:', error);
733
+ }
734
+ }
735
+
736
+ // Web search toggle functionality
737
+ searchToggle.addEventListener('click', () => {
738
+ webSearchEnabled = !webSearchEnabled;
739
+ searchToggle.classList.toggle('active', webSearchEnabled);
740
+ console.log('Web search enabled:', webSearchEnabled);
741
+ });
742
+
743
+ // Text input handling
744
+ textInput.addEventListener('keypress', (e) => {
745
+ if (e.key === 'Enter' && !e.shiftKey) {
746
+ e.preventDefault();
747
+ sendTextMessage();
748
+ }
749
+ });
750
+
751
+ sendButton.addEventListener('click', sendTextMessage);
752
+ endSessionButton.addEventListener('click', endSession);
753
+
754
+ async function sendTextMessage() {
755
+ const message = textInput.value.trim();
756
+ if (!message) return;
757
+
758
+ // Check for stop words
759
+ const stopWords = ["중단", "그만", "스톱", "stop", "닥쳐", "멈춰", "중지"];
760
+ if (stopWords.some(word => message.toLowerCase().includes(word))) {
761
+ addMessage('assistant', '대화를 중단합니다.');
762
+ return;
763
+ }
764
+
765
+ // Add user message to chat
766
+ addMessage('user', message);
767
+ textInput.value = '';
768
+
769
+ // Show sending indicator
770
+ const typingIndicator = document.createElement('div');
771
+ typingIndicator.classList.add('message', 'assistant');
772
+ typingIndicator.textContent = '입력 중...';
773
+ typingIndicator.id = 'typing-indicator';
774
+ chatMessages.appendChild(typingIndicator);
775
+ chatMessages.scrollTop = chatMessages.scrollHeight;
776
+
777
+ try {
778
+ // Send to text chat endpoint
779
+ const response = await fetch('/chat/text', {
780
+ method: 'POST',
781
+ headers: { 'Content-Type': 'application/json' },
782
+ body: JSON.stringify({
783
+ message: message,
784
+ web_search_enabled: webSearchEnabled,
785
+ session_id: currentSessionId,
786
+ user_name: userName,
787
+ memories: userMemories
788
+ })
789
+ });
790
+
791
+ const data = await response.json();
792
+
793
+ // Remove typing indicator
794
+ const indicator = document.getElementById('typing-indicator');
795
+ if (indicator) indicator.remove();
796
+
797
+ if (data.error) {
798
+ showError(data.error);
799
+ } else {
800
+ // Add assistant response
801
+ let content = data.response;
802
+ if (data.detected_language) {
803
+ content += ` <span class="language-info">[${data.detected_language}]</span>`;
804
+ }
805
+ addMessage('assistant', content);
806
+ }
807
+ } catch (error) {
808
+ console.error('Error sending text message:', error);
809
+ const indicator = document.getElementById('typing-indicator');
810
+ if (indicator) indicator.remove();
811
+ showError('메시지 전송 중 오류가 발생했습니다.');
812
+ }
813
+ }
814
+
815
+ function updateStatus(state) {
816
+ statusDot.className = 'status-dot ' + state;
817
+ if (state === 'connected') {
818
+ statusText.textContent = '연결됨';
819
+ sendButton.style.display = 'block';
820
+ endSessionButton.style.display = 'block';
821
+ isVoiceActive = true;
822
+ } else if (state === 'connecting') {
823
+ statusText.textContent = '연결 중...';
824
+ sendButton.style.display = 'none';
825
+ endSessionButton.style.display = 'none';
826
+ } else {
827
+ statusText.textContent = '연결 대기 중';
828
+ sendButton.style.display = 'block';
829
+ endSessionButton.style.display = 'block';
830
+ isVoiceActive = false;
831
+ }
832
+ }
833
+
834
+ function showToast(message, type = 'info') {
835
+ const toast = document.getElementById('error-toast');
836
+ toast.textContent = message;
837
+ toast.className = `toast ${type}`;
838
+ toast.style.display = 'block';
839
+ setTimeout(() => {
840
+ toast.style.display = 'none';
841
+ }, 5000);
842
+ }
843
+
844
+ function showError(message) {
845
+ showToast(message, 'error');
846
+ }
847
+
848
+ function updateButtonState() {
849
+ const button = document.getElementById('start-button');
850
+ if (peerConnection && (peerConnection.connectionState === 'connecting' || peerConnection.connectionState === 'new')) {
851
+ button.innerHTML = `
852
+ <div class="icon-with-spinner">
853
+ <div class="spinner"></div>
854
+ <span>연결 중...</span>
855
+ </div>
856
+ `;
857
+ updateStatus('connecting');
858
+ } else if (peerConnection && peerConnection.connectionState === 'connected') {
859
+ button.innerHTML = `
860
+ <div class="icon-with-spinner">
861
+ <div class="audio-visualizer" id="audio-visualizer">
862
+ <div class="visualizer-bar"></div>
863
+ <div class="visualizer-bar"></div>
864
+ <div class="visualizer-bar"></div>
865
+ <div class="visualizer-bar"></div>
866
+ <div class="visualizer-bar"></div>
867
+ </div>
868
+ <span>대화 종료</span>
869
+ </div>
870
+ `;
871
+ updateStatus('connected');
872
+ } else {
873
+ button.innerHTML = '대화 시작';
874
+ updateStatus('disconnected');
875
+ }
876
+ }
877
+
878
+ function setupAudioVisualization(stream) {
879
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
880
+ analyser = audioContext.createAnalyser();
881
+ audioSource = audioContext.createMediaStreamSource(stream);
882
+ audioSource.connect(analyser);
883
+ analyser.fftSize = 256;
884
+ const bufferLength = analyser.frequencyBinCount;
885
+ const dataArray = new Uint8Array(bufferLength);
886
+
887
+ const visualizerBars = document.querySelectorAll('.visualizer-bar');
888
+ const barCount = visualizerBars.length;
889
+
890
+ function updateAudioLevel() {
891
+ analyser.getByteFrequencyData(dataArray);
892
+
893
+ for (let i = 0; i < barCount; i++) {
894
+ const start = Math.floor(i * (bufferLength / barCount));
895
+ const end = Math.floor((i + 1) * (bufferLength / barCount));
896
+
897
+ let sum = 0;
898
+ for (let j = start; j < end; j++) {
899
+ sum += dataArray[j];
900
+ }
901
+
902
+ const average = sum / (end - start) / 255;
903
+ const scaleY = 0.1 + average * 0.9;
904
+ visualizerBars[i].style.transform = `scaleY(${scaleY})`;
905
+ }
906
+
907
+ animationFrame = requestAnimationFrame(updateAudioLevel);
908
+ }
909
+
910
+ updateAudioLevel();
911
+ }
912
+
913
+ async function setupWebRTC() {
914
+ const config = __RTC_CONFIGURATION__;
915
+ peerConnection = new RTCPeerConnection(config);
916
+ const timeoutId = setTimeout(() => {
917
+ showToast("연결이 평소보다 오래 걸리고 있습니다. VPN을 사용 중이신가요?", 'warning');
918
+ }, 5000);
919
+
920
+ try {
921
+ const stream = await navigator.mediaDevices.getUserMedia({
922
+ audio: true
923
+ });
924
+ setupAudioVisualization(stream);
925
+ stream.getTracks().forEach(track => {
926
+ peerConnection.addTrack(track, stream);
927
+ });
928
+ peerConnection.addEventListener('track', (evt) => {
929
+ if (audioOutput.srcObject !== evt.streams[0]) {
930
+ audioOutput.srcObject = evt.streams[0];
931
+ audioOutput.play();
932
+ }
933
+ });
934
+
935
+ // Create data channel for text messages
936
+ dataChannel = peerConnection.createDataChannel('text');
937
+ dataChannel.onopen = () => {
938
+ console.log('Data channel opened');
939
+ };
940
+ dataChannel.onmessage = (event) => {
941
+ const eventJson = JSON.parse(event.data);
942
+ if (eventJson.type === "error") {
943
+ showError(eventJson.message);
944
+ }
945
+ };
946
+
947
+ const offer = await peerConnection.createOffer();
948
+ await peerConnection.setLocalDescription(offer);
949
+ await new Promise((resolve) => {
950
+ if (peerConnection.iceGatheringState === "complete") {
951
+ resolve();
952
+ } else {
953
+ const checkState = () => {
954
+ if (peerConnection.iceGatheringState === "complete") {
955
+ peerConnection.removeEventListener("icegatheringstatechange", checkState);
956
+ resolve();
957
+ }
958
+ };
959
+ peerConnection.addEventListener("icegatheringstatechange", checkState);
960
+ }
961
+ });
962
+
963
+ peerConnection.addEventListener('connectionstatechange', () => {
964
+ console.log('connectionstatechange', peerConnection.connectionState);
965
+ if (peerConnection.connectionState === 'connected') {
966
+ clearTimeout(timeoutId);
967
+ const toast = document.getElementById('error-toast');
968
+ toast.style.display = 'none';
969
+ }
970
+ updateButtonState();
971
+ });
972
+
973
+ webrtc_id = Math.random().toString(36).substring(7);
974
+
975
+ const response = await fetch('/webrtc/offer', {
976
+ method: 'POST',
977
+ headers: { 'Content-Type': 'application/json' },
978
+ body: JSON.stringify({
979
+ sdp: peerConnection.localDescription.sdp,
980
+ type: peerConnection.localDescription.type,
981
+ webrtc_id: webrtc_id,
982
+ web_search_enabled: webSearchEnabled,
983
+ session_id: currentSessionId,
984
+ user_name: userName,
985
+ memories: userMemories
986
+ })
987
+ });
988
+
989
+ const serverResponse = await response.json();
990
+ if (serverResponse.status === 'failed') {
991
+ showError(serverResponse.meta.error === 'concurrency_limit_reached'
992
+ ? `너무 많은 연결입니다. 최대 한도는 ${serverResponse.meta.limit} 입니다.`
993
+ : serverResponse.meta.error);
994
+ stop();
995
+ return;
996
+ }
997
+
998
+ await peerConnection.setRemoteDescription(serverResponse);
999
+ const eventSource = new EventSource('/outputs?webrtc_id=' + webrtc_id);
1000
+ eventSource.addEventListener("output", (event) => {
1001
+ const eventJson = JSON.parse(event.data);
1002
+ let content = eventJson.content;
1003
+
1004
+ if (eventJson.detected_language) {
1005
+ content += ` <span class="language-info">[${eventJson.detected_language}]</span>`;
1006
+ }
1007
+ addMessage("assistant", content);
1008
+ });
1009
+ eventSource.addEventListener("search", (event) => {
1010
+ const eventJson = JSON.parse(event.data);
1011
+ if (eventJson.query) {
1012
+ addMessage("search-result", `웹 검색 중: "${eventJson.query}"`);
1013
+ }
1014
+ });
1015
+ } catch (err) {
1016
+ clearTimeout(timeoutId);
1017
+ console.error('Error setting up WebRTC:', err);
1018
+ showError('연결을 설정하지 못했습니다. 다시 시도해 주세요.');
1019
+ stop();
1020
+ }
1021
+ }
1022
+
1023
+ function addMessage(role, content, save = true) {
1024
+ const messageDiv = document.createElement('div');
1025
+ messageDiv.classList.add('message', role);
1026
+
1027
+ if (content.includes('<span')) {
1028
+ messageDiv.innerHTML = content;
1029
+ } else {
1030
+ messageDiv.textContent = content;
1031
+ }
1032
+ chatMessages.appendChild(messageDiv);
1033
+ chatMessages.scrollTop = chatMessages.scrollHeight;
1034
+
1035
+ // Save message to database if save flag is true
1036
+ if (save && currentSessionId && role !== 'memory-update' && role !== 'search-result') {
1037
+ fetch('/message/save', {
1038
+ method: 'POST',
1039
+ headers: { 'Content-Type': 'application/json' },
1040
+ body: JSON.stringify({
1041
+ session_id: currentSessionId,
1042
+ role: role,
1043
+ content: content
1044
+ })
1045
+ }).catch(error => console.error('Failed to save message:', error));
1046
+ }
1047
+ }
1048
+
1049
+ function stop() {
1050
+ console.log('[STOP] Stopping connection...');
1051
+
1052
+ // Cancel animation frame first
1053
+ if (animationFrame) {
1054
+ cancelAnimationFrame(animationFrame);
1055
+ animationFrame = null;
1056
+ }
1057
+
1058
+ // Close audio context
1059
+ if (audioContext) {
1060
+ audioContext.close();
1061
+ audioContext = null;
1062
+ analyser = null;
1063
+ audioSource = null;
1064
+ }
1065
+
1066
+ // Close data channel
1067
+ if (dataChannel) {
1068
+ dataChannel.close();
1069
+ dataChannel = null;
1070
+ }
1071
+
1072
+ // Close peer connection
1073
+ if (peerConnection) {
1074
+ console.log('[STOP] Current connection state:', peerConnection.connectionState);
1075
+
1076
+ // Stop all transceivers
1077
+ if (peerConnection.getTransceivers) {
1078
+ peerConnection.getTransceivers().forEach(transceiver => {
1079
+ if (transceiver.stop) {
1080
+ transceiver.stop();
1081
+ }
1082
+ });
1083
+ }
1084
+
1085
+ // Stop all senders
1086
+ if (peerConnection.getSenders) {
1087
+ peerConnection.getSenders().forEach(sender => {
1088
+ if (sender.track) {
1089
+ sender.track.stop();
1090
+ }
1091
+ });
1092
+ }
1093
+
1094
+ // Stop all receivers
1095
+ if (peerConnection.getReceivers) {
1096
+ peerConnection.getReceivers().forEach(receiver => {
1097
+ if (receiver.track) {
1098
+ receiver.track.stop();
1099
+ }
1100
+ });
1101
+ }
1102
+
1103
+ // Close the connection
1104
+ peerConnection.close();
1105
+
1106
+ // Clear the reference
1107
+ peerConnection = null;
1108
+
1109
+ console.log('[STOP] Connection closed');
1110
+ }
1111
+
1112
+ // Reset audio level
1113
+ audioLevel = 0;
1114
+ isVoiceActive = false;
1115
+
1116
+ // Update UI
1117
+ updateButtonState();
1118
+
1119
+ // Clear any existing webrtc_id
1120
+ if (webrtc_id) {
1121
+ console.log('[STOP] Clearing webrtc_id:', webrtc_id);
1122
+ webrtc_id = null;
1123
+ }
1124
+ }
1125
+
1126
+ startButton.addEventListener('click', () => {
1127
+ console.log('clicked');
1128
+ console.log(peerConnection, peerConnection?.connectionState);
1129
+ if (!peerConnection || peerConnection.connectionState !== 'connected') {
1130
+ setupWebRTC();
1131
+ } else {
1132
+ console.log('stopping');
1133
+ stop();
1134
+ }
1135
+ });
1136
+
1137
+ // Initialize on page load
1138
+ window.addEventListener('DOMContentLoaded', () => {
1139
+ sendButton.style.display = 'block';
1140
+ endSessionButton.style.display = 'block';
1141
+ startNewSession();
1142
+ loadHistory();
1143
+ loadMemories();
1144
+ });
1145
+ </script>
1146
+ </body>
1147
+
1148
+ </html>"""
1149
+
1150
+
1151
+ class BraveSearchClient:
1152
+ """Brave Search API client"""
1153
+ def __init__(self, api_key: str):
1154
+ self.api_key = api_key
1155
+ self.base_url = "https://api.search.brave.com/res/v1/web/search"
1156
+
1157
+ async def search(self, query: str, count: int = 10) -> List[Dict]:
1158
+ """Perform a web search using Brave Search API"""
1159
+ if not self.api_key:
1160
+ return []
1161
+
1162
+ headers = {
1163
+ "Accept": "application/json",
1164
+ "X-Subscription-Token": self.api_key
1165
+ }
1166
+ params = {
1167
+ "q": query,
1168
+ "count": count,
1169
+ "lang": "ko"
1170
+ }
1171
+
1172
+ async with httpx.AsyncClient() as client:
1173
+ try:
1174
+ response = await client.get(self.base_url, headers=headers, params=params)
1175
+ response.raise_for_status()
1176
+ data = response.json()
1177
+
1178
+ results = []
1179
+ if "web" in data and "results" in data["web"]:
1180
+ for result in data["web"]["results"][:count]:
1181
+ results.append({
1182
+ "title": result.get("title", ""),
1183
+ "url": result.get("url", ""),
1184
+ "description": result.get("description", "")
1185
+ })
1186
+ return results
1187
+ except Exception as e:
1188
+ print(f"Brave Search error: {e}")
1189
+ return []
1190
+
1191
+
1192
+ # Database helper class
1193
+ class PersonalAssistantDB:
1194
+ """Database manager for personal assistant"""
1195
+
1196
+ @staticmethod
1197
+ async def init():
1198
+ """Initialize database tables"""
1199
+ async with aiosqlite.connect(DB_PATH) as db:
1200
+ # Conversations table
1201
+ await db.execute("""
1202
+ CREATE TABLE IF NOT EXISTS conversations (
1203
+ id TEXT PRIMARY KEY,
1204
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
1205
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
1206
+ summary TEXT
1207
+ )
1208
+ """)
1209
+
1210
+ # Messages table
1211
+ await db.execute("""
1212
+ CREATE TABLE IF NOT EXISTS messages (
1213
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1214
+ session_id TEXT NOT NULL,
1215
+ role TEXT NOT NULL,
1216
+ content TEXT NOT NULL,
1217
+ detected_language TEXT,
1218
+ timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
1219
+ FOREIGN KEY (session_id) REFERENCES conversations(id)
1220
+ )
1221
+ """)
1222
+
1223
+ # User memories table - stores personal information
1224
+ await db.execute("""
1225
+ CREATE TABLE IF NOT EXISTS user_memories (
1226
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1227
+ category TEXT NOT NULL,
1228
+ content TEXT NOT NULL,
1229
+ confidence REAL DEFAULT 1.0,
1230
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
1231
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
1232
+ source_session_id TEXT,
1233
+ FOREIGN KEY (source_session_id) REFERENCES conversations(id)
1234
+ )
1235
+ """)
1236
+
1237
+ # Create indexes for better performance
1238
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_memories_category ON user_memories(category)")
1239
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id)")
1240
+
1241
+ await db.commit()
1242
+
1243
+ @staticmethod
1244
+ async def create_session(session_id: str):
1245
+ """Create a new conversation session"""
1246
+ async with aiosqlite.connect(DB_PATH) as db:
1247
+ await db.execute(
1248
+ "INSERT INTO conversations (id) VALUES (?)",
1249
+ (session_id,)
1250
+ )
1251
+ await db.commit()
1252
+
1253
+ @staticmethod
1254
+ async def save_message(session_id: str, role: str, content: str):
1255
+ """Save a message to the database"""
1256
+ # Check for None or empty content
1257
+ if not content:
1258
+ print(f"[SAVE_MESSAGE] Empty content for {role} message, skipping")
1259
+ return
1260
+
1261
+ # Detect language
1262
+ detected_language = None
1263
+ try:
1264
+ if content and len(content) > 10:
1265
+ detected_language = detect(content)
1266
+ except (LangDetectException, Exception) as e:
1267
+ print(f"Language detection error: {e}")
1268
+
1269
+ async with aiosqlite.connect(DB_PATH) as db:
1270
+ await db.execute(
1271
+ """INSERT INTO messages (session_id, role, content, detected_language)
1272
+ VALUES (?, ?, ?, ?)""",
1273
+ (session_id, role, content, detected_language)
1274
+ )
1275
+
1276
+ # Update conversation's updated_at timestamp
1277
+ await db.execute(
1278
+ "UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
1279
+ (session_id,)
1280
+ )
1281
+
1282
+ # Update conversation summary (use first user message as summary)
1283
+ if role == "user":
1284
+ cursor = await db.execute(
1285
+ "SELECT summary FROM conversations WHERE id = ?",
1286
+ (session_id,)
1287
+ )
1288
+ row = await cursor.fetchone()
1289
+ if row and not row[0]:
1290
+ summary = content[:100] + "..." if len(content) > 100 else content
1291
+ await db.execute(
1292
+ "UPDATE conversations SET summary = ? WHERE id = ?",
1293
+ (summary, session_id)
1294
+ )
1295
+
1296
+ await db.commit()
1297
+
1298
+ @staticmethod
1299
+ async def get_recent_conversations(limit: int = 10):
1300
+ """Get recent conversations"""
1301
+ async with aiosqlite.connect(DB_PATH) as db:
1302
+ cursor = await db.execute(
1303
+ """SELECT id, created_at, summary
1304
+ FROM conversations
1305
+ ORDER BY updated_at DESC
1306
+ LIMIT ?""",
1307
+ (limit,)
1308
+ )
1309
+ rows = await cursor.fetchall()
1310
+ return [
1311
+ {
1312
+ "id": row[0],
1313
+ "created_at": row[1],
1314
+ "summary": row[2] or "새 대화"
1315
+ }
1316
+ for row in rows
1317
+ ]
1318
+
1319
+ @staticmethod
1320
+ async def get_conversation_messages(session_id: str):
1321
+ """Get all messages for a conversation"""
1322
+ async with aiosqlite.connect(DB_PATH) as db:
1323
+ cursor = await db.execute(
1324
+ """SELECT role, content, detected_language, timestamp
1325
+ FROM messages
1326
+ WHERE session_id = ?
1327
+ ORDER BY timestamp ASC""",
1328
+ (session_id,)
1329
+ )
1330
+ rows = await cursor.fetchall()
1331
+ return [
1332
+ {
1333
+ "role": row[0],
1334
+ "content": row[1],
1335
+ "detected_language": row[2],
1336
+ "timestamp": row[3]
1337
+ }
1338
+ for row in rows
1339
+ ]
1340
+
1341
+ @staticmethod
1342
+ async def save_memory(category: str, content: str, session_id: str = None, confidence: float = 1.0):
1343
+ """Save or update a user memory"""
1344
+ async with aiosqlite.connect(DB_PATH) as db:
1345
+ # Check if similar memory exists
1346
+ cursor = await db.execute(
1347
+ """SELECT id, content FROM user_memories
1348
+ WHERE category = ? AND content LIKE ?
1349
+ LIMIT 1""",
1350
+ (category, f"%{content[:20]}%")
1351
+ )
1352
+ existing = await cursor.fetchone()
1353
+
1354
+ if existing:
1355
+ # Update existing memory
1356
+ await db.execute(
1357
+ """UPDATE user_memories
1358
+ SET content = ?, confidence = ?, updated_at = CURRENT_TIMESTAMP,
1359
+ source_session_id = ?
1360
+ WHERE id = ?""",
1361
+ (content, confidence, session_id, existing[0])
1362
+ )
1363
+ else:
1364
+ # Insert new memory
1365
+ await db.execute(
1366
+ """INSERT INTO user_memories (category, content, confidence, source_session_id)
1367
+ VALUES (?, ?, ?, ?)""",
1368
+ (category, content, confidence, session_id)
1369
+ )
1370
+
1371
+ await db.commit()
1372
+
1373
+ @staticmethod
1374
+ async def get_all_memories():
1375
+ """Get all user memories"""
1376
+ async with aiosqlite.connect(DB_PATH) as db:
1377
+ cursor = await db.execute(
1378
+ """SELECT category, content, confidence, updated_at
1379
+ FROM user_memories
1380
+ ORDER BY category, updated_at DESC"""
1381
+ )
1382
+ rows = await cursor.fetchall()
1383
+ return [
1384
+ {
1385
+ "category": row[0],
1386
+ "content": row[1],
1387
+ "confidence": row[2],
1388
+ "updated_at": row[3]
1389
+ }
1390
+ for row in rows
1391
+ ]
1392
+
1393
+ @staticmethod
1394
+ async def extract_and_save_memories(session_id: str):
1395
+ """Extract memories from conversation and save them"""
1396
+ # Get all messages from the session
1397
+ messages = await PersonalAssistantDB.get_conversation_messages(session_id)
1398
+
1399
+ if not messages:
1400
+ return
1401
+
1402
+ # Prepare conversation text for analysis
1403
+ conversation_text = "\n".join([
1404
+ f"{msg['role']}: {msg['content']}"
1405
+ for msg in messages if msg.get('content')
1406
+ ])
1407
+
1408
+ # Use GPT to extract memories
1409
+ client = openai.AsyncOpenAI()
1410
+
1411
+ try:
1412
+ response = await client.chat.completions.create(
1413
+ model="gpt-4.1-mini",
1414
+ messages=[
1415
+ {
1416
+ "role": "system",
1417
+ "content": """You are a memory extraction system. Extract personal information from conversations.
1418
+
1419
+ Categories to extract:
1420
+ - personal_info: 이름, 나이, 성별, 직업, 거주지
1421
+ - preferences: 좋아하는 것, 싫어하는 것, 취향
1422
+ - important_dates: 생일, 기념일, 중요한 날짜
1423
+ - relationships: 가족, 친구, 동료 관계
1424
+ - hobbies: 취미, 관심사
1425
+ - health: 건강 상태, 알레르기, 의료 정보
1426
+ - goals: 목표, 계획, 꿈
1427
+ - routines: 일상, 습관, 루틴
1428
+ - work: 직장, 업무, 프로젝트
1429
+ - education: 학력, 전공, 학습
1430
+
1431
+ Return as JSON array with format:
1432
+ [
1433
+ {
1434
+ "category": "category_name",
1435
+ "content": "extracted information in Korean",
1436
+ "confidence": 0.0-1.0
1437
+ }
1438
+ ]
1439
+
1440
+ Only extract clear, factual information. Do not make assumptions."""
1441
+ },
1442
+ {
1443
+ "role": "user",
1444
+ "content": f"Extract memories from this conversation:\n\n{conversation_text}"
1445
+ }
1446
+ ],
1447
+ temperature=0.3,
1448
+ max_tokens=2000
1449
+ )
1450
+
1451
+ # Parse and save memories
1452
+ memories_text = response.choices[0].message.content
1453
+
1454
+ # Extract JSON from response
1455
+ import re
1456
+ json_match = re.search(r'\[.*\]', memories_text, re.DOTALL)
1457
+ if json_match:
1458
+ memories = json.loads(json_match.group())
1459
+
1460
+ for memory in memories:
1461
+ if memory.get('content') and len(memory['content']) > 5:
1462
+ await PersonalAssistantDB.save_memory(
1463
+ category=memory.get('category', 'general'),
1464
+ content=memory['content'],
1465
+ session_id=session_id,
1466
+ confidence=memory.get('confidence', 0.8)
1467
+ )
1468
+
1469
+ print(f"Extracted and saved {len(memories)} memories from session {session_id}")
1470
+
1471
+ except Exception as e:
1472
+ print(f"Error extracting memories: {e}")
1473
+
1474
+
1475
+ # Initialize search client globally
1476
+ brave_api_key = os.getenv("BSEARCH_API")
1477
+ search_client = BraveSearchClient(brave_api_key) if brave_api_key else None
1478
+ print(f"Search client initialized: {search_client is not None}, API key present: {bool(brave_api_key)}")
1479
+
1480
+ # Store connection settings
1481
+ connection_settings = {}
1482
+
1483
+ # Initialize OpenAI client for text chat
1484
+ client = openai.AsyncOpenAI()
1485
+
1486
+
1487
+ def update_chatbot(chatbot: list[dict], response: ResponseAudioTranscriptDoneEvent):
1488
+ chatbot.append({"role": "assistant", "content": response.transcript})
1489
+ return chatbot
1490
+
1491
+
1492
+ def format_memories_for_prompt(memories: Dict[str, List[str]]) -> str:
1493
+ """Format memories for inclusion in system prompt"""
1494
+ if not memories:
1495
+ return ""
1496
+
1497
+ memory_text = "\n\n=== 기억된 정보 ===\n"
1498
+ for category, items in memories.items():
1499
+ if items and isinstance(items, list):
1500
+ memory_text += f"\n[{category}]\n"
1501
+ for item in items:
1502
+ if item: # Check if item is not None or empty
1503
+ memory_text += f"- {item}\n"
1504
+
1505
+ return memory_text
1506
+
1507
+
1508
+ async def process_text_chat(message: str, web_search_enabled: bool, session_id: str,
1509
+ user_name: str = "", memories: Dict = None) -> Dict[str, str]:
1510
+ """Process text chat using GPT-4o-mini model"""
1511
+ try:
1512
+ # Check for empty or None message
1513
+ if not message:
1514
+ return {"error": "메시지가 비어있습니다."}
1515
+
1516
+ # Check for stop words
1517
+ stop_words = ["중단", "그만", "스톱", "stop", "닥쳐", "멈춰", "중지"]
1518
+ if any(word in message.lower() for word in stop_words):
1519
+ return {
1520
+ "response": "대화를 중단합니다.",
1521
+ "detected_language": "ko"
1522
+ }
1523
+
1524
+ # Build system prompt with memories
1525
+ base_prompt = f"""You are a personal AI assistant for {user_name if user_name else 'the user'}.
1526
+ You remember all previous conversations and personal information about the user.
1527
+ Be friendly, helpful, and personalized in your responses.
1528
+ Always use the information you remember to make conversations more personal and relevant.
1529
+ IMPORTANT: Give only ONE response. Do not repeat or give multiple answers."""
1530
+
1531
+ # Add memories to prompt
1532
+ if memories:
1533
+ memory_text = format_memories_for_prompt(memories)
1534
+ base_prompt += memory_text
1535
+
1536
+ messages = [{"role": "system", "content": base_prompt}]
1537
+
1538
+ # Handle web search if enabled
1539
+ if web_search_enabled and search_client and message:
1540
+ search_keywords = ["날씨", "기온", "비", "눈", "뉴스", "소식", "현재", "최근",
1541
+ "오늘", "지금", "가격", "환율", "주가", "weather", "news",
1542
+ "current", "today", "price", "2024", "2025"]
1543
+
1544
+ should_search = any(keyword in message.lower() for keyword in search_keywords)
1545
+
1546
+ if should_search:
1547
+ search_results = await search_client.search(message)
1548
+ if search_results:
1549
+ search_context = "웹 검색 결과:\n\n"
1550
+ for i, result in enumerate(search_results[:5], 1):
1551
+ search_context += f"{i}. {result['title']}\n{result['description']}\n\n"
1552
+
1553
+ messages.append({
1554
+ "role": "system",
1555
+ "content": "다음 웹 검색 결과를 참고하여 답변하세요:\n\n" + search_context
1556
+ })
1557
+
1558
+ messages.append({"role": "user", "content": message})
1559
+
1560
+ # Call GPT-4o-mini
1561
+ response = await client.chat.completions.create(
1562
+ model="gpt-4.1-mini",
1563
+ messages=messages,
1564
+ temperature=0.7,
1565
+ max_tokens=2000
1566
+ )
1567
+
1568
+ response_text = response.choices[0].message.content
1569
+
1570
+ # Detect language
1571
+ detected_language = None
1572
+ try:
1573
+ if response_text and len(response_text) > 10:
1574
+ detected_language = detect(response_text)
1575
+ except:
1576
+ pass
1577
+
1578
+ # Save messages to database
1579
+ if session_id:
1580
+ await PersonalAssistantDB.save_message(session_id, "user", message)
1581
+ await PersonalAssistantDB.save_message(session_id, "assistant", response_text)
1582
+
1583
+ return {
1584
+ "response": response_text,
1585
+ "detected_language": detected_language
1586
+ }
1587
+
1588
+ except Exception as e:
1589
+ print(f"Error in text chat: {e}")
1590
+ return {"error": str(e)}
1591
+
1592
+
1593
+ class OpenAIHandler(AsyncStreamHandler):
1594
+ def __init__(self, web_search_enabled: bool = False, webrtc_id: str = None,
1595
+ session_id: str = None, user_name: str = "", memories: Dict = None) -> None:
1596
+ super().__init__(
1597
+ expected_layout="mono",
1598
+ output_sample_rate=SAMPLE_RATE,
1599
+ output_frame_size=480,
1600
+ input_sample_rate=SAMPLE_RATE,
1601
+ )
1602
+ self.connection = None
1603
+ self.output_queue = asyncio.Queue()
1604
+ self.search_client = search_client
1605
+ self.function_call_in_progress = False
1606
+ self.current_function_args = ""
1607
+ self.current_call_id = None
1608
+ self.webrtc_id = webrtc_id
1609
+ self.web_search_enabled = web_search_enabled
1610
+ self.session_id = session_id
1611
+ self.user_name = user_name
1612
+ self.memories = memories or {}
1613
+ self.is_responding = False # Track if already responding
1614
+ self.should_stop = False # Track if conversation should stop
1615
+
1616
+ print(f"[INIT] Handler created with web_search={web_search_enabled}, session_id={session_id}, user={user_name}")
1617
+
1618
+ def copy(self):
1619
+ if connection_settings:
1620
+ recent_ids = sorted(connection_settings.keys(),
1621
+ key=lambda k: connection_settings[k].get('timestamp', 0),
1622
+ reverse=True)
1623
+ if recent_ids:
1624
+ recent_id = recent_ids[0]
1625
+ settings = connection_settings[recent_id]
1626
+
1627
+ print(f"[COPY] Copying settings from {recent_id}:")
1628
+
1629
+ return OpenAIHandler(
1630
+ web_search_enabled=settings.get('web_search_enabled', False),
1631
+ webrtc_id=recent_id,
1632
+ session_id=settings.get('session_id'),
1633
+ user_name=settings.get('user_name', ''),
1634
+ memories=settings.get('memories', {})
1635
+ )
1636
+
1637
+ print(f"[COPY] No settings found, creating default handler")
1638
+ return OpenAIHandler(web_search_enabled=False)
1639
+
1640
+ async def search_web(self, query: str) -> str:
1641
+ """Perform web search and return formatted results"""
1642
+ if not self.search_client or not self.web_search_enabled:
1643
+ return "웹 검색이 비활성화되어 있습니다."
1644
+
1645
+ print(f"Searching web for: {query}")
1646
+ results = await self.search_client.search(query)
1647
+ if not results:
1648
+ return f"'{query}'에 대한 검색 결과를 찾을 수 없습니다."
1649
+
1650
+ formatted_results = []
1651
+ for i, result in enumerate(results, 1):
1652
+ formatted_results.append(
1653
+ f"{i}. {result['title']}\n"
1654
+ f" URL: {result['url']}\n"
1655
+ f" {result['description']}\n"
1656
+ )
1657
+
1658
+ return f"웹 검색 결과 '{query}':\n\n" + "\n".join(formatted_results)
1659
+
1660
+ async def process_text_message(self, message: str):
1661
+ """Process text message from user"""
1662
+ if self.connection:
1663
+ await self.connection.conversation.item.create(
1664
+ item={
1665
+ "type": "message",
1666
+ "role": "user",
1667
+ "content": [{"type": "input_text", "text": message}]
1668
+ }
1669
+ )
1670
+ await self.connection.response.create()
1671
+
1672
+ async def start_up(self):
1673
+ """Connect to realtime API"""
1674
+ if connection_settings and self.webrtc_id:
1675
+ if self.webrtc_id in connection_settings:
1676
+ settings = connection_settings[self.webrtc_id]
1677
+ self.web_search_enabled = settings.get('web_search_enabled', False)
1678
+ self.session_id = settings.get('session_id')
1679
+ self.user_name = settings.get('user_name', '')
1680
+ self.memories = settings.get('memories', {})
1681
+
1682
+ print(f"[START_UP] Updated settings from storage for {self.webrtc_id}")
1683
+
1684
+ self.client = openai.AsyncOpenAI()
1685
+
1686
+ print(f"[REALTIME API] Connecting...")
1687
+
1688
+ # Build system prompt with memories
1689
+ base_instructions = f"""You are a personal AI assistant for {self.user_name if self.user_name else 'the user'}.
1690
+ You remember all previous conversations and personal information about the user.
1691
+ Be friendly, helpful, and personalized in your responses.
1692
+ Always use the information you remember to make conversations more personal and relevant.
1693
+ IMPORTANT: Give only ONE response per user input. Do not repeat yourself or give multiple answers."""
1694
+
1695
+ # Add memories to prompt
1696
+ if self.memories:
1697
+ memory_text = format_memories_for_prompt(self.memories)
1698
+ base_instructions += memory_text
1699
+
1700
+ # Define the web search function
1701
+ tools = []
1702
+ if self.web_search_enabled and self.search_client:
1703
+ tools = [{
1704
+ "type": "function",
1705
+ "function": {
1706
+ "name": "web_search",
1707
+ "description": "Search the web for current information. Use this for weather, news, prices, current events, or any time-sensitive topics.",
1708
+ "parameters": {
1709
+ "type": "object",
1710
+ "properties": {
1711
+ "query": {
1712
+ "type": "string",
1713
+ "description": "The search query"
1714
+ }
1715
+ },
1716
+ "required": ["query"]
1717
+ }
1718
+ }
1719
+ }]
1720
+
1721
+ search_instructions = (
1722
+ "\n\nYou have web search capabilities. "
1723
+ "Use web_search for current information like weather, news, prices, etc."
1724
+ )
1725
+
1726
+ instructions = base_instructions + search_instructions
1727
+ else:
1728
+ instructions = base_instructions
1729
+
1730
+ async with self.client.beta.realtime.connect(
1731
+ model="gpt-4o-mini-realtime-preview-2024-12-17"
1732
+ ) as conn:
1733
+ session_update = {
1734
+ "turn_detection": {
1735
+ "type": "server_vad",
1736
+ "threshold": 0.5,
1737
+ "prefix_padding_ms": 300,
1738
+ "silence_duration_ms": 200
1739
+ },
1740
+ "instructions": instructions,
1741
+ "tools": tools,
1742
+ "tool_choice": "auto" if tools else "none",
1743
+ "temperature": 0.7,
1744
+ "max_response_output_tokens": 4096,
1745
+ "modalities": ["text", "audio"],
1746
+ "voice": "alloy"
1747
+ }
1748
+
1749
+ try:
1750
+ await conn.session.update(session=session_update)
1751
+ self.connection = conn
1752
+ print(f"Connected with tools: {len(tools)} functions")
1753
+ print(f"Session update successful")
1754
+ except Exception as e:
1755
+ print(f"Error updating session: {e}")
1756
+ raise
1757
+
1758
+ async for event in self.connection:
1759
+ # Debug log for all events
1760
+ if hasattr(event, 'type'):
1761
+ if event.type not in ["response.audio.delta", "response.audio.done"]:
1762
+ print(f"[EVENT] Type: {event.type}")
1763
+
1764
+ # Handle user input audio transcription
1765
+ if event.type == "conversation.item.input_audio_transcription.completed":
1766
+ if hasattr(event, 'transcript') and event.transcript:
1767
+ user_text = event.transcript.lower()
1768
+ stop_words = ["중단", "그만", "스톱", "stop", "닥쳐", "멈춰", "중지"]
1769
+
1770
+ if any(word in user_text for word in stop_words):
1771
+ print(f"[STOP DETECTED] User said: {event.transcript}")
1772
+ self.should_stop = True
1773
+ if self.connection:
1774
+ try:
1775
+ await self.connection.response.cancel()
1776
+ except:
1777
+ pass
1778
+ continue
1779
+
1780
+ # Save user message to database
1781
+ if self.session_id:
1782
+ await PersonalAssistantDB.save_message(self.session_id, "user", event.transcript)
1783
+
1784
+ # Handle user transcription for stop detection (alternative event)
1785
+ elif event.type == "conversation.item.created":
1786
+ if hasattr(event, 'item') and hasattr(event.item, 'role') and event.item.role == "user":
1787
+ if hasattr(event.item, 'content') and event.item.content:
1788
+ for content_item in event.item.content:
1789
+ if hasattr(content_item, 'transcript') and content_item.transcript:
1790
+ user_text = content_item.transcript.lower()
1791
+ stop_words = ["중단", "그만", "스톱", "stop", "닥쳐", "멈춰", "중지"]
1792
+
1793
+ if any(word in user_text for word in stop_words):
1794
+ print(f"[STOP DETECTED] User said: {content_item.transcript}")
1795
+ self.should_stop = True
1796
+ if self.connection:
1797
+ try:
1798
+ await self.connection.response.cancel()
1799
+ except:
1800
+ pass
1801
+ continue
1802
+
1803
+ # Save user message to database
1804
+ if self.session_id:
1805
+ await PersonalAssistantDB.save_message(self.session_id, "user", content_item.transcript)
1806
+
1807
+ elif event.type == "response.audio_transcript.done":
1808
+ # Prevent multiple responses
1809
+ if self.is_responding:
1810
+ print("[DUPLICATE RESPONSE] Skipping duplicate response")
1811
+ continue
1812
+
1813
+ self.is_responding = True
1814
+ print(f"[RESPONSE] Transcript: {event.transcript[:100] if event.transcript else 'None'}...")
1815
+
1816
+ # Detect language
1817
+ detected_language = None
1818
+ try:
1819
+ if event.transcript and len(event.transcript) > 10:
1820
+ detected_language = detect(event.transcript)
1821
+ except Exception as e:
1822
+ print(f"Language detection error: {e}")
1823
+
1824
+ # Save to database
1825
+ if self.session_id and event.transcript:
1826
+ await PersonalAssistantDB.save_message(self.session_id, "assistant", event.transcript)
1827
+
1828
+ output_data = {
1829
+ "event": event,
1830
+ "detected_language": detected_language
1831
+ }
1832
+ await self.output_queue.put(AdditionalOutputs(output_data))
1833
+
1834
+ elif event.type == "response.done":
1835
+ # Reset responding flag when response is complete
1836
+ self.is_responding = False
1837
+ self.should_stop = False
1838
+ print("[RESPONSE DONE] Response completed")
1839
+
1840
+ elif event.type == "response.audio.delta":
1841
+ # Check if we should stop
1842
+ if self.should_stop:
1843
+ continue
1844
+
1845
+ if hasattr(event, 'delta'):
1846
+ await self.output_queue.put(
1847
+ (
1848
+ self.output_sample_rate,
1849
+ np.frombuffer(
1850
+ base64.b64decode(event.delta), dtype=np.int16
1851
+ ).reshape(1, -1),
1852
+ ),
1853
+ )
1854
+
1855
+ # Handle errors
1856
+ elif event.type == "error":
1857
+ print(f"[ERROR] {event}")
1858
+ self.is_responding = False
1859
+
1860
+ # Handle function calls
1861
+ elif event.type == "response.function_call_arguments.start":
1862
+ print(f"Function call started")
1863
+ self.function_call_in_progress = True
1864
+ self.current_function_args = ""
1865
+ self.current_call_id = getattr(event, 'call_id', None)
1866
+
1867
+ elif event.type == "response.function_call_arguments.delta":
1868
+ if self.function_call_in_progress:
1869
+ self.current_function_args += event.delta
1870
+
1871
+ elif event.type == "response.function_call_arguments.done":
1872
+ if self.function_call_in_progress:
1873
+ print(f"Function call done, args: {self.current_function_args}")
1874
+ try:
1875
+ args = json.loads(self.current_function_args)
1876
+ query = args.get("query", "")
1877
+
1878
+ # Emit search event to client
1879
+ await self.output_queue.put(AdditionalOutputs({
1880
+ "type": "search",
1881
+ "query": query
1882
+ }))
1883
+
1884
+ # Perform the search
1885
+ search_results = await self.search_web(query)
1886
+ print(f"Search results length: {len(search_results)}")
1887
+
1888
+ # Send function result back to the model
1889
+ if self.connection and self.current_call_id:
1890
+ await self.connection.conversation.item.create(
1891
+ item={
1892
+ "type": "function_call_output",
1893
+ "call_id": self.current_call_id,
1894
+ "output": search_results
1895
+ }
1896
+ )
1897
+ await self.connection.response.create()
1898
+
1899
+ except Exception as e:
1900
+ print(f"Function call error: {e}")
1901
+ finally:
1902
+ self.function_call_in_progress = False
1903
+ self.current_function_args = ""
1904
+ self.current_call_id = None
1905
+
1906
+ async def receive(self, frame: tuple[int, np.ndarray]) -> None:
1907
+ if not self.connection:
1908
+ print(f"[RECEIVE] No connection, skipping")
1909
+ return
1910
+ try:
1911
+ if frame is None or len(frame) < 2:
1912
+ print(f"[RECEIVE] Invalid frame")
1913
+ return
1914
+
1915
+ _, array = frame
1916
+ if array is None:
1917
+ print(f"[RECEIVE] Null array")
1918
+ return
1919
+
1920
+ array = array.squeeze()
1921
+ audio_message = base64.b64encode(array.tobytes()).decode("utf-8")
1922
+ await self.connection.input_audio_buffer.append(audio=audio_message)
1923
+ except Exception as e:
1924
+ print(f"Error in receive: {e}")
1925
+
1926
+ async def emit(self) -> tuple[int, np.ndarray] | AdditionalOutputs | None:
1927
+ item = await wait_for_item(self.output_queue)
1928
+
1929
+ if isinstance(item, dict) and item.get('type') == 'text_message':
1930
+ await self.process_text_message(item['content'])
1931
+ return None
1932
+
1933
+ return item
1934
+
1935
+ async def shutdown(self) -> None:
1936
+ print(f"[SHUTDOWN] Called")
1937
+
1938
+ if self.connection:
1939
+ await self.connection.close()
1940
+ self.connection = None
1941
+ print("[REALTIME API] Connection closed")
1942
+
1943
+
1944
+ # Create initial handler instance
1945
+ handler = OpenAIHandler(web_search_enabled=False)
1946
+
1947
+ # Create components
1948
+ chatbot = gr.Chatbot(type="messages")
1949
+
1950
+ # Create stream with handler instance
1951
+ stream = Stream(
1952
+ handler,
1953
+ mode="send-receive",
1954
+ modality="audio",
1955
+ additional_inputs=[chatbot],
1956
+ additional_outputs=[chatbot],
1957
+ additional_outputs_handler=update_chatbot,
1958
+ rtc_configuration=get_twilio_turn_credentials() if get_space() else None,
1959
+ concurrency_limit=5 if get_space() else None,
1960
+ time_limit=300 if get_space() else None,
1961
+ )
1962
+
1963
+ app = FastAPI()
1964
+
1965
+ # Mount stream
1966
+ stream.mount(app)
1967
+
1968
+ # Initialize database on startup
1969
+ @app.on_event("startup")
1970
+ async def startup_event():
1971
+ try:
1972
+ await PersonalAssistantDB.init()
1973
+ print(f"Database initialized at: {DB_PATH}")
1974
+ print(f"Persistent directory: {PERSISTENT_DIR}")
1975
+ print(f"DB file exists: {os.path.exists(DB_PATH)}")
1976
+
1977
+ # Check if we're in Hugging Face Space
1978
+ if os.path.exists("/data"):
1979
+ print("Running in Hugging Face Space with persistent storage")
1980
+ # List files in persistent directory
1981
+ try:
1982
+ files = os.listdir(PERSISTENT_DIR)
1983
+ print(f"Files in persistent directory: {files}")
1984
+ except Exception as e:
1985
+ print(f"Error listing files: {e}")
1986
+ except Exception as e:
1987
+ print(f"Error during startup: {e}")
1988
+ # Try to create directory if it doesn't exist
1989
+ os.makedirs(PERSISTENT_DIR, exist_ok=True)
1990
+ await PersonalAssistantDB.init()
1991
+
1992
+ # Intercept offer to capture settings
1993
+ @app.post("/webrtc/offer", include_in_schema=False)
1994
+ async def custom_offer(request: Request):
1995
+ """Intercept offer to capture settings"""
1996
+ body = await request.json()
1997
+
1998
+ webrtc_id = body.get("webrtc_id")
1999
+ web_search_enabled = body.get("web_search_enabled", False)
2000
+ session_id = body.get("session_id")
2001
+ user_name = body.get("user_name", "")
2002
+ memories = body.get("memories", {})
2003
+
2004
+ print(f"[OFFER] Received offer with webrtc_id: {webrtc_id}")
2005
+ print(f"[OFFER] web_search_enabled: {web_search_enabled}")
2006
+ print(f"[OFFER] session_id: {session_id}")
2007
+ print(f"[OFFER] user_name: {user_name}")
2008
+
2009
+ # Store settings with timestamp
2010
+ if webrtc_id:
2011
+ connection_settings[webrtc_id] = {
2012
+ 'web_search_enabled': web_search_enabled,
2013
+ 'session_id': session_id,
2014
+ 'user_name': user_name,
2015
+ 'memories': memories,
2016
+ 'timestamp': asyncio.get_event_loop().time()
2017
+ }
2018
+
2019
+ print(f"[OFFER] Stored settings for {webrtc_id}")
2020
+
2021
+ # Remove our custom route temporarily
2022
+ custom_route = None
2023
+ for i, route in enumerate(app.routes):
2024
+ if hasattr(route, 'path') and route.path == "/webrtc/offer" and route.endpoint == custom_offer:
2025
+ custom_route = app.routes.pop(i)
2026
+ break
2027
+
2028
+ # Forward to stream's offer handler
2029
+ print(f"[OFFER] Forwarding to stream.offer()")
2030
+ response = await stream.offer(body)
2031
+
2032
+ # Re-add our custom route
2033
+ if custom_route:
2034
+ app.routes.insert(0, custom_route)
2035
+
2036
+ print(f"[OFFER] Response status: {response.get('status', 'unknown') if isinstance(response, dict) else 'OK'}")
2037
+
2038
+ return response
2039
+
2040
+
2041
+ @app.post("/session/new")
2042
+ async def create_new_session():
2043
+ """Create a new chat session"""
2044
+ session_id = str(uuid.uuid4())
2045
+ await PersonalAssistantDB.create_session(session_id)
2046
+ return {"session_id": session_id}
2047
+
2048
+
2049
+ @app.post("/session/end")
2050
+ async def end_session(request: Request):
2051
+ """End session and extract memories"""
2052
+ body = await request.json()
2053
+ session_id = body.get("session_id")
2054
+
2055
+ if not session_id:
2056
+ return {"error": "session_id required"}
2057
+
2058
+ # Extract and save memories from the conversation
2059
+ await PersonalAssistantDB.extract_and_save_memories(session_id)
2060
+
2061
+ return {"status": "ok"}
2062
+
2063
+
2064
+ @app.post("/message/save")
2065
+ async def save_message(request: Request):
2066
+ """Save a message to the database"""
2067
+ body = await request.json()
2068
+ session_id = body.get("session_id")
2069
+ role = body.get("role")
2070
+ content = body.get("content")
2071
+
2072
+ if not all([session_id, role, content]):
2073
+ return {"error": "Missing required fields"}
2074
+
2075
+ await PersonalAssistantDB.save_message(session_id, role, content)
2076
+ return {"status": "ok"}
2077
+
2078
+
2079
+ @app.get("/history/recent")
2080
+ async def get_recent_history():
2081
+ """Get recent conversation history"""
2082
+ conversations = await PersonalAssistantDB.get_recent_conversations()
2083
+ return conversations
2084
+
2085
+
2086
+ @app.get("/history/{session_id}")
2087
+ async def get_conversation(session_id: str):
2088
+ """Get messages for a specific conversation"""
2089
+ messages = await PersonalAssistantDB.get_conversation_messages(session_id)
2090
+ return messages
2091
+
2092
+
2093
+ @app.get("/memory/all")
2094
+ async def get_all_memories():
2095
+ """Get all user memories"""
2096
+ memories = await PersonalAssistantDB.get_all_memories()
2097
+ return memories
2098
+
2099
+
2100
+ @app.post("/chat/text")
2101
+ async def chat_text(request: Request):
2102
+ """Handle text chat messages using GPT-4o-mini"""
2103
+ try:
2104
+ body = await request.json()
2105
+ message = body.get("message", "")
2106
+ web_search_enabled = body.get("web_search_enabled", False)
2107
+ session_id = body.get("session_id")
2108
+ user_name = body.get("user_name", "")
2109
+ memories = body.get("memories", {})
2110
+
2111
+ if not message:
2112
+ return {"error": "메시지가 비어있습니다."}
2113
+
2114
+ # Process text chat
2115
+ result = await process_text_chat(message, web_search_enabled, session_id, user_name, memories)
2116
+
2117
+ return result
2118
+
2119
+ except Exception as e:
2120
+ print(f"Error in chat_text endpoint: {e}")
2121
+ return {"error": "채팅 처리 중 오류가 발생했습니다."}
2122
+
2123
+
2124
+ @app.post("/text_message/{webrtc_id}")
2125
+ async def receive_text_message(webrtc_id: str, request: Request):
2126
+ """Receive text message from client"""
2127
+ body = await request.json()
2128
+ message = body.get("content", "")
2129
+
2130
+ # Find the handler for this connection
2131
+ if webrtc_id in stream.handlers:
2132
+ handler = stream.handlers[webrtc_id]
2133
+ # Queue the text message for processing
2134
+ await handler.output_queue.put({
2135
+ 'type': 'text_message',
2136
+ 'content': message
2137
+ })
2138
+
2139
+ return {"status": "ok"}
2140
+
2141
+
2142
+ @app.get("/outputs")
2143
+ async def outputs(webrtc_id: str):
2144
+ """Stream outputs including search events"""
2145
+ async def output_stream():
2146
+ async for output in stream.output_stream(webrtc_id):
2147
+ if hasattr(output, 'args') and output.args:
2148
+ # Check if it's a search event
2149
+ if isinstance(output.args[0], dict) and output.args[0].get('type') == 'search':
2150
+ yield f"event: search\ndata: {json.dumps(output.args[0])}\n\n"
2151
+ # Regular transcript event with language info
2152
+ elif isinstance(output.args[0], dict) and 'event' in output.args[0]:
2153
+ event_data = output.args[0]
2154
+ if 'event' in event_data and hasattr(event_data['event'], 'transcript'):
2155
+ data = {
2156
+ "role": "assistant",
2157
+ "content": event_data['event'].transcript,
2158
+ "detected_language": event_data.get('detected_language')
2159
+ }
2160
+ yield f"event: output\ndata: {json.dumps(data)}\n\n"
2161
+
2162
+ return StreamingResponse(output_stream(), media_type="text/event-stream")
2163
+
2164
+
2165
+ @app.get("/")
2166
+ async def index():
2167
+ """Serve the HTML page"""
2168
+ rtc_config = get_twilio_turn_credentials() if get_space() else None
2169
+ html_content = HTML_CONTENT.replace("__RTC_CONFIGURATION__", json.dumps(rtc_config))
2170
+ return HTMLResponse(content=html_content)
2171
+
2172
+
2173
+ if __name__ == "__main__":
2174
+ import uvicorn
2175
+
2176
+ mode = os.getenv("MODE")
2177
+ if mode == "UI":
2178
+ stream.ui.launch(server_port=7860)
2179
+ elif mode == "PHONE":
2180
+ stream.fastphone(host="0.0.0.0", port=7860)
2181
+ else:
2182
+ uvicorn.run(app, host="0.0.0.0", port=7860)