- 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>
267 lines
9.4 KiB
TypeScript
Executable file
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.";
|
|
}
|
|
};
|