Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

351 lines
9.7 KiB
TypeScript

/**
* Classifies Neynar cast embeds (image / video / audio / webpage / frame / quoted
* cast) and turns them into the runtime's `Media` attachments. Image embeds are
* captioned with the vision model (`IMAGE_DESCRIPTION`); other kinds get a derived
* title/description. Results are memoized per embed id in an instance cache. Used
* by `InteractionManager` to enrich inbound mentions/replies with media.
*/
import { type IAgentRuntime, type Media, ModelType, withStandaloneTrajectory } from "@elizaos/core";
import type { EmbedCast, EmbedUrl, Embed as NeynarEmbed } from "@neynar/nodejs-sdk/build/api";
export function isEmbedUrl(embed: EmbedCast | EmbedUrl): embed is EmbedUrl {
return "url" in embed;
}
export function isEmbedCast(embed: EmbedCast | EmbedUrl): embed is EmbedCast {
return "cast" in embed;
}
function getMediaTypeFromUrl(
url: string,
contentType?: string | null
): "image" | "video" | "audio" | "webpage" | "unknown" {
const lowerUrl = url.toLowerCase();
const lowerContentType = contentType?.toLowerCase() || "";
if (lowerContentType.startsWith("image/")) return "image";
if (lowerContentType.startsWith("video/")) return "video";
if (lowerContentType.startsWith("audio/")) return "audio";
const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".ico"];
const videoExtensions = [".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"];
const audioExtensions = [".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a"];
if (imageExtensions.some((ext) => lowerUrl.includes(ext))) return "image";
if (videoExtensions.some((ext) => lowerUrl.includes(ext))) return "video";
if (audioExtensions.some((ext) => lowerUrl.includes(ext))) return "audio";
return "webpage";
}
export interface ProcessedEmbed {
id: string;
url: string;
type: "image" | "video" | "audio" | "webpage" | "cast" | "frame" | "unknown";
title?: string;
description?: string;
text?: string;
source: string;
metadata?: {
width?: number;
height?: number;
duration?: number;
contentType?: string;
castHash?: string;
authorFid?: number;
authorUsername?: string;
};
}
export class EmbedManager {
private runtime: IAgentRuntime;
private embedCache: Map<string, ProcessedEmbed> = new Map();
constructor(runtime: IAgentRuntime) {
this.runtime = runtime;
}
async processEmbeds(embeds: NeynarEmbed[]): Promise<Media[]> {
if (embeds.length === 0) {
return [];
}
this.runtime.logger.info(
{ embedCount: embeds.length },
"[EmbedManager] Processing embeds from cast"
);
const processedMedia: Media[] = [];
for (const embed of embeds) {
try {
const processed = await this.processEmbed(embed);
if (processed) {
processedMedia.push(this.toMedia(processed));
}
} catch (error) {
this.runtime.logger.warn(
{ error: error instanceof Error ? error.message : String(error) },
"[EmbedManager] Failed to process embed"
);
}
}
this.runtime.logger.info(
{
processedCount: processedMedia.length,
types: processedMedia.map((m) => m.source),
},
"[EmbedManager] Finished processing embeds"
);
return processedMedia;
}
async processEmbed(embed: NeynarEmbed): Promise<ProcessedEmbed | null> {
if (isEmbedUrl(embed)) {
return this.processUrlEmbed(embed);
} else if (isEmbedCast(embed)) {
return this.processCastEmbed(embed);
}
this.runtime.logger.debug("[EmbedManager] Unknown embed type");
return null;
}
private async processUrlEmbed(embed: EmbedUrl): Promise<ProcessedEmbed> {
const { url, metadata } = embed;
const embedId = `embed-${this.hashUrl(url)}`;
const cached = this.embedCache.get(embedId);
if (cached) {
return cached;
}
const contentType = metadata?.content_type;
const mediaType = getMediaTypeFromUrl(url, contentType);
if (metadata?.frame) {
return this.processFrameEmbed(embed, embedId);
}
let processed: ProcessedEmbed;
switch (mediaType) {
case "image":
processed = await this.processImageEmbed(embed, embedId);
break;
case "video":
processed = await this.processVideoEmbed(embed, embedId);
break;
case "audio":
processed = await this.processAudioEmbed(embed, embedId);
break;
default:
processed = await this.processWebpageEmbed(embed, embedId);
}
this.embedCache.set(embedId, processed);
return processed;
}
private async processImageEmbed(embed: EmbedUrl, embedId: string): Promise<ProcessedEmbed> {
const { url, metadata } = embed;
let description = "An image attachment";
let title = "Image";
try {
const result = await withStandaloneTrajectory(
this.runtime,
{ source: "farcaster-embed" },
async () =>
this.runtime.useModel(ModelType.IMAGE_DESCRIPTION, {
prompt:
"Analyze this image and provide a concise title and description. Focus on the main subject and any notable details.",
imageUrl: url,
})
);
if (result && typeof result === "object") {
const typedResult = result as { title?: string; description?: string };
description = typedResult.description || description;
title = typedResult.title || title;
} else if (typeof result === "string") {
description = result;
}
this.runtime.logger.info(
{
url: `${url.substring(0, 60)}...`,
descriptionLength: description.length,
title,
},
"[EmbedManager] Processed image with vision model"
);
} catch (error) {
this.runtime.logger.warn(
{ url, error: error instanceof Error ? error.message : String(error) },
"[EmbedManager] Failed to describe image, using fallback"
);
}
return {
id: embedId,
url,
type: "image",
title,
description,
text: description,
source: "Farcaster",
metadata: {
width: metadata?.image?.width_px,
height: metadata?.image?.height_px,
contentType: metadata?.content_type || "image/*",
},
};
}
private async processVideoEmbed(embed: EmbedUrl, embedId: string): Promise<ProcessedEmbed> {
const { url, metadata } = embed;
const description = metadata?.video?.duration_s
? `Video (${Math.round(metadata.video.duration_s)}s)`
: "Video attachment";
return {
id: embedId,
url,
type: "video",
title: "Video",
description,
text: description,
source: "Farcaster",
metadata: {
duration: metadata?.video?.duration_s,
contentType: metadata?.content_type || "video/*",
},
};
}
private async processAudioEmbed(embed: EmbedUrl, embedId: string): Promise<ProcessedEmbed> {
const { url, metadata } = embed;
const description = "Audio attachment";
return {
id: embedId,
url,
type: "audio",
title: "Audio",
description,
text: description,
source: "Farcaster",
metadata: {
contentType: metadata?.content_type || "audio/*",
},
};
}
private async processWebpageEmbed(embed: EmbedUrl, embedId: string): Promise<ProcessedEmbed> {
const { url, metadata } = embed;
const html = metadata?.html;
const title = html?.ogTitle || html?.ogSiteName || "Web Page";
const hostnameMatch = url.match(/^(?:https?:\/\/)?([^/?#]+)/);
const hostname = hostnameMatch ? hostnameMatch[1] : url;
const description = html?.ogDescription || `Link to ${hostname}`;
return {
id: embedId,
url,
type: "webpage",
title,
description,
text: `${title}: ${description}`,
source: "Web",
metadata: {
contentType: "text/html",
},
};
}
private async processFrameEmbed(embed: EmbedUrl, embedId: string): Promise<ProcessedEmbed> {
const { url, metadata } = embed;
const frame = metadata?.frame;
const title = frame?.title || "Farcaster Frame";
const description = `Interactive Frame: ${title}`;
return {
id: embedId,
url,
type: "frame",
title,
description,
text: description,
source: "Frame",
metadata: {
contentType: "application/x-farcaster-frame",
},
};
}
private async processCastEmbed(embed: EmbedCast): Promise<ProcessedEmbed> {
const cast = embed.cast;
const embedId = `cast-${cast.hash}`;
const cached = this.embedCache.get(embedId);
if (cached) {
return cached;
}
const authorUsername = cast.author.username || "unknown";
const title = `Quoted cast from @${authorUsername}`;
const description = cast.text || "";
const processed: ProcessedEmbed = {
id: embedId,
url: `https://warpcast.com/${authorUsername}/${cast.hash.slice(0, 10)}`,
type: "cast",
title,
description,
text: `[Quote from @${authorUsername}]: ${description}`,
source: "Farcaster",
metadata: {
castHash: cast.hash,
authorFid: cast.author.fid,
authorUsername,
},
};
this.embedCache.set(embedId, processed);
return processed;
}
private toMedia(embed: ProcessedEmbed): Media {
return {
id: embed.id,
url: embed.url,
title: embed.title || embed.type,
source: embed.source,
description: embed.description,
text: embed.text,
};
}
private hashUrl(url: string): string {
let hash = 0;
for (let i = 0; i < url.length; i++) {
const char = url.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash).toString(36);
}
clearCache(): void {
this.embedCache.clear();
}
}