apix/lib/providers/meta-client.ts
Khoa.vo e69c6ba64d
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
chore: Remove Grok integration, simplify Settings UI
- Removed all Grok-related code, API routes, and services
- Removed crawl4ai service and meta-crawl client
- Simplified Settings to always show cookie inputs for Meta AI
- Hid advanced wrapper settings behind collapsible section
- Provider selection now only shows Whisk and Meta AI
- Fixed unused imports and type definitions
2026-01-07 19:21:51 +07:00

620 lines
23 KiB
TypeScript

/**
* Meta AI Client for Image Generation
*
* Uses the Meta AI web interface via GraphQL
* Requires cookies from a logged-in meta.ai session
*
* Image Model: Imagine (Emu)
*
* Based on: https://github.com/Strvm/meta-ai-api
*/
const META_AI_BASE = "https://www.meta.ai";
const GRAPHQL_ENDPOINT = `${META_AI_BASE}/api/graphql/`;
interface MetaAIOptions {
cookies: string;
}
interface MetaImageResult {
url: string;
data?: string; // base64
prompt: string;
model: string;
}
interface MetaSession {
lsd?: string;
fb_dtsg?: string;
accessToken?: string;
externalConversationId?: string;
}
// Aspect ratio types for Meta AI
export type AspectRatio = 'portrait' | 'landscape' | 'square';
const ORIENTATION_MAP: Record<AspectRatio, string> = {
'portrait': 'VERTICAL', // 9:16
'landscape': 'HORIZONTAL', // 16:9
'square': 'SQUARE' // 1:1
};
export class MetaAIClient {
private cookies: string;
private session: MetaSession = {};
private useFreeWrapper: boolean = true;
private freeWrapperUrl: string = 'http://localhost:8000';
constructor(options: MetaAIOptions & { useFreeWrapper?: boolean; freeWrapperUrl?: string }) {
this.cookies = this.normalizeCookies(options.cookies);
this.useFreeWrapper = options.useFreeWrapper !== undefined ? options.useFreeWrapper : true;
this.freeWrapperUrl = options.freeWrapperUrl || 'http://localhost:8000';
console.log("[Meta AI] Cookie string length:", this.cookies.length);
if (this.cookies) {
this.parseSessionFromCookies();
}
}
/**
* Normalize cookies from string or JSON format
* Handles cases where user pastes JSON array from extension/devtools
*/
private normalizeCookies(cookies: string): string {
if (!cookies) return "";
try {
// Check if it looks like JSON
if (cookies.trim().startsWith('[')) {
const parsed = JSON.parse(cookies);
if (Array.isArray(parsed)) {
return parsed
.map((c: any) => `${c.name}=${c.value}`)
.join('; ');
}
}
} catch (e) {
// Not JSON, assume string
}
return cookies;
}
/**
* Parse session tokens from cookies
*/
private parseSessionFromCookies(): void {
// Extract lsd token if present in cookies
const lsdMatch = this.cookies.match(/lsd=([^;]+)/);
if (lsdMatch) {
this.session.lsd = lsdMatch[1];
}
// Extract fb_dtsg if present
const dtsgMatch = this.cookies.match(/fb_dtsg=([^;]+)/);
if (dtsgMatch) {
this.session.fb_dtsg = dtsgMatch[1];
}
}
/**
* Get the initialized session tokens (call initSession first if not done)
*/
async getSession(): Promise<MetaSession & { externalConversationId?: string }> {
if (!this.useFreeWrapper && !this.session.lsd && !this.session.fb_dtsg) {
await this.initSession();
}
return this.session;
}
/**
* Get the normalized cookie string
*/
getCookies(): string {
return this.cookies;
}
/**
* Generate images using Meta AI's Imagine model
*/
async generate(prompt: string, numImages: number = 4, aspectRatio: AspectRatio = 'portrait'): Promise<MetaImageResult[]> {
console.log(`[Meta AI] Generating images for: "${prompt.substring(0, 50)}..." (${aspectRatio})`);
if (this.useFreeWrapper) {
return this.generateWithFreeWrapper(prompt, numImages);
}
// First, get the access token and session info if not already fetched
if (!this.session.accessToken) {
await this.initSession();
}
// Use "Imagine" prefix for image generation
const imagePrompt = prompt.toLowerCase().startsWith('imagine')
? prompt
: `Imagine ${prompt}`;
// Send the prompt via GraphQL with aspect ratio
const response = await this.sendPrompt(imagePrompt, aspectRatio);
// Extract image URLs from response
const images = this.extractImages(response, prompt);
if (images.length === 0) {
// Meta AI might return the prompt response without images immediately
// We may need to poll for the result
console.log("[Meta AI] No images in initial response, polling...");
return this.pollForImages(response, prompt);
}
return images;
}
/**
* Generate using free API wrapper (mir-ashiq/metaai-api)
* Connects to local docker service
*/
/**
* Generate using free API wrapper (mir-ashiq/metaai-api)
* Connects to local docker service
*/
private async generateWithFreeWrapper(prompt: string, numImages: number): Promise<MetaImageResult[]> {
console.log(`[Meta Wrapper] Generating image for: "${prompt.substring(0, 50)}..." via ${this.freeWrapperUrl}`);
const cookieDict = this.parseCookiesToDict(this.cookies);
const response = await fetch(`${this.freeWrapperUrl}/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
message: `Imagine ${prompt}`,
stream: false,
cookies: cookieDict
})
});
if (!response.ok) {
const errorText = await response.text();
console.error("[Meta Wrapper] Error:", response.status, errorText);
throw new Error(`Meta Wrapper Error: ${response.status} - ${errorText.substring(0, 200)}`);
}
const data = await response.json();
console.log("[Meta Wrapper] Response:", JSON.stringify(data, null, 2).substring(0, 500));
// Check for images in response
const images: MetaImageResult[] = [];
// mir-ashiq/metaai-api returns { media: [{ url: "...", ... }] }
if (data.media && Array.isArray(data.media)) {
data.media.forEach((m: any) => {
if (m.url) images.push({ url: m.url, prompt, model: "meta-wrapper" });
});
}
// Fallback checks
if (images.length === 0 && data.images && Array.isArray(data.images)) {
data.images.forEach((url: string) => {
images.push({ url, prompt, model: "meta-wrapper" });
});
}
if (images.length === 0 && data.sources && Array.isArray(data.sources)) {
data.sources.forEach((s: any) => {
if (s.url && (s.url.includes('.jpg') || s.url.includes('.png') || s.url.includes('.webp'))) {
images.push({ url: s.url, prompt, model: "meta-wrapper-source" });
}
});
}
if (images.length === 0) {
console.warn("[Meta Wrapper] No images found via /chat endpoint", data);
throw new Error("Meta Wrapper returned no images. Please check if the prompt triggered image generation.");
}
return images;
}
private parseCookiesToDict(cookieStr: string): Record<string, string> {
const dict: Record<string, string> = {};
if (!cookieStr) return dict;
// Handle basic key=value; format
cookieStr.split(';').forEach(pair => {
const [key, ...rest] = pair.trim().split('=');
if (key && rest.length > 0) {
dict[key] = rest.join('=');
}
});
return dict;
}
private async generateWithFreeWrapperFallback(prompt: string): Promise<MetaImageResult[]> {
// Fallback logic not needed if /chat works
throw new Error("Meta Wrapper endpoint /chat failed.");
}
/**
* Initialize session - get access token from meta.ai page
*/
private async initSession(): Promise<void> {
console.log("[Meta AI] Initializing session...");
const response = await fetch(META_AI_BASE, {
headers: {
"Cookie": this.cookies,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Dest": "document",
"Accept-Language": "en-US,en;q=0.9"
}
});
const html = await response.text();
// Extract access token from page HTML
// Pattern 1: Simple JSON key
let tokenMatch = html.match(/"accessToken":"([^"]+)"/);
// Pattern 2: Inside a config object or different spacing
if (!tokenMatch) {
tokenMatch = html.match(/accessToken["']\s*:\s*["']([^"']+)["']/);
}
// Pattern 3: LSD token backup (sometimes needed)
const lsdMatch = html.match(/"LSD",\[\],{"token":"([^"]+)"/) ||
html.match(/"lsd":"([^"]+)"/) ||
html.match(/name="lsd" value="([^"]+)"/);
if (lsdMatch) {
this.session.lsd = lsdMatch[1];
}
// Pattern 4: DTSG token (critical for some requests)
const dtsgMatch = html.match(/"DTSGInitialData".*?"token":"([^"]+)"/) ||
html.match(/"token":"([^"]+)"/); // Less specific fallback
if (tokenMatch) {
this.session.accessToken = tokenMatch[1];
console.log("[Meta AI] Got access token");
} else if (html.includes('login_form') || html.includes('login_page')) {
throw new Error("Meta AI: Cookies expired or invalid (Login page detected). Please update cookies.");
} else {
console.warn("[Meta AI] Warning: Failed to extract access token. functionality may be limited.");
console.log("HTML Preview:", html.substring(0, 200));
// Don't throw here, try to proceed with just cookies/LSD
}
if (dtsgMatch) {
this.session.fb_dtsg = dtsgMatch[1];
}
// Enhanced logging for debugging
console.log("[Meta AI] Session tokens extracted:", {
hasAccessToken: !!this.session.accessToken,
hasLsd: !!this.session.lsd,
hasDtsg: !!this.session.fb_dtsg
});
if (!this.session.accessToken && !this.session.lsd) {
console.warn("[Meta AI] CRITICAL: No authentication tokens found. Check if cookies are valid.");
}
}
/**
* Send prompt via GraphQL mutation
*/
/**
* Send prompt via GraphQL mutation (Abra - for Image Generation)
* Using EXACT variable structure from Python Strvm/meta-ai-api library
*/
private async sendPrompt(prompt: string, aspectRatio: AspectRatio = 'portrait'): Promise<any> {
// Generate external conversation ID (UUID) and offline threading ID (Snowflake-like)
const externalConversationId = crypto.randomUUID();
const timestamp = Date.now();
const randomPart = Math.floor(Math.random() * 4194304); // 22 bits
const offlineThreadingId = ((BigInt(timestamp) << 22n) | BigInt(randomPart)).toString();
// Store for polling
this.session.externalConversationId = externalConversationId;
// Map aspect ratio to Meta AI orientation
const orientation = ORIENTATION_MAP[aspectRatio];
const variables = {
message: {
sensitive_string_value: prompt // Python uses sensitive_string_value, not text!
},
externalConversationId: externalConversationId,
offlineThreadingId: offlineThreadingId,
suggestedPromptIndex: null,
flashVideoRecapInput: { images: [] },
flashPreviewInput: null,
promptPrefix: null,
entrypoint: "ABRA__CHAT__TEXT",
icebreaker_type: "TEXT",
imagineClientOptions: { orientation: orientation }, // Aspect ratio control
__relay_internal__pv__AbraDebugDevOnlyrelayprovider: false,
__relay_internal__pv__WebPixelRatiorelayprovider: 1
};
console.log("[Meta AI] Sending Variables:", JSON.stringify(variables, null, 2));
const body = new URLSearchParams({
fb_api_caller_class: "RelayModern",
fb_api_req_friendly_name: "useAbraSendMessageMutation",
variables: JSON.stringify(variables),
server_timestamps: "true",
doc_id: "7783822248314888", // Abra Mutation ID
...(this.session.lsd && { lsd: this.session.lsd }),
...(this.session.fb_dtsg && { fb_dtsg: this.session.fb_dtsg })
});
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": this.cookies,
"Origin": META_AI_BASE,
"Referer": `${META_AI_BASE}/`,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Dest": "empty",
"Accept-Language": "en-US,en;q=0.9",
...(this.session.accessToken && { "Authorization": `OAuth ${this.session.accessToken}` })
},
body: body.toString()
});
const rawText = await response.text();
console.log("[Meta AI] Response received, parsing streaming data...");
if (!response.ok) {
throw new Error(`Meta AI Error: ${response.status} - ${rawText.substring(0, 500)}`);
}
// Meta AI returns streaming response (multiple JSON lines)
// We need to find the final response where streaming_state === "OVERALL_DONE"
let lastValidResponse: any = null;
const lines = rawText.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
// Check for streaming state in both direct and nested paths
const streamingState =
parsed?.data?.xfb_abra_send_message?.bot_response_message?.streaming_state ||
parsed?.data?.node?.bot_response_message?.streaming_state;
if (streamingState === "OVERALL_DONE") {
console.log("[Meta AI] Found OVERALL_DONE response");
lastValidResponse = parsed;
break;
}
// Keep track of any valid response with imagine_card
const imagineCard =
parsed?.data?.xfb_abra_send_message?.bot_response_message?.imagine_card ||
parsed?.data?.node?.bot_response_message?.imagine_card;
if (imagineCard?.session?.media_sets) {
lastValidResponse = parsed;
}
} catch (e) {
// Skip non-JSON lines
continue;
}
}
if (!lastValidResponse) {
if (rawText.includes("login_form") || rawText.includes("facebook.com/login")) {
throw new Error("Meta AI: Session expired. Please refresh your cookies.");
}
throw new Error("Meta AI: No valid response found in streaming data");
}
console.log("[Meta AI] Successfully parsed streaming response");
return lastValidResponse;
}
/**
* Extract image URLs from Meta AI response
*/
private extractImages(response: any, originalPrompt: string): MetaImageResult[] {
const images: MetaImageResult[] = [];
// Navigate through the response structure
// Abra streaming: xfb_abra_send_message.bot_response_message
// Abra direct: node.bot_response_message
// Kadabra: useKadabraSendMessageMutation.node.bot_response_message
const messageData = response?.data?.xfb_abra_send_message?.bot_response_message ||
response?.data?.useKadabraSendMessageMutation?.node?.bot_response_message ||
response?.data?.node?.bot_response_message ||
response?.data?.xabraAIPreviewMessageSendMutation?.message;
// For Polling/KadabraPromptRootQuery which also contains imagine_cards
const pollMessages = response?.data?.kadabra_prompt?.messages?.edges || [];
if (pollMessages.length > 0) {
for (const edge of pollMessages) {
const nodeImages = this.extractImagesFromMessage(edge?.node, originalPrompt);
images.push(...nodeImages);
}
}
if (messageData) {
images.push(...this.extractImagesFromMessage(messageData, originalPrompt));
}
// --- STRENGTHENED FALLBACK ---
// If still no images, but we have data, do a recursive search for any CDN URLs
if (images.length === 0 && response?.data) {
console.log("[Meta AI] Structured extraction failed, attempting recursive search...");
const foundUrls = this.recursiveSearchForImages(response.data);
for (const url of foundUrls) {
images.push({
url: url,
prompt: originalPrompt,
model: "meta"
});
}
}
if (images.length === 0) {
console.log("[Meta AI] Extraction failed. Response keys:", Object.keys(response || {}));
if (response?.data) console.log("[Meta AI] Data keys:", Object.keys(response.data));
}
return images;
}
/**
* Recursive search for image-like URLs in the JSON tree
*/
private recursiveSearchForImages(obj: any, found: Set<string> = new Set()): string[] {
if (!obj || typeof obj !== 'object') return [];
for (const key in obj) {
const val = obj[key];
if (typeof val === 'string') {
if ((val.includes('fbcdn.net') || val.includes('meta.ai')) &&
(val.includes('.jpg') || val.includes('.png') || val.includes('.webp') || val.includes('image_uri=') || val.includes('/imagine/'))) {
found.add(val);
}
} else if (typeof val === 'object') {
this.recursiveSearchForImages(val, found);
}
}
return Array.from(found);
}
/**
* Helper to extract images from a single message node
*/
private extractImagesFromMessage(messageData: any, originalPrompt: string): MetaImageResult[] {
const images: MetaImageResult[] = [];
if (!messageData) return images;
// Check for imagine_card (image generation response)
const imagineCard = messageData?.imagine_card;
if (imagineCard?.session?.media_sets) {
for (const mediaSet of imagineCard.session.media_sets) {
if (mediaSet?.imagine_media) {
for (const media of mediaSet.imagine_media) {
const url = media?.uri || media?.image_uri || media?.image?.uri;
if (url) {
images.push({
url: url,
prompt: originalPrompt,
model: "meta"
});
}
}
}
}
}
// Check for attachments (alternative path)
const attachments = messageData?.attachments;
if (attachments) {
for (const attachment of attachments) {
const media = attachment?.media;
const url = media?.image_uri || media?.uri || media?.image?.uri;
if (url) {
images.push({
url: url,
prompt: originalPrompt,
model: "meta"
});
}
}
}
return images;
}
/**
* Poll for image generation completion
*/
private async pollForImages(initialResponse: any, prompt: string): Promise<MetaImageResult[]> {
// Kadabra uses external_conversation_id for polling
const conversationId = initialResponse?.data?.useKadabraSendMessageMutation?.node?.external_conversation_id ||
initialResponse?.data?.node?.external_conversation_id;
if (!conversationId) {
console.warn("[Meta AI] No conversation ID found for polling");
return [];
}
const maxAttempts = 30;
const pollInterval = 2000;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
console.log(`[Meta AI] Polling attempt ${attempt + 1}/${maxAttempts}...`);
await new Promise(resolve => setTimeout(resolve, pollInterval));
const variables = {
external_conversation_id: conversationId
};
const body = new URLSearchParams({
fb_api_caller_class: "RelayModern",
fb_api_req_friendly_name: "KadabraPromptRootQuery",
variables: JSON.stringify(variables),
doc_id: "25290569913909283", // KadabraPromptRootQuery ID
...(this.session.lsd && { lsd: this.session.lsd }),
...(this.session.fb_dtsg && { fb_dtsg: this.session.fb_dtsg })
});
try {
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": this.cookies,
"Origin": META_AI_BASE,
...(this.session.accessToken && { "Authorization": `OAuth ${this.session.accessToken}` })
},
body: body.toString()
});
const data = await response.json();
const images = this.extractImages(data, prompt);
if (images.length > 0) {
console.log(`[Meta AI] Got ${images.length} image(s) after polling!`);
return images;
}
// Check for failure status
const status = data?.data?.kadabra_prompt?.status;
if (status === "FAILED" || status === "ERROR") {
console.error("[Meta AI] Generation failed during polling");
break;
}
} catch (e: any) {
console.error("[Meta AI] Poll error:", e.message);
}
}
return [];
}
/**
* Download image from URL and convert to base64
*/
async downloadAsBase64(url: string): Promise<string> {
const response = await fetch(url, {
headers: {
"Cookie": this.cookies,
"Referer": META_AI_BASE
}
});
const buffer = await response.arrayBuffer();
return Buffer.from(buffer).toString('base64');
}
}