@rubytech/create-maxy 0.3.4 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/maxy/app/page.tsx +125 -162
- package/payload/maxy/app/public/page.tsx +266 -0
- package/payload/maxy/proxy.ts +11 -32
package/package.json
CHANGED
|
@@ -1,176 +1,132 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState, useRef, useEffect,
|
|
3
|
+
import { useState, useRef, useEffect, FormEvent } from 'react'
|
|
4
|
+
import { ActivityEvent, type AdminEvent } from './admin/components/ActivityEvent'
|
|
4
5
|
|
|
5
6
|
interface Message {
|
|
6
|
-
role: '
|
|
7
|
-
content
|
|
7
|
+
role: 'admin' | 'maxy'
|
|
8
|
+
content?: string
|
|
9
|
+
events?: AdminEvent[]
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
'What can Maxy actually do for me day-to-day?',
|
|
16
|
-
]
|
|
17
|
-
|
|
18
|
-
/** Generate a random session ID (Taskmaster prepends its own anon- prefix). */
|
|
19
|
-
function generateSessionId(): string {
|
|
20
|
-
return crypto.randomUUID()
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export default function ChatPage() {
|
|
12
|
+
export default function AdminPage() {
|
|
13
|
+
const [authenticated, setAuthenticated] = useState(false)
|
|
14
|
+
const [pin, setPin] = useState('')
|
|
15
|
+
const [pinError, setPinError] = useState('')
|
|
16
|
+
const [sessionKey, setSessionKey] = useState<string | null>(null)
|
|
24
17
|
const [messages, setMessages] = useState<Message[]>([])
|
|
25
|
-
const [remainingSuggestions, setRemainingSuggestions] = useState(suggestions)
|
|
26
18
|
const [input, setInput] = useState('')
|
|
27
19
|
const [isStreaming, setIsStreaming] = useState(false)
|
|
28
20
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
29
21
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
30
|
-
const
|
|
31
|
-
const sessionKeyRef = useRef<string | null>(null)
|
|
32
|
-
const sessionPendingRef = useRef<Promise<string | null> | null>(null)
|
|
22
|
+
const pinInputRef = useRef<HTMLInputElement>(null)
|
|
33
23
|
|
|
34
|
-
// Scroll to bottom when messages change
|
|
35
24
|
useEffect(() => {
|
|
36
25
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
37
26
|
}, [messages])
|
|
38
27
|
|
|
39
|
-
// Nudge timer — if no input after 15s, show gentle prompt
|
|
40
28
|
useEffect(() => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return [{ role: 'maxy', content: 'No wrong answers. Just tell me what\'s on your mind.' }]
|
|
45
|
-
})
|
|
46
|
-
}, 15000)
|
|
47
|
-
return () => {
|
|
48
|
-
if (nudgeTimerRef.current) clearTimeout(nudgeTimerRef.current)
|
|
49
|
-
}
|
|
50
|
-
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
51
|
-
|
|
52
|
-
// Focus input on mount
|
|
53
|
-
useEffect(() => {
|
|
54
|
-
inputRef.current?.focus()
|
|
55
|
-
}, [])
|
|
29
|
+
if (authenticated) inputRef.current?.focus()
|
|
30
|
+
else pinInputRef.current?.focus()
|
|
31
|
+
}, [authenticated])
|
|
56
32
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
33
|
+
async function handlePinSubmit(e: FormEvent) {
|
|
34
|
+
e.preventDefault()
|
|
35
|
+
setPinError('')
|
|
60
36
|
|
|
61
|
-
|
|
62
|
-
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch('/api/admin/session', {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
body: JSON.stringify({ pin }),
|
|
42
|
+
})
|
|
63
43
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
headers: { 'Content-Type': 'application/json' },
|
|
69
|
-
body: JSON.stringify({ session_id: generateSessionId() }),
|
|
70
|
-
})
|
|
71
|
-
if (!res.ok) return null
|
|
72
|
-
const data = await res.json()
|
|
73
|
-
sessionKeyRef.current = data.session_key
|
|
74
|
-
return data.session_key as string
|
|
75
|
-
} catch {
|
|
76
|
-
return null
|
|
77
|
-
} finally {
|
|
78
|
-
sessionPendingRef.current = null
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const data = await res.json().catch(() => ({}))
|
|
46
|
+
setPinError(data.error || 'Invalid PIN')
|
|
47
|
+
return
|
|
79
48
|
}
|
|
80
|
-
})()
|
|
81
49
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (!text || isStreaming) return
|
|
88
|
-
|
|
89
|
-
// Clear nudge timer
|
|
90
|
-
if (nudgeTimerRef.current) {
|
|
91
|
-
clearTimeout(nudgeTimerRef.current)
|
|
92
|
-
nudgeTimerRef.current = null
|
|
50
|
+
const data = await res.json()
|
|
51
|
+
setSessionKey(data.session_key)
|
|
52
|
+
setAuthenticated(true)
|
|
53
|
+
} catch {
|
|
54
|
+
setPinError('Could not connect.')
|
|
93
55
|
}
|
|
56
|
+
}
|
|
94
57
|
|
|
95
|
-
|
|
96
|
-
|
|
58
|
+
async function sendMessage(text: string) {
|
|
59
|
+
if (!text || isStreaming || !sessionKey) return
|
|
97
60
|
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
setMessages(updatedMessages)
|
|
61
|
+
const adminMessage: Message = { role: 'admin', content: text }
|
|
62
|
+
const currentLength = messages.length
|
|
63
|
+
setMessages(prev => [...prev, adminMessage])
|
|
102
64
|
setInput('')
|
|
103
65
|
setIsStreaming(true)
|
|
104
66
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
67
|
+
const maxyMessage: Message = { role: 'maxy', events: [] }
|
|
68
|
+
setMessages(prev => [...prev, maxyMessage])
|
|
69
|
+
const messageIndex = currentLength + 1
|
|
108
70
|
|
|
109
71
|
try {
|
|
110
|
-
|
|
111
|
-
const sessionKey = await ensureSession()
|
|
112
|
-
if (!sessionKey) {
|
|
113
|
-
throw new Error('session')
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const res = await fetch('/api/chat', {
|
|
72
|
+
const res = await fetch('/api/admin/chat', {
|
|
117
73
|
method: 'POST',
|
|
118
74
|
headers: { 'Content-Type': 'application/json' },
|
|
119
75
|
body: JSON.stringify({ message: text, session_key: sessionKey }),
|
|
120
76
|
})
|
|
121
77
|
|
|
122
|
-
if (!res.ok)
|
|
123
|
-
const errorData = await res.json().catch(() => ({}))
|
|
124
|
-
throw new Error(errorData.error || 'Something went wrong')
|
|
125
|
-
}
|
|
78
|
+
if (!res.ok) throw new Error('Chat request failed')
|
|
126
79
|
|
|
127
80
|
const reader = res.body?.getReader()
|
|
128
|
-
const decoder = new TextDecoder()
|
|
129
|
-
|
|
130
81
|
if (!reader) throw new Error('No response stream')
|
|
131
82
|
|
|
132
|
-
|
|
133
|
-
let
|
|
83
|
+
const decoder = new TextDecoder()
|
|
84
|
+
let buffer = ''
|
|
134
85
|
|
|
135
86
|
while (true) {
|
|
136
87
|
const { done, value } = await reader.read()
|
|
137
88
|
if (done) break
|
|
138
89
|
|
|
139
|
-
|
|
140
|
-
const parts =
|
|
141
|
-
|
|
90
|
+
buffer += decoder.decode(value, { stream: true })
|
|
91
|
+
const parts = buffer.split('\n')
|
|
92
|
+
buffer = parts.pop()!
|
|
142
93
|
|
|
143
94
|
for (const line of parts) {
|
|
144
95
|
if (!line.startsWith('data: ')) continue
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
96
|
+
const payload = line.slice(6)
|
|
97
|
+
if (payload === '[DONE]') continue
|
|
98
|
+
|
|
99
|
+
let parsed: { type: string; [key: string]: unknown }
|
|
100
|
+
try {
|
|
101
|
+
parsed = JSON.parse(payload)
|
|
102
|
+
} catch {
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
147
105
|
|
|
148
|
-
|
|
149
|
-
try { parsed = JSON.parse(data) } catch { continue }
|
|
106
|
+
if (parsed.type === 'done' || parsed.type === 'session_init') continue
|
|
150
107
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
108
|
+
const event = parsed as AdminEvent
|
|
109
|
+
|
|
110
|
+
setMessages(prev => {
|
|
111
|
+
const updated = [...prev]
|
|
112
|
+
const msg = updated[messageIndex]
|
|
113
|
+
if (msg && msg.role === 'maxy') {
|
|
114
|
+
updated[messageIndex] = {
|
|
115
|
+
...msg,
|
|
116
|
+
events: [...(msg.events ?? []), event],
|
|
159
117
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
118
|
+
}
|
|
119
|
+
return updated
|
|
120
|
+
})
|
|
163
121
|
}
|
|
164
122
|
}
|
|
165
123
|
} catch (err) {
|
|
166
124
|
const errorMessage = err instanceof Error ? err.message : 'Something went wrong'
|
|
167
125
|
setMessages(prev => {
|
|
168
126
|
const updated = [...prev]
|
|
169
|
-
updated[
|
|
127
|
+
updated[messageIndex] = {
|
|
170
128
|
role: 'maxy',
|
|
171
|
-
|
|
172
|
-
? "I'm having trouble connecting right now. Try refreshing the page."
|
|
173
|
-
: "Sorry, I hit a snag. Try again in a moment.",
|
|
129
|
+
events: [{ type: 'text', content: `Error: ${errorMessage}` }],
|
|
174
130
|
}
|
|
175
131
|
return updated
|
|
176
132
|
})
|
|
@@ -185,56 +141,67 @@ export default function ChatPage() {
|
|
|
185
141
|
sendMessage(input.trim())
|
|
186
142
|
}
|
|
187
143
|
|
|
144
|
+
if (!authenticated) {
|
|
145
|
+
return (
|
|
146
|
+
<div className="chat-page admin-page">
|
|
147
|
+
<header className="chat-header">
|
|
148
|
+
<img src="/brand/maxy-black.png" alt="Maxy" className="chat-logo" />
|
|
149
|
+
<h1 className="chat-tagline">Maxy</h1>
|
|
150
|
+
<p className="chat-intro">Convenience as standard.</p>
|
|
151
|
+
</header>
|
|
152
|
+
<div className="admin-pin-form">
|
|
153
|
+
<form onSubmit={handlePinSubmit}>
|
|
154
|
+
<input
|
|
155
|
+
ref={pinInputRef}
|
|
156
|
+
type="password"
|
|
157
|
+
value={pin}
|
|
158
|
+
onChange={e => setPin(e.target.value)}
|
|
159
|
+
placeholder="Enter PIN"
|
|
160
|
+
className="chat-input"
|
|
161
|
+
autoFocus
|
|
162
|
+
/>
|
|
163
|
+
<button type="submit" className="chat-send" disabled={!pin}>
|
|
164
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
165
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
166
|
+
<polyline points="12 5 19 12 12 19" />
|
|
167
|
+
</svg>
|
|
168
|
+
</button>
|
|
169
|
+
</form>
|
|
170
|
+
{pinError && <p className="admin-pin-error">{pinError}</p>}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
188
176
|
return (
|
|
189
|
-
<div className="chat-page">
|
|
177
|
+
<div className="chat-page admin-page">
|
|
190
178
|
<header className="chat-header">
|
|
191
179
|
<img src="/brand/maxy-black.png" alt="Maxy" className="chat-logo" />
|
|
192
|
-
<h1 className="chat-tagline">
|
|
193
|
-
<p className="chat-intro">
|
|
194
|
-
Hey, I{'\u2019'}m Maxy. I{'\u2019'}m a personal AI that handles the
|
|
195
|
-
stuff you don{'\u2019'}t want to think about. I{'\u2019'}m not a product
|
|
196
|
-
yet{'\u2009'}{'\u2014'}{'\u2009'}I{'\u2019'}m learning what stresses people
|
|
197
|
-
most so I can be built right. Tell me what{'\u2019'}s on your mind.
|
|
198
|
-
No advice, no judgment, just a conversation.
|
|
199
|
-
</p>
|
|
200
|
-
<div className="chat-trust">
|
|
201
|
-
<span className="chat-trust-item">Anonymous</span>
|
|
202
|
-
<span className="chat-trust-sep">{'\u00B7'}</span>
|
|
203
|
-
<span className="chat-trust-item">Private</span>
|
|
204
|
-
<span className="chat-trust-sep">{'\u00B7'}</span>
|
|
205
|
-
<span className="chat-trust-item">No sign-up</span>
|
|
206
|
-
<span className="chat-trust-sep">{'\u00B7'}</span>
|
|
207
|
-
<span className="chat-trust-item">Free</span>
|
|
208
|
-
</div>
|
|
180
|
+
<h1 className="chat-tagline">Maxy</h1>
|
|
181
|
+
<p className="chat-intro">Convenience as standard.</p>
|
|
209
182
|
</header>
|
|
210
183
|
|
|
211
184
|
<div className="chat-messages">
|
|
212
185
|
{messages.map((msg, i) => (
|
|
213
|
-
<div key={i} className={`message ${msg.role}`}>
|
|
214
|
-
|
|
215
|
-
{msg.content
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
<
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
186
|
+
<div key={i} className={`message ${msg.role === 'admin' ? 'visitor' : 'maxy'}`}>
|
|
187
|
+
{msg.role === 'admin' ? (
|
|
188
|
+
<div className="bubble">{msg.content}</div>
|
|
189
|
+
) : (
|
|
190
|
+
<div className="admin-activity">
|
|
191
|
+
{msg.events?.map((event, j) => (
|
|
192
|
+
<ActivityEvent key={j} event={event} />
|
|
193
|
+
))}
|
|
194
|
+
{isStreaming && i === messages.length - 1 && (
|
|
195
|
+
<span className="typing-indicator">
|
|
196
|
+
<span className="typing-dot" />
|
|
197
|
+
<span className="typing-dot" />
|
|
198
|
+
<span className="typing-dot" />
|
|
199
|
+
</span>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
223
203
|
</div>
|
|
224
204
|
))}
|
|
225
|
-
{!isStreaming && remainingSuggestions.length > 0 && (
|
|
226
|
-
<div className="chat-suggestions">
|
|
227
|
-
{remainingSuggestions.map(s => (
|
|
228
|
-
<button
|
|
229
|
-
key={s}
|
|
230
|
-
className="chat-suggestion"
|
|
231
|
-
onClick={() => sendMessage(s)}
|
|
232
|
-
>
|
|
233
|
-
{s}
|
|
234
|
-
</button>
|
|
235
|
-
))}
|
|
236
|
-
</div>
|
|
237
|
-
)}
|
|
238
205
|
<div ref={messagesEndRef} />
|
|
239
206
|
</div>
|
|
240
207
|
|
|
@@ -246,7 +213,7 @@ export default function ChatPage() {
|
|
|
246
213
|
type="text"
|
|
247
214
|
value={input}
|
|
248
215
|
onChange={e => setInput(e.target.value)}
|
|
249
|
-
placeholder="Tell
|
|
216
|
+
placeholder="Tell Maxy what you need..."
|
|
250
217
|
disabled={isStreaming}
|
|
251
218
|
aria-label="Type your message"
|
|
252
219
|
/>
|
|
@@ -263,10 +230,6 @@ export default function ChatPage() {
|
|
|
263
230
|
</button>
|
|
264
231
|
</form>
|
|
265
232
|
</div>
|
|
266
|
-
|
|
267
|
-
<footer className="chat-footer">
|
|
268
|
-
<a href="https://maxy.bot">maxy.bot {'\u2197'}</a>
|
|
269
|
-
</footer>
|
|
270
233
|
</div>
|
|
271
234
|
)
|
|
272
235
|
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect, useCallback, FormEvent } from 'react'
|
|
4
|
+
|
|
5
|
+
interface Message {
|
|
6
|
+
role: 'maxy' | 'visitor'
|
|
7
|
+
content: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const suggestions: string[] = []
|
|
11
|
+
|
|
12
|
+
/** Generate a random session ID (Taskmaster prepends its own anon- prefix). */
|
|
13
|
+
function generateSessionId(): string {
|
|
14
|
+
return crypto.randomUUID()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function ChatPage() {
|
|
18
|
+
const [messages, setMessages] = useState<Message[]>([])
|
|
19
|
+
const [remainingSuggestions, setRemainingSuggestions] = useState(suggestions)
|
|
20
|
+
const [input, setInput] = useState('')
|
|
21
|
+
const [isStreaming, setIsStreaming] = useState(false)
|
|
22
|
+
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
23
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
24
|
+
const nudgeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
25
|
+
const sessionKeyRef = useRef<string | null>(null)
|
|
26
|
+
const sessionPendingRef = useRef<Promise<string | null> | null>(null)
|
|
27
|
+
|
|
28
|
+
// Scroll to bottom when messages change
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
31
|
+
}, [messages])
|
|
32
|
+
|
|
33
|
+
// Nudge timer — if no input after 15s, show gentle prompt
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
nudgeTimerRef.current = setTimeout(() => {
|
|
36
|
+
setMessages(prev => {
|
|
37
|
+
if (prev.length > 0) return prev // user already started chatting
|
|
38
|
+
return [{ role: 'maxy', content: 'No wrong answers. Just tell me what\'s on your mind.' }]
|
|
39
|
+
})
|
|
40
|
+
}, 15000)
|
|
41
|
+
return () => {
|
|
42
|
+
if (nudgeTimerRef.current) clearTimeout(nudgeTimerRef.current)
|
|
43
|
+
}
|
|
44
|
+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
45
|
+
|
|
46
|
+
// Focus input on mount
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
inputRef.current?.focus()
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
/** Ensure we have a Taskmaster session, creating one if needed. */
|
|
52
|
+
const ensureSession = useCallback(async (): Promise<string | null> => {
|
|
53
|
+
if (sessionKeyRef.current) return sessionKeyRef.current
|
|
54
|
+
|
|
55
|
+
// Deduplicate concurrent calls
|
|
56
|
+
if (sessionPendingRef.current) return sessionPendingRef.current
|
|
57
|
+
|
|
58
|
+
const promise = (async () => {
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch('/api/session', {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({ session_id: generateSessionId() }),
|
|
64
|
+
})
|
|
65
|
+
if (!res.ok) return null
|
|
66
|
+
const data = await res.json()
|
|
67
|
+
sessionKeyRef.current = data.session_key
|
|
68
|
+
return data.session_key as string
|
|
69
|
+
} catch {
|
|
70
|
+
return null
|
|
71
|
+
} finally {
|
|
72
|
+
sessionPendingRef.current = null
|
|
73
|
+
}
|
|
74
|
+
})()
|
|
75
|
+
|
|
76
|
+
sessionPendingRef.current = promise
|
|
77
|
+
return promise
|
|
78
|
+
}, [])
|
|
79
|
+
|
|
80
|
+
async function sendMessage(text: string) {
|
|
81
|
+
if (!text || isStreaming) return
|
|
82
|
+
|
|
83
|
+
// Clear nudge timer
|
|
84
|
+
if (nudgeTimerRef.current) {
|
|
85
|
+
clearTimeout(nudgeTimerRef.current)
|
|
86
|
+
nudgeTimerRef.current = null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Remove this suggestion if it was from the list
|
|
90
|
+
setRemainingSuggestions(prev => prev.filter(s => s !== text))
|
|
91
|
+
|
|
92
|
+
// Add visitor message
|
|
93
|
+
const visitorMessage: Message = { role: 'visitor', content: text }
|
|
94
|
+
const updatedMessages = [...messages, visitorMessage]
|
|
95
|
+
setMessages(updatedMessages)
|
|
96
|
+
setInput('')
|
|
97
|
+
setIsStreaming(true)
|
|
98
|
+
|
|
99
|
+
// Add placeholder for Maxy's response
|
|
100
|
+
const placeholderIndex = updatedMessages.length
|
|
101
|
+
setMessages(prev => [...prev, { role: 'maxy', content: '' }])
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
// Ensure we have a session before sending
|
|
105
|
+
const sessionKey = await ensureSession()
|
|
106
|
+
if (!sessionKey) {
|
|
107
|
+
throw new Error('session')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const res = await fetch('/api/chat', {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'Content-Type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({ message: text, session_key: sessionKey }),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
const errorData = await res.json().catch(() => ({}))
|
|
118
|
+
throw new Error(errorData.error || 'Something went wrong')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const reader = res.body?.getReader()
|
|
122
|
+
const decoder = new TextDecoder()
|
|
123
|
+
|
|
124
|
+
if (!reader) throw new Error('No response stream')
|
|
125
|
+
|
|
126
|
+
let accumulated = ''
|
|
127
|
+
let sseBuffer = ''
|
|
128
|
+
|
|
129
|
+
while (true) {
|
|
130
|
+
const { done, value } = await reader.read()
|
|
131
|
+
if (done) break
|
|
132
|
+
|
|
133
|
+
sseBuffer += decoder.decode(value, { stream: true })
|
|
134
|
+
const parts = sseBuffer.split('\n')
|
|
135
|
+
sseBuffer = parts.pop()! // keep incomplete trailing segment
|
|
136
|
+
|
|
137
|
+
for (const line of parts) {
|
|
138
|
+
if (!line.startsWith('data: ')) continue
|
|
139
|
+
const data = line.slice(6)
|
|
140
|
+
if (data === '[DONE]') continue
|
|
141
|
+
|
|
142
|
+
let parsed
|
|
143
|
+
try { parsed = JSON.parse(data) } catch { continue }
|
|
144
|
+
|
|
145
|
+
if (parsed.error) throw new Error(parsed.error)
|
|
146
|
+
if (parsed.text) {
|
|
147
|
+
accumulated += parsed.text
|
|
148
|
+
setMessages(prev => {
|
|
149
|
+
const updated = [...prev]
|
|
150
|
+
updated[placeholderIndex] = {
|
|
151
|
+
role: 'maxy',
|
|
152
|
+
content: accumulated,
|
|
153
|
+
}
|
|
154
|
+
return updated
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} catch (err) {
|
|
160
|
+
const errorMessage = err instanceof Error ? err.message : 'Something went wrong'
|
|
161
|
+
setMessages(prev => {
|
|
162
|
+
const updated = [...prev]
|
|
163
|
+
updated[placeholderIndex] = {
|
|
164
|
+
role: 'maxy',
|
|
165
|
+
content: errorMessage === 'session'
|
|
166
|
+
? "I'm having trouble connecting right now. Try refreshing the page."
|
|
167
|
+
: "Sorry, I hit a snag. Try again in a moment.",
|
|
168
|
+
}
|
|
169
|
+
return updated
|
|
170
|
+
})
|
|
171
|
+
} finally {
|
|
172
|
+
setIsStreaming(false)
|
|
173
|
+
inputRef.current?.focus()
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function handleSubmit(e: FormEvent) {
|
|
178
|
+
e.preventDefault()
|
|
179
|
+
sendMessage(input.trim())
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<div className="chat-page">
|
|
184
|
+
<header className="chat-header">
|
|
185
|
+
<img src="/brand/maxy-black.png" alt="Maxy" className="chat-logo" />
|
|
186
|
+
<h1 className="chat-tagline">No Stress. Quiet Life.</h1>
|
|
187
|
+
<p className="chat-intro">
|
|
188
|
+
Hey, I{'\u2019'}m Maxy. I{'\u2019'}m a personal AI that handles the
|
|
189
|
+
stuff you don{'\u2019'}t want to think about. I{'\u2019'}m not a product
|
|
190
|
+
yet{'\u2009'}{'\u2014'}{'\u2009'}I{'\u2019'}m learning what stresses people
|
|
191
|
+
most so I can be built right. Tell me what{'\u2019'}s on your mind.
|
|
192
|
+
No advice, no judgment, just a conversation.
|
|
193
|
+
</p>
|
|
194
|
+
<div className="chat-trust">
|
|
195
|
+
<span className="chat-trust-item">Anonymous</span>
|
|
196
|
+
<span className="chat-trust-sep">{'\u00B7'}</span>
|
|
197
|
+
<span className="chat-trust-item">Private</span>
|
|
198
|
+
<span className="chat-trust-sep">{'\u00B7'}</span>
|
|
199
|
+
<span className="chat-trust-item">No sign-up</span>
|
|
200
|
+
<span className="chat-trust-sep">{'\u00B7'}</span>
|
|
201
|
+
<span className="chat-trust-item">Free</span>
|
|
202
|
+
</div>
|
|
203
|
+
</header>
|
|
204
|
+
|
|
205
|
+
<div className="chat-messages">
|
|
206
|
+
{messages.map((msg, i) => (
|
|
207
|
+
<div key={i} className={`message ${msg.role}`}>
|
|
208
|
+
<div className="bubble">
|
|
209
|
+
{msg.content || (
|
|
210
|
+
<span className="typing-indicator">
|
|
211
|
+
<span className="typing-dot" />
|
|
212
|
+
<span className="typing-dot" />
|
|
213
|
+
<span className="typing-dot" />
|
|
214
|
+
</span>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
))}
|
|
219
|
+
{!isStreaming && remainingSuggestions.length > 0 && (
|
|
220
|
+
<div className="chat-suggestions">
|
|
221
|
+
{remainingSuggestions.map(s => (
|
|
222
|
+
<button
|
|
223
|
+
key={s}
|
|
224
|
+
className="chat-suggestion"
|
|
225
|
+
onClick={() => sendMessage(s)}
|
|
226
|
+
>
|
|
227
|
+
{s}
|
|
228
|
+
</button>
|
|
229
|
+
))}
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
<div ref={messagesEndRef} />
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<div className="chat-input-area">
|
|
236
|
+
<form className="chat-form" onSubmit={handleSubmit}>
|
|
237
|
+
<input
|
|
238
|
+
ref={inputRef}
|
|
239
|
+
className="chat-input"
|
|
240
|
+
type="text"
|
|
241
|
+
value={input}
|
|
242
|
+
onChange={e => setInput(e.target.value)}
|
|
243
|
+
placeholder="Tell me what you need taking care of in your life!"
|
|
244
|
+
disabled={isStreaming}
|
|
245
|
+
aria-label="Type your message"
|
|
246
|
+
/>
|
|
247
|
+
<button
|
|
248
|
+
className="chat-send"
|
|
249
|
+
type="submit"
|
|
250
|
+
disabled={isStreaming || !input.trim()}
|
|
251
|
+
aria-label="Send message"
|
|
252
|
+
>
|
|
253
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
254
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
255
|
+
<polyline points="12 5 19 12 12 19" />
|
|
256
|
+
</svg>
|
|
257
|
+
</button>
|
|
258
|
+
</form>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<footer className="chat-footer">
|
|
262
|
+
<a href="https://maxy.bot">maxy.bot {'\u2197'}</a>
|
|
263
|
+
</footer>
|
|
264
|
+
</div>
|
|
265
|
+
)
|
|
266
|
+
}
|
package/payload/maxy/proxy.ts
CHANGED
|
@@ -1,42 +1,20 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server'
|
|
2
2
|
|
|
3
|
-
function isLocalAccess(host: string): boolean {
|
|
4
|
-
return host.includes('.local') ||
|
|
5
|
-
host.includes('localhost') ||
|
|
6
|
-
host.includes('127.0.0.1') ||
|
|
7
|
-
host.includes('192.168.') ||
|
|
8
|
-
host.includes('10.') ||
|
|
9
|
-
host.startsWith('172.')
|
|
10
|
-
}
|
|
11
|
-
|
|
12
3
|
export function proxy(request: NextRequest) {
|
|
13
4
|
const host = request.headers.get('host') || ''
|
|
14
5
|
const { pathname } = request.nextUrl
|
|
15
6
|
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return NextResponse.rewrite(new URL('/admin', request.url))
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Allow admin API from local network and admin.maxy.bot
|
|
23
|
-
if (pathname.startsWith('/api/admin/')) {
|
|
24
|
-
if (!isLocalAccess(host) && !host.includes('admin.maxy.bot')) {
|
|
25
|
-
return new NextResponse(JSON.stringify({ error: 'Admin API not available on this domain' }), {
|
|
26
|
-
status: 403,
|
|
27
|
-
headers: { 'Content-Type': 'application/json' },
|
|
28
|
-
})
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// public.maxy.bot → serve public chat
|
|
33
|
-
if (host.includes('public.maxy.bot')) {
|
|
34
|
-
return NextResponse.next()
|
|
7
|
+
// public.maxy.bot → serve public chat (not admin)
|
|
8
|
+
if (host.includes('public.maxy.bot') && pathname === '/') {
|
|
9
|
+
return NextResponse.rewrite(new URL('/public', request.url))
|
|
35
10
|
}
|
|
36
11
|
|
|
37
|
-
// admin
|
|
38
|
-
if (host.includes('
|
|
39
|
-
return NextResponse.
|
|
12
|
+
// Block admin API from public subdomain
|
|
13
|
+
if (host.includes('public.maxy.bot') && pathname.startsWith('/api/admin/')) {
|
|
14
|
+
return new NextResponse(JSON.stringify({ error: 'Not available' }), {
|
|
15
|
+
status: 403,
|
|
16
|
+
headers: { 'Content-Type': 'application/json' },
|
|
17
|
+
})
|
|
40
18
|
}
|
|
41
19
|
|
|
42
20
|
// getmaxy.com → serve landing page
|
|
@@ -45,7 +23,7 @@ export function proxy(request: NextRequest) {
|
|
|
45
23
|
}
|
|
46
24
|
|
|
47
25
|
// maxy.bot (bare) → redirect to public.maxy.bot
|
|
48
|
-
if (
|
|
26
|
+
if (host === 'maxy.bot' || host === 'www.maxy.bot') {
|
|
49
27
|
return NextResponse.redirect(`https://public.maxy.bot${pathname}`, 301)
|
|
50
28
|
}
|
|
51
29
|
|
|
@@ -54,6 +32,7 @@ export function proxy(request: NextRequest) {
|
|
|
54
32
|
return NextResponse.redirect(`https://public.maxy.bot${pathname}`, 301)
|
|
55
33
|
}
|
|
56
34
|
|
|
35
|
+
// Everything else (local access, admin.maxy.bot) → root page (admin)
|
|
57
36
|
return NextResponse.next()
|
|
58
37
|
}
|
|
59
38
|
|