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:
Generated
+2
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import './donations/createDonation';
|
||||
import './donations/getDonations';
|
||||
import './ai/chat';
|
||||
+24
-13
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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, I’m the Miracles in Motion assistant. I can help you find programs, request support, donate, or volunteer. If something needs human follow-up, I’ll 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 = `I’m 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>
|
||||
)
|
||||
}
|
||||
Vendored
+11
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user