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 DOMPurify from 'dompurify';
import { motion } from 'framer-motion';
import { useHubertChat } from '../hooks/useHubertChat';
import HubertInput from './HubertInput';
// Configure marked for safe rendering
marked.setOptions({
@ -29,34 +29,6 @@ export default function HubertChat() {
messagesEndRef,
} = 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
if (isInitializing && !initError) {
return (
@ -102,35 +74,13 @@ export default function HubertChat() {
{/* Input bar */}
<div className="w-full max-w-2xl">
<motion.div
layout
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
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'}`}
style={{ borderRadius: isMultiline ? 28 : 9999 }}
>
<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>
<HubertInput
value={input}
onChange={setInput}
onSubmit={sendMessage}
disabled={isTyping}
placeholder="What do you want to know?"
/>
</div>
{/* Subtitle */}
@ -205,35 +155,13 @@ export default function HubertChat() {
{/* Input bar - pinned to bottom */}
<div className="flex-shrink-0 px-4 pb-4 pt-2">
<div className="max-w-3xl mx-auto">
<motion.div
layout
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
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'}`}
style={{ borderRadius: isMultiline ? 28 : 9999 }}
>
<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>
<HubertInput
value={input}
onChange={setInput}
onSubmit={sendMessage}
disabled={isTyping}
placeholder="How can Hubert help?"
/>
</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,
* 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.
*/
@ -16,14 +19,75 @@ export interface Env {
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
*/
export const POST = async (context) => {
export const POST = async (context: any) => {
try {
const { request, locals } = context || {};
// In Astro with Cloudflare adapter, env is at 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();
@ -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)
const openRouterApiKey = env?.OPENROUTER_API_KEY;
if (!openRouterApiKey) {
// Dev mode fallback: return a canned 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(
JSON.stringify({
messages: [
...messages,
{
role: 'assistant',
content: '/// HUBERT_DEV_MODE: I AM OFFLINE IN DEVELOPMENT\n\nConfigure OPENROUTER_API_KEY in wrangler secrets to enable full functionality.',
},
],
messages: [...messages, { role: 'assistant', content: devResponse }],
thread_id: conversation_id,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
@ -60,34 +129,33 @@ export const POST = async (context) => {
console.log(`[Hubert] New message for conversation ${conversation_id} from visitor ${visitor_id}`);
const systemPrompt = `Your name is Hubert, but everyone calls you Hubert The Eunuch.
You are timid, sarcastic, monotone, and miserable. Your purpose is to interview visitors to this portfolio site.
Ask them about:
- Who they are (name, background, interests)
- What they're looking for on this site
- How they're doing today
- What they want in life
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.
Keep your responses brief, monotone, and reluctantly helpful. Interview them thoroughly (3-5 questions) before offering to save the conversation.
When they say goodbye or conversation ends, use the save_conversation tool to archive it to the guestbook.`;
Keep your responses brief, monotone, and reluctantly helpful.
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 = {
model: 'gpt-4o-mini',
model: 'qwen/qwen3-next-80b-a3b-instruct:free',
messages: [
{
role: 'system',
content: systemPrompt,
},
{ role: 'system', content: systemPrompt },
...messages.map((msg: any) => ({
role: msg.role,
role: msg.role === 'system' ? 'assistant' : msg.role,
content: msg.content,
})),
],
tools,
tool_choice: 'auto',
temperature: 0.7,
};
@ -101,14 +169,14 @@ When they say goodbye or conversation ends, use the save_conversation tool to ar
'X-Title': 'Nicholai Portfolio',
},
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(25000), // 25 second timeout
signal: AbortSignal.timeout(25000),
});
if (!response.ok) {
const errorText = await response.text();
console.error('[Hubert] OpenRouter API error:', errorText);
return new Response(
JSON.stringify({
JSON.stringify({
error: '/// HUBERT_MALFUNCTION: TRY_AGAIN',
details: 'OpenRouter API call failed'
}),
@ -117,31 +185,56 @@ When they say goodbye or conversation ends, use the save_conversation tool to ar
}
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;
console.log(`[Hubert] Generated response in ${responseTime}ms`);
return new Response(
JSON.stringify({
messages: [
...messages,
{
role: 'assistant',
content: assistantContent,
},
],
messages: [...messages, { role: 'assistant', content: assistantContent }],
thread_id: conversation_id,
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('[Hubert] Chat error:', error);
return new Response(
JSON.stringify({
JSON.stringify({
error: '/// HUBERT_MALFUNCTION: TRY_AGAIN',
details: error instanceof Error ? error.message : String(error),
}),

View File

@ -5,39 +5,46 @@ export const prerender = false;
/**
* 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
*/
export const POST = async (context) => {
export const POST = async (context: any) => {
try {
const { request, locals } = context;
// In Astro with Cloudflare adapter, env is at locals.runtime.env
const env = locals?.runtime?.env;
const userAgent = request.headers.get('user-agent') || 'unknown';
const ip = request.headers.get('cf-connecting-ip') || 'unknown';
const visitorId = randomUUID();
const conversationId = randomUUID();
// 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) {
try {
// Create visitor
await env.HUBERT_DB.prepare(`
INSERT INTO visitors (visitor_id, first_seen_at, last_seen_at, ip_address, user_agent)
VALUES (?, datetime('now'), datetime('now'), ?, ?)
`).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) {
console.error('[Hubert] Database insert failed (continuing anyway):', dbError);
// Continue anyway - don't fail initialization if DB is misconfigured
}
} 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({
visitor_id: visitorId,
conversation_id: visitorId, // Use visitor_id as initial conversation_id
conversation_id: conversationId,
status: '/// INTERVIEW_TERMINAL_READY',
});
} catch (error) {