feat: Improve UI layout and navigation

- Increase logo size (48x48 desktop, 56x56 mobile) for better visibility
- Add logo as favicon
- Add logo to mobile header
- Move user menu to navigation bars (sidebar on desktop, bottom bar on mobile)
- Fix desktop chat layout - container structure prevents voice controls cutoff
- Fix mobile bottom bar - use icon-only ActionIcons instead of truncated text buttons
- Hide Create Node/New Conversation buttons on mobile to save header space
- Make fixed header and voice controls work properly with containers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-09 14:43:11 +00:00
parent 0b632a31eb
commit f0284ef813
74 changed files with 6996 additions and 629 deletions

View File

@@ -1,17 +1,25 @@
import { GoogleGenerativeAI } from '@google/generative-ai';
const genAI = new GoogleGenerativeAI(process.env.GOOGLE_API_KEY!);
// Validate required environment variables
if (!process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
throw new Error('GOOGLE_GENERATIVE_AI_API_KEY environment variable is required');
}
if (!process.env.GOOGLE_EMBEDDING_MODEL) {
throw new Error('GOOGLE_EMBEDDING_MODEL environment variable is required (e.g., gemini-embedding-001)');
}
const genAI = new GoogleGenerativeAI(process.env.GOOGLE_GENERATIVE_AI_API_KEY);
const embeddingModel = genAI.getGenerativeModel({
model: 'text-embedding-004',
model: process.env.GOOGLE_EMBEDDING_MODEL,
});
/**
* Generates a vector embedding for a given text using Google's text-embedding-004 model.
* The output is a 768-dimension vector (not 1536 as originally specified).
* Generates a vector embedding for a given text using the configured Google embedding model.
*
* @param text - The text to embed
* @returns A 768-dimension vector (Array<number>)
* @returns A vector embedding (dimension depends on model)
*/
export async function generateEmbedding(text: string): Promise<number[]> {
try {

170
lib/app-machine.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* App-Level State Machine
*
* Manages the top-level application state across three main modes:
* - convo: Active conversation (voice or text)
* - edit: Editing a node
* - galaxy: 3D visualization of node graph
*
* This machine sits above the conversation machine (which contains voice/text modes).
* It does NOT duplicate the voice mode logic - that lives in voice-machine.ts.
*/
import { setup, assign } from 'xstate';
export interface NodeDraft {
title: string;
content: string;
conversationContext?: string; // Last N messages as context
}
export interface Node {
id: string;
title: string;
content: string;
atp_uri?: string;
}
interface AppContext {
currentNodeId: string | null;
pendingNodeDraft: NodeDraft | null;
mode: 'mobile' | 'desktop';
lastError: string | null;
}
type AppEvent =
| { type: 'NAVIGATE_TO_CONVO' }
| { type: 'NAVIGATE_TO_EDIT'; nodeId?: string; draft?: NodeDraft }
| { type: 'NAVIGATE_TO_GALAXY' }
| { type: 'CREATE_NODE_FROM_CONVERSATION'; draft: NodeDraft }
| { type: 'PUBLISH_NODE_SUCCESS'; nodeId: string }
| { type: 'CANCEL_EDIT' }
| { type: 'SET_MODE'; mode: 'mobile' | 'desktop' }
| { type: 'ERROR'; message: string };
export const appMachine = setup({
types: {
context: {} as AppContext,
events: {} as AppEvent,
},
actions: {
setCurrentNode: assign({
currentNodeId: ({ event }) =>
event.type === 'NAVIGATE_TO_EDIT' ? event.nodeId || null : null,
}),
setPendingDraft: assign({
pendingNodeDraft: ({ event }) => {
if (event.type === 'NAVIGATE_TO_EDIT' && event.draft) {
console.log('[App Machine] Setting pending draft:', event.draft);
return event.draft;
}
if (event.type === 'CREATE_NODE_FROM_CONVERSATION') {
console.log('[App Machine] Creating node from conversation:', event.draft);
return event.draft;
}
return null;
},
}),
clearDraft: assign({
pendingNodeDraft: null,
currentNodeId: null,
}),
setPublishedNode: assign({
currentNodeId: ({ event }) =>
event.type === 'PUBLISH_NODE_SUCCESS' ? event.nodeId : null,
pendingNodeDraft: null,
}),
setMode: assign({
mode: ({ event }) => (event.type === 'SET_MODE' ? event.mode : 'desktop'),
}),
setError: assign({
lastError: ({ event }) => (event.type === 'ERROR' ? event.message : null),
}),
clearError: assign({
lastError: null,
}),
logTransition: ({ context, event }) => {
console.log('[App Machine] Event:', event.type);
console.log('[App Machine] Context:', {
currentNodeId: context.currentNodeId,
hasDraft: !!context.pendingNodeDraft,
mode: context.mode,
});
},
},
}).createMachine({
id: 'app',
initial: 'convo',
context: {
currentNodeId: null,
pendingNodeDraft: null,
mode: 'desktop',
lastError: null,
},
on: {
SET_MODE: {
actions: ['setMode', 'logTransition'],
},
ERROR: {
actions: ['setError', 'logTransition'],
},
},
states: {
convo: {
tags: ['conversation'],
entry: ['clearError', 'logTransition'],
on: {
NAVIGATE_TO_EDIT: {
target: 'edit',
actions: ['setCurrentNode', 'setPendingDraft', 'logTransition'],
},
CREATE_NODE_FROM_CONVERSATION: {
target: 'edit',
actions: ['setPendingDraft', 'logTransition'],
},
NAVIGATE_TO_GALAXY: {
target: 'galaxy',
actions: ['logTransition'],
},
},
},
edit: {
tags: ['editing'],
entry: ['clearError', 'logTransition'],
on: {
NAVIGATE_TO_CONVO: {
target: 'convo',
actions: ['logTransition'],
},
NAVIGATE_TO_GALAXY: {
target: 'galaxy',
actions: ['logTransition'],
},
PUBLISH_NODE_SUCCESS: {
target: 'galaxy',
actions: ['setPublishedNode', 'logTransition'],
},
CANCEL_EDIT: {
target: 'convo',
actions: ['clearDraft', 'logTransition'],
},
},
},
galaxy: {
tags: ['visualization'],
entry: ['clearError', 'logTransition'],
on: {
NAVIGATE_TO_CONVO: {
target: 'convo',
actions: ['logTransition'],
},
NAVIGATE_TO_EDIT: {
target: 'edit',
actions: ['setCurrentNode', 'setPendingDraft', 'logTransition'],
},
},
},
},
});

View File

@@ -38,9 +38,10 @@ export async function getOAuthClient(): Promise<NodeOAuthClient> {
if (isDev) {
// Development: Use localhost loopback client
// Per ATproto spec, we encode metadata in the client_id query params
// Request 'transition:generic' scope for repository write access
const clientId = `http://localhost/?${new URLSearchParams({
redirect_uri: callbackUrl,
scope: 'atproto',
scope: 'atproto transition:generic',
}).toString()}`;
console.log('[OAuth] Initializing development client with loopback exception');
@@ -50,7 +51,7 @@ export async function getOAuthClient(): Promise<NodeOAuthClient> {
clientMetadata: {
client_id: clientId,
redirect_uris: [callbackUrl],
scope: 'atproto',
scope: 'atproto transition:generic',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
application_type: 'native',

View File

@@ -38,6 +38,7 @@ async function getDB(): Promise<Surreal> {
export function createSessionStore(): NodeSavedSessionStore {
return {
async set(did: string, sessionData: NodeSavedSession): Promise<void> {
console.log('[SessionStore] Setting session for DID:', did);
const db = await getDB();
try {
@@ -50,12 +51,14 @@ export function createSessionStore(): NodeSavedSessionStore {
if (Array.isArray(existing) && existing.length > 0) {
// Update existing record
console.log('[SessionStore] Updating existing session');
await db.merge(recordId, {
session_data: sessionData,
updated_at: new Date().toISOString(),
});
} else {
// Create new record
console.log('[SessionStore] Creating new session');
await db.create(recordId, {
did,
session_data: sessionData,
@@ -63,12 +66,17 @@ export function createSessionStore(): NodeSavedSessionStore {
updated_at: new Date().toISOString(),
});
}
console.log('[SessionStore] ✓ Session saved successfully');
} catch (error) {
console.error('[SessionStore] Error setting session:', error);
throw error;
} finally {
await db.close();
}
},
async get(did: string): Promise<NodeSavedSession | undefined> {
console.log('[SessionStore] Getting session for DID:', did);
const db = await getDB();
try {
@@ -77,7 +85,11 @@ export function createSessionStore(): NodeSavedSessionStore {
// db.select() returns an array when selecting a specific record ID
const record = Array.isArray(result) ? result[0] : result;
console.log('[SessionStore] Get result:', { found: !!record, hasSessionData: !!record?.session_data });
return record?.session_data;
} catch (error) {
console.error('[SessionStore] Error getting session:', error);
throw error;
} finally {
await db.close();
}

View File

@@ -1,31 +1,42 @@
import Surreal from 'surrealdb';
/**
* Connects to the SurrealDB instance and authenticates with the user's JWT.
* This enforces row-level security defined in the schema.
* Connects to the SurrealDB instance with root credentials.
*
* IMPORTANT: This connects as root, so queries MUST filter by user_did
* to enforce data isolation. The caller is responsible for providing
* the correct user_did from the verified JWT.
*
* @param token - The user's app-specific (SurrealDB) JWT
* @returns The authenticated SurrealDB instance
*/
export async function connectToDB(token: string): Promise<Surreal> {
export async function connectToDB(): Promise<Surreal> {
const SURREALDB_URL = process.env.SURREALDB_URL;
const SURREALDB_NAMESPACE = process.env.SURREALDB_NS;
const SURREALDB_DATABASE = process.env.SURREALDB_DB;
const SURREALDB_USER = process.env.SURREALDB_USER;
const SURREALDB_PASS = process.env.SURREALDB_PASS;
if (!SURREALDB_URL || !SURREALDB_NAMESPACE || !SURREALDB_DATABASE) {
throw new Error('SurrealDB configuration is missing');
}
if (!SURREALDB_USER || !SURREALDB_PASS) {
throw new Error('SurrealDB credentials are missing');
}
// Create a new instance for each request to avoid connection state issues
const db = new Surreal();
// Connect to SurrealDB
await db.connect(SURREALDB_URL);
// Authenticate as the user for this request.
// This enforces the row-level security (PERMISSIONS)
// defined in the schema for all subsequent queries.
await db.authenticate(token);
// Sign in with root credentials
// NOTE: We use root access because our JWT-based auth is app-level,
// not SurrealDB-level. Queries must filter by user_did from the verified JWT.
await db.signin({
username: SURREALDB_USER,
password: SURREALDB_PASS,
});
// Use the correct namespace and database
await db.use({

204
lib/voice-machine.ts Normal file
View File

@@ -0,0 +1,204 @@
/**
* Voice Mode State Machine - Clean, Canonical Design
*
* This machine represents the voice conversation flow.
* All logic is in the machine definition, not in React effects.
*/
import { setup, assign, fromPromise } from 'xstate';
interface VoiceContext {
transcript: string;
lastSpokenMessageId: string | null;
error: string | null;
audioUrl: string | null;
aiText: string | null;
}
type VoiceEvent =
| { type: 'START_VOICE' }
| { type: 'STOP_VOICE' }
| { type: 'START_LISTENING' }
| { type: 'USER_STARTED_SPEAKING' }
| { type: 'FINALIZED_PHRASE'; phrase: string }
| { type: 'UTTERANCE_END' }
| { type: 'SILENCE_TIMEOUT' }
| { type: 'USER_MESSAGE_SUBMITTED' }
| { type: 'AI_RESPONSE_RECEIVED'; messageId: string; text: string }
| { type: 'TTS_GENERATION_COMPLETE'; audioUrl: string }
| { type: 'TTS_PLAYBACK_STARTED' }
| { type: 'TTS_PLAYBACK_FINISHED' }
| { type: 'SKIP_AUDIO' }
| { type: 'ERROR'; message: string };
export const voiceMachine = setup({
types: {
context: {} as VoiceContext,
events: {} as VoiceEvent,
},
actions: {
setTranscript: assign({
transcript: ({ event }) =>
event.type === 'FINALIZED_PHRASE' ? event.phrase : '',
}),
appendPhrase: assign({
transcript: ({ context, event }) =>
event.type === 'FINALIZED_PHRASE'
? context.transcript + (context.transcript ? ' ' : '') + event.phrase
: context.transcript,
}),
clearTranscript: assign({
transcript: '',
}),
setLastSpoken: assign({
lastSpokenMessageId: ({ event }) =>
event.type === 'AI_RESPONSE_RECEIVED' ? event.messageId : null,
aiText: ({ event }) =>
event.type === 'AI_RESPONSE_RECEIVED' ? event.text : null,
}),
setAudioUrl: assign({
audioUrl: ({ event }) =>
event.type === 'TTS_GENERATION_COMPLETE' ? event.audioUrl : null,
}),
clearAudio: assign({
audioUrl: null,
aiText: null,
}),
setError: assign({
error: ({ event }) => (event.type === 'ERROR' ? event.message : null),
}),
clearError: assign({
error: null,
}),
},
}).createMachine({
id: 'voice',
initial: 'idle',
context: {
transcript: '',
lastSpokenMessageId: null,
error: null,
audioUrl: null,
aiText: null,
},
states: {
idle: {
tags: ['voiceIdle'],
on: {
START_VOICE: 'checkingForGreeting',
STOP_VOICE: 'idle',
},
},
checkingForGreeting: {
tags: ['checking'],
// This state checks if there's an unspoken AI message
// In React, an effect will check messages and send appropriate event
on: {
AI_RESPONSE_RECEIVED: {
target: 'generatingTTS',
actions: 'setLastSpoken',
},
START_LISTENING: 'listening',
},
},
listening: {
tags: ['listening'],
entry: ['clearTranscript', 'clearAudio'],
on: {
USER_STARTED_SPEAKING: 'userSpeaking',
STOP_VOICE: 'idle',
},
},
userSpeaking: {
tags: ['userSpeaking'],
on: {
FINALIZED_PHRASE: {
target: 'userSpeaking',
actions: 'appendPhrase',
reenter: true,
},
UTTERANCE_END: 'timingOut',
STOP_VOICE: 'idle',
},
},
timingOut: {
tags: ['timingOut'],
entry: () => console.log('[Voice Machine] Entered timingOut state, 3-second timer starting'),
after: {
3000: {
target: 'submittingUser',
actions: () => console.log('[Voice Machine] 3 seconds elapsed, transitioning to submittingUser'),
},
},
on: {
USER_STARTED_SPEAKING: 'userSpeaking', // User started talking again, cancel timeout
// Don't handle FINALIZED_PHRASE here - just let the timer run
STOP_VOICE: 'idle',
},
},
submittingUser: {
tags: ['submitting'],
// React effect submits the transcript
on: {
USER_MESSAGE_SUBMITTED: 'waitingForAI',
ERROR: {
target: 'idle',
actions: 'setError',
},
STOP_VOICE: 'idle',
},
},
waitingForAI: {
tags: ['waitingForAI'],
// React effect polls/waits for AI response
on: {
AI_RESPONSE_RECEIVED: {
target: 'generatingTTS',
actions: 'setLastSpoken',
},
ERROR: {
target: 'idle',
actions: 'setError',
},
STOP_VOICE: 'idle',
},
},
generatingTTS: {
tags: ['aiGenerating', 'canSkipAudio'],
// React effect generates TTS
on: {
TTS_GENERATION_COMPLETE: {
target: 'playingTTS',
actions: 'setAudioUrl',
},
SKIP_AUDIO: 'listening',
ERROR: {
target: 'listening',
actions: 'setError',
},
STOP_VOICE: 'idle',
},
},
playingTTS: {
tags: ['aiSpeaking', 'canSkipAudio'],
// React effect plays audio
on: {
TTS_PLAYBACK_FINISHED: 'listening',
SKIP_AUDIO: 'listening',
ERROR: {
target: 'listening',
actions: 'setError',
},
STOP_VOICE: 'idle',
},
},
},
});