modcomms/frontend/services/geminiService.ts
Vadym Samoilenko 5c338c31fb Fix WebSocket connection dropped during long proof analysis
- Add 25s heartbeat ping from backend to prevent Apache/proxy idle-timeout
  killing the connection during 1-3 min analysis runs
- Handle heartbeat silently in both analyzeProof and analyzeWIPProof frontend handlers
- Run PDF rasterization via asyncio.to_thread so heartbeats aren't blocked
- Wrap analyze_proof with asyncio.wait_for(timeout=300) for a hard 5-min cap
- Log dropped send_message calls in ConnectionManager instead of swallowing silently
- cloudrun.yaml: add sessionAffinity, startup probe, raise containerConcurrency 4→10,
  document DISABLE_AUTH option

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 11:23:59 +00:00

267 lines
9.4 KiB
TypeScript
Executable file

import type { AgentReview, SubReview, AgentName, PDFPage } from '../types';
import { IPublicClientApplication } from '@azure/msal-browser';
import { getAccessToken } from './authService';
// WebSocket URL for backend communication
const WS_URL = import.meta.env.VITE_BACKEND_WS_URL || 'ws://localhost:8000/ws/analyze';
const HTTP_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000';
/**
* Options for proof analysis with optional database persistence.
*/
export interface AnalyzeProofOptions {
campaignId?: string;
proofName?: string;
channel?: string;
subChannel?: string;
proofType?: string;
/** Brand to use for brand guidelines analysis: 'Barclays' or 'Barclaycard' */
brand?: string;
}
/**
* Result of proof analysis, including optional database IDs if persisted.
*/
export interface AnalyzeProofResult {
review: AgentReview;
proofId?: string;
versionId?: string;
pdfPages?: PDFPage[];
isIdenticalFile?: boolean;
}
/**
* Analyze a proof using the backend WebSocket API.
* Provides real-time updates as each agent completes.
* Now requires MSAL instance to acquire access token.
* Optionally pass campaign info to persist results to database.
*/
export const analyzeProof = async (
file: File,
onAgentUpdate: (name: AgentName | 'Summary', review?: SubReview) => void,
msalInstance: IPublicClientApplication,
options?: AnalyzeProofOptions,
onNotification?: (message: string) => void,
): Promise<AnalyzeProofResult> => {
// Acquire token before connecting
const accessToken = await getAccessToken(msalInstance);
if (!accessToken) {
throw new Error('Failed to acquire access token. Please sign in again.');
}
return new Promise((resolve, reject) => {
const ws = new WebSocket(WS_URL);
let resolved = false;
ws.onopen = () => {
// Convert file to base64 and send
const reader = new FileReader();
reader.onloadend = () => {
const base64Data = (reader.result as string).split(',')[1];
const message: Record<string, any> = {
type: 'analyze',
file_data: base64Data,
file_type: file.type,
is_wip: false,
access_token: accessToken
};
// Include campaign info for database persistence if provided
if (options?.campaignId) {
message.campaign_id = options.campaignId;
}
if (options?.proofName) {
message.proof_name = options.proofName;
}
if (options?.channel) {
message.channel = options.channel;
}
if (options?.subChannel) {
message.sub_channel = options.subChannel;
}
if (options?.proofType) {
message.proof_type = options.proofType;
}
if (options?.brand) {
message.brand = options.brand;
}
ws.send(JSON.stringify(message));
};
reader.onerror = () => {
ws.close();
reject(new Error('Failed to read file'));
};
reader.readAsDataURL(file);
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
switch (message.type) {
case 'agent_started':
// Agent is starting - can optionally show loading state
break;
case 'agent_completed':
// Agent completed - update UI
onAgentUpdate(message.agent_name as AgentName, message.review);
break;
case 'summary':
// Summary ready
onAgentUpdate('Summary');
break;
case 'complete':
// Analysis complete - resolve with full result
resolved = true;
ws.close();
resolve({
review: message.result as AgentReview,
proofId: message.proof_id,
versionId: message.version_id,
pdfPages: message.pdf_pages as PDFPage[] | undefined,
isIdenticalFile: message.is_identical_file as boolean | undefined,
});
break;
case 'heartbeat':
// Server keepalive — ignore silently
break;
case 'model_fallback':
onNotification?.('The primary AI model is currently unavailable. Analysis is continuing with the backup model and may take longer than usual.');
break;
case 'error':
// Error occurred
resolved = true;
ws.close();
reject(new Error(message.message || 'Analysis failed'));
break;
}
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
ws.onerror = () => {
if (!resolved) {
resolved = true;
reject(new Error('WebSocket connection error. Is the backend running?'));
}
};
ws.onclose = (event) => {
if (!resolved && !event.wasClean) {
resolved = true;
reject(new Error('WebSocket connection closed unexpectedly'));
}
};
});
};
/**
* Analyze a work-in-progress proof.
* Returns a conversational summary for the creative team.
*/
export const analyzeWIPProof = async (
file: File,
onAgentUpdate: (name: AgentName | 'Summary', review?: SubReview) => void,
msalInstance: IPublicClientApplication
): Promise<string> => {
// Acquire token before connecting
const accessToken = await getAccessToken(msalInstance);
if (!accessToken) {
throw new Error('Failed to acquire access token. Please sign in again.');
}
return new Promise((resolve, reject) => {
const ws = new WebSocket(WS_URL);
let resolved = false;
ws.onopen = () => {
const reader = new FileReader();
reader.onloadend = () => {
const base64Data = (reader.result as string).split(',')[1];
ws.send(JSON.stringify({
type: 'analyze',
file_data: base64Data,
file_type: file.type,
is_wip: true,
access_token: accessToken
}));
};
reader.onerror = () => {
ws.close();
reject(new Error('Failed to read file'));
};
reader.readAsDataURL(file);
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'heartbeat') {
// Server keepalive — ignore silently
} else if (message.type === 'agent_completed') {
onAgentUpdate(message.agent_name as AgentName, message.review);
} else if (message.type === 'summary') {
onAgentUpdate('Summary');
} else if (message.type === 'complete') {
resolved = true;
ws.close();
resolve(message.result?.leadAgentSummary || 'Analysis complete.');
} else if (message.type === 'error') {
resolved = true;
ws.close();
reject(new Error(message.message || 'Analysis failed'));
}
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
ws.onerror = () => {
if (!resolved) {
resolved = true;
reject(new Error('WebSocket connection error'));
}
};
ws.onclose = (event) => {
if (!resolved && !event.wasClean) {
resolved = true;
reject(new Error('Connection closed unexpectedly'));
}
};
});
};
/**
* Get a chat response from the WIP Lead Agent.
* Uses HTTP REST endpoint (not WebSocket).
*/
export const getWIPChatResponse = async (prompt: string): Promise<string> => {
try {
const response = await fetch(`${HTTP_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data.response || data.message || 'No response from Lead Agent.';
} catch (error) {
console.error('Error getting WIP chat response:', error);
// Fallback message when backend chat endpoint is not available
return "I'm sorry, the chat feature requires the backend chat endpoint to be implemented. For now, please use the proof analysis feature.";
}
};