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:
18
lib/ai.ts
18
lib/ai.ts
@@ -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
170
lib/app-machine.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
27
lib/db.ts
27
lib/db.ts
@@ -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
204
lib/voice-machine.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user