ai-chatbot/lib/agents/psd-remote-asset.ts
Your Name edc24cb956
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: refine PSD templates and result exports
2026-05-14 20:29:27 +08:00

287 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import "server-only";
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_RETRY_COUNT = 3;
const RETRY_DELAY_MS = 1_000;
const DEFAULT_MEMORY_CACHE_TTL_MS = 10 * 60 * 1000;
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 256 * 1024 * 1024;
const DEFAULT_MEMORY_CACHE_MAX_ENTRY_BYTES = 32 * 1024 * 1024;
const remoteAssetCacheKey = "__agent_psd_remote_asset_cache__";
type RemoteAssetCacheEntry = {
buffer: Buffer;
byteLength: number;
expiresAt: number;
lastAccessAt: number;
};
type RemoteAssetCacheState = {
entries: Map<string, RemoteAssetCacheEntry>;
inFlight: Map<string, Promise<Buffer>>;
totalBytes: number;
};
function getRemoteAssetCacheState(): RemoteAssetCacheState {
const globalObj = globalThis as typeof globalThis & {
[remoteAssetCacheKey]?: RemoteAssetCacheState;
};
if (!globalObj[remoteAssetCacheKey]) {
globalObj[remoteAssetCacheKey] = {
entries: new Map<string, RemoteAssetCacheEntry>(),
inFlight: new Map<string, Promise<Buffer>>(),
totalBytes: 0,
};
}
return globalObj[remoteAssetCacheKey] as RemoteAssetCacheState;
}
function parsePositiveInteger(value: string | undefined, fallback: number) {
const parsed = Number.parseInt(String(value || ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function getRemoteAssetCacheConfig() {
return {
ttlMs: parsePositiveInteger(
process.env.AGENT_PSD_REMOTE_ASSET_CACHE_TTL_MS,
DEFAULT_MEMORY_CACHE_TTL_MS
),
maxBytes: parsePositiveInteger(
process.env.AGENT_PSD_REMOTE_ASSET_CACHE_MAX_BYTES,
DEFAULT_MEMORY_CACHE_MAX_BYTES
),
maxEntryBytes: parsePositiveInteger(
process.env.AGENT_PSD_REMOTE_ASSET_CACHE_MAX_ENTRY_BYTES,
DEFAULT_MEMORY_CACHE_MAX_ENTRY_BYTES
),
};
}
function isRemoteAssetMemoryCacheEnabled() {
return process.env.AGENT_PSD_REMOTE_ASSET_CACHE_DISABLED !== "1";
}
function isCacheableRemoteAssetUrl(url: string) {
return !/^(data|blob):/i.test(String(url || "").trim());
}
function pruneRemoteAssetCache(state: RemoteAssetCacheState, maxBytes: number) {
const now = Date.now();
for (const [key, entry] of state.entries) {
if (entry.expiresAt <= now) {
state.entries.delete(key);
state.totalBytes -= entry.byteLength;
}
}
if (state.totalBytes <= maxBytes) {
return;
}
const entriesByLastAccess = Array.from(state.entries.entries()).sort(
(left, right) => left[1].lastAccessAt - right[1].lastAccessAt
);
for (const [key, entry] of entriesByLastAccess) {
if (state.totalBytes <= maxBytes) break;
state.entries.delete(key);
state.totalBytes -= entry.byteLength;
}
}
function readRemoteAssetMemoryCache(url: string) {
if (!isRemoteAssetMemoryCacheEnabled() || !isCacheableRemoteAssetUrl(url)) {
return null;
}
const state = getRemoteAssetCacheState();
const entry = state.entries.get(url);
if (!entry) {
return null;
}
if (entry.expiresAt <= Date.now()) {
state.entries.delete(url);
state.totalBytes -= entry.byteLength;
return null;
}
entry.lastAccessAt = Date.now();
return entry.buffer;
}
function writeRemoteAssetMemoryCache(url: string, buffer: Buffer) {
if (!isRemoteAssetMemoryCacheEnabled() || !isCacheableRemoteAssetUrl(url)) {
return;
}
const { ttlMs, maxBytes, maxEntryBytes } = getRemoteAssetCacheConfig();
const byteLength = buffer.byteLength;
if (byteLength <= 0 || byteLength > maxEntryBytes) {
return;
}
const state = getRemoteAssetCacheState();
const previous = state.entries.get(url);
if (previous) {
state.totalBytes -= previous.byteLength;
}
state.entries.set(url, {
buffer,
byteLength,
expiresAt: Date.now() + ttlMs,
lastAccessAt: Date.now(),
});
state.totalBytes += byteLength;
pruneRemoteAssetCache(state, maxBytes);
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function describeRemoteAssetLocation(url: string) {
const normalizedUrl = String(url || "").trim();
if (!normalizedUrl) {
return "未知地址";
}
try {
const parsed = new URL(normalizedUrl);
return parsed.host || parsed.hostname || normalizedUrl;
} catch {
return normalizedUrl.length > 80 ? `${normalizedUrl.slice(0, 77)}...` : normalizedUrl;
}
}
function isRetryableStatus(status: number) {
return status === 408 || status === 425 || status === 429 || status >= 500;
}
function isRetryableFetchError(error: unknown) {
if (!(error instanceof Error)) {
return true;
}
const message = `${error.name} ${error.message}`.toLowerCase();
return (
error.name === "AbortError" ||
message.includes("fetch failed") ||
message.includes("timeout") ||
message.includes("timed out") ||
message.includes("econnreset") ||
message.includes("socket") ||
message.includes("network") ||
message.includes("terminated") ||
message.includes("und_err")
);
}
export async function fetchRemoteAssetBuffer(
url: string,
options?: {
label?: string;
timeoutMs?: number;
retries?: number;
useMemoryCache?: boolean;
}
) {
const normalizedUrl = String(url || "").trim();
const label = String(options?.label || "远程资源").trim() || "远程资源";
if (!normalizedUrl) {
throw new Error(`${label}地址为空`);
}
const attempts = Math.max(1, Math.round(Number(options?.retries || DEFAULT_RETRY_COUNT)));
const timeoutMs = Math.max(5_000, Math.round(Number(options?.timeoutMs || DEFAULT_TIMEOUT_MS)));
const location = describeRemoteAssetLocation(normalizedUrl);
const shouldUseMemoryCache = options?.useMemoryCache !== false;
if (shouldUseMemoryCache) {
const cachedBuffer = readRemoteAssetMemoryCache(normalizedUrl);
if (cachedBuffer) {
return cachedBuffer;
}
const cacheState = getRemoteAssetCacheState();
const inFlightRequest = cacheState.inFlight.get(normalizedUrl);
if (inFlightRequest) {
return inFlightRequest;
}
}
const downloadPromise = (async () => {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetch(normalizedUrl, {
cache: "no-store",
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
const statusError = new Error(`${label}下载失败(${response.status})`);
if (attempt < attempts && isRetryableStatus(response.status)) {
console.warn("[PSD Remote Asset] transient status, retrying download:", {
label,
location,
status: response.status,
attempt,
attempts,
});
await sleep(RETRY_DELAY_MS * attempt);
continue;
}
throw statusError;
}
const buffer = Buffer.from(await response.arrayBuffer());
if (shouldUseMemoryCache) {
writeRemoteAssetMemoryCache(normalizedUrl, buffer);
}
return buffer;
} catch (error) {
if (attempt < attempts && isRetryableFetchError(error)) {
console.warn("[PSD Remote Asset] transient fetch error, retrying download:", {
label,
location,
attempt,
attempts,
error,
});
await sleep(RETRY_DELAY_MS * attempt);
continue;
}
console.error("[PSD Remote Asset] failed to download asset:", {
label,
location,
attemptsTried: attempt,
error,
});
throw new Error(`${label}下载失败,请稍后重试或检查资源是否可访问(${location})`);
}
}
throw new Error(`${label}下载失败,请稍后重试或检查资源是否可访问(${location})`);
})();
if (shouldUseMemoryCache && isCacheableRemoteAssetUrl(normalizedUrl)) {
const cacheState = getRemoteAssetCacheState();
cacheState.inFlight.set(normalizedUrl, downloadPromise);
downloadPromise.then(
() => {
cacheState.inFlight.delete(normalizedUrl);
},
() => {
cacheState.inFlight.delete(normalizedUrl);
}
);
}
return downloadPromise;
}