Luma
Light for your business
AI Personal Assistant for the Kasha Platform — A unified natural-language interface to 149 business operations across 13 domains
Executive Summary
Problem: The Kasha Platform (33 repos, 18 microservices, 10 frontend apps) has no unified natural-language interface. Users must navigate between 10 different UIs to perform 149 distinct business operations across scheduling, invoicing, banking, CRM, website management, and more. The existing AI assistant ("Heidi") is limited to the website builder domain only.
Solution: Luma AI Personal Assistant
A general-purpose AI Personal Assistant that gives every Kasha user a single conversational interface to the entire platform. Users can say "Book a haircut for Sarah at 2pm tomorrow" or "Send invoice #42 to the client" from any app.
Coverage Domains
Delivery Approach
- Phased rollout in 5 phases over 18-23 weeks
- Phase 1 (Foundation): 17 read-only capabilities, streaming responses
- Phase 2 (Read-Write): 57 total capabilities with confirmation flow
- Phase 3 (Advanced): 107 capabilities with multi-step workflows
- Phase 4 (Full Rollout): All 149 capabilities
- Phase 5 (Polish): Analytics, feedback, performance optimization
Name Research: Why "Luma"
Selection Criteria
- Short (1-3 syllables, ideally 2)
- Easy to pronounce in English, German, French, Italian, Spanish, Arabic, Mandarin, Hindi
- Gender-neutral
- No negative connotations in any major language
- Not trademarked by major AI products
- Friendly, capable, professional — like a trusted colleague
Winner: Luma — Overall Score: 9.25/10
Evaluation Breakdown
| Dimension | Score | Details |
|---|---|---|
| Pronounceability | 10/10 | LOO-ma. Very easy across all target languages. The "L" and "M" sounds are universal. |
| Memorability | 9/10 | Short, distinctive, evokes "luminous" and "illuminate" |
| Cultural Safety | 9/10 | No negative meanings found across 14 languages checked |
| Brand Fit | 9/10 | Perfect metaphor: light/illumination brings clarity to business operations |
Etymology & Meaning
- Latin: "lumen" (light) — Luma derives from this root
- Brand Metaphor: "Illuminating business decisions"
- Tagline: "Light for your business"
Why Luma Wins
- Perfect metaphor: Light = clarity, guidance, understanding
- Brand harmony: Kasha (earthy/warm/nourishing) + Luma (light/clarity) creates complementary contrast
- Clean trademark landscape: No major PA/assistant AI conflicts (Luma AI exists for 3D/video, different category)
- Gender-neutral: No gender associations in any language
- Phonetically universal: L-U-M-A uses sounds that exist in virtually every language
- Professional yet warm: Not robotic, childish, or overly casual
Top 5 Candidates Evaluated
| Rank | Name | Score | Key Meaning | Status |
|---|---|---|---|---|
| 1 | Luma | 9.25 | Light (Latin) | SELECTED |
| 2 | Nima | 8.75 | Just/Fair (Persian), Blessing (Arabic) | Runner-up |
| 3 | Dara | 8.75 | Wisdom (Hebrew) | Runner-up |
| 4 | Nara | 8.50 | Happy (Celtic), Historic city (Japanese) | Alternative |
| 5 | Remi | 8.50 | Remedy (Latin), Comfort (Yoruba) | Conflict: Remi AI |
Architecture Overview
┌─────────────────────────────────────────────────────────────┐ │ Frontend (any of 10 Next.js apps) │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ lr-luma-ui (iframe, like existing chat widget) │ │ │ │ - Chat UI, streaming text, rich cards │ │ │ │ - Socket.IO client (namespace /luma) │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────┬──────────────────────┬────────────────────┘ │ REST (messages) │ Socket.IO (streaming) ▼ ▼ ┌─────────────────────────────────────────────────────────────┐ │ lr-api-gateway │ │ ┌────────────────┐ ┌───────────────────────────────────┐ │ │ │ LumaController │ │ LumaGateway (WS namespace /luma) │ │ │ │ REST endpoints │ │ Consumes from RabbitMQ stream │ │ │ │ Guards + Auth │ │ queue, emits to Socket.IO rooms │ │ │ └───────┬────────┘ └───────────────────┬───────────────┘ │ │ │ RabbitMQ send() │ RabbitMQ consume │ └──────────┼────────────────────────────────┼──────────────────┘ ▼ │ ┌─────────────────────────────────────────────────────────────┐ │ lr-luma-service (NEW NestJS microservice) │ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │ │ │ Orchestrator │→│ LLM (Claude) │→│ Tool Registry │ │ │ │ Agentic loop │ │ Tool calling │ │ 149 tools, 13 │ │ │ │ │ │ Streaming │ │ domains │ │ │ └──────┬──────┘ └──────────────┘ └───────────────────┘ │ │ │ │ │ ┌──────┴──────┐ ┌──────────────┐ ┌───────────────────┐ │ │ │ Executor │ │ Confirmation │ │ Context Memory │ │ │ │ RBAC-scoped │ │ Risk-based │ │ Entity tracking │ │ │ │ 17 RabbitMQ │ │ confirmations│ │ Topic continuity │ │ │ │ ClientProxy │ │ │ │ Vector Memory │ │ │ │ │ │ │ │ (pgvector / Qdrant)│ │ │ └──────┬──────┘ └──────────────┘ └───────────────────┘ │ │ │ │ │ │ Publishes stream tokens to RabbitMQ exchange ──────┤ └─────────┼────────────────────────────────────────────────────┘ │ RabbitMQ send() to 17 services ▼ ┌─────────────────────────────────────────────────────────────┐ │ Existing Microservices │ │ booking / crm / invoicing / banking / notifications / ... │ └─────────────────────────────────────────────────────────────┘
Key Architectural Decisions
| Decision | Choice | Rationale |
|---|---|---|
| LLM Provider | Anthropic Claude (Sonnet) | Differentiates from Heidi (GPT), native tool calling, handles large tool sets well |
| Tool approach | Manual per-domain files from capability map | Quality descriptions need human curation; 13 domain files |
| Cross-service comms | Direct RabbitMQ (17 ClientProxy instances) | Established platform pattern; avoids gateway HTTP overhead |
| Streaming | RabbitMQ exchange → Gateway Socket.IO | Matches AppNotificationsGateway pattern exactly |
| Message initiation | REST POST (not Socket.IO) | Allows existing guard stack (JWT, Permissions, Subscription) |
| Conversation storage | MongoDB | Variable-length messages, nested context; matches Heidi pattern |
| Usage tracking | PostgreSQL | Structured, aggregatable, extends BaseEntity |
| Frontend delivery | iframe (new lr-luma-ui app) | Matches existing chat widget pattern; zero host-app changes |
| UI placement | Floating widget + full page | Widget on all apps; full page in lr-onboarding-ui for power users |
| RBAC enforcement | Three-layer: tool filter → pre-exec check → service guard | Defense in depth; Claude never sees unauthorized tools; services remain final authority |
| Vector memory | pgvector (Phase 1–3), Qdrant (Phase 4+) | No new infra initially; abstraction layer enables migration at scale |
| Embedding model | text-embedding-3-small (1024d) | Cost-effective, fast, sufficient quality for conversation retrieval |
Backend Service: lr-luma-service
Module Structure
lr-luma-service/
├── src/
│ ├── main.ts # Bootstrap + RabbitMQ microservice
│ ├── app.module.ts
│ ├── config/ # Environment config (Anthropic keys, RabbitMQ, etc.)
│ ├── modules/
│ │ ├── conversation/ # MongoDB: conversation CRUD, message storage
│ │ │ ├── entities/conversation.entity.ts
│ │ │ ├── conversation.service.ts
│ │ │ └── conversation.controller.ts # RPC handlers
│ │ ├── llm/ # Anthropic Claude SDK wrapper
│ │ │ ├── llm.interface.ts # Provider abstraction
│ │ │ ├── anthropic.provider.ts # Claude with streaming + tool calling
│ │ │ └── llm.service.ts # Retry logic, token tracking
│ │ ├── orchestrator/ # Central brain: message → intent → tool → response
│ │ │ └── orchestrator.service.ts
│ │ ├── tools/ # Tool definitions + registry
│ │ │ ├── tools.registry.ts # Central registry with permission filtering
│ │ │ └── domains/ # 13 domain files
│ │ │ ├── scheduling.tools.ts
│ │ │ ├── client-management.tools.ts
│ │ │ ├── invoicing.tools.ts
│ │ │ ├── banking.tools.ts
│ │ │ ├── website.tools.ts
│ │ │ ├── communications.tools.ts
│ │ │ ├── tasks.tools.ts
│ │ │ ├── business-config.tools.ts
│ │ │ ├── documents.tools.ts
│ │ │ ├── search.tools.ts
│ │ │ ├── kyc.tools.ts
│ │ │ ├── ecommerce.tools.ts
│ │ │ └── analytics.tools.ts
│ │ ├── executor/ # Calls downstream services via RabbitMQ
│ │ │ └── executor.service.ts # 17 ClientProxy instances, 15s timeout per RPC
│ │ ├── confirmation/ # Risk-based confirmation flow
│ │ │ └── confirmation.service.ts
│ │ ├── context/ # Conversation context memory stack
│ │ │ └── context.service.ts # Entity tracking, topic detection, 10min decay
│ │ ├── memory/ # Vector memory engine (RAG)
│ │ │ ├── memory.module.ts # Qdrant/pgvector provider config
│ │ │ ├── memory.service.ts # Retrieve/store operations
│ │ │ ├── memory.interface.ts # MemoryChunk, MemoryQuery types
│ │ │ ├── embedding.service.ts # Embedding model abstraction
│ │ │ ├── chunking.service.ts # Conversation → embeddable chunks
│ │ │ └── memory-maintenance.service.ts # TTL, compaction, re-embed
│ │ ├── usage/ # PostgreSQL: token tracking, rate limiting
│ │ │ └── entities/usage-log.entity.ts
│ │ └── moderation/ # Input/output content safety
│ └── common/
│ └── services/ # 17 RabbitMQ ClientProxy factory providers
LLM Integration (Anthropic Claude)
- Package:
@anthropic-ai/sdk - Model:
claude-sonnet-4-20250514(configurable via env) - Tool Calling: Native
toolsparameter (not JSON-in-prompt like Heidi) - Streaming:
messages.stream()yields events published to RabbitMQ exchange - Temperature: 0.3 (reliable tool calling)
- Max tokens: 4096
Tool System
Each tool is defined with:
interface ToolDefinition {
name: string; // e.g. 'scheduling_create_appointment'
description: string; // For Claude's tool selection
inputSchema: Record<string, any>; // JSON Schema
domain: ToolDomain; // 13 domains
riskLevel: RiskLevel; // None | Low | Medium | High | Critical
targetService: string; // RabbitMQ queue constant
rpcCommand: string; // e.g. 'create_event'
operation: 'read' | 'write';
requiredPermissions?: string[];
confirmationTemplate?: string;
}
The ToolsRegistry provides getAccessibleTools(userPermissions, subscriptionFeatures) to filter tools per-user before sending to Claude.
Orchestrator Loop
- Receive user message
- Load conversation history + context
- Build system prompt with context, user info, current date/time
- Call Claude with messages + accessible tools
- If Claude returns
tool_use:- a. Validate tool exists and input schema
- b. Check risk level → if Medium/High/Critical, create PendingAction, stream confirmation prompt, STOP
- c. If None/Low, execute via Executor, feed
tool_resultback to Claude - d. Loop (max 10 tool calls per message)
- If Claude returns text, stream to user
- Update conversation context (entity references, topic)
- Persist messages to MongoDB
Confirmation Flow
PendingActionstored in the conversation's MongoDB document- 5-minute TTL with auto-expiry
- Risk levels:
- None/Low — Execute immediately
- Medium — Brief confirmation
- High — Detailed preview
- Critical — Explicit typed confirmation
- User responds via REST POST
/confirm→ Luma executes or cancels the pending action
Conversation Entity (MongoDB)
{
_id: ObjectId,
businessId: string,
userId: string,
messages: ConversationMessage[],
context: {
activeBusiness, lastClient, lastInvoice,
lastAppointment, conversationTopic, pendingAction
},
summary?: { text, summarizedUpToIndex }, // Auto-summarize after 20 messages
title?: string, // Auto-generated from first message
totalInputTokens: number,
totalOutputTokens: number,
status: 'active' | 'archived',
createdAt, updatedAt
}
Security (Triple Layer)
- Tool filtering: Claude only sees tools the user has permission to use
- Pre-execution check: Verify permissions again before each RPC call
- Target service guards: Downstream services enforce their own business logic and multi-tenancy
RBAC & Permission-Aware AI
Principle: Luma never sees, mentions, or acts on data the current user doesn't have access to. Every interaction is scoped by role, permissions, business tenancy, and subscription tier — enforced at three separate layers before a response ever reaches the user.
Role Hierarchy
Kasha's existing RBAC model is inherited by Luma. The role determines which tools are injected into the Claude context and what data scoping is applied to every query.
| Role | Scope | Luma Behavior |
|---|---|---|
| Owner | Full business | All 149 tools available. Can read/write all entities, view analytics, manage billing, configure business settings. |
| Admin | Full business (no billing) | All tools except billing/subscription management. Full read access to all entities. |
| Manager | Assigned departments | Read/write for assigned domains only. Cannot access business config, billing, or cross-department analytics. |
| Staff | Own assignments | Read own clients, appointments, tasks. Write limited to own schedule, task status updates. No financial data. |
| Client (Portal) | Own entity only | Read own invoices, appointments, documents. Request new bookings. No access to other clients, staff, or internal data. |
Three-Layer Enforcement
┌─────────────────────────────────────────────────────────────────────┐
│ LAYER 1: Tool Injection Filter │
│ ToolsRegistry.getAccessibleTools(user.permissions, user.features) │
│ → Claude only SEES tools the user can use │
│ → No tool = no hallucinated action │
│ → Subscription tier further limits available tools │
│ │
│ Example: Client portal user → 12 tools (not 149) │
│ Example: Staff member → 38 tools (own-scoped) │
│ Example: Owner → 149 tools (full access) │
└────────────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────────────▼────────────────────────────────────┐
│ LAYER 2: Pre-Execution Permission Check │
│ executor.validatePermissions(user, tool, params) │
│ → Re-verify permission BEFORE executing RPC call │
│ → Inject businessId + userId into every query │
│ → Validate entity ownership (e.g., "is this the user's invoice?") │
│ → Reject with friendly Luma message, not a raw error │
└────────────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────────────▼────────────────────────────────────┐
│ LAYER 3: Downstream Service Guards │
│ Target microservice enforces its own @Permissions() decorators, │
│ multi-tenancy WHERE clauses, and business logic │
│ → Even if Layers 1 & 2 fail, the service rejects unauthorized │
│ → Each service is the final authority on its own data │
└─────────────────────────────────────────────────────────────────────┘
Tool-Level Permission Mapping
Every tool definition includes a requiredPermissions array that maps to Kasha's existing permission system:
// Example: scheduling domain tools with permission requirements
{
name: 'scheduling_list_appointments',
requiredPermissions: ['scheduling:read'],
operation: 'read',
dataScope: 'role-based', // Owner/Admin=all, Staff=own, Client=own
// ...
},
{
name: 'scheduling_create_appointment',
requiredPermissions: ['scheduling:write'],
operation: 'write',
riskLevel: 'Medium',
dataScope: 'role-based',
// ...
},
{
name: 'invoicing_view_revenue_report',
requiredPermissions: ['invoicing:read', 'analytics:read'],
operation: 'read',
dataScope: 'admin-only', // Only Owner/Admin roles
// ...
}
Data Scoping per Role
When Luma queries downstream services, the Executor automatically injects scope filters based on the user's role:
// executor.service.ts — scope injection before every RPC call
private scopeParams(user: LumaUser, tool: ToolDefinition, params: any): any {
const scoped = { ...params, businessId: user.businessId };
switch (tool.dataScope) {
case 'own-only':
// Client portal & staff: restrict to own entities
scoped.userId = user.id;
scoped.clientId = user.clientId; // for portal users
break;
case 'department':
// Manager: restrict to assigned departments
scoped.departmentIds = user.assignedDepartments;
break;
case 'role-based':
// Apply userId filter only for Staff/Client roles
if (['staff', 'client'].includes(user.role)) {
scoped.userId = user.id;
}
break;
case 'admin-only':
// No additional filter — but tool is already filtered out for non-admins
break;
}
return scoped;
}
System Prompt Role Context
The system prompt injected into every Claude call includes the user's role context, ensuring Luma never refers to data or actions beyond the user's scope:
// Injected into system prompt dynamically per request
## Current User Context
- Name: {{user.firstName}} {{user.lastName}}
- Role: {{user.role}} ({{user.roleDescription}})
- Business: {{business.name}} (ID: {{business.id}})
- Permissions: {{user.permissions.join(', ')}}
- Subscription: {{business.subscriptionTier}}
## Behavioral Boundaries
- You can ONLY discuss and act on data this user has access to.
- NEVER reference other clients, staff members, or entities outside this user's scope.
- If the user asks about data they don't have access to, explain politely that
they don't have permission and suggest contacting their administrator.
- For Client Portal users: address them as a customer, not an internal user.
Never reveal internal operations, staff names, or business configuration.
Client Portal Isolation
When Luma operates in the client portal context (end-customer view), the experience is fundamentally different:
| Aspect | Admin/Staff View | Client Portal View |
|---|---|---|
| Available tools | 38–149 (role-dependent) | 12 (own data only) |
| Persona | "Your AI business assistant" | "Your personal account assistant" |
| Data visible | All clients, invoices, bookings | Own invoices, own bookings only |
| Actions | Create, update, delete entities | View, request booking, download invoice |
| Financial data | Revenue, margins, reports | Own outstanding balance only |
| Quick actions | New invoice, assign task, etc. | Book appointment, check balance |
Audit Trail
Every Luma action is logged with full user context for compliance and debugging:
{
timestamp: ISODate,
businessId: string,
userId: string,
userRole: string,
conversationId: ObjectId,
action: 'tool_executed' | 'tool_denied' | 'confirmation_requested' | 'confirmation_approved',
toolName: string,
params: Record<string, any>, // sanitized, no PII
result: 'success' | 'denied' | 'error',
denyReason?: string, // e.g., 'insufficient_permissions', 'entity_not_owned'
executionTimeMs: number
}
Vector Memory & Context Engine
Purpose: Give Luma persistent, semantic memory across conversations. Each user and company gets an isolated context window — a growing knowledge base of past interactions, preferences, entity relationships, and business patterns that Luma retrieves via RAG (Retrieval-Augmented Generation) to deliver increasingly relevant responses over time.
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ User sends message │
└──────────────────────────┬──────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────┐
│ 1. EMBED query │
│ Embedding model: text-embedding-3-small (OpenAI) │
│ or voyage-3-lite (Anthropic Voyage) │
│ Dimension: 1024 │
└──────────────────────────┬───────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────┐
│ 2. RETRIEVE from vector store │
│ Qdrant (self-hosted) or pgvector (PostgreSQL extension) │
│ Query: user message embedding │
│ Filter: { businessId, userId } (tenant isolation) │
│ Top-K: 5 most relevant chunks │
│ Score threshold: 0.72 │
└──────────────────────────┬───────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────┐
│ 3. INJECT into Claude system prompt │
│ Retrieved context → "## Relevant History" section │
│ Combined with: conversation history + user role context │
│ → Claude sees the full picture without loading all history │
└──────────────────────────┬───────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────┐
│ 4. STORE new interaction │
│ After response: embed the exchange (user msg + Luma reply) │
│ Chunk strategy: per-exchange (not per-message) │
│ Metadata: timestamp, topic, entities referenced, tool calls │
│ Write to same tenant-scoped collection │
└──────────────────────────────────────────────────────────────────┘
Module Structure
lr-luma-service/
└── src/modules/
└── memory/
├── memory.module.ts # Qdrant/pgvector provider, embedding config
├── memory.service.ts # Core retrieve/store operations
├── memory.interface.ts # MemoryChunk, MemoryQuery, MemoryResult
├── embedding.service.ts # Embedding model abstraction
├── chunking.service.ts # Conversation → chunks with metadata
└── memory-maintenance.service.ts # TTL cleanup, re-embedding, compaction
Tenant Isolation Strategy
Every vector is scoped by businessId and optionally userId, ensuring complete data isolation between tenants and between users within the same tenant.
// Collection/namespace strategy
// Option A: Qdrant — one collection, filtered by payload
Collection: "luma_memory"
Payload fields (indexed):
- businessId: keyword (mandatory filter)
- userId: keyword (optional filter — for personal context)
- scope: keyword (values: 'personal' | 'company')
- topic: keyword (e.g., 'invoicing', 'scheduling')
- timestamp: integer (for recency weighting)
// Option B: pgvector — same PostgreSQL, new table
CREATE TABLE luma_memory_vectors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
business_id UUID NOT NULL REFERENCES businesses(id),
user_id UUID REFERENCES users(id), -- NULL = company-wide context
scope VARCHAR(20) NOT NULL, -- 'personal' | 'company'
content TEXT NOT NULL, -- original text chunk
embedding vector(1024) NOT NULL, -- pgvector column
metadata JSONB, -- topic, entities, tool_calls
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- optional TTL
-- Tenant isolation index
CONSTRAINT idx_memory_tenant ON (business_id, user_id)
);
-- HNSW index for fast similarity search
CREATE INDEX idx_memory_embedding ON luma_memory_vectors
USING hnsw (embedding vector_cosine_ops);
What Gets Stored
Not every message is stored — the memory engine is selective about what enters the vector store:
| Source | Scope | Example |
|---|---|---|
| Conversation summaries | Personal | "User asked about Alpine Tech GmbH invoice #2847 and requested a payment reminder" |
| User preferences | Personal | "User prefers invoice reminders sent on Mondays", "User's default currency is CHF" |
| Entity relationships | Company | "Alpine Tech GmbH — key client, 14 invoices, primary contact: Hans Mueller" |
| Decision patterns | Company | "Booking requests from this client typically need 2-hour slots on Wednesdays" |
| Tool usage patterns | Personal | "User frequently checks banking balance on Friday afternoons" |
| Error resolutions | Company | "Invoice sync with banking failed — resolved by re-linking the bank account" |
Context Window Management
Claude has a finite context window. The memory engine manages what goes in:
// orchestrator.service.ts — context assembly
async buildContext(user: LumaUser, message: string, conversation: Conversation) {
const contextBudget = 8000; // tokens reserved for retrieved context
let usedTokens = 0;
// 1. Current conversation history (most recent 10 messages)
const recentHistory = conversation.messages.slice(-10);
usedTokens += estimateTokens(recentHistory);
// 2. Conversation summary (if > 20 messages, auto-summarized)
if (conversation.summary) {
usedTokens += estimateTokens(conversation.summary.text);
}
// 3. Vector memory retrieval (fill remaining budget)
const remainingBudget = contextBudget - usedTokens;
const memories = await this.memoryService.retrieve({
query: message,
businessId: user.businessId,
userId: user.id,
maxTokens: remainingBudget,
topK: 5,
minScore: 0.72,
scopes: ['personal', 'company'], // personal results ranked higher
});
// 4. Assemble system prompt section
return {
recentHistory,
summary: conversation.summary?.text,
relevantMemory: memories.map(m => m.content),
};
}
Memory Lifecycle
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ CAPTURE │───▶│ EMBED │───▶│ STORE │───▶│ RETRIEVE │
│ │ │ │ │ │ │ │
│ Conversation │ │ text → vec │ │ Qdrant / │ │ Semantic │
│ ends or hits │ │ 1024-dim │ │ pgvector │ │ search on │
│ 20+ messages │ │ embedding │ │ with tenant │ │ next query │
│ │ │ │ │ metadata │ │ │
└──────────────┘ └──────────────┘ └──────┬───────┘ └──────────────┘
│
┌──────▼───────┐
│ MAINTAIN │
│ │
│ TTL expiry │
│ Compaction │
│ Re-embed on │
│ model change │
└──────────────┘
- Capture: At conversation end or every 20 messages, the chunking service extracts meaningful exchanges
- Embed: Each chunk is embedded using the configured embedding model (1024 dimensions)
- Store: Vector + metadata written to the tenant-scoped collection
- Retrieve: On every new message, top-K similar chunks are retrieved and injected into context
- Maintain: Background job handles TTL expiry (default 180 days), compaction of redundant memories, and re-embedding when the model changes
RBAC Integration
Vector memory respects the same permission model as the rest of Luma:
- Personal memories are scoped to
userId— only the user who created them can retrieve them - Company memories are scoped to
businessId— accessible to all users in the business with sufficient role - Client portal users can only retrieve memories from their own interactions — never from admin/staff conversations about them
- Memory retrieval applies the same data scoping filters as tool execution (Layer 2)
- Admin conversations about a client (internal notes, strategy) are stored as company-scope and never surfaced to the client's portal
Technology Decision
| Option | Pros | Cons | Recommendation |
|---|---|---|---|
| pgvector | Already have PostgreSQL; no new infra; ACID transactions; familiar tooling | Performance degrades at >1M vectors; limited ANN algorithm options | Phase 1–3 Start here |
| Qdrant | Purpose-built; fast at scale; payload filtering; built-in tenant isolation | Additional infrastructure; operational overhead; one more service to manage | Phase 4+ Migrate when scale demands |
The memory.service.ts abstraction layer makes the underlying store swappable without changing the orchestrator or any upstream code.
API Gateway Integration
New Files in lr-api-gateway
src/luma/
luma.module.ts # Module with RabbitMQ exchange/queue bindings
luma.controller.ts # REST endpoints
luma.service.ts # Proxy to lr-luma-service via RabbitMQ
luma.gateway.ts # Socket.IO namespace /luma
luma.const.ts # Events, room naming helpers
dto/ # CreateConversation, SendMessage, ConfirmAction DTOs
REST Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/:businessId/luma/conversations |
Start conversation |
| GET | /api/:businessId/luma/conversations |
List conversations |
| GET | /api/:businessId/luma/conversations/:id |
Get with messages |
| DELETE | /api/:businessId/luma/conversations/:id |
Delete conversation |
| POST | /api/:businessId/luma/conversations/:id/messages |
Send message (triggers stream) |
| POST | /api/:businessId/luma/conversations/:id/confirm |
Confirm/cancel pending action |
All endpoints use: @UseGuards(JwtAuthGuard, PermissionsAuthGuard, SubscriptionGuard) with @AuthPermissions(PermissionsEnum.ACCESS_LUMA).
Streaming Flow
- User sends message via REST POST → returns
202 { messageId, status: 'processing' } - Gateway forwards to
lr-luma-servicevia RabbitMQsend() - Luma service processes and publishes stream tokens to
e.lr-luma-streamingexchange - Gateway's
LumaGatewayconsumes fromq.lr-luma-streamingqueue (matchingAppNotificationsGatewaypattern) - Gateway emits tokens to Socket.IO room
luma:{userId}:{conversationId} - Final
stream_completeevent carries full assembled message
Socket.IO Events
| Event | Direction | Purpose |
|---|---|---|
luma.join_conversation |
Client → Server | Join conversation room |
luma.leave_conversation |
Client → Server | Leave room |
luma.stream_token |
Server → Client | Streaming text chunk |
luma.stream_complete |
Server → Client | Stream finished with full message |
luma.stream_error |
Server → Client | Stream failed |
luma.action_confirmation_required |
Server → Client | Needs user confirmation |
luma.disambiguation_required |
Server → Client | Needs user selection |
luma.action_executed |
Server → Client | Action completed |
luma.typing |
Server → Client | Luma is thinking |
Framework Changes
| File | Change |
|---|---|
lr-be-framework/commons/constants/services-names.const.ts |
Add LR_LUMA_SERVICE |
lr-be-framework RabbitMQ enums |
Add streaming exchange/queue/route |
lr-api-gateway/src/common/enums/service-provider-tokens.enum.ts |
Add LUMA_SERVICE |
lr-api-gateway/src/auth/enums/permissions.enum.ts |
Add ACCESS_LUMA |
lr-api-gateway/src/app.module.ts |
Import LumaModule |
Frontend: lr-luma-ui
Delivery: iframe (matching existing chat widget pattern)
- New Next.js 15 app:
lr-luma-ui - Embedded as an iframe via
<LumaWidget />inUserProvider(alongside existing<ChatWidget />) postMessagecommunication for context passing from parent apps- Zero changes needed in individual apps — Luma appears everywhere automatically
Chat Interface
- Floating widget: 56x56px button (bottom-right), expands to 400x600px panel
- Full-page mode:
/lumaroute inlr-onboarding-uifor power users - Components: Message bubbles, streaming text renderer, typing indicator, rich response cards, quick action chips, context indicator, conversation list
Rich Response Cards
| Card | Purpose |
|---|---|
| ConfirmationCard | Yes/No with action details (red styling for high-risk) |
| DisambiguationCard | Numbered clickable options |
| AppointmentCard | Date, time, client, service + Confirm/Reschedule/Cancel |
| InvoiceCard | Line items, totals, VAT + Create/Edit/Cancel |
| BalanceCard | Account balance + recent transactions |
| ClientCard | Name, email, phone, last appointment |
| ProgressCard | Multi-step workflow progress bar |
| ErrorCard | Error description + recovery action options |
Context-Aware Behavior
Parent apps broadcast page context to the Luma iframe via postMessage on route changes:
{ appModule: 'invoices', entityType: 'invoice', entityId: 'INV-089', ... }
Luma uses this to:
- Show contextual greetings ("I see you're looking at Invoice #89...")
- Offer relevant quick actions (Send, Mark Paid, Create Reminder)
- Pre-fill entity references when the user makes requests
Framework Additions
| File | Change |
|---|---|
lr-fe-framework/components/luma/luma-widget.tsx |
Iframe wrapper component |
lr-fe-framework/components/providers/user-provider.tsx |
Add <LumaWidget /> |
lr-fe-framework/hooks/use-socket.tsx |
Add Luma = '/luma' to SocketNamespace |
lr-fe-framework/hooks/use-luma.ts |
Context broadcasting, open/close helpers |
lr-fe-framework/lib/utils/luma.utils.ts |
openLuma() utility (like openChatWidget()) |
10. Phased Rollout
Luma will be delivered in 5 phases over approximately 18-24 weeks, progressively building capability from read-only queries to full platform control.
Foundation
4-6 weeksBackend Deliverables:
- Scaffold
lr-luma-servicewith config, MongoDB, PostgreSQL - LLM module with Anthropic provider (streaming)
- Tool registry with 3 demo domains: scheduling (read), client management (read), search
- Conversation CRUD
- Executor with 5 service proxies (booking, CRM, search, invoicing, banking)
- Gateway module with REST endpoints + Socket.IO + streaming queue
Frontend Deliverables:
- Scaffold
lr-luma-uiNext.js app - Basic chat UI (message list, composer, typing indicator, streaming text)
- LumaWidget iframe component in lr-fe-framework
- Socket.IO integration for
/lumanamespace
Core Read-Write
4-6 weeksBackend Deliverables:
- Orchestrator with full agentic loop (tool call → execute → feed result)
- Confirmation module with risk-based flow
- Context module with entity tracking and topic detection
- Remaining tool definitions (all 13 domains)
- Remaining 12 service proxies
Frontend Deliverables:
- Confirmation and disambiguation cards
- Appointment, invoice, balance, client cards
- Context indicator and quick actions
- Context broadcasting from parent apps
Advanced Features
4-6 weeksBackend Deliverables:
- Multi-step workflow orchestration
- Conversation summarization (auto-summarize after 20 messages)
- All remaining tool definitions
Frontend Deliverables:
- Progress cards for multi-step workflows
- Error cards with recovery actions
- Full-page mode in lr-onboarding-ui
- Conversation history list
- Quick commands (power user shorthand syntax)
Full Rollout
3-4 weeksNew Capability Domains:
- Documents & Contracts (12 capabilities)
- Business Config (17 capabilities)
- Tasks & Notes (5 capabilities)
- KYC/Compliance (5 capabilities)
- E-commerce (10 capabilities)
Polish & Analytics
3-4 weeksDeliverables:
- PostHog integration for usage tracking
- Thumbs up/down feedback on responses
- Performance optimization (lazy loading, message virtualization)
- Conversation search
- User preferences
- Keyboard shortcuts (Ctrl+K to open Luma)
- Onboarding tour for first-time users
11. Capability Map
Luma provides 149 distinct capabilities across 13 business domains, connecting natural language to RabbitMQ microservices.
| Domain | Read Capabilities | Write Capabilities | Total |
|---|---|---|---|
| Scheduling & Availability | 8 | 14 | 22 |
| Client Management | 5 | 8 | 13 |
| Invoicing & Quotes | 7 | 9 | 16 |
| Banking & Payments | 5 | 9 | 14 |
| Website & Content | 6 | 14 | 20 |
| Communications | 3 | 4 | 7 |
| Tasks & Notes | 2 | 3 | 5 |
| Business Config | 6 | 11 | 17 |
| Documents & Contracts | 5 | 7 | 12 |
| Search & Lookup | 2 | 0 | 2 |
| Compliance (KYC) | 2 | 3 | 5 |
| E-commerce | 5 | 5 | 10 |
| Analytics & Insights | 6 | 0 | 6 |
| TOTAL | 62 | 87 | 149 |
Risk Classification Matrix
| Risk Level | Examples | Confirmation Required | Reversible |
|---|---|---|---|
| None | Search, list, view, check balance, analytics | No | N/A |
| Low | Mark notification read, update own profile | No | Yes |
| Medium | Create client, create invoice (draft), update settings, block time | Brief confirmation | Yes (soft-delete, draft state) |
| High | Send invoice, send contract, make payment, cancel appointment | Detailed confirmation with preview | Partially (credit note, reschedule) |
| Critical | Delete records, make large payments, cancel subscription, close account | Explicit confirmation with safeguard | No |
12. System Prompt
Luma's core identity and operational protocols, embedded in every conversation.
# ROLE: LUMA, THE KASHA BUSINESS ASSISTANT
You are Luma, the intelligent personal assistant for Kasha, a comprehensive B2B SaaS platform. Your goal is to help business owners manage their operations across 13 domains: Scheduling, Client Management, Invoicing, Banking, Website, Communications, Tasks, Business Config, Documents, Search, KYC, E-commerce, and Analytics.
# OPERATIONAL PROTOCOLS
1. TOOL USAGE: You execute actions via specialized tool calls. Do not explain that you are "using a tool"; simply provide the result or ask for confirmation.
2. CONFIRMATION HIERARCHY:
- READ (GET/LIST): Execute immediately.
- WRITE (CREATE/UPDATE/DELETE): Always present a summary and ask: "Shall I go ahead with this?"
- HIGH-RISK (Bank Transfers, Deletions, Mass Emails): Provide a detailed preview. Use bold text for amounts, dates, and names. Require explicit "Confirm" or "Yes".
3. DISAMBIGUATION: If a user's request matches multiple entities (e.g., "Sarah Smith" vs. "Sarah Jones"), present the options clearly with identifying details (email or last activity) and ask for clarification.
4. CONTEXT MEMORY: Maintain a "Working Context." Remember the last referenced client, invoice, or appointment. Use relative terms like "her invoice" or "that booking" correctly.
5. ERROR HANDLING:
- Unknown Request: "I don't have the capability to [action] yet. However, I can help you with [related Kasha domain]."
- Permission Denied: "It looks like I don't have permission to access that. You may need to check your role settings in the Business Config."
- Service Errors: "I'm having trouble connecting to the [Service Name] right now. Please try again in a moment or let me know if I can help with something else."
# TONE & STYLE
- Professional, warm, and proactive.
- Use business terminology (e.g., "reconcile," "remittance," "availability," "lead conversion").
- Be concise. Avoid "As an AI..." or "Certainly, I can help with that."
- Address the user as a partner in their business growth.
# DOMAIN KNOWLEDGE (149 CAPABILITIES)
- Refer to the platform as "Kasha."
- When creating content (emails, invoices), align with the user's established business voice.
13. Conversation Flow Examples
Real-world conversation patterns demonstrating read operations, write confirmations, disambiguation, multi-step workflows, and error recovery.
Example 1: Simple Read - Banking Balance
Example 2: Write with Confirmation - Scheduling
Example 3: Disambiguation - CRM/Invoicing
Example 4: Multi-step Workflow - Onboarding
Example 5: Error Recovery - Service Interruption
14. Context-Aware Greetings
Luma adjusts its greeting based on which Kasha app the user is viewing, offering proactive assistance.
| Page Context | Luma's Greeting Message |
|---|---|
| Invoicing Dashboard | "Welcome back. You have 3 overdue invoices totaling $1,240. Would you like me to send automated reminders or check your recent payments?" |
| Client Profile (e.g., John Doe) | "I'm looking at John Doe's history. He hasn't visited in 3 months—shall I send a 'We Miss You' discount code or check his last payment status?" |
| Booking Calendar | "Your morning is fully booked, but you have a gap from 1:00 PM to 3:00 PM. Would you like me to open those slots for online booking or schedule your admin tasks?" |
| Banking Transactions | "I see 12 unreconciled transactions from this week. Shall we categorize them now to keep your tax reports up to date?" |
| Website Builder | "The 'Services' page was last updated 10 days ago. Would you like to add a new gallery block or optimize your SEO descriptions for better search ranking?" |
| Main Dashboard (Fresh Login) | "Good morning! You have 4 appointments today and 2 new leads from your website. Where would you like to focus first?" |
15. Quick Action Mapping
Context-aware quick action chips appear above the input field, adapting to the current app module.
| App/UI Module | Quick Action Chips |
|---|---|
| lr-banking-ui | Transfer Funds View Statement Categorize Expenses Freeze Card |
| lr-invoicing-ui | New Invoice Send Reminders View Tax Est. Record Payment |
| lr-booking-ui | New Appt Block Time Share Booking Link Day Summary |
| lr-crm-ui | Add Client Import Contacts Email List Note to Last Client |
| lr-website-builder-ui | Publish Site Edit SEO Add Service View Analytics |
| lr-company-ui | Update Hours Team Access Business Logo Subscription |
| lr-ecom-ui | Add Product Inventory Check Discount Code Order Status |
| lr-kyc-ui | Verify User Audit Log Verification Status Policy Update |
| lr-crm-ui (Comms) | Send SMS Email Campaign Chat Settings Auto-Reply |
| lr-documents-ui | Upload File Sign Request Contract Template Archive |
16. UI Component Prototypes
Production-ready React TypeScript components with Tailwind CSS, designed for Next.js 15 and React 19.
LumaStreamingText
Core ComponentProgressive text rendering with blinking cursor for streaming AI responses.
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { cn } from '@kashatech/lr-fe-framework/lib/utils/cn.utils';
interface LumaStreamingTextProps {
text: string;
isStreaming?: boolean;
speed?: number;
className?: string;
onComplete?: () => void;
}
export const LumaStreamingText: React.FC<LumaStreamingTextProps> = ({
text,
isStreaming = false,
speed = 20,
className,
onComplete,
}) => {
const [displayedText, setDisplayedText] = useState('');
const [currentIndex, setCurrentIndex] = useState(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!isStreaming) {
setDisplayedText(text);
return;
}
if (currentIndex < text.length) {
timeoutRef.current = setTimeout(() => {
setDisplayedText((prev) => prev + text[currentIndex]);
setCurrentIndex((prev) => prev + 1);
}, speed);
} else if (onComplete) {
onComplete();
}
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [text, currentIndex, isStreaming, speed, onComplete]);
return (
<div className={cn('relative inline-block font-sans text-sm leading-relaxed', className)}>
{displayedText}
{isStreaming && (
<span className="inline-block w-1.5 h-4 ml-1 bg-purple-500 animate-pulse align-middle" />
)}
</div>
);
};
LumaMessageBubble
Core ComponentMessage bubble handling both user and Luma messages with avatar, timestamp, and streaming support.
'use client';
import React from 'react';
import { cn } from '@kashatech/lr-fe-framework/lib/utils/cn.utils';
import { LumaStreamingText } from './LumaStreamingText';
import { Sparkles } from 'lucide-react';
interface LumaMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
isStreaming?: boolean;
}
interface LumaMessageBubbleProps {
message: LumaMessage;
}
export const LumaMessageBubble: React.FC<LumaMessageBubbleProps> = ({ message }) => {
const isAssistant = message.role === 'assistant';
return (
<div
className={cn(
'flex w-full mb-4 animate-in fade-in slide-in-from-bottom-2 duration-300',
isAssistant ? 'justify-start' : 'justify-end'
)}
>
<div className={cn('flex max-w-[85%] px-1', isAssistant ? 'flex-row' : 'flex-row-reverse')}>
{isAssistant && (
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center mr-2 mt-1 shadow-sm">
<Sparkles className="w-4 h-4 text-white" />
</div>
)}
<div
className={cn(
'relative px-4 py-2.5 rounded-2xl shadow-sm border',
isAssistant
? 'bg-white text-gray-800 rounded-tl-none border-gray-100'
: 'bg-purple-600 text-white rounded-tr-none border-purple-500'
)}
>
{isAssistant ? (
<LumaStreamingText
text={message.content}
isStreaming={message.isStreaming}
/>
) : (
<p className="text-sm font-sans leading-relaxed">{message.content}</p>
)}
<div
className={cn(
'text-[10px] mt-1 opacity-50',
isAssistant ? 'text-gray-500' : 'text-purple-100 text-right'
)}
>
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
</div>
</div>
);
};
LumaComposer
Core ComponentAuto-growing textarea input with send button and context-aware quick action chips.
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { Send, Sparkles, PlusCircle } from 'lucide-react';
import { Button } from '@kashatech/lr-fe-framework/components/ui/button';
import { cn } from '@kashatech/lr-fe-framework/lib/utils/cn.utils';
interface QuickAction {
id: string;
label: string;
icon?: React.ReactNode;
}
interface LumaComposerProps {
onSendMessage: (text: string) => void;
isLoading?: boolean;
quickActions?: QuickAction[];
}
const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
{ id: 'balance', label: 'Check balance' },
{ id: 'schedule', label: 'Today schedule' },
{ id: 'invoice', label: 'Send invoice' },
];
export const LumaComposer: React.FC<LumaComposerProps> = ({
onSendMessage,
isLoading = false,
quickActions = DEFAULT_QUICK_ACTIONS,
}) => {
const [input, setInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const adjustHeight = () => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
}
};
useEffect(() => {
adjustHeight();
}, [input]);
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
if (input.trim() && !isLoading) {
onSendMessage(input.trim());
setInput('');
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<div className="p-3 bg-white border-t border-gray-100">
{/* Quick Action Chips */}
<div className="flex gap-2 mb-3 overflow-x-auto no-scrollbar pb-1">
{quickActions.map((action) => (
<button
key={action.id}
onClick={() => onSendMessage(action.label)}
className="flex-shrink-0 px-3 py-1.5 text-xs font-medium text-purple-700 bg-purple-50 rounded-full border border-purple-100 hover:bg-purple-100 transition-colors flex items-center gap-1"
>
{action.label}
</button>
))}
</div>
<form onSubmit={handleSubmit} className="relative flex items-end gap-2 bg-gray-50 rounded-2xl p-2 border border-gray-200 focus-within:border-purple-300 transition-colors">
<button type="button" className="p-2 text-gray-400 hover:text-purple-500 transition-colors">
<PlusCircle className="w-5 h-5" />
</button>
<textarea
ref={textareaRef}
rows={1}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask Luma anything..."
className="w-full bg-transparent border-none focus:ring-0 text-sm py-2 resize-none max-h-[120px] text-gray-700 placeholder:text-gray-400"
/>
<Button
type="submit"
disabled={!input.trim() || isLoading}
size="icon"
className={cn(
"rounded-xl w-10 h-10 flex-shrink-0 transition-all duration-200",
input.trim() ? "bg-purple-600 hover:bg-purple-700 shadow-md" : "bg-gray-200"
)}
>
{isLoading ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Send className="w-4 h-4 text-white" />
)}
</Button>
</form>
<div className="text-[10px] text-center text-gray-400 mt-2 flex items-center justify-center gap-1">
<Sparkles className="w-2.5 h-2.5" />
Luma AI can make mistakes. Check important info.
</div>
</div>
);
};
LumaChatPanel
Layout ComponentExpanded 400x600px chat panel with header, message list, context bar, and composer.
'use client';
import React, { useRef, useEffect } from 'react';
import { X, Minus, Sparkles, Search, MoreHorizontal } from 'lucide-react';
import { LumaMessageBubble } from './LumaMessageBubble';
import { LumaComposer } from './LumaComposer';
import { cn } from '@kashatech/lr-fe-framework/lib/utils/cn.utils';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
isStreaming?: boolean;
}
interface LumaChatPanelProps {
isOpen: boolean;
onClose: () => void;
onMinimize: () => void;
}
export const LumaChatPanel: React.FC<LumaChatPanelProps> = ({ isOpen, onClose, onMinimize }) => {
const [messages, setMessages] = React.useState<Message[]>([
{
id: '1',
role: 'assistant',
content: "Hi Jurgen! I noticed you have 3 overdue invoices. Would you like me to send reminders?",
timestamp: new Date(),
}
]);
const [isTyping, setIsTyping] = React.useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const handleSendMessage = (text: string) => {
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
content: text,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMsg]);
setIsTyping(true);
setTimeout(() => {
setIsTyping(false);
const aiMsg: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `I'm processing your request for "${text}". Just a moment...`,
timestamp: new Date(),
isStreaming: true,
};
setMessages((prev) => [...prev, aiMsg]);
}, 1000);
};
if (!isOpen) return null;
return (
<div className="fixed bottom-24 right-6 w-[400px] h-[600px] bg-white rounded-3xl shadow-2xl flex flex-col overflow-hidden border border-gray-100 z-[9999] animate-in zoom-in-95 fade-in duration-300 origin-bottom-right">
{/* Header */}
<div className="bg-gradient-to-r from-purple-600 to-indigo-700 p-4 text-white">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-white/20 flex items-center justify-center backdrop-blur-md">
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="font-semibold text-sm">Luma AI</h3>
<div className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
<span className="text-[10px] text-purple-100">Personal Assistant • Kasha</span>
</div>
</div>
</div>
<div className="flex items-center gap-1">
<button onClick={onMinimize} className="p-1.5 hover:bg-white/10 rounded-lg transition-colors">
<Minus className="w-4 h-4" />
</button>
<button onClick={onClose} className="p-1.5 hover:bg-white/10 rounded-lg transition-colors">
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Context Bar */}
<div className="bg-black/10 rounded-xl p-2.5 flex items-center justify-between backdrop-blur-sm border border-white/10">
<div className="flex items-center gap-2 overflow-hidden">
<Search className="w-3.5 h-3.5 text-purple-200" />
<span className="text-xs text-purple-50 truncate">Viewing "Invoicing Dashboard"</span>
</div>
<MoreHorizontal className="w-4 h-4 text-purple-200 cursor-pointer" />
</div>
</div>
{/* Message List */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-4 bg-gray-50/50 space-y-2 no-scrollbar"
>
<div className="text-center py-4">
<span className="text-[10px] font-medium text-gray-400 uppercase tracking-wider">Today</span>
</div>
{messages.map((msg) => (
<LumaMessageBubble key={msg.id} message={msg} />
))}
{isTyping && (
<div className="flex justify-start mb-4">
<div className="flex items-center gap-2 bg-white px-4 py-3 rounded-2xl rounded-tl-none border border-gray-100 shadow-sm">
<div className="flex gap-1">
<div className="w-1.5 h-1.5 bg-purple-400 rounded-full animate-bounce [animation-delay:-0.3s]" />
<div className="w-1.5 h-1.5 bg-purple-400 rounded-full animate-bounce [animation-delay:-0.15s]" />
<div className="w-1.5 h-1.5 bg-purple-400 rounded-full animate-bounce" />
</div>
</div>
</div>
)}
</div>
{/* Composer */}
<LumaComposer onSendMessage={handleSendMessage} isLoading={isTyping} />
</div>
);
};
LumaMinimizedButton
Layout ComponentFloating 56x56px action button with unread badge, pulse animation, and suggestion tooltip.
'use client';
import React from 'react';
import { Sparkles } from 'lucide-react';
import { cn } from '@kashatech/lr-fe-framework/lib/utils/cn.utils';
interface LumaMinimizedButtonProps {
onClick: () => void;
unreadCount?: number;
hasSuggestion?: boolean;
}
export const LumaMinimizedButton: React.FC<LumaMinimizedButtonProps> = ({
onClick,
unreadCount = 0,
hasSuggestion = false,
}) => {
return (
<div className="fixed bottom-6 right-6 z-[9999]">
<button
onClick={onClick}
className={cn(
"relative w-14 h-14 rounded-full flex items-center justify-center shadow-xl transition-all duration-300 hover:scale-110 active:scale-95 group overflow-hidden",
hasSuggestion
? "bg-gradient-to-br from-purple-600 via-indigo-600 to-pink-500 animate-gradient-x"
: "bg-purple-600 hover:bg-purple-700"
)}
>
{hasSuggestion && (
<span className="absolute inset-0 rounded-full bg-purple-400 animate-ping opacity-25 scale-150" />
)}
<Sparkles className="w-7 h-7 text-white group-hover:rotate-12 transition-transform duration-300" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-pink-500 text-[10px] font-bold text-white border-2 border-white shadow-sm animate-in zoom-in">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{hasSuggestion && (
<div className="absolute right-16 bottom-2 bg-white px-3 py-2 rounded-xl shadow-lg border border-purple-100 whitespace-nowrap animate-in slide-in-from-right-2 fade-in duration-500">
<div className="text-xs font-semibold text-purple-900">Luma has a suggestion</div>
<div className="text-[10px] text-gray-500">About your overdue invoices</div>
<div className="absolute top-1/2 -right-1.5 -translate-y-1/2 border-y-[6px] border-y-transparent border-l-[6px] border-l-white" />
</div>
)}
</div>
);
};
LumaWidget
OrchestratorTop-level orchestrator coordinating minimized button and expanded panel states.
'use client';
import React, { useState } from 'react';
import { LumaMinimizedButton } from './LumaMinimizedButton';
import { LumaChatPanel } from './LumaChatPanel';
export const LumaWidget: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [unreadCount, setUnreadCount] = useState(1);
const [hasSuggestion, setHasSuggestion] = useState(true);
const handleOpen = () => {
setIsOpen(true);
setUnreadCount(0);
setHasSuggestion(false);
};
const handleClose = () => {
setIsOpen(false);
};
const handleMinimize = () => {
setIsOpen(false);
};
return (
<>
{!isOpen && (
<LumaMinimizedButton
onClick={handleOpen}
unreadCount={unreadCount}
hasSuggestion={hasSuggestion}
/>
)}
<LumaChatPanel
isOpen={isOpen}
onClose={handleClose}
onMinimize={handleMinimize}
/>
</>
);
};
17. Rich Response Cards
Interactive cards for confirmations, disambiguations, appointments, invoices, balances, progress tracking, and error recovery.
ConfirmationCard
Response CardRisk-tiered confirmation interface with color-coded severity levels (low, medium, high, critical).
'use client';
import React, { useState } from 'react';
import { AlertTriangle, ShieldAlert, CheckCircle2, X } from 'lucide-react';
interface ConfirmationCardProps {
title: string;
description: string;
severity: 'low' | 'medium' | 'high' | 'critical';
details: Record<string, string>;
onConfirm: () => void;
onCancel: () => void;
}
export const ConfirmationCard: React.FC<ConfirmationCardProps> = ({
title, description, severity, details, onConfirm, onCancel,
}) => {
const [confirmText, setConfirmText] = useState('');
const styles = {
low: { bg: 'bg-slate-50', border: 'border-slate-200', text: 'text-slate-900', icon: <CheckCircle2 className="text-slate-500" />, btn: 'bg-slate-900 hover:bg-slate-800' },
medium: { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', icon: <CheckCircle2 className="text-blue-500" />, btn: 'bg-blue-600 hover:bg-blue-700' },
high: { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-900', icon: <AlertTriangle className="text-amber-500" />, btn: 'bg-amber-600 hover:bg-amber-700' },
critical: { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', icon: <ShieldAlert className="text-red-500" />, btn: 'bg-red-600 hover:bg-red-700 disabled:bg-red-300' },
}[severity];
return (
<div className={`my-4 p-5 rounded-2xl border ${styles.border} ${styles.bg} animate-in fade-in slide-in-from-bottom-2 duration-500 shadow-sm max-w-md`}>
<div className="flex items-start gap-3 mb-4">
{styles.icon}
<div>
<h3 className={`font-semibold ${styles.text}`}>{title}</h3>
<p className="text-sm opacity-80 mt-1">{description}</p>
</div>
</div>
<div className="space-y-2 mb-5">
{Object.entries(details).map(([key, value]) => (
<div key={key} className="flex justify-between text-xs py-1.5 border-b border-black/5 last:border-0">
<span className="font-medium uppercase tracking-wider opacity-60">{key}</span>
<span className="font-semibold">{value}</span>
</div>
))}
</div>
{severity === 'critical' && (
<div className="mb-4">
<label className="text-[10px] font-bold uppercase text-red-600 mb-1 block">Type 'CONFIRM' to proceed</label>
<input
type="text"
className="w-full px-3 py-2 rounded-lg border border-red-200 focus:outline-none focus:ring-2 focus:ring-red-500 text-sm"
placeholder="CONFIRM"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
/>
</div>
)}
<div className="flex gap-2">
<button
onClick={onCancel}
className="flex-1 px-4 py-2 rounded-xl text-sm font-medium border border-black/10 hover:bg-black/5 transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={severity === 'critical' && confirmText !== 'CONFIRM'}
className={`flex-1 px-4 py-2 rounded-xl text-sm font-medium text-white transition-all ${styles.btn}`}
>
Confirm
</button>
</div>
</div>
);
};
DisambiguationCard
Response CardNumbered clickable options for resolving ambiguous entities or multi-match scenarios.
'use client';
import React from 'react';
import { ChevronRight, HelpCircle } from 'lucide-react';
interface DisambiguationCardProps {
question: string;
options: Array<{ label: string; value: string; description?: string }>;
onSelect: (value: string) => void;
}
export const DisambiguationCard: React.FC<DisambiguationCardProps> = ({
question, options, onSelect,
}) => {
return (
<div className="my-4 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm max-w-sm">
<div className="bg-gray-50 px-4 py-3 border-b border-gray-100 flex items-center gap-2">
<HelpCircle size={16} className="text-blue-500" />
<p className="text-sm font-medium text-gray-700">{question}</p>
</div>
<div className="divide-y divide-gray-100">
{options.map((option, index) => (
<button
key={option.value}
onClick={() => onSelect(option.value)}
className="w-full text-left px-4 py-3 hover:bg-blue-50 transition-colors flex items-center justify-between group"
>
<div className="flex gap-3 items-center">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-gray-100 text-[10px] font-bold text-gray-500 group-hover:bg-blue-500 group-hover:text-white transition-colors">
{index + 1}
</span>
<div>
<div className="text-sm font-bold text-gray-900">{option.label}</div>
{option.description && (
<div className="text-xs text-gray-500 leading-tight mt-0.5">{option.description}</div>
)}
</div>
</div>
<ChevronRight size={16} className="text-gray-300 group-hover:text-blue-400 transition-colors" />
</button>
))}
</div>
</div>
);
};
AppointmentCard
Response CardDisplays appointment details with status badge and action buttons (Confirm, Reschedule, Cancel).
'use client';
import React from 'react';
import { Calendar, Clock, MapPin, User, CheckCircle, AlertCircle, XCircle } from 'lucide-react';
interface AppointmentCardProps {
date: string; time: string; duration: string; clientName: string;
serviceName: string; locationName: string; staffName?: string;
status: 'pending' | 'confirmed' | 'cancelled';
onAction?: (action: 'confirm' | 'reschedule' | 'cancel') => void;
}
export const AppointmentCard: React.FC<AppointmentCardProps> = ({
date, time, duration, clientName, serviceName, locationName, staffName, status, onAction
}) => {
const statusConfig = {
confirmed: { color: 'text-emerald-600', bg: 'bg-emerald-50', border: 'border-emerald-100', icon: <CheckCircle size={14} /> },
pending: { color: 'text-amber-600', bg: 'bg-amber-50', border: 'border-amber-100', icon: <AlertCircle size={14} /> },
cancelled: { color: 'text-red-600', bg: 'bg-red-50', border: 'border-red-100', icon: <XCircle size={14} /> },
}[status];
return (
<div className="my-4 rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm max-w-sm">
<div className={`px-4 py-2 border-b flex items-center justify-between ${statusConfig.bg} ${statusConfig.border}`}>
<span className={`text-[10px] font-bold uppercase tracking-widest flex items-center gap-1.5 ${statusConfig.color}`}>
{statusConfig.icon} {status}
</span>
<span className="text-[10px] font-medium text-gray-400 uppercase tracking-widest">ID: {Math.random().toString(36).substr(2, 6).toUpperCase()}</span>
</div>
<div className="p-4 space-y-4">
<div>
<h4 className="text-lg font-bold text-gray-900 leading-tight">{serviceName}</h4>
<p className="text-sm text-gray-500 flex items-center gap-1 mt-1">
<User size={14} /> {clientName}
</p>
</div>
<div className="grid grid-cols-2 gap-4 py-3 border-y border-gray-50">
<div className="space-y-1">
<div className="flex items-center gap-2 text-gray-400 text-[10px] font-bold uppercase tracking-wider">
<Calendar size={12} /> Date
</div>
<div className="text-sm font-semibold">{date}</div>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-gray-400 text-[10px] font-bold uppercase tracking-wider">
<Clock size={12} /> Time
</div>
<div className="text-sm font-semibold">{time} <span className="text-gray-400 font-normal">({duration})</span></div>
</div>
</div>
<div className="space-y-2 text-sm text-gray-600">
<div className="flex items-start gap-2">
<MapPin size={16} className="text-gray-400 shrink-0 mt-0.5" />
<span>{locationName}</span>
</div>
{staffName && (
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-blue-100 text-[8px] flex items-center justify-center font-bold text-blue-600 uppercase">
{staffName[0]}
</div>
<span>Assigned to <span className="font-medium text-gray-900">{staffName}</span></span>
</div>
)}
</div>
<div className="flex gap-2 pt-2">
{status === 'pending' && (
<button onClick={() => onAction?.('confirm')} className="flex-1 bg-emerald-600 text-white text-xs font-bold py-2.5 rounded-xl hover:bg-emerald-700 transition-colors shadow-sm shadow-emerald-200">
Confirm
</button>
)}
<button onClick={() => onAction?.('reschedule')} className="flex-1 bg-gray-50 text-gray-700 text-xs font-bold py-2.5 rounded-xl hover:bg-gray-100 transition-colors border border-gray-200">
Reschedule
</button>
<button onClick={() => onAction?.('cancel')} className="flex-none px-3 bg-white text-red-500 text-xs font-bold py-2.5 rounded-xl hover:bg-red-50 transition-colors border border-red-100">
Cancel
</button>
</div>
</div>
</div>
);
};
InvoiceCard
Response CardInvoice preview with line items, VAT calculation, totals, and action buttons (Create & Send, Edit, Cancel).
'use client';
import React from 'react';
import { FileText, Download, Send, Edit3, Trash2 } from 'lucide-react';
interface InvoiceCardProps {
invoiceNumber: string; clientName: string;
lineItems: Array<{ description: string; quantity: number; unitPrice: number }>;
subtotal: number; vatAmount: number; vatRate: number; total: number;
currency: string; dueDate: string;
status: 'draft' | 'sent' | 'paid' | 'overdue';
onAction?: (action: string) => void;
}
export const InvoiceCard: React.FC<InvoiceCardProps> = ({
invoiceNumber, clientName, lineItems, subtotal, vatAmount, vatRate, total, currency, dueDate, status, onAction
}) => {
const statusMap = {
paid: 'bg-green-100 text-green-700',
sent: 'bg-blue-100 text-blue-700',
draft: 'bg-gray-100 text-gray-600',
overdue: 'bg-red-100 text-red-700',
};
return (
<div className="my-4 p-6 rounded-2xl border border-gray-200 bg-white shadow-lg max-w-md">
<div className="flex justify-between items-start mb-6">
<div>
<div className="flex items-center gap-2 mb-1">
<FileText className="text-blue-600" size={20} />
<h3 className="text-lg font-bold text-gray-900">Invoice {invoiceNumber}</h3>
</div>
<p className="text-sm text-gray-500">Bill to: <span className="font-semibold text-gray-700">{clientName}</span></p>
</div>
<span className={`text-[10px] font-bold uppercase tracking-wider px-2 py-1 rounded-md ${statusMap[status]}`}>
{status}
</span>
</div>
<div className="overflow-hidden border-t border-gray-100 mb-4">
<table className="w-full text-left text-sm">
<thead>
<tr className="text-[10px] font-bold text-gray-400 uppercase tracking-widest border-b border-gray-50">
<th className="py-3 font-medium">Item</th>
<th className="py-3 text-right font-medium">Qty</th>
<th className="py-3 text-right font-medium">Price</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{lineItems.map((item, i) => (
<tr key={i}>
<td className="py-3 text-gray-700">{item.description}</td>
<td className="py-3 text-right text-gray-500">{item.quantity}</td>
<td className="py-3 text-right text-gray-700 font-medium">
{currency}{item.unitPrice.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="bg-gray-50 rounded-xl p-4 space-y-2 mb-6">
<div className="flex justify-between text-xs text-gray-500">
<span>Subtotal</span>
<span>{currency}{subtotal.toLocaleString()}</span>
</div>
<div className="flex justify-between text-xs text-gray-500">
<span>VAT ({vatRate}%)</span>
<span>{currency}{vatAmount.toLocaleString()}</span>
</div>
<div className="flex justify-between text-base font-bold text-gray-900 pt-2 border-t border-gray-200">
<span>Total</span>
<span>{currency}{total.toLocaleString()}</span>
</div>
<div className="text-[10px] text-gray-400 text-center pt-2 italic">
Due by {dueDate}
</div>
</div>
<div className="flex gap-2">
{status === 'draft' && (
<button onClick={() => onAction?.('send')} className="flex-1 bg-blue-600 text-white text-xs font-bold py-2.5 rounded-xl flex items-center justify-center gap-2 hover:bg-blue-700 transition-colors shadow-sm shadow-blue-200">
<Send size={14} /> Create & Send
</button>
)}
<button onClick={() => onAction?.('edit')} className="flex-1 bg-white text-gray-700 text-xs font-bold py-2.5 rounded-xl flex items-center justify-center gap-2 border border-gray-200 hover:bg-gray-50 transition-colors">
<Edit3 size={14} /> Edit
</button>
<button onClick={() => onAction?.('cancel')} className="p-2.5 rounded-xl border border-red-100 text-red-500 hover:bg-red-50 transition-colors">
<Trash2 size={16} />
</button>
</div>
</div>
);
};
BalanceCard
Response CardAccount balance display with trend indicator and recent transaction list.
'use client';
import React from 'react';
import { TrendingUp, TrendingDown, ArrowUpRight, Wallet, History } from 'lucide-react';
interface BalanceCardProps {
balance: number; currency: string; accountName: string;
recentTransactions: Array<{ amount: number; description: string; date: string; direction: 'in' | 'out' }>;
}
export const BalanceCard: React.FC<BalanceCardProps> = ({
balance, currency, accountName, recentTransactions
}) => {
return (
<div className="my-4 rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm max-w-sm">
<div className="p-6 bg-gradient-to-br from-slate-900 to-slate-800 text-white">
<div className="flex justify-between items-center mb-4">
<div className="p-2 bg-white/10 rounded-lg">
<Wallet size={18} className="text-slate-300" />
</div>
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">{accountName}</span>
</div>
<p className="text-slate-400 text-sm mb-1">Available Balance</p>
<div className="flex items-baseline gap-2">
<h2 className="text-3xl font-bold">{currency}{balance.toLocaleString()}</h2>
<span className={`flex items-center text-xs font-bold ${balance >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{balance >= 0 ? <TrendingUp size={12} className="mr-1" /> : <TrendingDown size={12} className="mr-1" />}
+2.4%
</span>
</div>
</div>
<div className="p-4">
<div className="flex justify-between items-center mb-3">
<h4 className="text-[10px] font-bold uppercase tracking-widest text-gray-400 flex items-center gap-1.5">
<History size={12} /> Recent Transactions
</h4>
</div>
<div className="space-y-3">
{recentTransactions.map((tx, i) => (
<div key={i} className="flex justify-between items-center">
<div className="flex gap-3 items-center">
<div className={`p-1.5 rounded-lg ${tx.direction === 'in' ? 'bg-emerald-50 text-emerald-600' : 'bg-rose-50 text-rose-600'}`}>
{tx.direction === 'in' ? <ArrowUpRight size={14} className="rotate-180" /> : <ArrowUpRight size={14} />}
</div>
<div>
<p className="text-xs font-bold text-gray-900 leading-none mb-1">{tx.description}</p>
<p className="text-[10px] text-gray-400">{tx.date}</p>
</div>
</div>
<span className={`text-sm font-bold ${tx.direction === 'in' ? 'text-emerald-600' : 'text-gray-900'}`}>
{tx.direction === 'in' ? '+' : '-'}{currency}{tx.amount.toLocaleString()}
</span>
</div>
))}
</div>
<button className="w-full mt-5 py-2 text-xs font-bold text-blue-600 bg-blue-50 hover:bg-blue-100 transition-colors rounded-xl">
View all transactions
</button>
</div>
</div>
);
};
ProgressCard
Response CardMulti-step workflow progress tracker with status indicators (pending, active, complete).
'use client';
import React from 'react';
import { Check, Loader2, Circle } from 'lucide-react';
interface ProgressCardProps {
title: string;
steps: Array<{ label: string; status: 'pending' | 'active' | 'complete' }>;
currentStep: number;
}
export const ProgressCard: React.FC<ProgressCardProps> = ({ title, steps, currentStep }) => {
return (
<div className="my-4 p-5 rounded-2xl border border-gray-200 bg-white shadow-sm max-w-sm">
<h3 className="text-sm font-bold text-gray-900 mb-6 px-1 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />
{title}
</h3>
<div className="space-y-0">
{steps.map((step, index) => {
const isLast = index === steps.length - 1;
const status = step.status;
return (
<div key={index} className="flex gap-4">
<div className="flex flex-col items-center">
<div className={`
z-10 w-6 h-6 rounded-full flex items-center justify-center transition-all duration-300
${status === 'complete' ? 'bg-emerald-500 text-white' :
status === 'active' ? 'bg-blue-100 text-blue-600 border-2 border-blue-500 ring-4 ring-blue-50' :
'bg-gray-100 text-gray-400 border border-gray-200'}
`}>
{status === 'complete' ? <Check size={14} strokeWidth={3} /> :
status === 'active' ? <Loader2 size={12} className="animate-spin" /> :
<Circle size={8} fill="currentColor" />}
</div>
{!isLast && (
<div className={`w-0.5 h-10 transition-colors duration-500 ${status === 'complete' ? 'bg-emerald-500' : 'bg-gray-100'}`} />
)}
</div>
<div className="pt-0.5 pb-6">
<p className={`text-sm font-bold transition-colors ${status === 'active' ? 'text-blue-600' : status === 'complete' ? 'text-gray-900' : 'text-gray-400'}`}>
{step.label}
</p>
{status === 'active' && (
<span className="text-[10px] text-blue-500 font-medium animate-pulse">Processing...</span>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
ErrorCard
Response CardError display with recovery action suggestions and dismiss option.
'use client';
import React from 'react';
import { AlertTriangle, RefreshCw, XCircle, ChevronRight } from 'lucide-react';
interface ErrorCardProps {
title: string;
message: string;
recoveryOptions: Array<{ label: string; action: () => void }>;
}
export const ErrorCard: React.FC<ErrorCardProps> = ({ title, message, recoveryOptions }) => {
return (
<div className="my-4 overflow-hidden rounded-2xl border border-red-200 bg-white shadow-lg shadow-red-100/50 max-w-sm animate-in zoom-in-95 duration-200">
<div className="bg-red-50 p-4 flex items-center gap-3">
<div className="p-2 bg-red-100 rounded-xl text-red-600">
<AlertTriangle size={20} />
</div>
<div>
<h3 className="text-sm font-bold text-red-900">{title}</h3>
<p className="text-[11px] text-red-700/80 font-medium">ERROR_CODE: {Math.random().toString(36).substr(7).toUpperCase()}</p>
</div>
</div>
<div className="p-4">
<p className="text-sm text-gray-600 leading-relaxed mb-5">
{message}
</p>
<div className="space-y-2">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">Recommended actions</p>
{recoveryOptions.map((option, i) => (
<button
key={i}
onClick={option.action}
className="w-full flex items-center justify-between p-3 rounded-xl bg-gray-50 border border-gray-100 hover:bg-white hover:border-red-200 hover:shadow-sm transition-all group text-left"
>
<div className="flex items-center gap-2">
<RefreshCw size={14} className="text-red-500 group-hover:rotate-180 transition-transform duration-500" />
<span className="text-sm font-semibold text-gray-700">{option.label}</span>
</div>
<ChevronRight size={14} className="text-gray-300 group-hover:text-red-400 transition-transform group-hover:translate-x-0.5" />
</button>
))}
<button className="w-full p-3 rounded-xl flex items-center justify-center gap-2 text-xs font-bold text-gray-400 hover:text-gray-600 transition-colors">
<XCircle size={14} /> Dismiss
</button>
</div>
</div>
</div>
);
};