Prepares the repo for its new home at soulcraftlabs/open-brainy ahead of the Forgejo transfer: package name, publish registry, release script, and every install/import reference across docs, src, tests, examples, and integrations now point at @soulcraftlabs/brainy on The Source. The npmjs storefront leg and byte-identity pair verification are stripped from the release script — The Source is now the only publish target. README gains an Open Brainy explainer and a registry note for consumers. @soulcraft/brainy 10.4.2 was the last release under the old name.
28 KiB
Vue.js Integration Guide
Complete guide to integrating Brainy with Vue.js applications, covering Vue 3, Nuxt.js, Composition API, Options API, and advanced patterns.
Runtime: Brainy 8.0 runs on Node.js 22+ and Bun, server-side only — it is not a browser library. In Vue apps, Brainy lives behind an HTTP endpoint (a Nuxt server route, or any Node/Bun backend), and your components call that endpoint. The composables, plugin, and store below are wrappers around
fetch; the single Brainy instance is created once on the server (see Nuxt Server Route).
🚀 Quick Start
Installation
npm create vue@latest my-brainy-app
cd my-brainy-app
npm install
npm install @soulcraftlabs/brainy
Basic Setup
The composable talks to a Brainy-backed endpoint (see Nuxt Server Route). It is always "ready" because there is no client-side initialization — the server owns the Brainy instance.
// src/composables/useBrainy.js
import { ref } from 'vue'
export function useBrainy(endpoint = '/api/brain') {
const error = ref(null)
const search = async (query, options = {}) => {
error.value = null
try {
const res = await fetch(`${endpoint}/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, options })
})
if (!res.ok) throw new Error(`Search failed: ${res.status}`)
return (await res.json()).results
} catch (err) {
error.value = err.message
console.error('Brainy search failed:', err)
return []
}
}
const add = async (data, type, metadata) => {
const res = await fetch(`${endpoint}/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data, type, metadata })
})
return (await res.json()).id
}
const stats = async () => {
const res = await fetch(`${endpoint}/stats`)
return await res.json()
}
return { search, add, stats, error: readonly(error) }
}
🧩 Composition API
Search Component
<!-- src/components/Search.vue -->
<template>
<div class="search-container">
<div class="search-input">
<input
v-model="query"
@input="handleSearch"
placeholder="Search..."
class="input"
/>
</div>
<div v-if="loading" class="loading">
Searching...
</div>
<div v-else class="results">
<div
v-for="result in results"
:key="result.id"
class="result-item"
>
<h3>{{ result.data }}</h3>
<div class="result-meta">
<span>Score: {{ (result.score * 100).toFixed(1) }}%</span>
<span v-if="result.metadata?.type">
Type: {{ result.metadata.type }}
</span>
</div>
</div>
<div v-if="query && !loading && results.length === 0" class="no-results">
No results found for "{{ query }}"
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useBrainy } from '../composables/useBrainy'
import { debounce } from '../utils/debounce'
const { search: searchBrain } = useBrainy()
const query = ref('')
const results = ref([])
const loading = ref(false)
const search = async (searchQuery) => {
if (!searchQuery.trim()) {
results.value = []
return
}
loading.value = true
try {
results.value = await searchBrain(searchQuery)
} catch (error) {
console.error('Search error:', error)
results.value = []
} finally {
loading.value = false
}
}
const debouncedSearch = debounce(search, 300)
const handleSearch = () => debouncedSearch(query.value)
// Watch for query changes
watch(query, (newQuery) => {
if (!newQuery.trim()) {
results.value = []
loading.value = false
}
})
</script>
<style scoped>
.search-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.search-input {
margin-bottom: 1.5rem;
}
.input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 1rem;
}
.input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
color: #6b7280;
}
.spinner {
width: 1.5rem;
height: 1.5rem;
border: 2px solid #e5e7eb;
border-top: 2px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 0.5rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.results {
space-y: 1rem;
}
.result-item {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.result-item h3 {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
font-weight: 600;
}
.result-meta {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
color: #6b7280;
}
.no-results {
text-align: center;
padding: 2rem;
color: #6b7280;
}
</style>
Data Management
<!-- src/components/DataManager.vue -->
<template>
<div class="data-manager">
<h2>Data Management</h2>
<div>
<!-- Add Data Form -->
<form @submit.prevent="addData" class="add-form">
<h3>Add New Data</h3>
<div class="form-group">
<label for="data">Data:</label>
<textarea
id="data"
v-model="newItem.data"
placeholder="Enter data..."
required
></textarea>
</div>
<div class="form-group">
<label for="type">Type:</label>
<select id="type" v-model="newItem.type">
<option value="concept">Concept</option>
<option value="document">Document</option>
<option value="person">Person</option>
<option value="project">Project</option>
</select>
</div>
<div class="form-group">
<label for="tags">Tags (comma-separated):</label>
<input
id="tags"
v-model="newItem.tags"
placeholder="tag1, tag2, tag3"
/>
</div>
<button type="submit" :disabled="!newItem.data.trim()">
Add Data
</button>
</form>
<!-- Stats -->
<div v-if="stats" class="stats">
<h3>Statistics</h3>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Total Items:</span>
<span class="stat-value">{{ stats.totalItems }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Storage Type:</span>
<span class="stat-value">{{ stats.storageType }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useBrainy } from '../composables/useBrainy'
const { add, stats: fetchStats } = useBrainy()
const newItem = reactive({
data: '',
type: 'concept',
tags: ''
})
const stats = ref(null)
onMounted(loadStats)
const addData = async () => {
try {
const metadata = {
addedAt: new Date().toISOString(),
tags: newItem.tags.split(',').map(tag => tag.trim()).filter(Boolean)
}
await add(newItem.data, newItem.type, metadata)
// Reset form
newItem.data = ''
newItem.type = 'concept'
newItem.tags = ''
// Refresh stats
await loadStats()
} catch (error) {
console.error('Failed to add data:', error)
}
}
const loadStats = async () => {
try {
stats.value = await fetchStats()
} catch (error) {
console.error('Failed to load stats:', error)
}
}
</script>
<style scoped>
.data-manager {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
.add-form {
background: #f9fafb;
padding: 1.5rem;
border-radius: 0.5rem;
margin-bottom: 2rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
}
.form-group textarea {
height: 100px;
resize: vertical;
}
button {
background: #3b82f6;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
button:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.stats {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1.5rem;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.stat-item {
display: flex;
justify-content: space-between;
}
.stat-label {
font-weight: 500;
}
.stat-value {
color: #3b82f6;
font-weight: 600;
}
</style>
🏗️ Options API
Search Component (Options API)
<!-- src/components/SearchOptions.vue -->
<template>
<div class="search-container">
<input
v-model="query"
@input="handleSearch"
placeholder="Search..."
class="search-input"
/>
<div v-if="loading" class="loading">Searching...</div>
<div class="results">
<div
v-for="result in results"
:key="result.id"
class="result-item"
>
<h3>{{ result.data }}</h3>
<p>Score: {{ (result.score * 100).toFixed(1) }}%</p>
</div>
</div>
</div>
</template>
<script>
import { debounce } from '../utils/debounce'
export default {
name: 'SearchOptions',
data() {
return {
query: '',
results: [],
loading: false
}
},
methods: {
async search(query) {
if (!query.trim()) {
this.results = []
return
}
this.loading = true
try {
const res = await fetch('/api/brain/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
})
this.results = (await res.json()).results
} catch (error) {
console.error('Search error:', error)
this.results = []
} finally {
this.loading = false
}
},
handleSearch: debounce(function() {
this.search(this.query)
}, 300)
},
watch: {
query(newQuery) {
if (!newQuery.trim()) {
this.results = []
this.loading = false
}
}
}
}
</script>
🔌 Vue Plugin
Global Brainy Plugin
The plugin exposes a global $searchBrain helper that calls your Brainy-backed endpoint. Brainy itself runs on the server (see Nuxt Server Route).
// src/plugins/brainy.js
export default {
install(app, options = {}) {
const endpoint = options.endpoint ?? '/api/brain'
// Add global search method (calls the server endpoint)
app.config.globalProperties.$searchBrain = async (query, searchOptions) => {
const res = await fetch(`${endpoint}/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, options: searchOptions })
})
return (await res.json()).results
}
}
}
Plugin Usage
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import BrainyPlugin from './plugins/brainy'
const app = createApp(App)
app.use(BrainyPlugin, {
endpoint: '/api/brain'
})
app.mount('#app')
<!-- Component using the plugin -->
<template>
<div>
<input v-model="query" @input="search" />
<div v-for="result in results" :key="result.id">
{{ result.data }}
</div>
</div>
</template>
<script>
export default {
data() {
return {
query: '',
results: []
}
},
methods: {
async search() {
// Use the global method
this.results = await this.$searchBrain(this.query)
}
}
}
</script>
🏰 Nuxt.js Integration
Nuxt's server engine (Nitro) is the natural home for Brainy: it runs on Node/Bun, so the Brainy instance lives in server/, and pages/components call its routes.
Nuxt Server Route
// server/utils/brain.js (server-only — Nitro never bundles this into the client)
import { Brainy } from '@soulcraftlabs/brainy'
let brainPromise
export function getBrain() {
if (!brainPromise) {
brainPromise = (async () => {
// new Brainy() auto-detects filesystem persistence on Node
const brain = new Brainy()
await brain.init()
return brain
})()
}
return brainPromise
}
// server/api/brain/search.post.js
import { getBrain } from '../../utils/brain'
export default defineEventHandler(async (event) => {
const { query, options } = await readBody(event)
const brain = await getBrain()
return { results: await brain.find(query, options) }
})
// server/api/brain/add.post.js
import { getBrain } from '../../utils/brain'
export default defineEventHandler(async (event) => {
const { data, type, metadata } = await readBody(event)
const brain = await getBrain()
return { id: await brain.add({ data, type, metadata }) }
})
// server/api/brain/stats.get.js
import { getBrain } from '../../utils/brain'
export default defineEventHandler(async () => {
const brain = await getBrain()
return await brain.stats()
})
Nuxt Composable
// composables/useBrainy.js
export const useBrainy = () => {
const search = async (query, options = {}) => {
return (await $fetch('/api/brain/search', {
method: 'POST',
body: { query, options }
})).results
}
const add = async (data, type, metadata) => {
return (await $fetch('/api/brain/add', {
method: 'POST',
body: { data, type, metadata }
})).id
}
const stats = async () => {
return await $fetch('/api/brain/stats')
}
return { search, add, stats }
}
Nuxt Page Example
<!-- pages/search.vue -->
<template>
<div>
<h1>Search</h1>
<input
v-model="query"
@input="handleSearch"
placeholder="Search..."
/>
<div v-if="searching" class="loading">Searching...</div>
<div class="results">
<div v-for="result in results" :key="result.id" class="result">
<h3>{{ result.data }}</h3>
<p>Score: {{ (result.score * 100).toFixed(1) }}%</p>
</div>
</div>
</div>
</template>
<script setup>
const { search } = useBrainy()
const query = ref('')
const results = ref([])
const searching = ref(false)
const handleSearch = debounce(async () => {
if (!query.value.trim()) {
results.value = []
return
}
searching.value = true
try {
results.value = await search(query.value)
} catch (error) {
console.error('Search failed:', error)
} finally {
searching.value = false
}
}, 300)
</script>
🛠️ Advanced Patterns
Global State Management with Pinia
The store caches results and stats client-side; all Brainy work happens behind the server endpoints.
// stores/brainy.js
import { defineStore } from 'pinia'
export const useBrainyStore = defineStore('brainy', () => {
const error = ref(null)
const stats = ref(null)
const search = async (query, options = {}) => {
error.value = null
try {
const res = await fetch('/api/brain/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, options })
})
return (await res.json()).results
} catch (err) {
error.value = err.message
throw err
}
}
const add = async (data, type, metadata) => {
const res = await fetch('/api/brain/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data, type, metadata })
})
const { id } = await res.json()
await loadStats() // Refresh stats
return id
}
const loadStats = async () => {
try {
const res = await fetch('/api/brain/stats')
stats.value = await res.json()
} catch (err) {
console.error('Failed to load stats:', err)
}
}
return {
error: readonly(error),
stats: readonly(stats),
search,
add,
loadStats
}
})
Real-time Search Component
<!-- src/components/RealTimeSearch.vue -->
<template>
<div class="realtime-search">
<div class="search-bar">
<input
ref="searchInput"
v-model="query"
@input="handleInput"
@focus="showResults = true"
@blur="handleBlur"
placeholder="Search..."
class="search-input"
/>
<div v-if="loading" class="search-spinner">⟳</div>
</div>
<Transition name="dropdown">
<div
v-if="showResults && (results.length > 0 || query.trim())"
class="search-dropdown"
>
<div
v-for="(result, index) in results"
:key="result.id"
:class="['result-item', { active: selectedIndex === index }]"
@mousedown="selectResult(result)"
@mouseenter="selectedIndex = index"
>
<div class="result-content">
<span class="result-text">{{ result.data }}</span>
<span class="result-score">{{ (result.score * 100).toFixed(0) }}%</span>
</div>
</div>
<div v-if="query.trim() && results.length === 0 && !loading" class="no-results">
No results found
</div>
</div>
</Transition>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { useBrainyStore } from '../stores/brainy'
import { debounce } from '../utils/debounce'
const emit = defineEmits(['select'])
const brainyStore = useBrainyStore()
const searchInput = ref(null)
const query = ref('')
const results = ref([])
const loading = ref(false)
const showResults = ref(false)
const selectedIndex = ref(-1)
const search = async (searchQuery) => {
if (!searchQuery.trim()) {
results.value = []
return
}
loading.value = true
try {
const searchResults = await brainyStore.search(searchQuery, { limit: 10 })
results.value = searchResults
} catch (error) {
console.error('Search error:', error)
results.value = []
} finally {
loading.value = false
}
}
const debouncedSearch = debounce(search, 200)
const handleInput = () => {
selectedIndex.value = -1
debouncedSearch(query.value)
}
const handleBlur = () => {
// Delay hiding to allow clicking on results
setTimeout(() => {
showResults.value = false
}, 150)
}
const selectResult = (result) => {
query.value = result.data
showResults.value = false
emit('select', result)
}
// Keyboard navigation
const handleKeydown = (event) => {
if (!showResults.value) return
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, results.value.length - 1)
break
case 'ArrowUp':
event.preventDefault()
selectedIndex.value = Math.max(selectedIndex.value - 1, -1)
break
case 'Enter':
event.preventDefault()
if (selectedIndex.value >= 0) {
selectResult(results.value[selectedIndex.value])
}
break
case 'Escape':
showResults.value = false
searchInput.value?.blur()
break
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
</script>
<style scoped>
.realtime-search {
position: relative;
width: 100%;
}
.search-bar {
position: relative;
display: flex;
align-items: center;
}
.search-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 1rem;
}
.search-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-spinner {
position: absolute;
right: 0.75rem;
animation: spin 1s linear infinite;
color: #6b7280;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
z-index: 1000;
max-height: 300px;
overflow-y: auto;
}
.result-item {
padding: 0.75rem;
cursor: pointer;
border-bottom: 1px solid #f3f4f6;
}
.result-item:last-child {
border-bottom: none;
}
.result-item:hover,
.result-item.active {
background: #f3f4f6;
}
.result-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.result-text {
flex: 1;
margin-right: 0.5rem;
}
.result-score {
font-size: 0.875rem;
color: #6b7280;
}
.no-results {
padding: 1rem;
text-align: center;
color: #6b7280;
}
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>
📊 Performance Optimization
Lazy Loading
<!-- src/components/LazyBrainComponent.vue -->
<template>
<div>
<button v-if="!loaded" @click="loadBrain" class="load-button">
Load AI Search
</button>
<Suspense v-else>
<template #default>
<SearchComponent />
</template>
<template #fallback>
<div class="loading">Loading AI...</div>
</template>
</Suspense>
</div>
</template>
<script setup>
import { ref, defineAsyncComponent } from 'vue'
const loaded = ref(false)
const SearchComponent = defineAsyncComponent(() =>
import('./Search.vue')
)
const loadBrain = () => {
loaded.value = true
}
</script>
Virtual Scrolling for Large Results
<!-- src/components/VirtualResults.vue -->
<template>
<div ref="container" class="virtual-container" @scroll="onScroll">
<div :style="{ height: totalHeight + 'px' }" class="virtual-spacer">
<div
:style="{ transform: `translateY(${offsetY}px)` }"
class="virtual-content"
>
<div
v-for="item in visibleItems"
:key="item.id"
class="result-item"
:style="{ height: itemHeight + 'px' }"
>
<h3>{{ item.data }}</h3>
<p>Score: {{ (item.score * 100).toFixed(1) }}%</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const props = defineProps({
items: Array,
itemHeight: { type: Number, default: 80 }
})
const container = ref(null)
const scrollTop = ref(0)
const containerHeight = ref(400)
const totalHeight = computed(() => props.items.length * props.itemHeight)
const visibleStart = computed(() => Math.floor(scrollTop.value / props.itemHeight))
const visibleEnd = computed(() => Math.min(
visibleStart.value + Math.ceil(containerHeight.value / props.itemHeight) + 1,
props.items.length
))
const visibleItems = computed(() =>
props.items.slice(visibleStart.value, visibleEnd.value)
)
const offsetY = computed(() => visibleStart.value * props.itemHeight)
const onScroll = () => {
scrollTop.value = container.value.scrollTop
}
onMounted(() => {
containerHeight.value = container.value.clientHeight
})
</script>
<style scoped>
.virtual-container {
height: 400px;
overflow: auto;
}
.virtual-spacer {
position: relative;
}
.virtual-content {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.result-item {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>
🧪 Testing
Component Testing with Vitest
The component talks to the server over fetch, so mock the endpoint, not Brainy itself.
// tests/components/Search.test.js
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import Search from '../src/components/Search.vue'
// Mock the server endpoint
beforeEach(() => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
results: [{ id: '1', data: 'Test result', score: 0.9 }]
})
})
})
describe('Search Component', () => {
let wrapper
beforeEach(() => {
wrapper = mount(Search)
})
it('renders search input', () => {
expect(wrapper.find('input').exists()).toBe(true)
})
it('performs search when input changes', async () => {
const input = wrapper.find('input')
await input.setValue('test query')
await input.trigger('input')
// Wait for debounced search
await new Promise(resolve => setTimeout(resolve, 350))
expect(global.fetch).toHaveBeenCalled()
expect(wrapper.text()).toContain('Test result')
})
})
E2E Testing with Playwright
// tests/e2e/search.spec.js
import { test, expect } from '@playwright/test'
test('search functionality works', async ({ page }) => {
await page.goto('/')
// Wait for brain to initialize
await page.waitForSelector('[data-testid="search-input"]')
// Perform search
await page.fill('[data-testid="search-input"]', 'test query')
// Wait for results
await page.waitForSelector('[data-testid="search-results"]')
// Check results
const results = await page.locator('[data-testid="result-item"]')
await expect(results).toHaveCountGreaterThan(0)
})
🚀 Production Tips
Bundle Optimization
Keep Brainy out of the client bundle — it belongs to the server build only. Mark it external for SSR so the bundler resolves it at runtime instead of inlining it.
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
ssr: {
external: ['@soulcraftlabs/brainy']
}
})
Error Handling
Wrap the endpoint call with retry/backoff so transient network failures don't surface to the user.
// src/composables/useBrainyWithErrorHandling.js
import { ref } from 'vue'
export function useBrainyWithErrorHandling(endpoint = '/api/brain') {
const error = ref(null)
const search = async (query, options = {}, maxRetries = 3) => {
error.value = null
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const res = await fetch(`${endpoint}/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, options })
})
if (!res.ok) throw new Error(`Search failed: ${res.status}`)
return (await res.json()).results
} catch (err) {
error.value = err.message
if (attempt === maxRetries) throw err
// Exponential backoff before retrying
await new Promise(resolve => setTimeout(resolve, 2000 * (attempt + 1)))
}
}
}
return {
error: readonly(error),
search
}
}
🎯 Complete Example
Here's a complete Vue 3 application structure:
my-brainy-vue-app/
├── server/ # Server-side (Node/Bun) — hosts Brainy
│ ├── utils/
│ │ └── brain.js # Shared Brainy instance (getBrain)
│ └── api/brain/
│ ├── search.post.js
│ ├── add.post.js
│ └── stats.get.js
├── src/
│ ├── components/
│ │ ├── Search.vue
│ │ ├── DataManager.vue
│ │ └── RealTimeSearch.vue
│ ├── composables/
│ │ ├── useBrainy.js
│ │ └── useBrainyWithErrorHandling.js
│ ├── stores/
│ │ └── brainy.js
│ ├── utils/
│ │ └── debounce.js
│ ├── App.vue
│ └── main.js
├── tests/
│ ├── components/
│ └── e2e/
├── vite.config.js
└── package.json
This provides a complete, production-ready Vue.js application: Brainy runs on the server, and the client talks to it over HTTP.
📚 Next Steps
- Framework Integration Guide - Multi-framework patterns
- Production Deployment - Deploy to production
- API Reference - Complete API documentation
- Examples Repository - More examples