Multi-assistant AI platform with RAG-driven context, agentic capabilities, tool calling, pluggable LLM providers, voice I/O, image generation, external integration (Discord and Telegram), long term memory and themeable interfaces. Built with Laravel and React.
VERA is a multi-assistant AI platform with agent mode, voice I/O, and deep integration across channels. Each assistant has its own independently configurable prompt, expression set, and knowledge base β all managed in the database. Assistants can operate in agent mode, calling tools across multiple steps to answer, calculate, or generate images. They respond through the web app, Telegram, and Discord. Voice mode lets you speak to an assistant and hear replies back, with pluggable STT and TTS backends. A RAG-powered archive injects relevant knowledge into every conversation, and long-term memory keeps assistants coherent across sessions. LLM, TTS, and image generation providers are all DB-managed and swappable from the UI β no config file edits needed.
- Backend: Laravel 13 (PHP 8.4)
- Frontend: React 19 (via Vite, React Router)
- LLM: Any OpenAI-compatible API or Anthropic β configured via the Providers UI
- Voice input (STT): whisper.cpp, local, single fixed backend (
.env-configured) - Voice output (TTS): DB-managed, fully user-editable via the Voice UI, same pattern as LLM providers. Four formats: self-hosted OpenAI-compatible (Orpheus, KittenTTS confirmed working), OpenAI TTS, Deepgram, ElevenLabs.
- Database: PostgreSQL
- Styling: Tailwind CSS v4
- Auth: Laravel Sanctum (SPA mode)
- PHP 8.4+
- Composer
- Node.js & npm
- PostgreSQL
- An LLM API endpoint (OpenRouter, Anthropic, a local Ollama-compatible server, etc.)
- (Optional, for Voice Mode)
whisper-cppandllama.cppβ see Voice Mode below
# Clone the repo
git clone <repo-url>
cd laravel-vera
# Install PHP dependencies
composer install
# Install JS dependencies
npm install
# Copy environment file
cp .env.example .env
# Generate app key
php artisan key:generate
# Run migrations
php artisan migrate
# Seed emotions
php artisan emotions:sync
# Seed the voice provider catalog (optional β only needed for Voice Mode)
php artisan db:seed --class=VoiceProviderSeeder
# Link public storage (required for emotion images and user uploads)
php artisan storage:link
# Create your user
php artisan tinker --execute 'User::create(["name" => "YourName", "email" => "[email protected]", "password" => bcrypt("yourpassword")]);'
# Start development
npm run devIf using Laravel Herd, add the site through Herd's UI. Otherwise run php artisan serve.
Archive entry embeddings are dispatched as async jobs. To process them, run the queue worker:
php artisan queue:workThis is only needed if you use the Archive feature. Without it, archive entries will be saved but won't have embeddings, so retrieval won't return results and Archive search's semantic matching won't surface them either (literal text matching still works without embeddings).
# Application
APP_URL=https://laravel-vera.test
# Database
DB_CONNECTION=pgsql
DB_DATABASE=vera
# Sanctum
SANCTUM_STATEFUL_DOMAINS=laravel-vera.test
# Default LLM provider (fallback if no model is selected in the UI)
AI_DEFAULT_URL=https://openrouter.ai/api/v1/chat/completions
AI_DEFAULT_API_KEY=
AI_DEFAULT_MODEL=google/gemma-3-27b-it
AI_DEFAULT_FORMAT=generic # generic (OpenAI-compatible) | anthropic
AI_DEFAULT_THINKING=false
AI_DEFAULT_MAX_TOKENS=4096
AI_DEFAULT_TIMEOUT=600
AI_STREAM=false
# Embedding provider (required for Archive RAG retrieval)
# Must be an OpenAI-compatible embeddings endpoint
AI_EMBEDDING_URL=https://openrouter.ai/api/v1
AI_EMBEDDING_MODEL=text-embedding-3-small
# Voice input (optional β required only for Voice Mode; single fixed backend, not DB-managed)
AI_STT_URL=http://localhost:8080
AI_STT_MODEL=medium
AI_STT_FORMAT=whisper
AI_STT_TIMEOUT=60
# Voice output fallback (optional β only used if no voice model is selected in the Voice UI)
AI_TTS_URL=http://localhost:5005/v1/audio/speech
AI_TTS_API_KEY=
AI_TTS_MODEL=orpheus
AI_TTS_FORMAT=openai_compatible
AI_TTS_VOICE=tara
AI_TTS_TIMEOUT=120
# Image generation fallback (optional β only used if no image-gen model is selected in the UI)
IMAGE_GEN_URL=https://openrouter.ai/api/v1/images
IMAGE_GEN_API_KEY=
IMAGE_GEN_MODEL=bytedance-seed/seedream-4.5
IMAGE_GEN_FORMAT=openrouter # openrouter | openai_compatible
IMAGE_GEN_TIMEOUT=120
# Agent mode (optional β tune tool-calling behavior for agent-mode assistants)
AGENT_STEP_LIMIT=10
AGENT_TOOL_TIMEOUT=60
AGENT_TOOL_RETRY_ATTEMPTS=3
AGENT_PROGRESS_CACHE_TTL=10
# Telegram (optional)
TELEGRAM_URL=https://api.telegram.org
TELEGRAM_BOT_TOKEN=
TELEGRAM_USER_ID=
TELEGRAM_CHAT_ID=
TELEGRAM_ASSISTANT_ID=
TELEGRAM_POLL_TIMEOUT=30 # getUpdates long-poll duration
TELEGRAM_SEND_TIMEOUT=15 # sendMessage HTTP timeout
TELEGRAM_TYPING_TIMEOUT=10 # sendChatAction ("typing...") HTTP timeout
TELEGRAM_FILE_TIMEOUT=15 # getFile HTTP timeout
TELEGRAM_DOWNLOAD_TIMEOUT=30 # downloading an attached file's bytes
# Discord (optional β see Discord Integration below)
DISCORD_API_URL=http://localhost:3001
DISCORD_API_SECRET=
DISCORD_API_TIMEOUT=10Providers and models are managed through the Providers page in the UI (/assistants/:id/providers). Each provider has:
- A base URL (any OpenAI-compatible endpoint, or Anthropic)
- An API key (encrypted at rest)
- A format (
genericfor OpenAI-compatible APIs,anthropicfor the Anthropic API)
Each model has:
- An endpoint/model identifier (e.g.
google/gemma-4-26b-a4b-it) - An optional thinking key β the JSON field name in the API response holding the model's reasoning/chain-of-thought text (e.g.
reasoning,reasoning_content); left blank if the model doesn't expose one - A supports tool calling toggle β required for the model to be usable by an agent-mode assistant
- Config (JSON, validated against the provider's schema) and additional config (JSON, merged into the request body as-is β an escape hatch for anything the schema doesn't cover) and a prompt override
The active model is selected per-user via the SELECT button in the Providers UI. If no model is selected, the fallback config from .env is used.
TTS is pluggable and DB-managed the same way LLM providers are, with the same full add/edit/delete UI. php artisan db:seed --class=VoiceProviderSeeder (see database/seeders/VoiceProviderSeeder.php) still exists and still runs, but only as a convenience β it pre-populates two ready-to-use self-hosted entries (Orpheus, KittenTTS); it's not the only way to add a provider.
Each provider has:
- A base URL, a format, and an optional API key. Four formats are supported:
openai_compatible(any backend speaking the OpenAI TTS request shape{model, input, voice}β raw audio β self-hosted backends like Orpheus/KittenTTS),openai_tts(OpenAI's own TTS API, adds a steerableinstructionsfield),deepgram, andelevenlabs - An
instructionsfield β plain text shown in the Voice UI telling you what to run before selecting this provider (most relevant to self-hosted backends; optional either way) - An optional JSON
promptβ injected into voice-mode conversations while this provider is active (see Prompt Configuration below)
Each model has:
- An
endpoint(the model identifier sent in requests) and avoiceslist you type in yourself β a hint for the picker, not an enforced option set, since the actual valid voices depend on whatever's currently available on the backend - Optional config (JSON, e.g.
timeout) and an optional JSONprompt, same injection mechanism as the provider's, layered on top of it. Foropenai_tts, atts instructionskey inside this prompt tree also doubles as the base text for that provider's steerable delivery instructions
Selection happens on the Voice page (/assistants/:id/voice): pick a voice from a model to activate it (SELECT is implicit β choosing a voice for an inactive model activates it in the same action). If no model is selected, AI_TTS_* from .env is used as a fallback, same pattern as the LLM side.
Known issue: creating a new voice model via "+ ADD MODEL" is currently broken (backend bug, tracked separately) β editing and deleting existing models, and full CRUD on providers, work fine.
Image generation is pluggable and user-editable, the same way LLM providers are (unlike voice, it's not seeder-managed). Providers and models are managed through the Image Gen Providers page in the UI (/assistants/:id/image-gen-providers). Each provider has:
- A base URL and a format (
openrouteroropenai_compatible) - An API key (encrypted at rest)
- Optional per-provider prompt instructions and a config schema
Each model has:
- An endpoint/model identifier (e.g.
bytedance-seed/seedream-4.5) - Optional config (JSON, e.g.
timeout) and prompt override
The active model is selected per-user via the SELECT button in the Image Gen Providers UI. If no model is selected, the fallback config from .env (IMAGE_GEN_*) is used.
Two ways to generate an image:
- Manual β type
/create-image <description>in any chat; the assistant enhances the prompt, generates the image, and replies in character about it. - Agent tool β an agent-mode assistant can call the
generate_imagetool on its own mid-conversation when asked to draw or show something. See Agent Mode below.
Both share the same enhancement/generation pipeline β see ARCHITECTURE.md β Agent Mode & Image Generation for the full flow.
Each assistant has a mode: assistant (default β a single reply per turn, no tools) or agent (the assistant can call tools across multiple steps before replying). Set on the assistant's edit page.
Agent mode requires an explicitly selected LLM model that supports tool-calling β sending a message to an agent-mode assistant without one returns an error. Built-in tools:
get_current_datetimeβ current date/timebasic_calculatorβ arithmetic expressionsgenerate_imageβ generates and shows an image (shares the pipeline described above)
Step limit, tool timeout, and retry behavior are configured via the AGENT_* env vars above, with an optional per-assistant step_limit override in agent_config. While an agent-mode turn is in progress, the chat UI shows the loop's current status (e.g. "Calling tool: generate_image"), polled from the backend. See ARCHITECTURE.md β Agent Mode & Image Generation for the full loop mechanics, timeout enforcement (requires the pcntl PHP extension), and known limitations.
The app supports multiple themes, selectable per-user via the Settings page (/assistants/:id/settings) and persisted in the database.
Available themes are defined in the Theme enum (app/Enums/Theme.php):
| Value | Description |
|---|---|
default |
Clean, minimal, light/dark |
terminal |
Classic green-on-black CRT terminal |
slate |
Cool blue-grey dark theme |
grimoire |
Dark, arcane, warm-toned |
Each theme is a CSS file under resources/css/themes/ that declares a set of semantic CSS custom properties scoped to [data-theme="<value>"]. The active theme is applied by ThemeContext as a data-theme attribute on <html>.
To add a new theme:
- Create
resources/css/themes/<name>.cssdefining all required CSS tokens under[data-theme="<name>"](use an existing theme file as reference) - Import it in
resources/css/styles.css - Add a new case to
app/Enums/Theme.php
The SettingsController@show endpoint returns available_themes by reading Theme::cases(), so the new option will appear in the UI automatically.
Each assistant's prompt is stored as a JSON object in the prompt column of the Assistant model (database-driven). At request time, PromptDirector receives this JSON, filters sections as needed, and assembles it into the system prompt via PromptBuilder. Available emotions are injected automatically from the assistant's emotion set.
The structure of the prompt JSON is flexible β any key becomes a section in the assembled system prompt. The opening_message field on the Assistant model is used as the first message when a new conversation is created.
Voice provider/model prompts work the same way but live outside the assistant's own prompt: when voice mode is active, voice provider prompt and voice model prompt are appended as their own sections, sourced from the active VoiceProvider/VoiceModel's prompt field (edited on the Voice page, not the Prompt page). This is how backend-specific instructions β e.g. Orpheus's inline <laugh>/<chuckle>/etc. vocal tags β stay out of the assistant's own prompt entirely, so switching to a provider without that capability (like KittenTTS) doesn't leave behind instructions for tags it can't produce.
Conversations can accumulate a running text summary β long_term_memory β that's injected back into the system prompt so an assistant stays coherent about events outside the LLM's actual context window. Manage it from the Memory page (a link on the chat screen):
- Edit the summary text directly, or trigger a summarization on demand ("Summarize since last" for just the new messages, "Summarize as far as possible" to redo the whole history)
- Toggle auto-summarize to have it run automatically once 50 unsummarized messages accumulate, with no manual action needed
- Customize how an assistant summarizes (tone, what to prioritize) via its per-assistant memory prompt, edited on the same page
Summarization runs as a queued job (SummarizeConversation) β it needs the same queue worker as Archive embeddings; without php artisan queue:work running, auto-summarize silently does nothing. See ARCHITECTURE.md β Conversation Memory for the full mechanics.
Speak to an assistant instead of typing, and hear replies read back. Fully optional β the app works the same without it if the backing services aren't running. See ARCHITECTURE.md β Voice Mode for the full pipeline, diagrams, and design rationale; this section only covers getting it running.
Voice mode needs two things answering over HTTP: an STT endpoint and one or more TTS endpoints. None of this is managed by the app, the queue, or Herd β they're external services you run and point the app at.
STT is a single fixed backend, pointed at via AI_STT_URL in .env β the app only depends on WhisperSttProvider talking to it. TTS is pluggable and DB-managed (see Voice Providers above): each backend gets a VoiceProvider row via VoiceProviderSeeder, and any backend speaking the OpenAI-compatible /v1/audio/speech shape works with zero new PHP code β only a new seed entry.
Two backends are confirmed working and seeded by default: Orpheus (3B, expressive, includes inline vocal tags) and KittenTTS (much smaller, CPU-only, no GPU/llama.cpp needed, no vocal tags). Orpheus specifically needs to run behind llama.cpp, not Ollama β Ollama's /v1/completions doesn't reliably honor the special tokens Orpheus-FastAPI's prompt format depends on to stay in "generate audio" mode (see ARCHITECTURE.md for details). That part is a real requirement, not a preference.
Example setup: Orpheus (macOS, via Homebrew) β substitute your own package manager / process manager on other platforms:
# 1. STT β whisper.cpp
brew install whisper-cpp
mkdir -p ~/whisper-models
curl -L -o ~/whisper-models/ggml-medium.bin \
https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin
whisper-server -m ~/whisper-models/ggml-medium.bin --host 127.0.0.1 --port 8080
# 2. TTS inference β llama.cpp, serving the Orpheus 3B model
brew install llama.cpp
# Get an Orpheus GGUF (e.g. via `ollama pull legraphista/Orpheus` and reuse its blob,
# or download a GGUF directly) β then:
llama-server -m /path/to/orpheus.gguf --host 127.0.0.1 --port 8081 -c 8192
# 3. TTS wrapper β Orpheus-FastAPI (separate repo, not part of this codebase)
git clone https://github.com/Lex-au/Orpheus-FastAPI.git
cd Orpheus-FastAPI
python3.11 -m venv venv && source venv/bin/activate # needs Python 3.8β3.11
pip3 install torch torchvision torchaudio # non-CUDA build on macOS
pip3 install -r requirements.txt
mkdir -p outputs static
cp .env.example .env
# edit .env: ORPHEUS_API_URL=http://127.0.0.1:8081/v1/completions
python app.py # serves on :5005Set AI_STT_URL in laravel-vera/.env to match the whisper-server URL. Orpheus's VoiceProvider row is already seeded pointing at http://127.0.0.1:5005/v1/audio/speech (VoiceProviderSeeder) β no .env change needed for TTS itself. Then go to the Voice page (/assistants/:id/voice), expand Orpheus, and pick a voice to activate it.
Why llama.cpp and not Ollama for TTS, even though Ollama is already a dependency for embeddings: Orpheus-FastAPI's completion prompt relies on special tokens to force the model into audio-token-generation mode, and Ollama's /v1/completions doesn't honor them reliably β it intermittently falls back to normal chat text instead of generating audio. llama-server handles it correctly and consistently. Full details in ARCHITECTURE.md.
Example setup: KittenTTS (macOS, via Homebrew) β much lighter, CPU-only, no llama.cpp involved:
brew install espeak-ng
git clone https://github.com/devnen/Kitten-TTS-Server.git
cd Kitten-TTS-Server
python3.12 -m venv venv && source venv/bin/activate # needs Python 3.10β3.12; 3.9 and 3.13 both fail dependency resolution
pip install -r requirements.txt
python server.py # serves on :8005, downloads the default model on first runKittenTTS's VoiceProvider row is already seeded pointing at http://127.0.0.1:8005/v1/audio/speech. To switch which underlying model size is loaded (Nano/Micro/Mini), use the wrapper's own web UI at http://127.0.0.1:8005 β it's a hot-swap-and-restart flow on their end, not something this app controls (see ARCHITECTURE.md for why).
This is specific to Orpheus, not TTS in general β KittenTTS runs in well under a second on CPU. Orpheus replies currently take 5β15 seconds to generate: it's a 3B-parameter model generating audio as thousands of discrete tokens, autoregressively, on consumer-grade hardware rather than dedicated inference hardware β this is architectural, not a misconfiguration. There's no streaming support in the current pipeline. See ARCHITECTURE.md β Known Limitations for the full breakdown and what would actually fix it.
Any assistant can hold conversations in Discord, the same way it does through the web app or Telegram. Unlike Telegram (a single long-poll command inside this app), Discord runs through a separate bridge service β node-discord-api β since Discord requires a persistent Gateway (WebSocket) connection per bot, not a poll loop. See ARCHITECTURE.md β Discord Integration for the full pipeline and data model.
- Clone and configure node-discord-api separately β it holds Discord bot tokens and the Gateway connections, and is never given database access; it only talks to this app over HTTP.
- Set
DISCORD_API_URLandDISCORD_API_SECRETin this app's.envto match the bridge's ownDISCORD_API_PORT/DISCORD_API_SECRETβ this secret authenticates the bridge's discovery requests into this app. - Generate a Sanctum token for the bridge to authenticate as your user when relaying messages:
Put that token in the bridge's own
php artisan tinker --execute 'echo App\Models\User::find(1)->createToken("discord-api")->plainTextToken;'.envasDISCORD_API_TOKENβ it's how the bridge calls this app'sdiscord-messagesendpoint as you, separate from the shared secret above (which only protects the discovery endpoint). - Go to an assistant's Discord page (
/assistants/:id/discord) to see which Discord servers/channels its bot is currently in, set each channel's trigger mode (off / always / on mention / on mention-by-name), and write optional per-server and per-channel prompt context.
This app never talks to Discord directly and never stores a bot token. The bridge owns the Gateway connection and calls two endpoints on this app:
GET /api/assistants/{assistant}/discord/discoveryβ returns the bridge's live view of its servers/channels, and this app's own config for each (trigger mode, prompt). Also syncsdiscord_servers/discord_channelsso they have a stable internal id to attach prompts to.POST /api/assistants/{assistant}/discord-messagesβ the bridge calls this once it decides a message should get a reply (per the trigger mode). Conversation history for that Discord channel is resolved and loaded entirely server-side, same as Telegram β the bridge only ever sends the new message, never the whole history.
Beyond one-on-one chat, any assistant (or a lightweight NPC) can also live in a World β a single-room 3D space you explore in first person and where you approach and talk to residents in place, rather than through a conversation list. Reachable from the Home page alongside Assistants and NPCs. See ARCHITECTURE.md β Worlds for the full runtime and data model.
From the Worlds section, Create world asks for a name, slug, description, a runtime environment GLB (the room itself), a theme (one of the app's four themes β see Theming β applied while chatting inside that world), and two separate context prompts: one appended only to companion-assistant conversations started in this world, one appended only to NPC conversations started in this world. Residents can be added and placed on the same create screen β staged locally and attached right after the world itself is saved β or later from the edit screen, which also has a delete-with-confirmation action.
A resident placement has a position, a stationary-or-roam behavior (roam takes a bounded radius), and two optional overrides scoped to that placement only: an opening message (replaces the resident's own opening message for conversations started in this world) and a custom prompt (appended on top of the world's own context prompt, for that resident alone).
NPCs are lightweight, assistant-backed characters managed in their own section (reachable from Home), reusing the same model/pose/prompt/archive tooling as a normal assistant β CreateNpcPage/EditAssistantPage render the existing assistant forms with kind="world_npc" rather than separate NPC-specific pages. An NPC is not tied to any one world; it can be added as a resident to any number of them, and removing it from a world only removes that placement, never the NPC itself (permanent deletion happens only from the NPC section, with confirmation).
A world is shared like an assistant is β accessible to whichever users have been granted it, rather than owned by a single creator. Each user's activity in a world is organized into sessions: opening a world from the Worlds list first lands on that world's sessions page, where you can resume a past session, start a new one, or delete one you no longer want. Sessions list most-recently-active first, and a world with none yet shows an empty state with a "new session" action.
Starting a new session gives you a genuinely fresh start: no remembered position, and fresh conversations with every resident β talking to the same resident again in a different session does not continue an earlier session's chat history. Resuming a session instead returns you to your last recorded position in the world and reopens that session's own conversations with residents. Deleting a session permanently removes it and its conversations; other sessions are unaffected.
Entering a world loads its GLB, builds a collision octree from it, and spawns you either at the nearest walkable point to the room's center (a new session) or at the session's last recorded position (a resumed session, restored once loaded and then saved back periodically and on exit). Movement is keyboard + mouse first-person with pointer lock; collision is resolved against real triangle geometry (not a bounding box), so passing through an actual doorway works while walking into a wall or furnishing doesn't β but only geometry whose mesh or group name contains "collision" (case-insensitive) is collidable. A GLB without that naming has no wall/furniture collision, only the room's own outer bounds. Losing browser focus stops movement until you click back in.
Approaching a resident within interaction range shows a C β Chat prompt; pressing C opens an in-world chat panel (pausing that resident's roaming) using the resident's conversation for the active session, its history, archive, and prompt β plus whichever world context prompt and per-resident overrides apply, and the world's own theme for as long as the panel is open. Residents far from the player skip animation/pose work entirely until back in range.
laravel-vera/
βββ app/
β βββ Actions/
β β βββ AppendWorldConversationContext.php # Appends the world's context prompt + resident custom_prompt override to an in-world conversation
β β βββ BuildArchiveFile.php # Renders an archive + entries to Markdown via FileBuilder
β β βββ SearchArchiveEntries.php # Hybrid (full-text + vector) archive entry search, merged via Reciprocal Rank Fusion
β β βββ SummarizeConversation.php # Long-term memory summarization logic (wrapped by the queued job)
β βββ Builders/
β β βββ FileBuilder.php # heading()/paragraph()/keyValue() β Markdown string
β β βββ PromptBuilder.php # Assembles system prompt from assistant config
β βββ Console/Commands/
β β βββ SyncEmotions.php # Seeds/syncs emotion records from config
β β βββ TelegramPollCommand.php # Long-polls Telegram for incoming messages
β βββ Contracts/
β β βββ AgentTool.php # Interface for agent-mode tools (name/description/parameters/handle)
β β βββ LlmProvider.php # LLM interface (chat method)
β β βββ SttProvider.php # STT interface (transcribe)
β β βββ TtsProvider.php # TTS interface (synthesize + fromModel)
β βββ Directors/
β β βββ PromptDirector.php # Reads assistant prompt config, builds system prompt
β βββ DTOs/
β β βββ AgentRunResult.php # Agent loop result: content + tool call summary
β β βββ ImageGenResult.php # Generated image: raw data + content type + enhanced prompt
β β βββ LlmResponse.php # Unified response: content + thinking
β β βββ ToolCallRequest.php # Parsed LLM tool-call request: id/name/arguments
β β βββ VoiceModeResult.php # content + ttsInstructions, from TtsProvider::parseLlmResponse()
β βββ Enums/
β β βββ AiProviderFormat.php # generic | anthropic
β β βββ AssistantMode.php # assistant | agent
β β βββ AssistantKind.php # assistant | world_npc
β β βββ WorldResidentBehavior.php # stationary | roam
β β βββ ImageGenProviderFormat.php # openrouter | openai_compatible
β β βββ VoiceProviderFormat.php # openai_compatible | openai_tts | deepgram | elevenlabs
β βββ Http/Controllers/
β β βββ Auth/
β β β βββ AuthController.php # Login/logout
β β βββ VadAssetController.php # Serves VAD's .mjs files with correct MIME type
β β βββ Api/
β β βββ AgentProgressController.php # Reads cached agent-loop status for the in-progress-turn indicator
β β βββ AiProviderController.php # CRUD for AI providers
β β βββ AiModelController.php # CRUD for AI models (thinking_key, supports_tools, config, additional_config)
β β βββ ArchiveController.php # Archive read/save (with async embedding), hybrid search, + Markdown export
β β βββ AssistantController.php # CRUD for assistants (multipart, emotion images, mode)
β β βββ AssistantEmotionController.php# Per-assistant emotion store/update/destroy
β β βββ AssistantMemoryPromptController.php # Show/update per-assistant memory summarization instructions
β β βββ AssistantPromptController.php # Prompt CRUD (show/store/update/destroy)
β β βββ ConversationController.php # CRUD + message sending (voice_mode flag, /create-image, agent-mode dispatch, sendDiscordMessage)
β β βββ ConversationMemoryController.php # Show/update/summarize/unlock a conversation's long-term memory
β β βββ DiscordController.php # Discovery proxy (syncs discord_servers/channels) + server/channel prompt updates
β β βββ EmotionController.php # Serve emotions with image/video URLs
β β βββ ImageGenProviderController.php# CRUD for image-gen providers
β β βββ ImageGenModelController.php # CRUD for image-gen models
β β βββ SettingsController.php # Theme + active LLM/voice/image-gen model + voice selection + Discord trigger mode
β β βββ VoiceController.php # Transcribe / synthesize
β β βββ VoiceProviderController.php # Full CRUD + prompt-only update
β β βββ VoiceModelController.php # Full CRUD + prompt-only update (store() currently broken)
β β βββ WorldController.php # CRUD for worlds, including environment upload/replace and world-owned asset cleanup
β β βββ WorldResidentController.php # Add/update/remove a resident placement (position, behavior, opening message/prompt overrides)
β β βββ WorldSessionController.php # Per-user world sessions: index/store/rename/destroy + position updates
β β βββ NpcController.php # Dedicated NPC CRUD, reusing AssistantController under the hood
β βββ Models/
β β βββ User.php
β β βββ Assistant.php # Assistant config (prompt, opening_message, emotions, mode, agent_config)
β β βββ AssistantUser.php # Pivot: user β assistant; memory_prompt (json)
β β βββ WorldUser.php # Pivot: user β world (worlds are shared the same way assistants are)
β β βββ Settings.php # Per-user, per-assistant settings (theme, model, voice, image-gen model)
β β βββ AiProvider.php # DB-managed LLM provider
β β βββ AiModel.php # DB-managed LLM model
β β βββ ImageGenProvider.php # User-managed image-gen provider
β β βββ ImageGenModel.php # User-managed image-gen model
β β βββ VoiceProvider.php # User-managed TTS provider (seeder pre-populates 2 convenience entries)
β β βββ VoiceModel.php # User-managed TTS model
β β βββ DiscordServer.php # Known Discord server (guild id + name)
β β βββ DiscordChannel.php # Known Discord channel, belongs to a DiscordServer
β β βββ AssistantDiscordServer.php # Per-assistant server prompt
β β βββ AssistantDiscordChannel.php # Per-assistant channel trigger mode + prompt
β β βββ Conversation.php # discord_channel_id ties a conversation to a Discord channel; world_session_id scopes it to one world session; long_term_memory/memory_checkpoint_message_id/memory_summarizing_at/auto_summarize_enabled
β β βββ Message.php # discord_message_id dedupes across assistants sharing a channel
β β βββ Emotion.php # Expression name + restricted flag
β β βββ Archive.php
β β βββ ArchiveEntry.php
β β βββ Tag.php
β β βββ Image.php # Polymorphic, stored on disk
β β βββ Video.php # Polymorphic, stored on disk
β β βββ World.php # name/slug/description, environment metadata, assistant/npc context prompts, settings (incl. theme); shared via WorldUser, not owned directly
β β βββ WorldResident.php # A world's placement of an assistant/NPC: position, rotation, behavior, per-placement overrides
β β βββ WorldSession.php # One user's continuous thread in a world: title, last recorded position (json); owns its own conversations
β βββ Policies/
β β βββ WorldPolicy.php # Worlds are scoped to users granted access via WorldUser
β βββ Jobs/
β β βββ EmbedArchiveEntry.php # Async vector embedding for archive entries
β β βββ SummarizeConversation.php # Queues Actions\SummarizeConversation; manages the memory_summarizing_at lock
β βββ Providers/
β β βββ AppServiceProvider.php # Binds EmbeddingProvider, SttProvider
β β βββ Stt/WhisperSttProvider.php # Talks to whisper-server
β βββ Services/
β βββ AgentLoop/
β β βββ AgentLoopRunner.php # Tool-calling loop: chat β tool_calls β execute β repeat until final/step_limit
β β βββ Tools/
β β βββ BasicCalculatorTool.php # basic_calculator tool
β β βββ GetCurrentDatetimeTool.php# get_current_datetime tool
β β βββ ImageGenerationTool.php # generate_image tool
β βββ ImageGenProviders/
β β βββ ImageGenManager.php # Resolves provider: DB model β config fallback (mirrors LlmManager)
β β βββ ImageGenerationService.php # Shared generate() used by /create-image and the agent tool
β β βββ ImageGenPromptEnhancer.php # LLM rewrites the raw prompt using persona/RAG/history
β β βββ OpenRouterImageGenProvider.php
β β βββ OpenAiCompatibleImageGenProvider.php
β βββ LlmProviders/
β β βββ LlmManager.php # Resolves provider: DB model β config fallback
β β βββ GenericProvider.php # OpenAI-compatible API
β β βββ AnthropicProvider.php
β βββ TtsProviders/
β β βββ TtsManager.php # Resolves provider: DB model β config fallback (mirrors LlmManager)
β β βββ OpenAiCompatibleTtsProvider.php # Self-hosted OpenAI-shaped backends (Orpheus, KittenTTS)
β β βββ OpenAiTtsProvider.php # OpenAI's TTS API; steerable instructions from [emotion] + prompt
β β βββ DeepgramTtsProvider.php # Token auth, model in query string
β β βββ ElevenLabsTtsProvider.php # xi-api-key header, voice id in URL path
β βββ TelegramService.php # Telegram API wrapper
βββ config/
β βββ agent.php # Step limit, tool timeout, retry attempts, progress cache TTL
β βββ ai.php # Default LLM + embedding + stt + tts + image_gen (fallback) + telegram + discord config
βββ .specify/ # Spec Kit (SDD) install: constitution, templates, extensions
βββ specs/ # Per-feature spec/plan/tasks artifacts (spec-driven features)
βββ database/
β βββ migrations/
β β βββ create_conversations_table.php
β β βββ create_messages_table.php
β β βββ create_images_table.php
β β βββ create_emotions_table.php
β β βββ create_videos_table.php
β β βββ create_ai_providers_table.php
β β βββ create_ai_models_table.php
β β βββ create_settings_table.php
β β βββ create_voice_providers_table.php # name/url/api_key/format/instructions/prompt
β β βββ create_voice_models_table.php # provider_id/name/endpoint/voices/config/prompt
β β βββ create_discord_servers_table.php # discord_guild_id/name
β β βββ create_discord_channels_table.php # discord_server_id/discord_channel_id/name
β β βββ create_assistant_discord_servers_table.php # assistant_user_id/discord_server_id/prompt (json)
β β βββ create_assistant_discord_channels_table.php # assistant_user_id/discord_channel_id/trigger_mode/prompt (json)
β β βββ create_worlds_table.php # name/slug/environment metadata/assistant+npc context prompts/settings (incl. theme)
β β βββ create_world_residents_table.php # world_id/assistant_id/position/rotation/behavior/behavior_settings/opening_message/custom_prompt
β β βββ create_world_user_table.php # Pivot: world_id/user_id β worlds moved off direct user_id onto this, mirroring assistant_user
β β βββ create_world_sessions_table.php # world_user_id/title/position (json); conversations gained a nullable world_session_id
β βββ seeders/
β βββ VoiceProviderSeeder.php # Seeds the TTS catalog (Orpheus, KittenTTS) β re-run to add more
βββ resources/js/
β βββ app.jsx # React entry + React Router routes
β βββ contexts/
β β βββ ThemeContext.jsx # Global theme state
β βββ layouts/
β β βββ AuthenticatedLayout.jsx # Auth guard + emotion state + boot sequence
β β βββ AssistantLayout.jsx # Assistant-scoped context (conversations, settings)
β βββ pages/
β β βββ LoginPage.jsx
β β βββ HomePage.jsx # Landing page: Assistants/Worlds/NPCs as sibling sections
β β βββ AssistantsPage.jsx # List/delete assistants
β β βββ CreateAssistantPage.jsx # Multipart assistant creation form (also renders NPC creation via a kind prop)
β β βββ EditAssistantPage.jsx # Edit assistant + manage emotions (also renders NPC editing via a kind prop)
β β βββ ConversationsPage.jsx # Conversation list
β β βββ ChatPage.jsx # Main chat interface; debounced localStorage draft persistence
β β βββ MemoryPage.jsx # Conversation long-term memory editor + auto-summarize toggle
β β βββ ArchivePage.jsx # Archive editor (RAG knowledge base), hybrid search, + Markdown export
β β βββ PromptPage.jsx # Visual prompt editor
β β βββ SettingsPage.jsx # Theme only
β β βββ ProvidersPage.jsx # AI provider/model management
β β βββ ImageGenProvidersPage.jsx # Image-gen provider/model management (same pattern as ProvidersPage)
β β βββ VoicePage.jsx # Voice provider/model management; select model/voice, edit prompts
β β βββ DiscordPage.jsx # Discord servers/channels; trigger mode + prompt editor per channel
β β βββ WorldsPage.jsx # List/edit worlds; entering a world goes to its sessions page
β β βββ CreateWorldPage.jsx # World creation form + staged resident placement
β β βββ EditWorldPage.jsx # Edit world + delete-with-confirmation
β β βββ WorldSessionsPage.jsx # List/resume/start/delete a world's sessions (mirrors ConversationsPage)
β β βββ WorldPage.jsx # First-person 3D exploration + in-world chat panel, scoped to the active session
β β βββ NpcsPage.jsx # NPC list with inline cards; CreateNpcPage renders CreateAssistantPage with kind="world_npc"
β βββ components/
β β βββ common/
β β β βββ Accordion.jsx # Reusable collapsible accordion
β β β βββ ConfirmationModal.jsx # Confirmation modal
β β β βββ Toggle.jsx # On/off switch
β β βββ ModelAccordion.jsx # Model config + select/deselect
β β βββ ProviderAccordion.jsx # Provider config + nested models
β β βββ ImageGenProviderAccordion.jsx # Image-gen provider config + nested models
β β βββ ImageGenModelAccordion.jsx # Image-gen model config + select/deselect
β β βββ AgentProgressIndicator.jsx # Polls and shows agent-loop status during an in-progress turn
β β βββ VoiceProviderAccordion.jsx # Editable provider form + prompt editor
β β βββ VoiceModelAccordion.jsx # Editable model form + voice picker (free text + hints) + prompt editor
β β βββ DiscordServerAccordion.jsx # Server prompt editor + nested channel accordions
β β βββ DiscordChannelAccordion.jsx # Trigger mode select (4 modes) + channel prompt editor
β β βββ AssistantMemoryPromptEditor.jsx # Prompt-tree editor for per-assistant memory instructions
β β βββ PromptTreeEditor.jsx # Manual/Paste-JSON toggle around a usePromptTree instance
β β βββ EmotionGrid.jsx # Emotion image manager (add/rename/replace/delete)
β β βββ PromptEditor.jsx # Local prompt tree editor (create/edit flows)
β β βββ PromptNode.jsx # Recursive prompt tree node editor
β β βββ EntryAccordion.jsx # Archive entry accordion
β β βββ Header.jsx # Navigation header
β β βββ Portrait.jsx # Expression display
β β βββ ChatMessage.jsx # Message rendering
β β βββ ThinkingBlock.jsx # Collapsible LLM reasoning
β β βββ BootSequence.jsx # Boot animation
β β βββ ConversationList.jsx # Sidebar conversation list
β β βββ ToastContainer.jsx # Toast notification display
β β βββ Scanlines.jsx # CRT scanline overlay
β β βββ WorldCard.jsx # World card (edit/enter β sessions page)
β β βββ WorldForm.jsx # Shared create/edit world form: metadata, environment, theme, context prompts
β β βββ WorldResidentsEditor.jsx # Eligible assistant/NPC picker + per-resident placement, behavior, and overrides
β β βββ WorldSessionList.jsx # Select/new/delete sessions for a world (mirrors ConversationList)
β β βββ world/
β β βββ WorldScene.jsx # Canvas: environment, first-person controller, residents, interaction system; restores/reports session position
β β βββ WorldEnvironment.jsx # Loads the GLB, builds the collision octree, resolves spawn position
β β βββ FirstPersonController.jsx # Keyboard/mouse movement, pointer lock, collision-resolved stepping
β β βββ ResidentController.jsx # Resident VRM load, pose/expression playback, stationary/roam movement
β β βββ InteractionSystem.jsx # Proximity detection (via a resident-position ref map) + C-to-chat
β β βββ WorldChat.jsx # In-world chat panel; resolves the resident's conversation scoped to the active session
β β βββ collisionCheck.js # WorldCollision: octree build, blocked-body check, stepped movement, spawn-finding
β β βββ groundHeight.js # Raycast-based ground height lookup against the collision octree
β β βββ clampToBounds.js # Clamps a position to the environment's overall bounding box
β βββ hooks/
β β βββ useAssistants.js # Assistant list + delete
β β βββ useEmotions.js # Emotion set fetching
β β βββ useLocalPrompt.js # Local-only prompt tree state
β β βββ usePrompt.js # Prompt tree CRUD + save/destroy (assistant prompt)
β β βββ usePromptTree.js # Generic prompt tree editing state (reused by voice + Discord prompts)
β β βββ useProviders.js # Provider/model CRUD + active model state
β β βββ useImageGenProviders.js # Image-gen provider/model CRUD + active model state
β β βββ useConversationMemory.js # Memory show/save/summarize/unlock, polls while summarizing
β β βββ useConversationChat.js # Shared message send/receive + pose-tag parsing, used by ChatPage and WorldChat
β β βββ useVoiceProviders.js # Provider/model CRUD + model/voice selection
β β βββ useDiscordSettings.js # Discovery data + immediate-save trigger mode changes
β β βββ useWorlds.js # World list fetching
β β βββ useWorldSessions.js # Per-world session list fetching (mirrors useWorlds)
β β βββ useToast.js # Toast notification state
β β βββ useVoiceMode.js # Mic capture + voice activity detection
β βββ utils/
β βββ api.js # API wrapper (fetch with auth)
β βββ formatMessage.jsx # Text formatting (actions, thoughts, OOC)
β βββ parsers.js # Response parsing (emotion tags, speech text cleanup)
βββ resources/views/welcome.blade.php # SPA shell; loads voice-mode's VAD bundle via <script>
βββ public/vendor/vad/ # Gitignored β VAD assets, regenerated from node_modules
βββ storage/app/vad/ # .mjs files, served with correct MIME type by Laravel
βββ storage/app/public/ # Expression images and user-uploaded images
- Multi-assistant architecture β each assistant has its own prompt, expression set, and opening message, all stored in the DB
- Multi-theme support β theme selection via Settings page, stored per-user in the DB
- Dynamic expression system β emotion images and videos served from the database, per assistant
- Restricted emotion set β alternate expressions unlocked based on context
- Authentication β Sanctum SPA auth with login flow
- Image sending β attach and send images for the assistant to analyze (stored on disk)
- Thinking display β collapsible view of the LLM's reasoning process
- Text formatting β actions in italics, inner thoughts in purple, OOC in bold cyan
- Boot sequence β animated startup with the assistant's opening message
- Structured prompt system β JSON-based assistant configuration, assembled on the backend
- Visual prompt editor β add, edit, rename, and delete prompt sections at any depth via the UI (
/prompt) - DB-driven LLM provider management β add/edit/delete providers and models via the UI; active model selected per-user
- Multi-format LLM support β OpenAI-compatible (
generic) and Anthropic formats - Config fallback β if no model is selected in the UI, the
.envdefault is used - Conversation persistence β messages stored in PostgreSQL
- Conversation management UI β list, create, delete, and rename conversations
- Archive with RAG β editable knowledge base with semantic retrieval injected into the system prompt; exportable as a Markdown file
- Archive hybrid search β instant client-side text matching across title/content/tags/keywords, plus a debounced server-side search combining full-text and semantic (vector) matching, merged and ranked via Reciprocal Rank Fusion
- Conversation memory β manual or automatic long-term summarization of a conversation, injected back into the system prompt, with per-assistant summarization instructions. See Conversation Memory
- Chat draft persistence β an unsent message survives navigating away and coming back, per (assistant, conversation), stored client-side
- Toast notifications β non-intrusive feedback for UI actions
- Telegram integration β long-poll bot for interacting with any configured assistant via Telegram
- Discord integration β assistants respond in Discord via a separate bridge service, with per-channel trigger modes (off/always/on mention/on mention-by-name), per-server and per-channel prompt context, and shared awareness between assistants configured for the same channel. See Discord Integration
- Voice Mode β speak to an assistant and hear replies read back; local STT (whisper.cpp, single fixed backend) and pluggable, DB-managed TTS. See Voice Mode
- Provider-agnostic TTS β any backend speaking the OpenAI-compatible
/v1/audio/speechshape plugs in via a seededVoiceProvider/VoiceModelrow, no new code. Orpheus and KittenTTS confirmed working - Per-provider/per-model voice prompts β backend-specific instructions (e.g. Orpheus's inline vocal tags) live on the
VoiceProvider/VoiceModelrecord and are injected only while that backend is active, via the same visual prompt-tree editor used for assistant prompts - Agent mode β assistants can be switched to an agentic loop that calls tools (
get_current_datetime,basic_calculator,generate_image) across multiple steps before replying, with a step limit, per-tool timeout/retry, and a live progress indicator in the chat UI. See Agent Mode - Image generation β DB-managed, user-editable provider/model catalog (same pattern as LLM providers); generate an image manually via
/create-image <description>in chat, or let an agent-mode assistant call it as a tool. See Image Generation Providers - Configurable 3D worlds β shared, single-room 3D spaces you explore in first person, with assistant and NPC residents you approach and chat with in place, organized into per-user sessions you can resume, start fresh, or delete. See Worlds
- Voice mode latency reduction for Orpheus specifically (streaming, faster local TTS, or cloud-hosted inference)
- Per-assistant voice settings beyond voice selection (speed, per-emotion tag mapping)
- Local image generation (ComfyUI/Stable Diffusion)
Emotions are stored in the database as Emotion records with associated Image and Video files on disk, scoped per assistant. Two sets exist:
- Standard set (
restricted = false) β default expressions - Restricted set (
restricted = true) β alternate expressions, unlocked via theunlockedquery param onGET /api/assistants/{assistant}/emotions
The LLM prefixes each response with an emotion tag (e.g. [annoyed]) which is parsed by the frontend and used to look up the matching expression asset.
Run php artisan emotions:sync to seed/update emotion records from config.
Emotions are now also manageable per-assistant directly through the UI on the Edit Assistant page (/assistants/:id/edit).
MIT β see LICENSE for details.