vulpecula-loom
Desktop AI chat app with Obsidian integration, OpenRouter support, and more
- Commits
- 45
- Files
- 157
- Size
- 1.9 MB
- Langs
- 6
- Stars
- 1
- Issues
- 8
- Created
- Nov 18, 2024
- Last push
- about 1 year ago
Vulpecula Loom - AI Chat with Obsidian Integration
Vulpecula Loom is a desktop application that combines the power of modern AI language models with seamless Obsidian vault integration. Built with Electron, Vue 3, and TypeScript, it offers a polished and efficient interface for AI-assisted writing and research.
Features
AI Chat
- Support for multiple AI models through OpenRouter
- Claude 3 (Opus & Sonnet)
- GPT-4
- GPT-3.5 Turbo
- Real-time token counting and cost tracking
- Markdown rendering with syntax highlighting
- Chat history with persistent storage
- Export conversations to Markdown
Obsidian Integration
Share your knowledge with AI by referencing your notes. Simply type @ in any message to seamlessly include content from your Obsidian vault:
-
Quick Note References
Hey AI, can you help me understand @quantum-computing-basics? Also, how does it relate to @quantum-entanglement?- Type
@anywhere in your message - Search your vault in real-time
- Select files from the popup
- Multiple files can be referenced in one message
- Type
-
How It Works
- When you include a note with
@, the entire note's content is sent to the AI - The AI can then reference and analyze your notes
- Example:
User: Can you compare the concepts in @classical-computing with @quantum-computing? Assistant: Looking at your notes, I can see several key differences... [Assistant can now reference the full content of both notes]
- When you include a note with
-
File Search Features
- Real-time search as you type after
@ - Instant results (debounced 150ms)
- Shows file previews
- Matches titles and content
- Keyboard navigation (↑↓ to select, Enter to choose)
- Results sorted by relevance:
- Title matches
- Content matches
- Recently modified
- Real-time search as you type after
-
Technical Implementation
- Files included via
includedFilesarray in messages - Each included file contains:
{ title: string; // File title path: string; // Full path to file content: string; // Complete file content } - Content is cached for performance
- Files monitored for changes
- Vault path stored securely
- Files included via
-
Security & Performance
- Files only accessible within your vault
- Content sanitized before display
- No external file access
- Efficient caching system
- Background indexing
- Incremental updates
-
File Inclusion
- Files can be included in messages using
@mentions - When a file is included, its entire content is added to the message
- Included files are tracked in the message's
includedFilesarray - Each included file contains:
{ title: string; // File title path: string; // Full path to file content: string; // Complete file content }
- Files can be included in messages using
-
File Search
- Real-time search as you type
@ - Debounced queries (150ms)
- Results limited to 10 files
- Results sorted by relevance
- Cache management for performance
- Real-time search as you type
-
Security
- Files are only accessible within the vault
- Content is sanitized before display
- No external file access allowed
- Vault path stored securely in electron-store
-
Performance
- File content cached in memory
- Incremental search updates
- Debounced search queries
- Background file indexing
User Interface
- Dark/Light mode support
- Native system integration
- Custom titlebar with gradient animation
- Responsive design with smooth transitions
- Keyboard shortcuts for common actions
IPC Communication
All inter-process communication (IPC) is centralized in electron/ipc/ with the following structure:
electron/ipc/
├── index.ts # Central IPC setup and system-level handlers
├── obsidian.ts # Obsidian integration handlers
└── store.ts # Electron store handlers
Type-Safe IPC Communication
All IPC channels are fully typed using TypeScript interfaces in src/types.ts under the IpcChannels interface.
This centralized type system provides:
- Auto-completion for channel names
- Parameter type checking
- Return type inference
- Compile-time validation
- Single source of truth for all IPC types
Example:
// In src/types.ts
export interface IpcChannels {
"open-external": (url: string) => Promise<void>;
"store-get": <K extends keyof StoreSchema>(key: K) => Promise<StoreSchema[K]>;
// ... more channel definitions
}
Adding New IPC Handlers
- Add the type definition to
src/types.tsunder theIpcChannelsinterface - Create a new handler in the appropriate domain file in
electron/ipc/ - Register the handler in
electron/ipc/index.ts - Update this documentation
Getting Started
Prerequisites
- Node.js (v18 or higher)
- npm or yarn
- An OpenRouter API key
- Obsidian vault (optional)
Building the Application
For macOS
git clone https://github.com/yourusername/vulpecula-loom.gitcd vulpecula-loomyarn installyarn build
The built application will be available in the release/{version}/ directory:
electron-vue-vite.app- The macOS application bundleelectron-vue-vite-{version}-arm64.dmg- The disk image installer for Apple Siliconelectron-vue-vite-{version}-x64.dmg- The disk image installer for Intel Macs
Note: To build a signed application for distribution, you'll need an Apple Developer account and appropriate certificates. See Apple's documentation for more details on app notarization.
TypeScript Conventions
The Central Type System
ALL types MUST live in src/types.ts. This is not just a convention - it's a requirement.
Why Centralization?
- Single source of truth
- No duplicate definitions
- Clear type hierarchies
- Easy to find and modify
- Better TypeScript tooling support
- Prevents circular dependencies
- Maintains type consistency
Type Categories
- OpenRouter Types (API models, responses)
- Chat Types (messages, history)
- Store Types (electron store schema)
- Electron IPC Types (channel definitions)
- UI Types (preferences, settings)
- Animation Types (configuration)
The Rules of Type Centralization
- ALL types MUST be in
src/types.ts - NO
.d.tsfiles allowed (except for third-party type declarations) - NO interface/type definitions in component files
- NO scattered type definitions across the codebase
- ALL types MUST be properly categorized and documented
- ALL types MUST be exported
❌ ILLEGAL:
- Types in component files
- Scattered
.d.tsfiles - Duplicate type definitions
- Undocumented types
- Types without proper categorization
✅ LEGAL:
- All types in
src/types.ts - Clear type categorization
- Proper exports
- Well-documented types
- Single source of truth
Type Safety vs Pragmatism
While we maintain strict type centralization, we allow any or more permissive types when:
- Dealing with third-party libraries
- Prototyping features
- Handling complex DOM elements
- Working with dynamic data
Remember: Type centralization is about organization, not restriction.
Architecture
Composables
The application uses a layered composable architecture:
-
useAIChat- The main chat composable that handles all chat functionality. This is the primary interface that components should use for chat operations. It provides:- Message handling
- Token tracking
- Cost calculation
- Chat persistence
- Streaming responses
- History management
-
useOpenRouter- Low-level OpenRouter API integration. Should not be used directly by components. Instead, useuseAIChatwhich provides a higher-level interface and proper state management. -
useSupabase- Handles chat persistence and history management -
useObsidianFiles- Manages Obsidian vault integration -
useTheme- Handles theme switching and persistence
⚠️ Important Implementation Notes
Chat Implementation
ALWAYS use useAIChat.ts for chat functionality!
The chat system is designed around the useAIChat composable, which handles:
- Message sending and receiving
- Chat history management
- Token tracking
- Cost calculation
- Model selection
- Temperature settings
- Chat statistics
❌ DO NOT try to implement chat functionality by directly using useOpenRouter or other lower-level composables. This will:
- Break the chat history system
- Cause message tracking issues
- Prevent proper token/cost tracking
- Summon dragons that will devour everyone
useOpenRouter should only be used for:
- API key management
- Model availability checking
- Model information
Electron Store Setup
The application uses electron-store for persistent storage. The store is exposed to the renderer process through a secure preload script.
Store Organization
-
Store Schema
- All store keys are defined in
src/types.ts - Keys follow a consistent naming pattern (e.g.,
api-key,theme) - Values are strongly typed
- All store keys are defined in
-
Access Pattern
- Always use the
useStorecomposable - Never access
window.electron.storedirectly - All store operations are async
- Always use the
-
Key Categories
- API Keys (
api-key) - UI Preferences (
theme,show-progress-bar) - Model Settings (
enabled-model-ids,pinned-models) - Application State (
remember-window-state) - Integration Settings (
obsidian-vault-path)
- API Keys (
-
Best Practices
- Use typed keys from
StoreSchema - Handle errors gracefully
- Provide default values
- Log store operations in development
- Clear store data responsibly
- Use typed keys from
Architecture Decisions
Type System
The application follows a strict type centralization pattern:
-
Central Type Repository
- ALL types MUST live in
src/types.ts - NO scattered
.d.tsfiles (except for third-party declarations) - NO interface/type definitions in component files
- ALL types MUST be properly categorized and documented
- ALL types MUST live in
-
Type Categories
- OpenRouter Types (API models, responses)
- Chat Types (messages, history)
- Store Types (electron store schema)
- Electron IPC Types (channel definitions)
- UI Types (preferences, settings)
- Animation Types (configuration)
- Obsidian Types (file handling, search)
-
Naming Conventions
- Interface names are PascalCase and descriptive (e.g.,
ObsidianFile,ChatMessage) - Options/Config interfaces end with respective suffix (e.g.,
ObsidianSearchOptions,AnimationConfig) - Props interfaces end with
Props(e.g.,ChatInputProps) - Return type interfaces end with
Return(e.g.,UseSupabaseReturn)
- Interface names are PascalCase and descriptive (e.g.,
Composable Architecture
The application uses a layered composable architecture:
-
Core Composables
useAIChat- Primary chat interfaceuseOpenRouter- Low-level API integrationuseSupabase- Chat persistenceuseObsidianFiles- Vault integrationuseTheme- Theme management
-
Composable Rules
- Each composable has a single responsibility
- Composables can depend on other composables
- State management handled through Vue's reactivity system
- Error handling at every layer
- Type-safe return values
-
Obsidian Integration
useObsidianFilesmanages all Obsidian interactions- File search with debounced queries
- Cache management for performance
- Type-safe IPC communication
- Error handling and state management
IPC Communication
-
Channel Organization
- All channels defined in
IpcChannelsinterface - Strongly typed parameters and return values
- Channels grouped by domain:
- Store operations (
store-get,store-set) - File operations (
search-obsidian-files,get-obsidian-file-content) - System operations (
open-external)
- Store operations (
- All channels defined in
-
Security Model
- Context isolation enabled
- Node integration disabled
- Explicit channel allowlist
- Type-safe preload script
-
Error Handling
- All IPC calls wrapped in try/catch
- Errors propagated to UI
- Fallback values defined
- Error states tracked in composables
State Management
-
Store Organization
- Electron store for persistent data
- Vue refs for component state
- Computed properties for derived state
- Watchers for side effects
-
Data Flow
- Props down, events up
- State centralized in composables
- IPC communication abstracted
- Type-safe store operations
File Structure
src/
├── components/ # Vue components
├── composables/ # Vue composables
├── electron/ # Electron main process
│ └── ipc/ # IPC handlers
├── lib/ # Shared utilities
└── types.ts # Central type definitions
Best Practices
-
Type Safety
- Use TypeScript's strict mode
- No
anytypes unless absolutely necessary - Proper type imports from central
types.ts - Type-safe IPC communication
-
Error Handling
- Graceful degradation
- User-friendly error messages
- Proper error propagation
- Error state management
-
Performance
- Debounced search queries
- File caching
- Lazy loading where appropriate
- Efficient IPC communication
-
Code Style
- Clear naming conventions
- Consistent file structure
- Proper documentation
- Single responsibility principle
Authentication & Data Security
Overview
The application uses Discord OAuth for authentication, managed through Supabase Auth. This ensures secure user authentication and data isolation.
Setup Requirements
1. Supabase Project Setup
- Create a new project at supabase.com
- Enable Discord OAuth provider:
- Add Discord application credentials
- Configure allowed callback URLs:
http://localhost:5173/auth/callback # Development https://your-domain.com/auth/callback # Production
- Configure RLS policies (see Database Schema section)
- Copy project credentials for environment setup
2. Environment Configuration
Create a .env file in the project root:
# Supabase Configuration
VITE_SUPABASE_URL=your_project_url
VITE_SUPABASE_KEY=your_anon_key # Public anon key, NOT service_role key
# Discord OAuth
VITE_DISCORD_CLIENT_ID=your_discord_client_id
Database Schema
Table Structure
-- Enable RLS
alter table public.vulpeculachats enable row level security;
-- Create table
create table public.vulpeculachats (
id uuid not null default gen_random_uuid(),
title text null,
messages jsonb not null default '[]'::jsonb,
model text not null,
metadata jsonb null default '{}'::jsonb,
created_at timestamp with time zone not null default now(),
updated_at timestamp with time zone not null default now(),
user_id uuid null,
thread text null,
constraint vulpeculachats_pkey primary key (id),
constraint vulpeculachats_user_id_fkey foreign key (user_id) references auth.users (id)
) tablespace pg_default;
-- Create index for efficient chat history sorting
create index if not exists idx_vulpeculachats_updated_at
on public.vulpeculachats using btree (updated_at)
tablespace pg_default;
Row Level Security (RLS) Policies
-- Allow users to read their own chats and any anonymous chats
create policy "Users can read their own chats"
on public.vulpeculachats for select
using (
auth.uid() = user_id
or user_id is null -- Allow reading chats with no user_id
);
-- Allow users to create chats, either owned or anonymous
create policy "Users can create chats"
on public.vulpeculachats for insert
with check (
auth.uid() = user_id
or user_id is null -- Allow creating chats with no user_id
);
-- Allow users to update their own chats and anonymous chats
create policy "Users can update their own chats"
on public.vulpeculachats for update
using (
auth.uid() = user_id
or user_id is null -- Allow updating chats with no user_id
);
-- Allow users to delete their own chats and anonymous chats
create policy "Users can delete their own chats"
on public.vulpeculachats for delete
using (
auth.uid() = user_id
or user_id is null -- Allow deleting chats with no user_id
);
Table Fields
vulpeculachats: Stores all chat sessions
id: UUID primary key, auto-generatedtitle: Optional chat titlemessages: JSONB array of messages (defaults to empty array){ id: string; role: "user" | "assistant" | "system"; content: string; timestamp: string; model?: string; tokens?: { prompt: number; completion: number; total: number; }; cost?: number; includedFiles?: Array<{ path: string; content: string; type: string; }>; }[]model: AI model identifier (e.g., "anthropic/claude-3-opus-20240229")metadata: Optional JSONB object for additional data (defaults to empty object){ lastModel?: string; lastUpdated?: string; messageCount?: number; summary?: string; autoTitle?: string; summaryLastUpdated?: string; stats?: { promptTokens: number; completionTokens: number; cost: number; totalMessages: number; }; }user_id: Optional reference to auth.users (nullable for anonymous chats)thread: Optional text field for thread identificationcreated_at/updated_at: Timestamps with timezone (auto-managed)
Security Features
- Row Level Security (RLS) enabled by default
- Index on updated_at for efficient chat history queries
- Full CRUD policies supporting:
- User-owned chats (where user_id = auth.uid())
- Anonymous chats (where user_id is null)
- Proper data isolation between users
Implementation Notes
- Anonymous chats (null user_id) are accessible to all users
- Each user can only access their own chats plus anonymous chats
- The updated_at index supports efficient chat history sorting
- JSONB fields (messages, metadata) support flexible schema evolution
- Timestamps are automatically managed by Postgres
Authentication Flow
- User visits app
useActiveUsercomposable checks auth state- If not authenticated:
- Shows Discord login option
- Handles OAuth redirect
- Manages session storage
- On successful auth:
- Session established
- User data loaded
- Chat history retrieved
Logging Philosophy
The application follows a minimalist logging approach:
-
Production Logging
- Critical errors only
- Authentication state changes
- Important system events
- No debug logs in production
-
Development Logging
- Controlled through
logger.ts - Debug logs only shown in development
- Singleton pattern for consistent logging
- No redundant initialization logs
- Controlled through
-
Log Categories
export type LogLevel = "debug" | "info" | "warn" | "error";debug: Development-only diagnostic informationinfo: Important but non-critical system eventswarn: Potential issues that don't stop executionerror: Critical issues that need attention
-
Implementation
- Centralized logger in
src/lib/logger.ts - Singleton patterns in key composables
- No console.log in production code
- Error tracking preserved for debugging
- Centralized logger in
-
Best Practices
- Use
logger.errorfor exceptions - Keep debug logs minimal
- No sensitive data in logs
- Clear, actionable error messages
- Use