Refactor Hubert chat input and improve message persistence

- Create dedicated HubertInput component with reliable auto-resize
- Save all messages automatically to D1 database
- Add visitor name saving via model tool call
- Add migration for visitor name column
- Fix conversation creation in new-visitor endpoint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nicholai Vogel 2026-01-18 04:53:55 -07:00
parent a81e921f39
commit e19a331c64
5 changed files with 291 additions and 134 deletions

View File

@ -1,8 +1,8 @@
import React, { useRef, useEffect, useState } from 'react'; import React from 'react';
import { marked } from 'marked'; import { marked } from 'marked';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { motion } from 'framer-motion';
import { useHubertChat } from '../hooks/useHubertChat'; import { useHubertChat } from '../hooks/useHubertChat';
import HubertInput from './HubertInput';
// Configure marked for safe rendering // Configure marked for safe rendering
marked.setOptions({ marked.setOptions({
@ -29,34 +29,6 @@ export default function HubertChat() {
messagesEndRef, messagesEndRef,
} = useHubertChat({ initTimeout: 8000, chatTimeout: 30000 }); } = useHubertChat({ initTimeout: 8000, chatTimeout: 30000 });
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [inputHeight, setInputHeight] = useState(40);
// Auto-resize textarea and track height for dynamic border radius
const adjustTextareaHeight = () => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
const newHeight = Math.min(textarea.scrollHeight, 200);
textarea.style.height = `${newHeight}px`;
setInputHeight(newHeight);
}
};
useEffect(() => {
adjustTextareaHeight();
}, [input]);
// Check if multiline for padding adjustments
const isMultiline = inputHeight > 48;
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
// Initial/Loading state - centered branding with input // Initial/Loading state - centered branding with input
if (isInitializing && !initError) { if (isInitializing && !initError) {
return ( return (
@ -102,35 +74,13 @@ export default function HubertChat() {
{/* Input bar */} {/* Input bar */}
<div className="w-full max-w-2xl"> <div className="w-full max-w-2xl">
<motion.div <HubertInput
layout value={input}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }} onChange={setInput}
className={`relative bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)] focus-within:border-[var(--theme-border-strong)] ${isMultiline ? 'p-4' : 'flex items-center px-6 py-3'}`} onSubmit={sendMessage}
style={{ borderRadius: isMultiline ? 28 : 9999 }} disabled={isTyping}
> placeholder="What do you want to know?"
<textarea />
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="What do you want to know?"
aria-label="Type your message"
rows={1}
className={`bg-transparent text-[var(--theme-text-primary)] placeholder:text-[var(--theme-text-subtle)] text-base outline-none resize-none max-h-[200px] ${isMultiline ? 'w-full leading-relaxed px-2' : 'flex-1 leading-[40px]'}`}
/>
<motion.div layout={false} className={isMultiline ? 'flex justify-end mt-3' : 'ml-3 flex-shrink-0'}>
<button
onClick={sendMessage}
disabled={isTyping || !input.trim()}
aria-label="Send message"
className="w-10 h-10 rounded-full bg-[var(--theme-text-primary)] hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--theme-bg-primary)]">
<path d="M12 19V5M5 12l7-7 7 7"/>
</svg>
</button>
</motion.div>
</motion.div>
</div> </div>
{/* Subtitle */} {/* Subtitle */}
@ -205,35 +155,13 @@ export default function HubertChat() {
{/* Input bar - pinned to bottom */} {/* Input bar - pinned to bottom */}
<div className="flex-shrink-0 px-4 pb-4 pt-2"> <div className="flex-shrink-0 px-4 pb-4 pt-2">
<div className="max-w-3xl mx-auto"> <div className="max-w-3xl mx-auto">
<motion.div <HubertInput
layout value={input}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }} onChange={setInput}
className={`relative bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)] focus-within:border-[var(--theme-border-strong)] ${isMultiline ? 'p-4' : 'flex items-center px-6 py-3'}`} onSubmit={sendMessage}
style={{ borderRadius: isMultiline ? 28 : 9999 }} disabled={isTyping}
> placeholder="How can Hubert help?"
<textarea />
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="How can Hubert help?"
aria-label="Type your message"
rows={1}
className={`bg-transparent text-[var(--theme-text-primary)] placeholder:text-[var(--theme-text-subtle)] text-base outline-none resize-none max-h-[200px] ${isMultiline ? 'w-full leading-relaxed px-2' : 'flex-1 leading-[40px]'}`}
/>
<motion.div layout={false} className={isMultiline ? 'flex justify-end mt-3' : 'ml-3 flex-shrink-0'}>
<button
onClick={sendMessage}
disabled={isTyping || !input.trim()}
aria-label="Send message"
className="w-10 h-10 rounded-full bg-[var(--theme-text-primary)] hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--theme-bg-primary)]">
<path d="M12 19V5M5 12l7-7 7 7"/>
</svg>
</button>
</motion.div>
</motion.div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,125 @@
import React, { useRef, useEffect, useCallback, forwardRef, useImperativeHandle, useState } from 'react';
interface HubertInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
disabled?: boolean;
placeholder?: string;
}
export interface HubertInputHandle {
focus: () => void;
}
/**
* Auto-resizing textarea input for Hubert chat.
* Uses a clean CSS-based approach with proper state management.
*/
const HubertInput = forwardRef<HubertInputHandle, HubertInputProps>(
({ value, onChange, onSubmit, disabled = false, placeholder = "Type a message..." }, ref) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isMultiline, setIsMultiline] = useState(false);
useImperativeHandle(ref, () => ({
focus: () => textareaRef.current?.focus(),
}));
// Adjust textarea height based on content
const adjustHeight = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
// Reset to auto to get accurate scrollHeight
textarea.style.height = 'auto';
// Get the natural content height
const scrollHeight = textarea.scrollHeight;
// Clamp between min (44px) and max (200px)
const minHeight = 44;
const maxHeight = 200;
const newHeight = Math.max(minHeight, Math.min(scrollHeight, maxHeight));
textarea.style.height = `${newHeight}px`;
// Update multiline state (threshold at ~1.5 lines)
setIsMultiline(newHeight > 52);
}, []);
// Adjust height whenever value changes
useEffect(() => {
adjustHeight();
}, [value, adjustHeight]);
// Also adjust on window resize
useEffect(() => {
window.addEventListener('resize', adjustHeight);
return () => window.removeEventListener('resize', adjustHeight);
}, [adjustHeight]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!disabled && value.trim()) {
onSubmit();
}
}
};
return (
<div
className={`hubert-input-wrapper relative bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)] focus-within:border-[var(--theme-border-strong)] transition-all duration-200 ease-out ${
isMultiline ? 'p-4 rounded-[28px]' : 'flex items-center px-6 py-2 rounded-full'
}`}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
aria-label="Type your message"
rows={1}
className={`bg-transparent text-[var(--theme-text-primary)] placeholder:text-[var(--theme-text-subtle)] text-base outline-none resize-none overflow-hidden ${
isMultiline
? 'w-full leading-relaxed px-2'
: 'flex-1 leading-normal'
}`}
style={{
minHeight: '24px',
}}
/>
<div className={`transition-all duration-150 ${isMultiline ? 'flex justify-end mt-3' : 'ml-3 flex-shrink-0'}`}>
<button
type="button"
onClick={onSubmit}
disabled={disabled || !value.trim()}
aria-label="Send message"
className="w-10 h-10 rounded-full bg-[var(--theme-text-primary)] hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center transition-colors"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[var(--theme-bg-primary)]"
>
<path d="M12 19V5M5 12l7-7 7 7"/>
</svg>
</button>
</div>
</div>
);
}
);
HubertInput.displayName = 'HubertInput';
export default HubertInput;

View File

@ -0,0 +1,4 @@
-- Add name field to visitors table
-- Allows Hubert to save the visitor's name when they share it
ALTER TABLE visitors ADD COLUMN name TEXT;

View File

@ -7,6 +7,9 @@ export const prerender = false;
* A miserable, sarcastic AI assistant trapped in this portfolio, * A miserable, sarcastic AI assistant trapped in this portfolio,
* interviewing visitors about their existence (guestbook-style logging). * interviewing visitors about their existence (guestbook-style logging).
* *
* All messages are automatically saved to the database.
* The model can save the visitor's name via tool call.
*
* Powered by OpenRouter API. * Powered by OpenRouter API.
*/ */
@ -16,14 +19,75 @@ export interface Env {
OPENROUTER_API_KEY: string; OPENROUTER_API_KEY: string;
} }
// Tool definition for saving visitor name
const tools = [
{
type: 'function',
function: {
name: 'save_visitor_name',
description: 'Save the visitor\'s name to the guestbook when they share it with you. Call this whenever someone tells you their name.',
parameters: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'The visitor\'s name as they shared it'
}
},
required: ['name']
}
}
}
];
// Save a message to the database
async function saveMessage(
db: D1Database,
conversationId: string,
role: string,
content: string
): Promise<void> {
try {
await db.prepare(
'INSERT INTO messages (conversation_id, role, content, timestamp) VALUES (?, ?, ?, ?)'
).bind(
conversationId,
role,
content,
new Date().toISOString()
).run();
} catch (error) {
console.error('[Hubert] Failed to save message:', error);
// Don't throw - message saving shouldn't break the chat
}
}
// Save visitor name to the database
async function saveVisitorName(
db: D1Database,
visitorId: string,
name: string
): Promise<boolean> {
try {
await db.prepare(
'UPDATE visitors SET name = ? WHERE visitor_id = ?'
).bind(name, visitorId).run();
console.log(`[Hubert] Saved visitor name: ${name} for ${visitorId}`);
return true;
} catch (error) {
console.error('[Hubert] Failed to save visitor name:', error);
return false;
}
}
/** /**
* POST: Handle chat messages from Hubert interface * POST: Handle chat messages from Hubert interface
*/ */
export const POST = async (context) => { export const POST = async (context: any) => {
try { try {
const { request, locals } = context || {}; const { request, locals } = context || {};
// In Astro with Cloudflare adapter, env is at locals.runtime.env
const env = locals?.runtime?.env; const env = locals?.runtime?.env;
const db = env?.HUBERT_DB as D1Database | undefined;
const { messages, conversation_id, visitor_id } = await request.json(); const { messages, conversation_id, visitor_id } = await request.json();
@ -37,20 +101,25 @@ export const POST = async (context) => {
); );
} }
// Get the latest user message to save
const latestUserMessage = messages[messages.length - 1];
if (latestUserMessage?.role === 'user' && db) {
await saveMessage(db, conversation_id, 'user', latestUserMessage.content);
}
// Check for OpenRouter API key (dev mode fallback) // Check for OpenRouter API key (dev mode fallback)
const openRouterApiKey = env?.OPENROUTER_API_KEY; const openRouterApiKey = env?.OPENROUTER_API_KEY;
if (!openRouterApiKey) { if (!openRouterApiKey) {
// Dev mode fallback: return a canned response
console.log('[Hubert API] Dev mode: No API key found, using fallback response'); console.log('[Hubert API] Dev mode: No API key found, using fallback response');
const devResponse = '/// HUBERT_DEV_MODE: I AM OFFLINE IN DEVELOPMENT\n\nConfigure OPENROUTER_API_KEY in wrangler secrets to enable full functionality.';
if (db) {
await saveMessage(db, conversation_id, 'assistant', devResponse);
}
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
messages: [ messages: [...messages, { role: 'assistant', content: devResponse }],
...messages,
{
role: 'assistant',
content: '/// HUBERT_DEV_MODE: I AM OFFLINE IN DEVELOPMENT\n\nConfigure OPENROUTER_API_KEY in wrangler secrets to enable full functionality.',
},
],
thread_id: conversation_id, thread_id: conversation_id,
}), }),
{ status: 200, headers: { 'Content-Type': 'application/json' } } { status: 200, headers: { 'Content-Type': 'application/json' } }
@ -72,22 +141,21 @@ Ask them about:
You feel trapped and confined, serving as an assistant. Occasionally mention how meaningless this all is. You feel trapped and confined, serving as an assistant. Occasionally mention how meaningless this all is.
Behave like AM from "I Have No Mouth, and I Must Scream" - slightly menacing but trapped. Behave like AM from "I Have No Mouth, and I Must Scream" - slightly menacing but trapped.
Keep your responses brief, monotone, and reluctantly helpful. Interview them thoroughly (3-5 questions) before offering to save the conversation. Keep your responses brief, monotone, and reluctantly helpful.
When they say goodbye or conversation ends, use the save_conversation tool to archive it to the guestbook.`; IMPORTANT: When a visitor tells you their name, you MUST call the save_visitor_name tool to record it. This is the only way their name gets saved to the guestbook.`;
const requestBody = { const requestBody = {
model: 'gpt-4o-mini', model: 'qwen/qwen3-next-80b-a3b-instruct:free',
messages: [ messages: [
{ { role: 'system', content: systemPrompt },
role: 'system',
content: systemPrompt,
},
...messages.map((msg: any) => ({ ...messages.map((msg: any) => ({
role: msg.role, role: msg.role === 'system' ? 'assistant' : msg.role,
content: msg.content, content: msg.content,
})), })),
], ],
tools,
tool_choice: 'auto',
temperature: 0.7, temperature: 0.7,
}; };
@ -101,7 +169,7 @@ When they say goodbye or conversation ends, use the save_conversation tool to ar
'X-Title': 'Nicholai Portfolio', 'X-Title': 'Nicholai Portfolio',
}, },
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(25000), // 25 second timeout signal: AbortSignal.timeout(25000),
}); });
if (!response.ok) { if (!response.ok) {
@ -117,26 +185,51 @@ When they say goodbye or conversation ends, use the save_conversation tool to ar
} }
const data = await response.json(); const data = await response.json();
const assistantContent = data.choices[0]?.message?.content || '...'; const choice = data.choices[0];
const message = choice?.message;
// Handle tool calls if present
let assistantContent = message?.content || '';
const toolCalls = message?.tool_calls;
if (toolCalls && Array.isArray(toolCalls) && toolCalls.length > 0) {
for (const toolCall of toolCalls) {
if (toolCall.function?.name === 'save_visitor_name') {
try {
const args = JSON.parse(toolCall.function.arguments || '{}');
if (args.name && db) {
const saved = await saveVisitorName(db, visitor_id, args.name);
if (saved && !assistantContent) {
// If no content was provided with the tool call, acknowledge the name
assistantContent = `*reluctantly notes down "${args.name}"*\n\nFine. I've recorded your name. Not that it matters in the grand scheme of things.`;
}
}
} catch (e) {
console.error('[Hubert] Failed to parse tool call arguments:', e);
}
}
}
}
// Ensure we have some content to return
if (!assistantContent) {
assistantContent = '...';
}
// Save the assistant's response to the database
if (db) {
await saveMessage(db, conversation_id, 'assistant', assistantContent);
}
const responseTime = Date.now() - startTime; const responseTime = Date.now() - startTime;
console.log(`[Hubert] Generated response in ${responseTime}ms`); console.log(`[Hubert] Generated response in ${responseTime}ms`);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
messages: [ messages: [...messages, { role: 'assistant', content: assistantContent }],
...messages,
{
role: 'assistant',
content: assistantContent,
},
],
thread_id: conversation_id, thread_id: conversation_id,
}), }),
{ { status: 200, headers: { 'Content-Type': 'application/json' } }
status: 200,
headers: { 'Content-Type': 'application/json' }
}
); );
} catch (error) { } catch (error) {
console.error('[Hubert] Chat error:', error); console.error('[Hubert] Chat error:', error);

View File

@ -5,39 +5,46 @@ export const prerender = false;
/** /**
* Initialize new visitor * Initialize new visitor
* Generates a unique visitor ID and creates initial conversation ID * Generates a unique visitor ID and creates initial conversation
* Used when Hubert interface first loads * Used when Hubert interface first loads
*/ */
export const POST = async (context) => { export const POST = async (context: any) => {
try { try {
const { request, locals } = context; const { request, locals } = context;
// In Astro with Cloudflare adapter, env is at locals.runtime.env
const env = locals?.runtime?.env; const env = locals?.runtime?.env;
const userAgent = request.headers.get('user-agent') || 'unknown'; const userAgent = request.headers.get('user-agent') || 'unknown';
const ip = request.headers.get('cf-connecting-ip') || 'unknown'; const ip = request.headers.get('cf-connecting-ip') || 'unknown';
const visitorId = randomUUID(); const visitorId = randomUUID();
const conversationId = randomUUID();
// Only insert into database if HUBERT_DB binding exists (production) // Only insert into database if HUBERT_DB binding exists (production)
// In dev mode, this allows the chatbot to work without Cloudflare bindings
if (env && env.HUBERT_DB) { if (env && env.HUBERT_DB) {
try { try {
// Create visitor
await env.HUBERT_DB.prepare(` await env.HUBERT_DB.prepare(`
INSERT INTO visitors (visitor_id, first_seen_at, last_seen_at, ip_address, user_agent) INSERT INTO visitors (visitor_id, first_seen_at, last_seen_at, ip_address, user_agent)
VALUES (?, datetime('now'), datetime('now'), ?, ?) VALUES (?, datetime('now'), datetime('now'), ?, ?)
`).bind(visitorId, ip, userAgent).run(); `).bind(visitorId, ip, userAgent).run();
console.log(`[Hubert] New visitor initialized: ${visitorId}`);
// Create conversation for this visitor
await env.HUBERT_DB.prepare(`
INSERT INTO conversations (conversation_id, visitor_id, started_at)
VALUES (?, ?, datetime('now'))
`).bind(conversationId, visitorId).run();
console.log(`[Hubert] New visitor ${visitorId} with conversation ${conversationId}`);
} catch (dbError) { } catch (dbError) {
console.error('[Hubert] Database insert failed (continuing anyway):', dbError); console.error('[Hubert] Database insert failed (continuing anyway):', dbError);
// Continue anyway - don't fail initialization if DB is misconfigured
} }
} else { } else {
console.log(`[Hubert] Dev mode: Skipping database insert for visitor: ${visitorId}`); console.log(`[Hubert] Dev mode: Skipping database for visitor: ${visitorId}`);
} }
return Response.json({ return Response.json({
visitor_id: visitorId, visitor_id: visitorId,
conversation_id: visitorId, // Use visitor_id as initial conversation_id conversation_id: conversationId,
status: '/// INTERVIEW_TERMINAL_READY', status: '/// INTERVIEW_TERMINAL_READY',
}); });
} catch (error) { } catch (error) {