mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
feat(memoro): add transcription fallback chain, AI provider fallbacks, and error tracking
Audio: WhisperX → Azure Realtime → FFmpeg → Azure Batch fallback chain with diarization. Server: mana-llm → Gemini → Azure OpenAI fallback, rate limiting middleware. Web: GlitchTip error tracking, error page, security headers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bee8bcb234
commit
90f6c0db39
10 changed files with 823 additions and 426 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -20,6 +20,7 @@ import { cleanupRoutes } from './routes/cleanup';
|
|||
import { meetingRoutes } from './routes/meetings';
|
||||
import { meetingWebhookRoutes } from './routes/meetings-webhooks';
|
||||
import { COSTS } from './lib/credits';
|
||||
import { rateLimiter } from './middleware/rate-limiter';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
|
|
@ -46,6 +47,16 @@ app.use(
|
|||
})
|
||||
);
|
||||
|
||||
// ── Rate limiting ─────────────────────────────────────────────────────────────
|
||||
|
||||
app.use(
|
||||
'/api/v1/*',
|
||||
rateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 100,
|
||||
})
|
||||
);
|
||||
|
||||
// ── Health check ───────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/health', (c) =>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,25 @@
|
|||
/**
|
||||
* AI text generation with Gemini (primary) → Azure OpenAI (fallback).
|
||||
* AI text generation with mana-llm (primary) → Gemini → Azure OpenAI (fallbacks).
|
||||
*
|
||||
* Mirrors the NestJS AiService without the DI framework.
|
||||
* Fallback chain:
|
||||
* 1. mana-llm (self-hosted, OpenAI-compatible API on port 3025)
|
||||
* 2. Gemini (Google Cloud)
|
||||
* 3. Azure OpenAI (Microsoft Cloud)
|
||||
*/
|
||||
|
||||
// Self-hosted mana-llm service
|
||||
const MANA_LLM_URL = process.env.MANA_LLM_URL || '';
|
||||
const MANA_LLM_MODEL = process.env.MANA_LLM_MODEL || 'ollama/gemma3:4b';
|
||||
|
||||
// Gemini (cloud fallback)
|
||||
const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||
const GEMINI_MODEL = 'gemini-2.0-flash-001';
|
||||
const GEMINI_DEFAULT_TEMPERATURE = 0.7;
|
||||
const GEMINI_DEFAULT_MAX_TOKENS = 1024;
|
||||
|
||||
// Azure OpenAI (cloud fallback)
|
||||
const AZURE_API_VERSION = '2024-02-01';
|
||||
const AZURE_DEFAULT_TEMPERATURE = 0.7;
|
||||
const AZURE_DEFAULT_MAX_TOKENS = 1024;
|
||||
|
||||
const DEFAULT_TEMPERATURE = 0.7;
|
||||
const DEFAULT_MAX_TOKENS = 1024;
|
||||
|
||||
export interface GenerateOptions {
|
||||
temperature?: number;
|
||||
|
|
@ -20,28 +28,79 @@ export interface GenerateOptions {
|
|||
}
|
||||
|
||||
/**
|
||||
* Generate text using Gemini with Azure OpenAI as fallback.
|
||||
* Generate text using mana-llm → Gemini → Azure OpenAI fallback chain.
|
||||
*/
|
||||
export async function generateText(prompt: string, options?: GenerateOptions): Promise<string> {
|
||||
const geminiKey = process.env.GEMINI_API_KEY;
|
||||
// Attempt 1: Self-hosted mana-llm
|
||||
if (MANA_LLM_URL) {
|
||||
const result = await callManaLLM(prompt, options);
|
||||
if (result !== null) return result;
|
||||
console.warn('[ai] mana-llm failed, falling back to Gemini');
|
||||
}
|
||||
|
||||
// Attempt 2: Gemini
|
||||
const geminiKey = process.env.GEMINI_API_KEY;
|
||||
if (geminiKey) {
|
||||
const result = await callGemini(prompt, geminiKey, options);
|
||||
if (result !== null) return result;
|
||||
console.warn('[ai] Gemini failed, falling back to Azure OpenAI');
|
||||
} else {
|
||||
console.warn('[ai] No GEMINI_API_KEY, using Azure OpenAI directly');
|
||||
}
|
||||
|
||||
// Attempt 3: Azure OpenAI
|
||||
const azureKey = process.env.AZURE_OPENAI_KEY;
|
||||
if (!azureKey) {
|
||||
throw new Error('No AI provider available: both GEMINI_API_KEY and AZURE_OPENAI_KEY are missing');
|
||||
if (azureKey) {
|
||||
const result = await callAzure(prompt, azureKey, options);
|
||||
if (result !== null) return result;
|
||||
}
|
||||
|
||||
const result = await callAzure(prompt, azureKey, options);
|
||||
if (result !== null) return result;
|
||||
throw new Error('All AI providers failed (mana-llm, Gemini, Azure OpenAI)');
|
||||
}
|
||||
|
||||
throw new Error('All AI providers failed');
|
||||
/**
|
||||
* Call self-hosted mana-llm service (OpenAI-compatible API).
|
||||
*/
|
||||
async function callManaLLM(prompt: string, options?: GenerateOptions): Promise<string | null> {
|
||||
const temperature = options?.temperature ?? DEFAULT_TEMPERATURE;
|
||||
const maxTokens = options?.maxTokens ?? DEFAULT_MAX_TOKENS;
|
||||
|
||||
try {
|
||||
const url = `${MANA_LLM_URL}/v1/chat/completions`;
|
||||
const start = Date.now();
|
||||
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
if (options?.systemInstruction) {
|
||||
messages.push({ role: 'system', content: options.systemInstruction });
|
||||
}
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: MANA_LLM_MODEL,
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`[ai] mana-llm error (${response.status}): ${errorText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
};
|
||||
const content = data.choices?.[0]?.message?.content?.trim() ?? '';
|
||||
console.debug(`[ai] mana-llm responded in ${Date.now() - start}ms (${content.length} chars)`);
|
||||
return content || null;
|
||||
} catch (error) {
|
||||
console.error(`[ai] mana-llm call failed: ${error instanceof Error ? error.message : error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function callGemini(
|
||||
|
|
@ -49,8 +108,8 @@ async function callGemini(
|
|||
apiKey: string,
|
||||
options?: GenerateOptions
|
||||
): Promise<string | null> {
|
||||
const temperature = options?.temperature ?? GEMINI_DEFAULT_TEMPERATURE;
|
||||
const maxOutputTokens = options?.maxTokens ?? GEMINI_DEFAULT_MAX_TOKENS;
|
||||
const temperature = options?.temperature ?? DEFAULT_TEMPERATURE;
|
||||
const maxOutputTokens = options?.maxTokens ?? DEFAULT_MAX_TOKENS;
|
||||
|
||||
try {
|
||||
const url = `${GEMINI_ENDPOINT}/${GEMINI_MODEL}:generateContent?key=${apiKey}`;
|
||||
|
|
@ -102,8 +161,8 @@ async function callAzure(
|
|||
return null;
|
||||
}
|
||||
|
||||
const temperature = options?.temperature ?? AZURE_DEFAULT_TEMPERATURE;
|
||||
const maxTokens = options?.maxTokens ?? AZURE_DEFAULT_MAX_TOKENS;
|
||||
const temperature = options?.temperature ?? DEFAULT_TEMPERATURE;
|
||||
const maxTokens = options?.maxTokens ?? DEFAULT_MAX_TOKENS;
|
||||
|
||||
try {
|
||||
const url = `${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${AZURE_API_VERSION}`;
|
||||
|
|
|
|||
63
apps/memoro/apps/server/src/middleware/rate-limiter.ts
Normal file
63
apps/memoro/apps/server/src/middleware/rate-limiter.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { MiddlewareHandler } from 'hono';
|
||||
|
||||
interface RateLimiterOptions {
|
||||
/** Time window in milliseconds (default: 60000 = 1 minute) */
|
||||
windowMs?: number;
|
||||
/** Max requests per window per IP (default: 100) */
|
||||
max?: number;
|
||||
}
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple in-memory rate limiter middleware for Hono.
|
||||
* Limits requests per IP address within a sliding time window.
|
||||
*/
|
||||
export function rateLimiter(options: RateLimiterOptions = {}): MiddlewareHandler {
|
||||
const windowMs = options.windowMs ?? 60_000;
|
||||
const max = options.max ?? 100;
|
||||
const store = new Map<string, RateLimitEntry>();
|
||||
|
||||
// Periodic cleanup of expired entries every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store) {
|
||||
if (now >= entry.resetAt) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}, 5 * 60_000);
|
||||
|
||||
return async (c, next) => {
|
||||
const ip =
|
||||
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
c.req.header('x-real-ip') ||
|
||||
'unknown';
|
||||
|
||||
const now = Date.now();
|
||||
let entry = store.get(ip);
|
||||
|
||||
if (!entry || now >= entry.resetAt) {
|
||||
entry = { count: 0, resetAt: now + windowMs };
|
||||
store.set(ip, entry);
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
|
||||
c.header('X-RateLimit-Limit', String(max));
|
||||
c.header('X-RateLimit-Remaining', String(Math.max(0, max - entry.count)));
|
||||
c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)));
|
||||
|
||||
if (entry.count > max) {
|
||||
return c.json(
|
||||
{ error: 'Too many requests', retryAfter: Math.ceil((entry.resetAt - now) / 1000) },
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
|
@ -33,6 +33,5 @@ PUBLIC_APPLE_REDIRECT_URI=http://localhost:5173/auth/apple-callback # Change to
|
|||
PUBLIC_POSTHOG_KEY=your-posthog-key
|
||||
PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
||||
|
||||
# Sentry Error Tracking (optional)
|
||||
# SENTRY_AUTH_TOKEN=your-sentry-auth-token
|
||||
# PUBLIC_SENTRY_DSN=https://YOUR_DSN@sentry.io/PROJECT_ID
|
||||
# GlitchTip Error Tracking (self-hosted, Sentry-compatible)
|
||||
PUBLIC_GLITCHTIP_DSN=
|
||||
|
|
|
|||
6
apps/memoro/apps/web/src/app.d.ts
vendored
6
apps/memoro/apps/web/src/app.d.ts
vendored
|
|
@ -27,11 +27,7 @@ declare module '$env/static/public' {
|
|||
export const PUBLIC_APPLE_REDIRECT_URI: string;
|
||||
export const PUBLIC_POSTHOG_KEY: string;
|
||||
export const PUBLIC_POSTHOG_HOST: string;
|
||||
export const PUBLIC_SENTRY_DSN: string;
|
||||
}
|
||||
|
||||
declare module '$env/static/private' {
|
||||
export const SENTRY_AUTH_TOKEN: string;
|
||||
export const PUBLIC_GLITCHTIP_DSN: string;
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
|
|||
12
apps/memoro/apps/web/src/hooks.client.ts
Normal file
12
apps/memoro/apps/web/src/hooks.client.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { initErrorTracking, handleSvelteError } from '@manacore/shared-error-tracking/browser';
|
||||
import type { HandleClientError } from '@sveltejs/kit';
|
||||
|
||||
initErrorTracking({
|
||||
serviceName: 'memoro-web',
|
||||
dsn: (window as any).__PUBLIC_GLITCHTIP_DSN__,
|
||||
environment: import.meta.env.MODE,
|
||||
});
|
||||
|
||||
export const handleError: HandleClientError = ({ error }) => {
|
||||
handleSvelteError(error);
|
||||
};
|
||||
|
|
@ -1,26 +1,32 @@
|
|||
/**
|
||||
* Server-side hooks for SvelteKit
|
||||
* Implements custom CSRF protection that allows OAuth callbacks
|
||||
* - Injects runtime environment variables for client-side use
|
||||
* - Custom CSRF protection that allows OAuth callbacks
|
||||
* - GlitchTip error tracking DSN injection
|
||||
*/
|
||||
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { injectUmamiAnalytics } from '@manacore/shared-utils/analytics-server';
|
||||
import { setSecurityHeaders } from '@manacore/shared-utils/security-headers';
|
||||
|
||||
// Get client-side URLs from environment (Docker runtime)
|
||||
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
|
||||
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
|
||||
const PUBLIC_MEMORO_SERVER_URL = process.env.PUBLIC_MEMORO_SERVER_URL || '';
|
||||
const PUBLIC_GLITCHTIP_DSN = process.env.PUBLIC_GLITCHTIP_DSN || '';
|
||||
|
||||
// Routes that are allowed to receive cross-origin POST requests
|
||||
// (OAuth callbacks from external providers)
|
||||
const ALLOWED_PATHS = [
|
||||
'/auth/apple-callback-handler', // Apple Sign-In OAuth callback (server endpoint)
|
||||
'/auth/apple-callback', // Apple Sign-In OAuth callback (legacy/fallback)
|
||||
'/auth/google-callback', // Google Sign-In OAuth callback (if needed)
|
||||
'/auth/apple-callback-handler',
|
||||
'/auth/apple-callback',
|
||||
'/auth/google-callback',
|
||||
];
|
||||
|
||||
/**
|
||||
* Custom CSRF protection that allows specific OAuth callback routes
|
||||
* while protecting all other routes
|
||||
*/
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const { request, url } = event;
|
||||
|
||||
// Only check POST, PATCH, PUT, DELETE requests
|
||||
// CSRF protection: block cross-origin mutations except OAuth callbacks
|
||||
if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(request.method)) {
|
||||
const origin = request.headers.get('origin');
|
||||
const forbidden =
|
||||
|
|
@ -29,20 +35,35 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||
!ALLOWED_PATHS.some((path) => url.pathname === path);
|
||||
|
||||
if (forbidden) {
|
||||
// Log the blocked request for debugging
|
||||
console.warn('CSRF: Blocked cross-origin request:', {
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
origin: origin,
|
||||
expectedOrigin: url.origin,
|
||||
});
|
||||
|
||||
return new Response('Cross-site POST form submissions are forbidden', {
|
||||
status: 403,
|
||||
});
|
||||
return new Response('Cross-site POST form submissions are forbidden', { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Allow the request to proceed
|
||||
return resolve(event);
|
||||
const response = await resolve(event, {
|
||||
transformPageChunk: ({ html }) => {
|
||||
const envScript = `<script>
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = ${JSON.stringify(PUBLIC_MANA_CORE_AUTH_URL_CLIENT)};
|
||||
window.__PUBLIC_MEMORO_SERVER_URL__ = ${JSON.stringify(PUBLIC_MEMORO_SERVER_URL)};
|
||||
window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
|
||||
</script>`;
|
||||
return injectUmamiAnalytics(html.replace('<head>', `<head>${envScript}`));
|
||||
},
|
||||
});
|
||||
|
||||
setSecurityHeaders(response, {
|
||||
connectSrc: [
|
||||
PUBLIC_MANA_CORE_AUTH_URL_CLIENT || 'http://localhost:3001',
|
||||
PUBLIC_MEMORO_SERVER_URL || 'http://localhost:3015',
|
||||
PUBLIC_GLITCHTIP_DSN ? new URL(PUBLIC_GLITCHTIP_DSN).origin : '',
|
||||
'http://localhost:3050', // mana-sync server
|
||||
].filter(Boolean),
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
PUBLIC_APPLE_REDIRECT_URI,
|
||||
PUBLIC_POSTHOG_KEY,
|
||||
PUBLIC_POSTHOG_HOST,
|
||||
PUBLIC_SENTRY_DSN,
|
||||
PUBLIC_GLITCHTIP_DSN,
|
||||
} from '$env/static/public';
|
||||
|
||||
export const env = {
|
||||
|
|
@ -48,16 +48,16 @@ export const env = {
|
|||
},
|
||||
},
|
||||
|
||||
// Error tracking (optional)
|
||||
sentry: {
|
||||
dsn: PUBLIC_SENTRY_DSN || '',
|
||||
// Error tracking (GlitchTip — Sentry-compatible, self-hosted)
|
||||
glitchtip: {
|
||||
dsn: PUBLIC_GLITCHTIP_DSN || '',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Helper to check if optional features are enabled
|
||||
export const features = {
|
||||
hasPosthog: !!PUBLIC_POSTHOG_KEY,
|
||||
hasSentry: !!PUBLIC_SENTRY_DSN,
|
||||
hasGlitchtip: !!PUBLIC_GLITCHTIP_DSN,
|
||||
} as const;
|
||||
|
||||
// Log environment configuration on startup (useful for debugging deployment issues)
|
||||
|
|
@ -69,7 +69,7 @@ if (typeof window !== 'undefined') {
|
|||
appleRedirectUri: env.oauth.appleRedirectUri || '❌ NOT SET',
|
||||
googleOAuth: !!env.oauth.googleClientId ? '✅ Configured' : '❌ Missing',
|
||||
posthog: features.hasPosthog ? '✅ Enabled' : '⚪ Disabled',
|
||||
sentry: features.hasSentry ? '✅ Enabled' : '⚪ Disabled',
|
||||
glitchtip: features.hasGlitchtip ? '✅ Enabled' : '⚪ Disabled',
|
||||
});
|
||||
|
||||
// Specific warning for Apple Sign-In if not configured
|
||||
|
|
|
|||
10
apps/memoro/apps/web/src/routes/+error.svelte
Normal file
10
apps/memoro/apps/web/src/routes/+error.svelte
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { _ } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-[60vh] flex-col items-center justify-center text-center">
|
||||
<h1 class="text-6xl font-bold text-amber-500 mb-4">{$page.status}</h1>
|
||||
<p class="text-xl text-muted-foreground mb-8">{$page.error?.message || $_('error.notFound')}</p>
|
||||
<a href="/" class="btn btn-primary">{$_('error.backToHome')}</a>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue