feat: Azure chat function wiring and MIM chat agent UI

- Register AI chat HTTP function and export api entrypoints.
- DI container and donation handlers updated for chat integration.
- Frontend: MimChatAgent, App wiring, Vite env types.
- Refresh api/package-lock.json after dependency install.

Made-with: Cursor
This commit is contained in:
defiQUG
2026-04-07 22:59:20 -07:00
parent 5ba74050fc
commit 101fdb90e2
8 changed files with 678 additions and 84 deletions
+2
View File
@@ -386,6 +386,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -1701,6 +1702,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
+153 -61
View File
@@ -1,19 +1,110 @@
import { CosmosClient, Database, Container } from '@azure/cosmos';
import { SecretClient } from '@azure/keyvault-secrets';
import { DefaultAzureCredential } from '@azure/identity';
export interface ServiceContainer {
cosmosClient: CosmosClient;
database: Database;
donationsContainer: Container;
volunteersContainer: Container;
programsContainer: Container;
secretClient: SecretClient;
}
class DIContainer {
private static instance: DIContainer;
private services: ServiceContainer | null = null;
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { CosmosClient } from '@azure/cosmos';
import { SecretClient } from '@azure/keyvault-secrets';
import { DefaultAzureCredential } from '@azure/identity';
import type { Donation } from './types';
export interface ServiceContainer {
donationsContainer: DonationContainerLike;
volunteersContainer: DonationContainerLike;
programsContainer: DonationContainerLike;
secretClient: SecretClientLike;
}
type QueryParameter = { name: string; value: string };
type DonationQuerySpec = {
query: string;
parameters?: QueryParameter[];
};
type DonationContainerLike = {
items: {
create(item: Donation): Promise<void>;
query(spec: DonationQuerySpec): {
fetchAll(): Promise<{ resources: Donation[] }>;
};
};
};
type SecretClientLike = {
getSecret(name: string): Promise<{ value?: string }>;
};
class EnvSecretClient implements SecretClientLike {
public async getSecret(name: string): Promise<{ value?: string }> {
const envName = name.toUpperCase().replace(/-/g, '_');
return { value: process.env[envName] };
}
}
class FileBackedContainer implements DonationContainerLike {
private readonly filePath: string;
public constructor(collectionName: string) {
const dataDir = process.env.MIM_DATA_DIR || path.resolve(process.cwd(), 'data');
this.filePath = path.join(dataDir, `${collectionName}.json`);
}
public items = {
create: async (item: Donation): Promise<void> => {
const items = await this.readAll();
items.push(item);
await this.writeAll(items);
},
query: (spec: DonationQuerySpec) => ({
fetchAll: async (): Promise<{ resources: Donation[] }> => {
let items = await this.readAll();
const status = spec.parameters?.find((parameter) => parameter.name === '@status')?.value;
const program = spec.parameters?.find((parameter) => parameter.name === '@program')?.value;
if (status) {
items = items.filter((item) => item.status === status);
}
if (program) {
items = items.filter((item) => item.program === program);
}
items.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
return { resources: items };
}
})
};
private async ensureFile(): Promise<void> {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
try {
await fs.access(this.filePath);
} catch {
await fs.writeFile(this.filePath, '[]\n', 'utf8');
}
}
private async readAll(): Promise<Donation[]> {
await this.ensureFile();
const raw = await fs.readFile(this.filePath, 'utf8');
if (!raw.trim()) {
return [];
}
return JSON.parse(raw) as Donation[];
}
private async writeAll(items: Donation[]): Promise<void> {
await this.ensureFile();
const tempPath = `${this.filePath}.tmp`;
await fs.writeFile(tempPath, `${JSON.stringify(items, null, 2)}\n`, 'utf8');
await fs.rename(tempPath, this.filePath);
}
}
class DIContainer {
private static instance: DIContainer;
private services: ServiceContainer | null = null;
private constructor() {}
@@ -24,50 +115,51 @@ class DIContainer {
return DIContainer.instance;
}
public async initializeServices(): Promise<ServiceContainer> {
if (this.services) {
return this.services;
}
try {
// Initialize Cosmos DB
const cosmosConnectionString = process.env.COSMOS_CONNECTION_STRING;
if (!cosmosConnectionString) {
throw new Error('COSMOS_CONNECTION_STRING is not configured');
}
const cosmosClient = new CosmosClient(cosmosConnectionString);
const databaseName = process.env.COSMOS_DATABASE_NAME || 'MiraclesInMotion';
const database = cosmosClient.database(databaseName);
// Get containers
const donationsContainer = database.container('donations');
const volunteersContainer = database.container('volunteers');
const programsContainer = database.container('programs');
// Initialize Key Vault
const keyVaultUrl = process.env.KEY_VAULT_URL;
if (!keyVaultUrl) {
throw new Error('KEY_VAULT_URL is not configured');
}
const credential = new DefaultAzureCredential();
const secretClient = new SecretClient(keyVaultUrl, credential);
this.services = {
cosmosClient,
database,
donationsContainer,
volunteersContainer,
programsContainer,
secretClient
};
console.log('✅ Services initialized successfully');
return this.services;
} catch (error) {
console.error('❌ Failed to initialize services:', error);
throw error;
public async initializeServices(): Promise<ServiceContainer> {
if (this.services) {
return this.services;
}
try {
const cosmosConnectionString = process.env.COSMOS_CONNECTION_STRING;
const keyVaultUrl = process.env.KEY_VAULT_URL;
let donationsContainer: DonationContainerLike;
let volunteersContainer: DonationContainerLike;
let programsContainer: DonationContainerLike;
if (cosmosConnectionString) {
const cosmosClient = new CosmosClient(cosmosConnectionString);
const databaseName = process.env.COSMOS_DATABASE_NAME || 'MiraclesInMotion';
const database = cosmosClient.database(databaseName);
donationsContainer = database.container('donations') as unknown as DonationContainerLike;
volunteersContainer = database.container('volunteers') as unknown as DonationContainerLike;
programsContainer = database.container('programs') as unknown as DonationContainerLike;
} else {
donationsContainer = new FileBackedContainer('donations');
volunteersContainer = new FileBackedContainer('volunteers');
programsContainer = new FileBackedContainer('programs');
}
const secretClient = keyVaultUrl
? new SecretClient(keyVaultUrl, new DefaultAzureCredential())
: new EnvSecretClient();
this.services = {
donationsContainer,
volunteersContainer,
programsContainer,
secretClient
};
console.log(
`Services initialized successfully (${cosmosConnectionString ? 'cosmos' : 'local-file'} storage, ${keyVaultUrl ? 'key-vault' : 'env'} secrets)`
);
return this.services;
} catch (error) {
console.error('❌ Failed to initialize services:', error);
throw error;
}
}
@@ -79,4 +171,4 @@ class DIContainer {
}
}
export default DIContainer;
export default DIContainer;
+181
View File
@@ -0,0 +1,181 @@
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
const DEFAULT_MODEL = 'grok-3';
const DEFAULT_BASE_URL = 'https://api.x.ai/v1';
const MAX_MESSAGES = 10;
const MAX_MESSAGE_CHARS = 4000;
type ChatRole = 'system' | 'assistant' | 'user';
interface ChatMessage {
role: ChatRole;
content: string;
}
interface ChatRequestBody {
messages?: ChatMessage[];
pageContext?: Record<string, string | undefined>;
}
interface xAIChatResponse {
choices?: Array<{
message?: {
content?: string;
};
}>;
output_text?: string;
output?: Array<{
content?: Array<{
text?: string;
}>;
}>;
}
function jsonResponse(status: number, body: Record<string, unknown>): HttpResponseInit {
return {
status,
jsonBody: body,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
}
};
}
function normalizeMessages(messages: ChatMessage[] | undefined): ChatMessage[] {
if (!Array.isArray(messages)) {
return [];
}
return messages
.filter((message): message is ChatMessage => Boolean(message && typeof message === 'object'))
.map(message => ({
role: message.role,
content: String(message.content || '').trim().slice(0, MAX_MESSAGE_CHARS)
}))
.filter(message => ['assistant', 'user', 'system'].includes(message.role) && message.content.length > 0)
.slice(-MAX_MESSAGES);
}
function buildSystemPrompt(pagePath: string): string {
return [
'You are the Miracles in Motion website assistant for MIM4U.',
'Answer as a warm, concise concierge for a nonprofit that supports students with school supplies, clothing, shoes, emergency support, volunteer coordination, donations, and sponsorships.',
'Use only the provided context and the user messages. If you are unsure, say so and direct the user to [email protected].',
'Keep replies practical and short. Prefer bullets only when they improve clarity.',
'When relevant, guide users to these routes: #/donate, #/volunteers, #/request-assistance, #/impact, #/stories.',
'Do not invent phone numbers, addresses, office hours, eligibility rules, or application steps.',
'If someone needs urgent support, tell them to use #/request-assistance and contact [email protected] directly.',
`Current page route: ${pagePath || '/'}`
].join('\n');
}
function extractReply(payload: xAIChatResponse): string {
const choiceReply = payload.choices?.[0]?.message?.content?.trim();
if (choiceReply) {
return choiceReply;
}
const outputText = payload.output_text?.trim();
if (outputText) {
return outputText;
}
const outputReply = payload.output
?.flatMap(item => item.content ?? [])
.map(item => item.text?.trim() ?? '')
.filter(Boolean)
.join('\n')
.trim();
return outputReply || '';
}
export async function chatWithAssistant(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
if (request.method === 'OPTIONS') {
return jsonResponse(200, { ok: true });
}
const apiKey = process.env.XAI_API_KEY?.trim();
if (!apiKey) {
return jsonResponse(503, {
success: false,
error: 'Assistant is not configured on the server.',
timestamp: new Date().toISOString()
});
}
try {
const body = await request.json() as ChatRequestBody;
const messages = normalizeMessages(body.messages);
if (messages.length === 0) {
return jsonResponse(400, {
success: false,
error: 'At least one message is required.',
timestamp: new Date().toISOString()
});
}
const pagePath = body.pageContext?.path || '/';
const model = process.env.XAI_MODEL?.trim() || process.env.EXPLORER_AI_MODEL?.trim() || DEFAULT_MODEL;
const baseUrl = (process.env.XAI_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(/\/+$/, '');
const upstreamResponse = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model,
stream: false,
messages: [
{ role: 'system', content: buildSystemPrompt(pagePath) },
...messages
]
})
});
const payload = await upstreamResponse.json() as xAIChatResponse;
if (!upstreamResponse.ok) {
context.error('xAI upstream returned an error', upstreamResponse.status, payload);
return jsonResponse(502, {
success: false,
error: 'Assistant upstream request failed.',
timestamp: new Date().toISOString()
});
}
const reply = extractReply(payload);
if (!reply) {
return jsonResponse(502, {
success: false,
error: 'Assistant returned an empty response.',
timestamp: new Date().toISOString()
});
}
return jsonResponse(200, {
success: true,
reply,
model,
timestamp: new Date().toISOString()
});
} catch (error) {
context.error('Error in ai chat handler:', error);
return jsonResponse(500, {
success: false,
error: 'Assistant request failed.',
timestamp: new Date().toISOString()
});
}
}
app.http('chatWithAssistant', {
methods: ['POST', 'OPTIONS'],
authLevel: 'anonymous',
route: 'ai/chat',
handler: chatWithAssistant
});
+14 -10
View File
@@ -31,13 +31,17 @@ export async function createDonation(request: HttpRequest, context: InvocationCo
}
// Initialize Stripe if payment method is stripe
let stripePaymentIntentId: string | undefined;
if (donationRequest.paymentMethod === 'stripe') {
try {
const stripeSecretKey = await secretClient.getSecret('stripe-secret-key');
const stripe = new Stripe(stripeSecretKey.value!, {
apiVersion: '2025-02-24.acacia'
});
let stripePaymentIntentId: string | undefined;
if (donationRequest.paymentMethod === 'stripe') {
try {
const stripeSecretKey = await secretClient.getSecret('stripe-secret-key');
if (!stripeSecretKey.value) {
throw new Error('stripe-secret-key is not configured');
}
const stripe = new Stripe(stripeSecretKey.value!, {
apiVersion: '2025-02-24.acacia'
});
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(donationRequest.amount * 100), // Convert to cents
@@ -89,8 +93,8 @@ export async function createDonation(request: HttpRequest, context: InvocationCo
updatedAt: new Date().toISOString()
};
// Save to Cosmos DB
await donationsContainer.items.create(donation);
// Persist the donation using the active backend.
await donationsContainer.items.create(donation);
const response: ApiResponse<Donation> = {
success: true,
@@ -132,4 +136,4 @@ app.http('createDonation', {
authLevel: 'anonymous',
route: 'donations',
handler: createDonation
});
});
+3
View File
@@ -0,0 +1,3 @@
import './donations/createDonation';
import './donations/getDonations';
import './ai/chat';
+24 -13
View File
@@ -58,9 +58,10 @@ import {
import AIAssistancePortal from './components/AIAssistancePortal'
// Phase 3B: Enterprise Components
import AdvancedAnalyticsDashboard from './components/AdvancedAnalyticsDashboard'
import MobileVolunteerApp from './components/MobileVolunteerApp'
import StaffTrainingDashboard from './components/StaffTrainingDashboard'
import AdvancedAnalyticsDashboard from './components/AdvancedAnalyticsDashboard'
import MobileVolunteerApp from './components/MobileVolunteerApp'
import StaffTrainingDashboard from './components/StaffTrainingDashboard'
import MimChatAgent from './components/MimChatAgent'
// Phase 4: Extracted Components
import { Navigation } from './components/Navigation'
@@ -4602,9 +4603,9 @@ function AppContent() {
}
}
return (
<div className={`min-h-screen transition-colors duration-300 bg-neutral-50 text-neutral-900 antialiased selection:bg-primary-200/60 dark:bg-neutral-950 dark:text-neutral-50 ${darkMode ? 'dark' : ''}`}>
<BackgroundDecor />
return (
<div className={`min-h-screen transition-colors duration-300 bg-neutral-50 text-neutral-900 antialiased selection:bg-primary-200/60 dark:bg-neutral-950 dark:text-neutral-50 ${darkMode ? 'dark' : ''}`}>
<BackgroundDecor />
<SkipToContent />
{/* Offline Indicator */}
@@ -4627,10 +4628,20 @@ function AppContent() {
<main id="content" className="relative flex-1">
{renderPage()}
</main>
<Footer />
<StickyDonate />
<CookieBanner />
</div>
)
}
<Footer />
{![
'/admin-portal',
'/volunteer-portal',
'/resource-portal',
'/analytics',
'/ai-portal',
'/advanced-analytics',
'/mobile-volunteer',
'/staff-training'
].includes(currentPath) && <MimChatAgent currentPath={currentPath} />}
<StickyDonate />
<CookieBanner />
</div>
)
}
+290
View File
@@ -0,0 +1,290 @@
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Bot, MessageCircle, Send, Sparkles, X } from 'lucide-react'
type ChatRole = 'system' | 'assistant' | 'user'
interface ChatMessage {
id: string
role: Exclude<ChatRole, 'system'>
content: string
}
interface BackendChatResponse {
reply?: string
error?: string
model?: string
}
const CHAT_ENABLED = import.meta.env.VITE_ENABLE_CHAT === 'true'
const CHAT_API_BASE_URL = (import.meta.env.VITE_CHAT_API_BASE_URL?.trim() || '').replace(/\/+$/, '')
const CONTACT_EMAIL = import.meta.env.VITE_CONTACT_EMAIL?.trim() || '[email protected]'
const SUGGESTIONS = [
'How do I request help with school supplies?',
'What kinds of clothing assistance do you offer?',
'How can I donate or volunteer today?',
'What emergency support is available for students?'
]
const INITIAL_MESSAGE: ChatMessage = {
id: 'welcome',
role: 'assistant',
content: `Hi, Im the Miracles in Motion assistant. I can help you find programs, request support, donate, or volunteer. If something needs human follow-up, Ill point you to ${CONTACT_EMAIL}.`
}
function buildMessages(history: ChatMessage[]): Array<{ role: ChatRole; content: string }> {
return history.slice(-8).map(message => ({
role: message.role,
content: message.content
}))
}
interface MimChatAgentProps {
currentPath: string
}
export default function MimChatAgent({ currentPath }: MimChatAgentProps) {
const [isOpen, setIsOpen] = useState(false)
const [input, setInput] = useState('')
const [messages, setMessages] = useState<ChatMessage[]>([INITIAL_MESSAGE])
const [isSending, setIsSending] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const bodyRef = useRef<HTMLDivElement | null>(null)
const isConfigured = CHAT_ENABLED
const hasConversation = messages.length > 1
const chatEndpoint = `${CHAT_API_BASE_URL}/api/ai/chat`
const quickSuggestions = useMemo(
() => (hasConversation ? SUGGESTIONS.slice(0, 2) : SUGGESTIONS),
[hasConversation]
)
useEffect(() => {
if (!bodyRef.current) {
return
}
bodyRef.current.scrollTop = bodyRef.current.scrollHeight
}, [messages, isOpen])
if (!isConfigured) {
return null
}
const handleSubmit = async (event?: FormEvent<HTMLFormElement>, presetPrompt?: string) => {
event?.preventDefault()
const prompt = (presetPrompt ?? input).trim()
if (!prompt || isSending) {
return
}
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: prompt
}
const nextMessages = [...messages, userMessage]
setMessages(nextMessages)
setInput('')
setIsSending(true)
setErrorMessage(null)
try {
const response = await fetch(chatEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
messages: buildMessages(nextMessages),
pageContext: {
path: currentPath
}
})
})
if (!response.ok) {
throw new Error(`xAI returned ${response.status}`)
}
const payload = (await response.json()) as BackendChatResponse
const assistantReply = payload.reply?.trim() || "I couldn't generate a response just now. Please try again."
setMessages(prev => [
...prev,
{
id: `assistant-${Date.now()}`,
role: 'assistant',
content: assistantReply
}
])
} catch (error) {
console.error('MIM chat request failed:', error)
const fallback = `Im having trouble reaching the assistant right now. Please try again in a moment, or email ${CONTACT_EMAIL} for help.`
setErrorMessage(fallback)
setMessages(prev => [
...prev,
{
id: `assistant-error-${Date.now()}`,
role: 'assistant',
content: fallback
}
])
} finally {
setIsSending(false)
}
}
const resetConversation = () => {
setMessages([INITIAL_MESSAGE])
setInput('')
setErrorMessage(null)
}
return (
<div className="fixed bottom-5 right-5 z-[70] flex max-w-[calc(100vw-2rem)] flex-col items-end gap-3 sm:bottom-6 sm:right-6">
<AnimatePresence>
{isOpen && (
<motion.section
id="mim-chat-agent-panel"
initial={{ opacity: 0, y: 18, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.96 }}
transition={{ duration: 0.18 }}
className="w-[min(24rem,calc(100vw-2rem))] overflow-hidden rounded-[1.75rem] border border-white/60 bg-white/92 shadow-[0_24px_80px_rgba(219,39,119,0.22)] backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/92"
aria-label="Miracles in Motion AI chat assistant"
>
<div className="bg-gradient-to-r from-primary-600 via-pink-500 to-fuchsia-500 px-4 py-4 text-white">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.2em] text-white/80">
<Sparkles className="h-4 w-4" />
MIM Assistant
</div>
<h2 className="mt-1 text-lg font-semibold">Ask Miracles in Motion</h2>
<p className="mt-1 text-sm text-white/85">
Program guidance, donation help, and quick next steps.
</p>
</div>
<button
type="button"
onClick={() => setIsOpen(false)}
className="rounded-full border border-white/25 bg-white/10 p-2 text-white transition hover:bg-white/20"
aria-label="Close chat assistant"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div ref={bodyRef} className="max-h-[26rem] space-y-4 overflow-y-auto px-4 py-4">
{messages.map(message => (
<div
key={message.id}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] rounded-2xl px-4 py-3 text-sm leading-6 shadow-sm ${
message.role === 'user'
? 'rounded-br-md bg-neutral-900 text-white dark:bg-white dark:text-neutral-900'
: 'rounded-bl-md bg-pink-50 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-100'
}`}
>
{message.content}
</div>
</div>
))}
{isSending && (
<div className="flex justify-start">
<div className="rounded-2xl rounded-bl-md bg-pink-50 px-4 py-3 text-sm text-neutral-700 dark:bg-neutral-900 dark:text-neutral-200">
Thinking...
</div>
</div>
)}
{!hasConversation && (
<div className="flex flex-wrap gap-2">
{quickSuggestions.map(suggestion => (
<button
key={suggestion}
type="button"
onClick={() => void handleSubmit(undefined, suggestion)}
className="rounded-full border border-pink-200 bg-white px-3 py-2 text-left text-xs font-medium text-neutral-700 transition hover:border-pink-300 hover:bg-pink-50 dark:border-white/10 dark:bg-neutral-950 dark:text-neutral-200 dark:hover:bg-neutral-900"
>
{suggestion}
</button>
))}
</div>
)}
</div>
<div className="border-t border-neutral-200/80 px-4 py-4 dark:border-white/10">
<form onSubmit={event => void handleSubmit(event)} className="space-y-3">
<div className="flex items-end gap-2">
<textarea
value={input}
onChange={event => setInput(event.target.value)}
rows={2}
placeholder="Ask about programs, requests, donations, or volunteering..."
className="min-h-[3.25rem] flex-1 resize-none rounded-2xl border border-neutral-200 bg-white px-4 py-3 text-sm text-neutral-900 outline-none transition focus:border-pink-400 focus:ring-2 focus:ring-pink-200 dark:border-white/10 dark:bg-neutral-950 dark:text-white dark:focus:border-pink-500 dark:focus:ring-pink-500/20"
/>
<button
type="submit"
disabled={!input.trim() || isSending}
className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-r from-primary-600 to-pink-500 text-white shadow-lg transition hover:scale-[1.02] disabled:cursor-not-allowed disabled:opacity-60"
aria-label="Send message"
>
<Send className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
AI can help you navigate the site, but humans can help at {CONTACT_EMAIL}.
</p>
<button
type="button"
onClick={resetConversation}
className="text-xs font-medium text-pink-600 transition hover:text-pink-700 dark:text-pink-400 dark:hover:text-pink-300"
>
Clear
</button>
</div>
</form>
{errorMessage && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
{errorMessage}
</p>
)}
</div>
</motion.section>
)}
</AnimatePresence>
<motion.button
type="button"
onClick={() => setIsOpen(open => !open)}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.98 }}
className="group inline-flex items-center gap-3 rounded-full border border-white/50 bg-neutral-950 px-4 py-3 text-sm font-semibold text-white shadow-[0_18px_55px_rgba(17,24,39,0.3)] transition hover:bg-neutral-900 dark:border-white/10"
aria-expanded={isOpen}
aria-controls="mim-chat-agent-panel"
>
<span className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-gradient-to-r from-primary-600 to-pink-500 text-white shadow-inner">
{isOpen ? <X className="h-5 w-5" /> : <MessageCircle className="h-5 w-5" />}
</span>
<span className="hidden sm:flex sm:flex-col sm:items-start sm:leading-tight">
<span>Need help?</span>
<span className="text-xs font-normal text-white/70">Chat with MIM Assistant</span>
</span>
<Bot className="hidden h-4 w-4 text-white/60 sm:block" />
</motion.button>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_ENABLE_CHAT?: string
readonly VITE_CHAT_API_BASE_URL?: string
readonly VITE_CONTACT_EMAIL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}