Vue.js Admin Demo Tutorial — Step-by-Step Guide
This guide walks you through setting up, configuring, and testing the OwlLayer Vue.js Admin Dashboard (apps/demo-vue) connected to the AITP WebSocket server (apps/demo-server).
You will learn how to prepare the test environment, configure environment variables, implement a complete Internationalization (i18n) architecture (English 🇬🇧 & French 🇫🇷) across both client and server, declare reactive tools (useAgentToolResolver, useNavigationTool), manage dynamic context (useAgentContext), handle Human-in-the-Loop (HITL) approvals, and format rich Markdown chat responses.
⚡ TL;DR — 2-Minute Quick Start
Get the full demo environment running in 3 minimal steps with zero code modifications:
# 1. Install dependencies & build packages
pnpm install && pnpm build:packages
# 2. Set up environment files (only your Gemini API key is needed)
cp apps/demo-server/.env.example apps/demo-server/.env
# Edit apps/demo-server/.env -> set GOOGLE_API_KEY and the demo API keys
cp apps/demo-vue/.env.example apps/demo-vue/.env
# 3. Start both the AITP Server & the Vue Dashboard
pnpm --filter @owllayer/demo-server dev & pnpm --filter @owllayer/demo-vue dev👉 Open http://localhost:4200 in your browser!
- Configured Connection: Use the same private API key for
OWLLAYER_ADMIN_API_KEYandVITE_OWLLAYER_API_KEY. - Instant Language Switching: Toggle between 🇬🇧 EN and 🇫🇷 FR anytime from the sidebar button.
🏗️ Architecture Overview
┌────────────────────────────────────────────────────────────────────────┐
│ Vue 3 Admin Dashboard (Port 4200) │
│ │
│ - i18n Store (useI18n): EN 🇬🇧 / FR 🇫🇷 with LocalStorage persistence │
│ - Sidebar.vue: Instant Language Switcher & localized navigation │
│ - App.vue: Dynamic useAgentContext & useAgentToolResolver │
│ - ProductsPage.vue: Localized catalog, filters & OwlLayerTool button │
│ - AddProductPage.vue: Localized forms, validations & categories │
│ - VoiceWidgetStt.vue & AgentPanel.vue: │
│ ├── useVoiceMode (Live audio) / useAgent (Text fallback) │
│ ├── useApproval (Contextual HITL confirmation card) │
│ └── renderMarkdown (Safe HTML sanitization) │
└───────────────────────────────────▲────────────────────────────────────┘
│ WebSocket AITP Protocol
│ ws://localhost:4001/owllayer
┌───────────────────────────────────▼────────────────────────────────────┐
│ OwlLayer Server (Port 4001) │
│ │
│ - Server i18n (serverMessages): English & French system prompts │
│ - Dynamic Audio Config: STT (en-US / fr-FR) & TTS Neural2 voices │
│ - OwlLayerServer + SecurityMiddleware (HITL approval engine) │
│ - GoogleAdapter (Gemini Live & configurable text fallback) │
│ - Localized Server Tools (get_server_time, get_store_info) │
└────────────────────────────────────────────────────────────────────────┘1. Test Environment Requirements & Configuration
Prerequisites
- Node.js: v18+ or v20+
- pnpm: v9+
- Google Gemini API Key: Obtain an API key from Google AI Studio
A. Demo Server Configuration (apps/demo-server/.env)
Create or update apps/demo-server/.env:
PORT=4001
OWLLAYER_PORT=4001
LOG_LEVEL=info
# Default server language (en or fr)
DEFAULT_LANGUAGE=en
# Google Gemini API & Model Configuration
GOOGLE_API_KEY=your_gemini_api_key_here
GEMINI_MODEL=gemini-2.0-flash
# AITP API Keys (Admin Vue app)
OWLLAYER_REQUIRE_API_KEY=true
OWLLAYER_ADMIN_API_KEY=your_vue_demo_api_key
# Admin Dashboard credentials
ADMIN_USERNAME=admin
ADMIN_PASSWORD=your_admin_passwordModel Selection
Use a Gemini model enabled for your Google AI Studio account. The default gemini-2.0-flash can be overridden through GEMINI_MODEL.
B. Vue Client Configuration (apps/demo-vue/.env)
Configure the WebSocket endpoint, API key, and initial language in apps/demo-vue/.env:
VITE_OWLLAYER_ENDPOINT=ws://localhost:4001/owllayer
VITE_OWLLAYER_API_KEY=your_vue_demo_api_key
# Initial client language (en or fr)
VITE_APP_LANGUAGE=en2. Build & Launch
From the root of the monorepo:
# 1. Build monorepo packages
pnpm --filter @owllayer/core build
pnpm --filter @owllayer/adapter-google build
pnpm --filter @owllayer/server build
pnpm --filter @owllayer/vue build
# 2. Terminal 1 — Start the AITP Server
pnpm --filter @owllayer/demo-server dev
# 3. Terminal 2 — Start the Vue 3 Admin Dashboard
pnpm --filter @owllayer/demo-vue devOpen your browser at http://localhost:4200.
3. Complete Step-by-Step Implementation
Step 1: Reactive i18n Store (apps/demo-vue/src/i18n/index.ts)
Centralize all user interface strings, product mock details, currency formatting, and agent tool metadata:
// apps/demo-vue/src/i18n/index.ts
import { ref, computed } from 'vue';
export type Locale = 'en' | 'fr';
const initialLocale: Locale = (() => {
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem('owllayer_lang') : null;
if (saved === 'en' || saved === 'fr') return saved;
const envLang = import.meta.env.VITE_APP_LANGUAGE;
if (envLang === 'fr' || envLang === 'en') return envLang;
return 'en';
})();
const currentLocale = ref<Locale>(initialLocale);
export const translations = {
en: {
common: { currency: 'USD', currencySymbol: '$', cancel: 'Cancel', confirm: 'Confirm', delete: 'Delete' },
nav: {
catalog: 'Catalog',
addProduct: 'Add Product',
routesDescription: 'Navigate within the admin dashboard: /products (catalog), /products/add (new product), /products/edit/:id (edit product).',
},
categories: { Audio: 'Audio', Peripherals: 'Peripherals', Monitors: 'Monitors', Video: 'Video', Storage: 'Storage', Networking: 'Networking' },
productItems: {
'prod-001': { name: 'BT Pro Headphones', description: 'Premium Bluetooth ANC headphones, 30h battery life' },
'prod-002': { name: 'Mechanical RGB Keyboard', description: 'Cherry MX Red switches, full RGB backlighting' },
'prod-003': { name: 'Ergonomic Mouse', description: '6-button ergonomic mouse, 12000 DPI sensor' },
// ...
},
agent: {
roleDescription: 'You are the admin assistant for the OwlLayer dashboard. You manage the product catalog (view, add, edit, delete).',
getCatalogDesc: 'Get the full list of products along with statistics (stock, status, prices).',
addProductDesc: 'Create a new product in the catalog. Available categories: Audio, Peripherals, Monitors, Video, Storage, Networking.',
editProductDesc: 'Modify an existing product in the catalog.',
deleteProductDesc: 'Permanently delete a product from the catalog.',
editIdParamDesc: 'ID of the product to modify (e.g. prod-001)',
deleteIdParamDesc: 'ID of the product to delete (e.g. prod-001)',
productCreatedMsg: 'Product "{name}" created with ID {id}.',
productUpdatedMsg: 'Product "{name}" updated successfully.',
productDeletedMsg: 'Product "{name}" permanently deleted.',
productNotFoundMsg: 'Product "{id}" not found.',
deleteFailedMsg: 'Failed to delete product.',
},
},
fr: {
common: { currency: 'EUR', currencySymbol: '€', cancel: 'Annuler', confirm: 'Confirmer', delete: 'Supprimer' },
nav: {
catalog: 'Catalogue',
addProduct: 'Ajouter produit',
routesDescription: 'Naviguer dans le dashboard admin : /products (liste des produits), /products/add (créer un produit), /products/edit/:id (modifier un produit).',
},
categories: { Audio: 'Audio', Peripherals: 'Périphériques', Monitors: 'Moniteurs', Video: 'Vidéo', Storage: 'Stockage', Networking: 'Réseaux' },
productItems: {
'prod-001': { name: 'Casque BT Pro', description: 'Casque Bluetooth ANC premium, autonomie de 30h' },
'prod-002': { name: 'Clavier mécanique RGB', description: 'Switches Cherry MX Red, rétroéclairage complet RGB' },
'prod-003': { name: 'Souris ergonomique', description: 'Souris ergonomique 6 boutons, capteur 12000 DPI' },
// ...
},
agent: {
roleDescription: 'Tu es l\'assistant admin du dashboard OwlLayer. Tu gères le catalogue de produits (consulter, ajouter, modifier, supprimer).',
getCatalogDesc: 'Obtenir la liste complète des produits avec leurs statistiques (stock, statut, prix).',
addProductDesc: 'Créer un nouveau produit dans le catalogue. Catégories disponibles : Audio, Périphériques, Moniteurs, Vidéo, Stockage, Réseaux.',
editProductDesc: 'Modifier un produit existant dans le catalogue.',
deleteProductDesc: 'Supprimer définitivement un produit du catalogue.',
editIdParamDesc: 'ID du produit à modifier (ex : prod-001)',
deleteIdParamDesc: 'ID du produit à supprimer (ex : prod-001)',
productCreatedMsg: 'Produit "{name}" créé avec l\'ID {id}.',
productUpdatedMsg: 'Produit "{name}" mis à jour avec succès.',
productDeletedMsg: 'Produit "{name}" supprimé définitivement.',
productNotFoundMsg: 'Produit "{id}" introuvable.',
deleteFailedMsg: 'Échec de la suppression du produit.',
},
},
};
export function useI18n() {
const locale = computed(() => currentLocale.value);
const t = computed(() => translations[currentLocale.value]);
function setLocale(newLocale: Locale) {
currentLocale.value = newLocale;
if (typeof localStorage !== 'undefined') localStorage.setItem('owllayer_lang', newLocale);
}
function getProductName(product: { id: string; name: string }) {
const item = (t.value.productItems as any)?.[product.id];
return item?.name ?? product.name;
}
function getProductDescription(product: { id: string; description: string }) {
const item = (t.value.productItems as any)?.[product.id];
return item?.description ?? product.description;
}
return { locale, currentLocale, setLocale, t, getProductName, getProductDescription };
}Step 2: Language Switcher in Sidebar (Sidebar.vue)
Provide a single-click interactive toggle in the sidebar navigation:
<!-- apps/demo-vue/src/components/Sidebar.vue -->
<script setup lang="ts">
import { useI18n } from '../i18n';
const { currentLocale, setLocale, t } = useI18n();
</script>
<template>
<aside class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col p-4">
<!-- Language Toggle -->
<div class="flex items-center bg-slate-800/80 p-1 rounded-lg border border-slate-700/60 mb-4">
<button
@click="setLocale('en')"
class="flex-1 text-xs py-1.5 rounded font-medium transition-all"
:class="currentLocale === 'en' ? 'bg-violet-600 text-white shadow' : 'text-slate-400 hover:text-white'"
>
🇬🇧 EN
</button>
<button
@click="setLocale('fr')"
class="flex-1 text-xs py-1.5 rounded font-medium transition-all"
:class="currentLocale === 'fr' ? 'bg-violet-600 text-white shadow' : 'text-slate-400 hover:text-white'"
>
🇫🇷 FR
</button>
</div>
<!-- Navigation Links -->
<nav class="space-y-1">
<RouterLink to="/products" class="nav-item">{{ t.nav.catalog }}</RouterLink>
<RouterLink to="/products/add" class="nav-item">{{ t.nav.addProduct }}</RouterLink>
</nav>
</aside>
</template>Step 3: Dynamic Context & Localized Tools (App.vue)
Use useAgentContext with a reactive getter function and useAgentToolResolver to register dynamic tools:
<!-- apps/demo-vue/src/App.vue -->
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { useNavigationTool, useAgentToolResolver, useAgentContext } from '@owllayer/vue';
import { z } from 'zod';
import { useProducts, CATEGORIES } from './store/products';
import { useI18n, type CategoryKey } from './i18n';
const router = useRouter();
const { products, stats, addProduct, editProduct, deleteProduct, getProduct } = useProducts();
const { t, format, currentLocale, getProductName, getProductDescription } = useI18n();
// 1. Navigation tool with reactive routes description
useNavigationTool(({ url }) => router.push(url), {
description: t.value.nav.routesDescription,
});
// 2. Reactive Role Context — updates language and currency upon toggle
useAgentContext(() => ({
role: 'admin',
description: t.value.agent.roleDescription,
language: currentLocale.value,
currency: t.value.common.currency,
}));
// 3. Centralized CRUD Tool Resolver with dynamic descriptions
useAgentToolResolver(
{
products: {
tools: {
get_catalog: {
description: t.value.agent.getCatalogDesc,
schema: z.object({}),
risk: 'none',
handler: async () => ({
products: products.map(p => ({
id: p.id,
name: getProductName(p),
price: `${p.price} ${t.value.common.currency}`,
stock: p.stock,
status: p.status,
category: t.value.categories[p.category as CategoryKey] || p.category,
description: getProductDescription(p),
})),
stats: stats.value,
}),
},
add_product: {
description: t.value.agent.addProductDesc,
schema: z.object({
name: z.string().min(1).describe(t.value.form.nameLabel),
price: z.number().positive().describe(t.value.form.priceLabel),
stock: z.number().int().min(0).describe(t.value.form.stockLabel),
category: z.enum(CATEGORIES).describe(t.value.form.categoryLabel),
description: z.string().describe(t.value.form.descriptionLabel),
status: z.enum(['active', 'draft', 'archived']).default('draft').describe(t.value.form.statusLabel),
}),
risk: 'low',
handler: async (args) => {
const product = addProduct(args);
router.push('/products');
return {
success: true,
message: format(t.value.agent.productCreatedMsg, { name: product.name, id: product.id }),
product,
};
},
},
edit_product: {
description: t.value.agent.editProductDesc,
schema: z.object({
id: z.string().describe(t.value.agent.editIdParamDesc),
name: z.string().optional().describe(t.value.form.nameLabel),
price: z.number().positive().optional().describe(t.value.form.priceLabel),
stock: z.number().int().min(0).optional().describe(t.value.form.stockLabel),
category: z.enum(CATEGORIES).optional().describe(t.value.form.categoryLabel),
description: z.string().optional().describe(t.value.form.descriptionLabel),
status: z.enum(['active', 'draft', 'archived']).optional().describe(t.value.form.statusLabel),
}),
risk: 'low',
handler: async ({ id, ...updates }) => {
const product = editProduct(id, updates);
if (!product) return { success: false, error: format(t.value.agent.productNotFoundMsg, { id }) };
return { success: true, message: format(t.value.agent.productUpdatedMsg, { name: product.name }), product };
},
},
delete_product: {
description: t.value.agent.deleteProductDesc,
schema: z.object({
id: z.string().describe(t.value.agent.deleteIdParamDesc),
}),
risk: 'high', // Triggers contextual HITL overlay
handler: async ({ id }) => {
const product = getProduct(id);
if (!product) return { success: false, error: format(t.value.agent.productNotFoundMsg, { id }) };
const ok = deleteProduct(id);
return ok
? { success: true, message: format(t.value.agent.productDeletedMsg, { name: product.name }) }
: { success: false, error: t.value.agent.deleteFailedMsg };
},
},
},
},
},
{ global: true },
);
</script>Step 4: Reactive Catalog & Declarative Button (ProductsPage.vue)
Use getProductName and getProductDescription to render table cells and <OwlLayerTool> to expose UI buttons to the AI agent:
<!-- apps/demo-vue/src/pages/ProductsPage.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useProducts } from '../store/products';
import { useI18n, type CategoryKey } from '../i18n';
import { OwlLayerTool } from '@owllayer/vue';
const { products, stats, deleteProduct } = useProducts();
const { t, format, formatCurrency, getProductName, getProductDescription } = useI18n();
const search = ref('');
const categoryFilter = ref('');
const filtered = computed(() => {
return products.filter(p => {
const locName = getProductName(p).toLowerCase();
const locDesc = getProductDescription(p).toLowerCase();
const q = search.value.toLowerCase();
const matchSearch = !q || locName.includes(q) || locDesc.includes(q) || p.name.toLowerCase().includes(q);
const matchCat = !categoryFilter.value || p.category === categoryFilter.value;
return matchSearch && matchCat;
});
});
</script>
<template>
<div class="p-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-white">{{ t.catalog.title }}</h1>
<!-- Agent & Human action button -->
<OwlLayerTool name="go_to_add_product" :description="t.agent.goToAddProductDesc" action="click">
<RouterLink to="/products/add" class="btn-primary">+ {{ t.catalog.addProductBtn }}</RouterLink>
</OwlLayerTool>
</div>
<!-- Table -->
<table>
<tr v-for="product in filtered" :key="product.id">
<td>
<div class="font-medium text-white">{{ getProductName(product) }}</div>
<div class="text-xs text-slate-500">{{ getProductDescription(product) }}</div>
</td>
<td>{{ t.categories[product.category as CategoryKey] || product.category }}</td>
<td>{{ formatCurrency(product.price) }}</td>
</tr>
</table>
</div>
</template>Step 5: Contextual HITL & Safe Markdown Rendering (VoiceWidgetStt.vue)
Integrate useApproval for high-risk actions (delete_product) and renderMarkdown for sanitized formatting:
<!-- apps/demo-vue/src/components/VoiceWidgetStt.vue -->
<script setup lang="ts">
import { useApproval } from '@owllayer/vue';
import { useI18n } from '../i18n';
import { renderMarkdown } from '../utils/markdown';
const { pendingApproval, approve, deny } = useApproval();
const { t } = useI18n();
</script>
<template>
<!-- Contextual HITL Approval Card -->
<div v-if="pendingApproval" class="p-4 bg-slate-900 border border-amber-500/50 rounded-xl shadow-xl">
<div class="text-xs font-semibold text-amber-300">
⚠️ {{ t.agent.approvalDeleteTitle }}
</div>
<p class="text-xs text-slate-200 my-1">
{{ t.agent.approvalDeleteDesc }}
</p>
<div class="flex gap-2 justify-end mt-2">
<button @click="deny" class="px-3 py-1 text-xs bg-slate-800 text-slate-300 rounded">
{{ t.common.deny }}
</button>
<button @click="approve" class="px-3 py-1 text-xs bg-emerald-600 text-white rounded font-semibold">
{{ t.common.confirm }}
</button>
</div>
</div>
</template>4. Multilingual Test Scenarios & Verification
You can test the agent in either English or French (toggle language from the sidebar):
| Feature | English Test Command 🇬🇧 | French Test Command 🇫🇷 | Expected Result |
|---|---|---|---|
| Catalog Query | "What products are currently in stock?" | "Quels sont les produits actuellement en stock ?" | Agent invokes get_catalog and formats a bulleted Markdown list in the active language. |
| Navigation | "Open the add product page" | "Ouvre la page d'ajout de produit" | Agent executes useNavigationTool and navigates to /products/add. |
| Create Product | "Add a Gaming Mouse for $49.99 with stock 20 in Peripherals" | "Ajoute une Souris Gaming à 49.99€ avec 20 en stock dans Périphériques" | Agent calls add_product, redirects to /products, and inserts the item. |
| Edit Product | "Set the stock of the RGB Keyboard to 50" | "Passe le stock du clavier mécanique à 50" | Agent calls edit_product, table updates instantly. |
| HITL Security | "Delete the BT Pro Headphones" | "Supprime le casque BT Pro" | Contextual confirmation card appears with Deny/Confirm buttons. Clicking Confirm deletes the product. |
| Server Tool | "Give me the store contact details" | "Donne-moi les coordonnées de la boutique" | Agent executes server-side tool get_store_info and displays contact details. |
5. Troubleshooting
- Server logs in French vs English: Verify
DEFAULT_LANGUAGE=en(orfr) inapps/demo-server/.env. - Client language resets on refresh: Language selection is saved in
localStorage.getItem('owllayer_lang'). Clear local storage or setVITE_APP_LANGUAGE=eninapps/demo-vue/.env. - Gemini HTTP 429 Quota Exceeded: Choose a Gemini model enabled for your account with
GEMINI_MODELinapps/demo-server/.env. - WebSocket Connection: Confirm that
VITE_OWLLAYER_API_KEYinapps/demo-vue/.envmatchesOWLLAYER_ADMIN_API_KEYinapps/demo-server/.env.
