diff --git a/api/package-lock.json b/api/package-lock.json index 0522dd7..a9fa71c 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -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", diff --git a/api/src/DIContainer.ts b/api/src/DIContainer.ts index 171d97e..21eabbf 100644 --- a/api/src/DIContainer.ts +++ b/api/src/DIContainer.ts @@ -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; + 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 => { + 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 { + 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 { + 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 { + 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 { - 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 { + 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; \ No newline at end of file +export default DIContainer; diff --git a/api/src/ai/chat.ts b/api/src/ai/chat.ts new file mode 100644 index 0000000..2dc7fe9 --- /dev/null +++ b/api/src/ai/chat.ts @@ -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; +} + +interface xAIChatResponse { + choices?: Array<{ + message?: { + content?: string; + }; + }>; + output_text?: string; + output?: Array<{ + content?: Array<{ + text?: string; + }>; + }>; +} + +function jsonResponse(status: number, body: Record): 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 info@miraclesinmotion.org.', + '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 info@miraclesinmotion.org 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 { + 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 +}); diff --git a/api/src/donations/createDonation.ts b/api/src/donations/createDonation.ts index 2a45fd3..202bc5a 100644 --- a/api/src/donations/createDonation.ts +++ b/api/src/donations/createDonation.ts @@ -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 = { success: true, @@ -132,4 +136,4 @@ app.http('createDonation', { authLevel: 'anonymous', route: 'donations', handler: createDonation -}); \ No newline at end of file +}); diff --git a/api/src/index.ts b/api/src/index.ts new file mode 100644 index 0000000..81e45e9 --- /dev/null +++ b/api/src/index.ts @@ -0,0 +1,3 @@ +import './donations/createDonation'; +import './donations/getDonations'; +import './ai/chat'; diff --git a/src/App.tsx b/src/App.tsx index b4d6f4d..f6f9d55 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( -
- + return ( +
+ {/* Offline Indicator */} @@ -4627,10 +4628,20 @@ function AppContent() {
{renderPage()}
- -
- - -
- ) -} \ No newline at end of file + +
+ {![ + '/admin-portal', + '/volunteer-portal', + '/resource-portal', + '/analytics', + '/ai-portal', + '/advanced-analytics', + '/mobile-volunteer', + '/staff-training' + ].includes(currentPath) && } + + +
+ ) +} diff --git a/src/components/MimChatAgent.tsx b/src/components/MimChatAgent.tsx new file mode 100644 index 0000000..9cff24e --- /dev/null +++ b/src/components/MimChatAgent.tsx @@ -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 + 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() || 'info@miraclesinmotion.org' + +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([INITIAL_MESSAGE]) + const [isSending, setIsSending] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + const bodyRef = useRef(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, 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 ( +
+ + {isOpen && ( + +
+
+
+
+ + MIM Assistant +
+

Ask Miracles in Motion

+

+ Program guidance, donation help, and quick next steps. +

+
+ +
+
+ +
+ {messages.map(message => ( +
+
+ {message.content} +
+
+ ))} + + {isSending && ( +
+
+ Thinking... +
+
+ )} + + {!hasConversation && ( +
+ {quickSuggestions.map(suggestion => ( + + ))} +
+ )} +
+ +
+
void handleSubmit(event)} className="space-y-3"> +
+