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
+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';