openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
462 lines (461 loc) • 18.6 kB
JavaScript
import "./src-vebZIeLe.js";
import { t as expectDefined } from "./expect-CyE8FADM.js";
import { _ as parseCanonicalIpAddress, c as isIpv4Address, i as isCanonicalDottedDecimalIPv4, n as isBlockedSpecialUseIpv4Address, r as isBlockedSpecialUseIpv6Address, t as extractEmbeddedIpv4FromIpv6, u as isLegacyIpv4Literal, y as parseLooseIpAddress } from "./ip-BkT2Is0E.js";
import { t as hasHttpUrlPrefix } from "./url-protocol-OU3K-ySz.js";
import { t as parseInlineDirectives } from "./directive-tags-Wp1S72ap.js";
import { r as parseFenceSpans } from "./fences-DtD_oijY.js";
import { t as MarkdownItCallable } from "./markdown-it-BjEtoM4V.js";
//#region packages/markdown-core/src/image-spans.ts
/** Finds inline image destinations without treating code or escaped syntax as media. */
function findMarkdownImageSpans(markdown) {
const md = new MarkdownItCallable("commonmark");
md.inline.ruler.enableOnly(["image"]);
const parseImage = expectDefined(md.inline.ruler.getRules("")[0], "Markdown image rule");
md.configure("commonmark");
md.normalizeLink = (url) => url;
md.validateLink = () => true;
const environment = {};
const blocks = md.parse(markdown, environment);
const lineOffsets = [0];
for (const newline of markdown.matchAll(/\r\n?|\n/g)) lineOffsets.push(newline.index + newline[0].length);
const sourceLines = markdown.replace(/\0/g, "�").split(/\r\n?|\n/);
const images = [];
let source = "";
let inlineLines = [];
let inlineLine = 0;
const sourceOffset = (position) => {
for (let next = inlineLines[inlineLine + 1]; next && next.start <= position; next = inlineLines[inlineLine + 1]) inlineLine += 1;
return position + expectDefined(inlineLines[inlineLine], "Markdown inline line").offset;
};
md.inline.ruler.at("image", (state, silent) => {
const start = state.pos;
const matched = parseImage(state, silent);
if (matched && !silent && state.src === source) {
const token = expectDefined(state.tokens.at(-1), "Parsed Markdown image");
if (token.meta?.label) return matched;
images.push({
start: sourceOffset(start),
end: sourceOffset(state.pos),
destination: String(expectDefined(token.attrGet("src"), "Markdown image destination"))
});
}
return matched;
});
for (const block of blocks) {
if (block.type !== "inline" || !block.map || !block.children?.some((token) => token.type === "image")) continue;
source = block.content;
const firstLine = block.map[0];
let start = 0;
inlineLines = source.split("\n").map((content, index) => {
const original = expectDefined(sourceLines[firstLine + index], "Markdown source line");
const text = content.trimStart();
const offset = expectDefined(lineOffsets[firstLine + index], "Markdown source offset") + original.indexOf(text) - (content.length - text.length) - start;
const line = {
start,
offset
};
start += content.length + 1;
return line;
});
inlineLine = 0;
md.inline.parse(source, md, environment, []);
}
return images;
}
//#endregion
//#region src/media/audio-tags.ts
/**
* Extract audio mode tag from text.
* Supports [[audio_as_voice]] to send audio as voice message instead of file.
* Default is file (preserves backward compatibility).
*/
function parseAudioTag(text) {
const result = parseInlineDirectives(text, { stripReplyTags: false });
return {
text: result.text,
audioAsVoice: result.audioAsVoice,
hadTag: result.hasAudioTag
};
}
//#endregion
//#region src/media/parse.ts
/** Captures legacy MEDIA: attachment directives from model/tool output. */
const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/gi;
const RENDERABLE_ASSISTANT_MEDIA_PREFIX_RE = /^(?:https?:\/\/|data:(?:image|audio|video)\/|file:|~|\/|[a-z]:[\\/])/iu;
function isRelativeAssistantMediaReference(url) {
const trimmed = url.trim();
return Boolean(trimmed) && !RENDERABLE_ASSISTANT_MEDIA_PREFIX_RE.test(trimmed);
}
const FILE_URL_PREFIX_RE = /^file:(?:\/\/)?/i;
function normalizeMediaSource(src) {
return src.replace(FILE_URL_PREFIX_RE, "");
}
const TRAILING_SERIALIZED_JSON_AFTER_EXT_RE = /^(.*\.\w{1,10})\\?"(?=[\]},:]|$).*/s;
function cleanCandidate(raw) {
const stripped = raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
return TRAILING_SERIALIZED_JSON_AFTER_EXT_RE.exec(stripped)?.[1] ?? stripped;
}
const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
const MEDIA_SOURCE_ROOT_RE = /^(?:[a-z]:[\\/]|[/~]|\.{1,2}[\\/]|\\\\)/i;
const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
const HAS_FILE_EXT = /\.\w{1,10}$/;
const TRAVERSAL_SEGMENT_RE = /(?:^|[/\\])\.\.(?:[/\\]|$)/;
function isSupportedHomeRelativePath(candidate) {
return candidate.startsWith("~/") || candidate.startsWith("~\\");
}
function hasTraversalOrUnsupportedHomeDirPrefix(candidate) {
return candidate.startsWith("../") || candidate === ".." || candidate.startsWith("~") && !isSupportedHomeRelativePath(candidate) || TRAVERSAL_SEGMENT_RE.test(candidate);
}
function looksLikeLocalFilePath(candidate) {
return candidate.startsWith("/") || candidate.startsWith("./") || candidate.startsWith("../") || candidate.startsWith("~") || WINDOWS_DRIVE_RE.test(candidate) || candidate.startsWith("\\\\") || !SCHEME_RE.test(candidate) && (candidate.includes("/") || candidate.includes("\\"));
}
function isLikelyLocalPath(candidate) {
if (hasTraversalOrUnsupportedHomeDirPrefix(candidate)) return false;
return candidate.startsWith("/") || candidate.startsWith("./") || isSupportedHomeRelativePath(candidate) || WINDOWS_DRIVE_RE.test(candidate) || candidate.startsWith("\\\\") || !SCHEME_RE.test(candidate) && (candidate.includes("/") || candidate.includes("\\"));
}
function normalizeRemoteMediaHostname(value) {
const normalized = value.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.+$/, "");
if (normalized.split(".").some((label) => label.length === 0)) return "";
return normalized;
}
function isBlockedRemoteMediaHostname(hostname) {
const normalized = normalizeRemoteMediaHostname(hostname);
if (!normalized) return true;
if (!normalized.includes(".")) return true;
if (normalized === "localhost" || normalized === "localhost.localdomain" || normalized === "metadata.google.internal" || normalized.endsWith(".localhost") || normalized.endsWith(".local") || normalized.endsWith(".internal")) return true;
const strictIp = parseCanonicalIpAddress(normalized);
if (strictIp) {
if (isIpv4Address(strictIp)) return isBlockedSpecialUseIpv4Address(strictIp);
if (isBlockedSpecialUseIpv6Address(strictIp)) return true;
const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(strictIp);
return embeddedIpv4 ? isBlockedSpecialUseIpv4Address(embeddedIpv4) : false;
}
if (normalized.includes(":") && !parseLooseIpAddress(normalized)) return true;
return !isCanonicalDottedDecimalIPv4(normalized) && isLegacyIpv4Literal(normalized);
}
function isAllowedRemoteMediaUrl(candidate) {
try {
const parsed = new URL(candidate);
return parsed.protocol === "https:" && !parsed.username && !parsed.password && !isBlockedRemoteMediaHostname(parsed.hostname);
} catch {
return false;
}
}
function isValidMedia(source, opts) {
const candidate = normalizeMediaSource(source);
if (!candidate) return false;
if (candidate.length > 4096) return false;
if (!opts?.allowSpaces && /\s/.test(candidate)) return false;
if (hasHttpUrlPrefix(candidate)) return isAllowedRemoteMediaUrl(candidate);
if (isLikelyLocalPath(candidate)) return true;
if (hasTraversalOrUnsupportedHomeDirPrefix(candidate)) return false;
if (opts?.allowBareFilename && !SCHEME_RE.test(candidate) && HAS_FILE_EXT.test(candidate)) return true;
return false;
}
function beginsIndependentMediaSource(raw) {
const candidate = normalizeMediaSource(cleanCandidate(raw));
return MEDIA_SOURCE_ROOT_RE.test(candidate) || SCHEME_RE.test(candidate);
}
function splitUnquotedMediaDirectiveParts(payload) {
const parts = [];
let previousEnd = 0;
for (const match of payload.matchAll(/\S+/g)) {
const candidate = normalizeMediaSource(cleanCandidate(match[0]));
const previous = parts.at(-1);
const previousCandidate = previous ? normalizeMediaSource(cleanCandidate(previous)) : "";
if (MEDIA_SOURCE_ROOT_RE.test(previousCandidate) && !beginsIndependentMediaSource(candidate) && (!HAS_FILE_EXT.test(previousCandidate) || !isValidMedia(candidate))) parts[parts.length - 1] = `${previous}${payload.slice(previousEnd, match.index)}${match[0]}`;
else parts.push(match[0]);
previousEnd = match.index + match[0].length;
}
return parts;
}
function unwrapQuoted(value) {
const trimmed = value.trim();
if (trimmed.length < 2) return;
const first = trimmed[0];
if (first !== trimmed[trimmed.length - 1]) return;
if (first !== `"` && first !== "'" && first !== "`") return;
return trimmed.slice(1, -1).trim();
}
function normalizeMarkdownImageDestination(destination) {
return normalizeMediaSource(destination.trim());
}
function mayContainFenceMarkers(input) {
return input.includes("```") || input.includes("~~~");
}
function cleanLineText(text) {
return text.replace(/[ \t]{2,}/g, " ").trim();
}
const MAX_MARKDOWN_IMAGE_LINE_LENGTH = 2e4;
const MAX_MARKDOWN_IMAGE_MATCHES_PER_LINE = 50;
function isRemoteMarkdownImageMedia(candidate) {
return hasHttpUrlPrefix(candidate) && isValidMedia(candidate);
}
function collectMarkdownImageSegments(params) {
const { matches } = params;
if (matches.length === 0) return {
lineSegments: [],
foundMedia: false
};
const segmentPieces = [];
const visiblePieces = [];
const lineSegments = [];
let cursor = 0;
let foundMedia = false;
for (const match of matches) {
const before = params.line.slice(cursor, match.start);
segmentPieces.push(before);
visiblePieces.push(before);
const target = normalizeMarkdownImageDestination(match.destination);
const selectedTarget = params.allowlist?.get(target);
if (selectedTarget || !params.allowlist && isRemoteMarkdownImageMedia(target)) {
const beforeText = cleanLineText(segmentPieces.join(""));
if (beforeText) lineSegments.push({
type: "text",
text: beforeText
});
segmentPieces.length = 0;
const mediaTarget = selectedTarget ?? target;
params.media.push(mediaTarget);
lineSegments.push({
type: "media",
url: mediaTarget
});
foundMedia = true;
} else {
const original = params.line.slice(match.start, match.end);
segmentPieces.push(original);
visiblePieces.push(original);
}
cursor = match.end;
}
const after = params.line.slice(cursor);
segmentPieces.push(after);
visiblePieces.push(after);
const trailingText = cleanLineText(segmentPieces.join(""));
if (trailingText) lineSegments.push({
type: "text",
text: trailingText
});
return {
cleanedLine: cleanLineText(visiblePieces.join("")) || void 0,
lineSegments,
foundMedia
};
}
/** Splits tool/stdout text into visible text, media attachments, voice tags, and ordered segments. */
function splitMediaFromOutput(raw, options = {}) {
const trimmedRaw = raw.trimEnd();
if (!trimmedRaw.trim()) return { text: "" };
const markdownImageAllowlist = options.markdownImageAllowlist === void 0 ? void 0 : new Map(options.markdownImageAllowlist.map((source) => [normalizeMarkdownImageDestination(source), source]));
const extractMarkdownImages = markdownImageAllowlist !== void 0 || options.extractMarkdownImages === true;
const extractMediaDirectives = options.extractMediaDirectives !== false;
const mayContainMediaToken = extractMediaDirectives && /media:/i.test(trimmedRaw);
const mayContainMarkdownImage = extractMarkdownImages && trimmedRaw.includes("![");
const mayContainAudioTag = trimmedRaw.includes("[[");
if (!mayContainMediaToken && !mayContainMarkdownImage && !mayContainAudioTag) return { text: trimmedRaw };
const media = [];
let foundMediaToken = false;
const segments = [];
let lastTextSegment;
const pushTextSegment = (text) => {
const last = segments[segments.length - 1];
if (last?.type === "text") last.text = `${last.text}\n${text.trim() ? text : ""}`;
else if (!text.trim()) {
if (last?.type === "media" && lastTextSegment && !lastTextSegment.text.endsWith("\n")) lastTextSegment.text += "\n";
} else {
lastTextSegment = {
type: "text",
text
};
segments.push(lastTextSegment);
}
};
const fenceSpans = mayContainFenceMarkers(trimmedRaw) ? parseFenceSpans(trimmedRaw) : [];
const lines = trimmedRaw.split("\n");
const keptLines = [];
const markdownImages = mayContainMarkdownImage && lines.some((line) => line.length <= MAX_MARKDOWN_IMAGE_LINE_LENGTH && line.includes("![")) ? findMarkdownImageSpans(trimmedRaw) : [];
let markdownImageIndex = 0;
let lineOffset = 0;
let fenceIndex = 0;
for (const line of lines) {
const lineEnd = lineOffset + line.length;
const lineImages = [];
for (; markdownImageIndex < markdownImages.length; markdownImageIndex += 1) {
const match = expectDefined(markdownImages[markdownImageIndex], "Markdown image span");
if (match.start >= lineEnd) break;
if (line.length <= MAX_MARKDOWN_IMAGE_LINE_LENGTH && lineImages.length < MAX_MARKDOWN_IMAGE_MATCHES_PER_LINE && match.start >= lineOffset && match.end <= lineEnd) lineImages.push({
...match,
start: match.start - lineOffset,
end: match.end - lineOffset
});
}
let fence = fenceSpans[fenceIndex];
while (fence && lineOffset >= fence.end) {
fenceIndex += 1;
fence = fenceSpans[fenceIndex];
}
if (fence && lineOffset >= fence.start) {
keptLines.push(line);
pushTextSegment(line);
lineOffset += line.length + 1;
continue;
}
const linePrefix = line.trimStart().slice(0, 6);
if (!extractMediaDirectives || !linePrefix.toUpperCase().startsWith("MEDIA:")) {
const markdownImageResult = extractMarkdownImages ? collectMarkdownImageSegments({
line,
matches: lineImages,
media,
allowlist: markdownImageAllowlist
}) : {
lineSegments: [],
foundMedia: false
};
if (!markdownImageResult.foundMedia) {
keptLines.push(line);
pushTextSegment(line);
} else {
foundMediaToken = true;
if (markdownImageResult.cleanedLine) keptLines.push(markdownImageResult.cleanedLine);
for (const segment of markdownImageResult.lineSegments) {
if (segment.type === "text") {
pushTextSegment(segment.text);
continue;
}
segments.push(segment);
}
}
lineOffset += line.length + 1;
continue;
}
const matches = Array.from(line.matchAll(MEDIA_TOKEN_RE));
if (matches.length === 0) {
keptLines.push(line);
pushTextSegment(line);
lineOffset += line.length + 1;
continue;
}
const pieces = [];
const lineSegments = [];
let cursor = 0;
for (const match of matches) {
const start = match.index ?? 0;
pieces.push(line.slice(cursor, start));
const payload = expectDefined(match[1], "parse regex capture 1");
const unwrapped = unwrapQuoted(payload);
const payloadValue = unwrapped ?? payload;
const parts = unwrapped ? [unwrapped] : splitUnquotedMediaDirectiveParts(payload);
const mediaStartIndex = media.length;
let validCount = 0;
const invalidParts = [];
let hasValidMedia = false;
for (const part of parts) {
const candidate = cleanCandidate(part);
if (isValidMedia(candidate, { allowSpaces: Boolean(unwrapped) || /\s/.test(candidate) })) {
media.push(candidate);
hasValidMedia = true;
foundMediaToken = true;
validCount += 1;
} else if (!/\s/.test(part) || !hasTraversalOrUnsupportedHomeDirPrefix(candidate)) invalidParts.push(part);
}
const trimmedPayload = payloadValue.trim();
const looksLikeLocalPath = looksLikeLocalFilePath(trimmedPayload) || FILE_URL_PREFIX_RE.test(trimmedPayload);
if (!unwrapped && validCount === 1 && invalidParts.length > 0 && !parts.slice(1).some(beginsIndependentMediaSource) && /\s/.test(payloadValue) && looksLikeLocalPath) {
const fallback = cleanCandidate(payloadValue);
if (isValidMedia(fallback, { allowSpaces: true })) {
media.splice(mediaStartIndex, media.length - mediaStartIndex, fallback);
hasValidMedia = true;
foundMediaToken = true;
invalidParts.length = 0;
}
}
if (!hasValidMedia && !unwrapped && /\s/.test(payloadValue)) {
const spacedFallback = cleanCandidate(payloadValue);
if (isValidMedia(spacedFallback, {
allowSpaces: true,
allowBareFilename: true
})) {
media.splice(mediaStartIndex, media.length - mediaStartIndex, spacedFallback);
hasValidMedia = true;
foundMediaToken = true;
invalidParts.length = 0;
}
}
if (!hasValidMedia) {
const fallback = cleanCandidate(payloadValue);
if (isValidMedia(fallback, {
allowSpaces: true,
allowBareFilename: true
})) {
media.push(fallback);
hasValidMedia = true;
foundMediaToken = true;
invalidParts.length = 0;
}
}
if (hasValidMedia) {
const beforeText = cleanLineText(pieces.join(""));
if (beforeText) lineSegments.push({
type: "text",
text: beforeText
});
pieces.length = 0;
for (const url of media.slice(mediaStartIndex)) lineSegments.push({
type: "media",
url
});
if (invalidParts.length > 0) pieces.push(invalidParts.join(" "));
} else if (looksLikeLocalPath) foundMediaToken = true;
else pieces.push(match[0]);
cursor = start + match[0].length;
}
pieces.push(line.slice(cursor));
const cleanedLine = cleanLineText(pieces.join(""));
if (cleanedLine) {
keptLines.push(cleanedLine);
lineSegments.push({
type: "text",
text: cleanedLine
});
}
for (const segment of lineSegments) {
if (segment.type === "text") {
pushTextSegment(segment.text);
continue;
}
segments.push(segment);
}
lineOffset += line.length + 1;
}
const visibleText = keptLines.join("\n").replace(/^(?:[ \t]*\n)+/, "");
const audioTagResult = options.extractAudioDirectives === false ? {
text: visibleText,
audioAsVoice: false
} : parseAudioTag(visibleText);
const cleanedText = audioTagResult.text.trimEnd();
const hasAudioAsVoice = audioTagResult.audioAsVoice;
if (media.length === 0) {
const parsedText = foundMediaToken || hasAudioAsVoice ? cleanedText : trimmedRaw;
const result = {
text: parsedText,
segments: parsedText ? [{
type: "text",
text: parsedText
}] : []
};
if (hasAudioAsVoice) result.audioAsVoice = true;
return result;
}
return {
text: cleanedText,
mediaUrls: media,
segments: segments.length > 0 ? segments : [{
type: "text",
text: cleanedText
}],
...hasAudioAsVoice ? { audioAsVoice: true } : {}
};
}
//#endregion
export { splitMediaFromOutput as n, isRelativeAssistantMediaReference as t };