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:
+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';
|
||||
Reference in New Issue
Block a user