@rubytech/create-maxy 0.3.4 → 0.3.6

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/dist/index.js CHANGED
@@ -170,6 +170,10 @@ function ensureNeo4jPassword() {
170
170
  // Fresh install — safe to set initial password
171
171
  const password = resetNeo4jWithFreshPassword();
172
172
  writeFileSync(passwordFile, password, { mode: 0o600 });
173
+ // Also save to persistent location (~/.maxy/)
174
+ const persistDir = resolve(process.env.HOME ?? "/root", ".maxy");
175
+ mkdirSync(persistDir, { recursive: true });
176
+ writeFileSync(join(persistDir, ".neo4j-password"), password, { mode: 0o600 });
173
177
  }
174
178
  function installNeo4j() {
175
179
  if (commandExists("neo4j")) {
@@ -187,8 +191,12 @@ function installNeo4j() {
187
191
  shell("apt-get", ["update", "-qq"], { sudo: true });
188
192
  shell("apt-get", ["install", "-y", "-qq", "neo4j"], { sudo: true });
189
193
  shell("sed", ["-i", "s/#server.default_listen_address=0.0.0.0/server.default_listen_address=127.0.0.1/", "/etc/neo4j/neo4j.conf"], { sudo: true });
190
- // Generate strong random password
194
+ // Generate strong random password — stored in persistent location (~/.maxy/)
191
195
  const password = randomBytes(24).toString("base64url");
196
+ const persistDir = resolve(process.env.HOME ?? "/root", ".maxy");
197
+ mkdirSync(persistDir, { recursive: true });
198
+ writeFileSync(join(persistDir, ".neo4j-password"), password, { mode: 0o600 });
199
+ // Also write to install dir (will be there when deploy step runs)
192
200
  const configDir = resolve(INSTALL_DIR, "platform/config");
193
201
  mkdirSync(configDir, { recursive: true });
194
202
  writeFileSync(join(configDir, ".neo4j-password"), password, { mode: 0o600 });
@@ -229,21 +237,20 @@ function deployPayload() {
229
237
  if (!existsSync(PAYLOAD_DIR)) {
230
238
  throw new Error(`Payload not found at ${PAYLOAD_DIR}. Package may be corrupted.`);
231
239
  }
232
- // Preserve config (passwords, accounts) across deploys
233
- const configDir = join(INSTALL_DIR, "platform/config");
234
- const passwordFile = join(configDir, ".neo4j-password");
235
- const accountsDir = join(configDir, "accounts");
236
- const configBackup = join(INSTALL_DIR, ".config-backup");
237
- // Back up config before wiping
238
- let hasPasswordBackup = false;
239
- if (existsSync(passwordFile)) {
240
- mkdirSync(configBackup, { recursive: true });
241
- cpSync(passwordFile, join(configBackup, ".neo4j-password"));
242
- hasPasswordBackup = true;
243
- }
244
- if (existsSync(accountsDir)) {
245
- mkdirSync(configBackup, { recursive: true });
246
- cpSync(accountsDir, join(configBackup, "accounts"), { recursive: true });
240
+ // Persistent config lives at ~/.maxy/ — survives rm -rf ~/maxy
241
+ const persistentDir = resolve(process.env.HOME ?? "/root", ".maxy");
242
+ const persistentPasswordFile = join(persistentDir, ".neo4j-password");
243
+ const persistentAccountsDir = join(persistentDir, "accounts");
244
+ // Migrate: if password is in old location, move to persistent
245
+ const oldPasswordFile = join(INSTALL_DIR, "platform/config/.neo4j-password");
246
+ if (existsSync(oldPasswordFile) && !existsSync(persistentPasswordFile)) {
247
+ mkdirSync(persistentDir, { recursive: true });
248
+ cpSync(oldPasswordFile, persistentPasswordFile);
249
+ }
250
+ const oldAccountsDir = join(INSTALL_DIR, "platform/config/accounts");
251
+ if (existsSync(oldAccountsDir) && !existsSync(persistentAccountsDir)) {
252
+ mkdirSync(persistentDir, { recursive: true });
253
+ cpSync(oldAccountsDir, persistentAccountsDir, { recursive: true });
247
254
  }
248
255
  // Wipe old platform/maxy to prevent stale files
249
256
  for (const dir of ["platform", "maxy", "docs", ".claude"]) {
@@ -258,18 +265,16 @@ function deployPayload() {
258
265
  recursive: true,
259
266
  force: true,
260
267
  });
261
- // Restore backed-up config (password + accounts)
268
+ // Link persistent config into install directory
269
+ const configDir = join(INSTALL_DIR, "platform/config");
262
270
  mkdirSync(configDir, { recursive: true });
263
- if (hasPasswordBackup && existsSync(join(configBackup, ".neo4j-password"))) {
264
- cpSync(join(configBackup, ".neo4j-password"), passwordFile);
265
- console.log(" Restored Neo4j password from previous install.");
266
- }
267
- if (existsSync(join(configBackup, "accounts"))) {
268
- cpSync(join(configBackup, "accounts"), accountsDir, { recursive: true, force: true });
269
- console.log(" Restored account data from previous install.");
271
+ if (existsSync(persistentPasswordFile)) {
272
+ cpSync(persistentPasswordFile, join(configDir, ".neo4j-password"));
273
+ console.log(" Restored Neo4j password.");
270
274
  }
271
- if (existsSync(configBackup)) {
272
- rmSync(configBackup, { recursive: true });
275
+ if (existsSync(persistentAccountsDir)) {
276
+ cpSync(persistentAccountsDir, join(configDir, "accounts"), { recursive: true, force: true });
277
+ console.log(" Restored account data.");
273
278
  }
274
279
  console.log(` Deployed to ${INSTALL_DIR}`);
275
280
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Install Maxy — your personal AI assistant",
5
5
  "bin": {
6
6
  "create-maxy": "./dist/index.js"
@@ -1,176 +1,132 @@
1
1
  'use client'
2
2
 
3
- import { useState, useRef, useEffect, useCallback, FormEvent } from 'react'
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: 'maxy' | 'visitor'
7
- content: string
7
+ role: 'admin' | 'maxy'
8
+ content?: string
9
+ events?: AdminEvent[]
8
10
  }
9
11
 
10
- const suggestions = [
11
- 'What is Maxy and how is it different from just using ChatGPT?',
12
- 'What do I need to buy and how much will it all cost me each month?',
13
- 'How do I actually set it up and talk to it — at home and when I\u2019m out?',
14
- 'Is my family\u2019s data private, and can you see what we\u2019re talking about?',
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 nudgeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
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
- nudgeTimerRef.current = setTimeout(() => {
42
- setMessages(prev => {
43
- if (prev.length > 0) return prev // user already started chatting
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
- /** Ensure we have a Taskmaster session, creating one if needed. */
58
- const ensureSession = useCallback(async (): Promise<string | null> => {
59
- if (sessionKeyRef.current) return sessionKeyRef.current
33
+ async function handlePinSubmit(e: FormEvent) {
34
+ e.preventDefault()
35
+ setPinError('')
60
36
 
61
- // Deduplicate concurrent calls
62
- if (sessionPendingRef.current) return sessionPendingRef.current
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
- const promise = (async () => {
65
- try {
66
- const res = await fetch('/api/session', {
67
- method: 'POST',
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
- sessionPendingRef.current = promise
83
- return promise
84
- }, [])
85
-
86
- async function sendMessage(text: string) {
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
- // Remove this suggestion if it was from the list
96
- setRemainingSuggestions(prev => prev.filter(s => s !== text))
58
+ async function sendMessage(text: string) {
59
+ if (!text || isStreaming || !sessionKey) return
97
60
 
98
- // Add visitor message
99
- const visitorMessage: Message = { role: 'visitor', content: text }
100
- const updatedMessages = [...messages, visitorMessage]
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
- // Add placeholder for Maxy's response
106
- const placeholderIndex = updatedMessages.length
107
- setMessages(prev => [...prev, { role: 'maxy', content: '' }])
67
+ const maxyMessage: Message = { role: 'maxy', events: [] }
68
+ setMessages(prev => [...prev, maxyMessage])
69
+ const messageIndex = currentLength + 1
108
70
 
109
71
  try {
110
- // Ensure we have a session before sending
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
- let accumulated = ''
133
- let sseBuffer = ''
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
- sseBuffer += decoder.decode(value, { stream: true })
140
- const parts = sseBuffer.split('\n')
141
- sseBuffer = parts.pop()! // keep incomplete trailing segment
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 data = line.slice(6)
146
- if (data === '[DONE]') continue
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
- let parsed
149
- try { parsed = JSON.parse(data) } catch { continue }
106
+ if (parsed.type === 'done' || parsed.type === 'session_init') continue
150
107
 
151
- if (parsed.error) throw new Error(parsed.error)
152
- if (parsed.text) {
153
- accumulated += parsed.text
154
- setMessages(prev => {
155
- const updated = [...prev]
156
- updated[placeholderIndex] = {
157
- role: 'maxy',
158
- content: accumulated,
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
- return updated
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[placeholderIndex] = {
127
+ updated[messageIndex] = {
170
128
  role: 'maxy',
171
- content: errorMessage === 'session'
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">No Stress. Quiet Life.</h1>
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
- <div className="bubble">
215
- {msg.content || (
216
- <span className="typing-indicator">
217
- <span className="typing-dot" />
218
- <span className="typing-dot" />
219
- <span className="typing-dot" />
220
- </span>
221
- )}
222
- </div>
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 me what you need taking care of in your life!"
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
+ }
@@ -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
- // Local network access (maxy.local:19200, 192.168.x.x, etc.) admin interface
17
- // On the local network, the user IS the admin. No public chat on local.
18
- if (isLocalAccess(host) && pathname === '/') {
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.maxy.bot serve admin page
38
- if (host.includes('admin.maxy.bot') && pathname === '/') {
39
- return NextResponse.rewrite(new URL('/admin', request.url))
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 ((host === 'maxy.bot' || host === 'www.maxy.bot') && !host.includes('public.') && !host.includes('admin.')) {
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