fix(knowledge-fs): persist document images across formats

This commit is contained in:
Jyong 2026-08-26 03:57:35 -04:00
parent 207640bcd1
commit 77a5de2fc9
26 changed files with 1364 additions and 8 deletions

View File

@ -30,6 +30,10 @@ from services.knowledge_fs.object_storage import (
KnowledgeFSObjectStorageUnavailableError,
)
from services.knowledge_fs.query_images import KnowledgeFSQueryImageError, load_query_image
from services.knowledge_fs.remote_images import (
KnowledgeFSRemoteImageError,
load_remote_image,
)
_METADATA_HEADER = "X-Knowledge-FS-Metadata"
_CHECKSUM_HEADER = "X-Knowledge-FS-Checksum-Sha256"
@ -95,6 +99,12 @@ class KnowledgeFSQueryImageQuery(BaseModel):
subject_id: str = Field(min_length=1)
class KnowledgeFSRemoteImageQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
url: str = Field(min_length=1, max_length=8_192)
register_response_schema_models(
inner_api_ns,
KnowledgeFSObjectMetadataResponse,
@ -142,6 +152,28 @@ class KnowledgeFSQueryImageApi(Resource):
return response
@inner_api_ns.route("/knowledge-fs/remote-image")
class KnowledgeFSRemoteImageApi(Resource):
"""Resolve one bounded document image through Dify's SSRF-protected network client."""
@knowledge_fs_inner_api_only
@inner_api_ns.doc(params=query_params_from_model(KnowledgeFSRemoteImageQuery))
@inner_api_ns.produces(["image/gif", "image/jpeg", "image/png", "image/webp"])
def get(self) -> Response:
try:
query = KnowledgeFSRemoteImageQuery.model_validate(request.args.to_dict(flat=True))
image = load_remote_image(query.url)
except ValidationError as exc:
raise _invalid_request_error() from exc
except KnowledgeFSRemoteImageError as exc:
raise _remote_image_http_error(exc) from exc
response = Response(image.body, content_type=image.mime_type)
response.content_length = image.byte_size
response.headers["X-Knowledge-FS-Remote-Image-Sha256"] = image.sha256
return response
@inner_api_ns.route("/knowledge-fs/storage/object")
class KnowledgeFSObjectApi(Resource):
"""Read, write, or delete one logical KnowledgeFS object."""
@ -357,3 +389,22 @@ def _not_found_error() -> KnowledgeFSObjectStorageHttpError:
description="KnowledgeFS object was not found.",
status_code=HTTPStatus.NOT_FOUND,
)
def _remote_image_http_error(error: KnowledgeFSRemoteImageError) -> KnowledgeFSObjectStorageHttpError:
status_by_code = {
"REMOTE_IMAGE_BLOCKED": HTTPStatus.FORBIDDEN,
"REMOTE_IMAGE_CONTENT_UNSUPPORTED": HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
"REMOTE_IMAGE_EMPTY": HTTPStatus.UNPROCESSABLE_ENTITY,
"REMOTE_IMAGE_NOT_FOUND": HTTPStatus.NOT_FOUND,
"REMOTE_IMAGE_RATE_LIMITED": HTTPStatus.TOO_MANY_REQUESTS,
"REMOTE_IMAGE_REQUEST_REJECTED": HTTPStatus.UNPROCESSABLE_ENTITY,
"REMOTE_IMAGE_TOO_LARGE": HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
"REMOTE_IMAGE_UPSTREAM_UNAVAILABLE": HTTPStatus.BAD_GATEWAY,
"REMOTE_IMAGE_URL_INVALID": HTTPStatus.BAD_REQUEST,
}
return KnowledgeFSObjectStorageHttpError(
error_code=error.code.lower(),
description=str(error),
status_code=status_by_code.get(error.code, HTTPStatus.BAD_GATEWAY),
)

View File

@ -1,6 +1,6 @@
{
"schemaVersion": 5,
"subtreeTree": "c6d1bb478d08368b88c87b9178dd9db46add8719",
"subtreeTree": "2badcef0264f09fd0e0d33577866b8cbb4278a8b",
"openapiSha256": "2cf348c68bbe65dd51bbde9a0a4f91398beeebd79e89e9288c9386b26ae09796",
"capabilityV2AuthManifestSha256": "e322a2fa779d1f40b95c54c1021cffecaec77abbd7b34899573dcdf4ff353109",
"capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3",

View File

@ -0,0 +1,187 @@
"""SSRF-safe, bounded retrieval for KnowledgeFS document image references."""
from __future__ import annotations
import hashlib
import urllib.parse
from dataclasses import dataclass
import httpx
from core.file import remote_fetcher
from core.helper import ssrf_proxy
from core.tools.errors import ToolSSRFError
KNOWLEDGE_FS_REMOTE_IMAGE_MAX_BYTES = 10 * 1024 * 1024
KNOWLEDGE_FS_REMOTE_IMAGE_MAX_URL_CHARS = 8_192
_REMOTE_IMAGE_ACCEPT = "image/webp,image/png,image/jpeg,image/gif;q=0.9,*/*;q=0.1"
class KnowledgeFSRemoteImageError(ValueError):
"""Safe, classified failure returned across the trusted KnowledgeFS bridge."""
def __init__(self, code: str, message: str, *, retryable: bool) -> None:
super().__init__(message)
self.code = code
self.retryable = retryable
@dataclass(frozen=True)
class KnowledgeFSResolvedRemoteImage:
byte_size: int
mime_type: str
body: bytes
sha256: str
def load_remote_image(url: str) -> KnowledgeFSResolvedRemoteImage:
"""Resolve one user-authored image URL through Dify's SSRF-protected file client."""
normalized_url = _validate_remote_image_url(url)
try:
response = remote_fetcher.make_request(
"GET",
normalized_url,
follow_redirects=True,
headers={
"Accept": _REMOTE_IMAGE_ACCEPT,
"Accept-Encoding": "identity",
},
max_retries=1,
stream_response=True,
)
except ToolSSRFError as exc:
raise KnowledgeFSRemoteImageError(
"REMOTE_IMAGE_BLOCKED",
"Remote image access was blocked by network safety policy.",
retryable=False,
) from exc
except (httpx.TimeoutException, httpx.RequestError, ssrf_proxy.MaxRetriesExceededError) as exc:
raise KnowledgeFSRemoteImageError(
"REMOTE_IMAGE_UPSTREAM_UNAVAILABLE",
"Remote image service is temporarily unavailable.",
retryable=True,
) from exc
try:
_assert_remote_status(response.status_code)
declared_size = _content_length(response)
if declared_size is not None and declared_size > KNOWLEDGE_FS_REMOTE_IMAGE_MAX_BYTES:
raise KnowledgeFSRemoteImageError(
"REMOTE_IMAGE_TOO_LARGE",
"Remote image exceeds the configured size limit.",
retryable=False,
)
try:
buffered = ssrf_proxy.buffer_response(
response,
max_response_bytes=KNOWLEDGE_FS_REMOTE_IMAGE_MAX_BYTES,
)
except ssrf_proxy.ResponseTooLargeError as exc:
raise KnowledgeFSRemoteImageError(
"REMOTE_IMAGE_TOO_LARGE",
"Remote image exceeds the configured size limit.",
retryable=False,
) from exc
except ssrf_proxy.UnsupportedResponseEncodingError as exc:
raise KnowledgeFSRemoteImageError(
"REMOTE_IMAGE_CONTENT_UNSUPPORTED",
"Remote image response encoding is not supported.",
retryable=False,
) from exc
except Exception:
response.close()
raise
body = buffered.content
if not body:
raise KnowledgeFSRemoteImageError(
"REMOTE_IMAGE_EMPTY",
"Remote image is empty.",
retryable=False,
)
mime_type = _detect_image_mime_type(body)
return KnowledgeFSResolvedRemoteImage(
byte_size=len(body),
mime_type=mime_type,
body=body,
sha256=hashlib.sha256(body).hexdigest(),
)
def _validate_remote_image_url(url: str) -> str:
normalized = url.strip()
if not normalized or len(normalized) > KNOWLEDGE_FS_REMOTE_IMAGE_MAX_URL_CHARS:
raise KnowledgeFSRemoteImageError(
"REMOTE_IMAGE_URL_INVALID",
"Remote image URL is invalid.",
retryable=False,
)
parsed = urllib.parse.urlsplit(normalized)
if (
parsed.scheme.lower() not in {"http", "https"}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
):
raise KnowledgeFSRemoteImageError(
"REMOTE_IMAGE_URL_INVALID",
"Remote image URL is invalid.",
retryable=False,
)
return normalized
def _assert_remote_status(status_code: int) -> None:
if 200 <= status_code < 300:
return
if status_code in {404, 410}:
raise KnowledgeFSRemoteImageError(
"REMOTE_IMAGE_NOT_FOUND",
"Remote image was not found.",
retryable=False,
)
if status_code == 429:
raise KnowledgeFSRemoteImageError(
"REMOTE_IMAGE_RATE_LIMITED",
"Remote image service rate limited the request.",
retryable=True,
)
if status_code in {408, 425} or status_code >= 500:
raise KnowledgeFSRemoteImageError(
"REMOTE_IMAGE_UPSTREAM_UNAVAILABLE",
"Remote image service is temporarily unavailable.",
retryable=True,
)
raise KnowledgeFSRemoteImageError(
"REMOTE_IMAGE_REQUEST_REJECTED",
"Remote image request was rejected.",
retryable=False,
)
def _content_length(response: httpx.Response) -> int | None:
value = response.headers.get("content-length")
if value is None:
return None
try:
parsed = int(value)
except ValueError:
return None
return parsed if parsed >= 0 else None
def _detect_image_mime_type(body: bytes) -> str:
if body.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if body.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if body.startswith((b"GIF87a", b"GIF89a")):
return "image/gif"
if len(body) >= 12 and body.startswith(b"RIFF") and body[8:12] == b"WEBP":
return "image/webp"
raise KnowledgeFSRemoteImageError(
"REMOTE_IMAGE_CONTENT_UNSUPPORTED",
"Remote image content is not supported.",
retryable=False,
)

View File

@ -15,6 +15,7 @@ from controllers.inner_api.knowledge_fs.storage import (
KnowledgeFSObjectMetadataApi,
KnowledgeFSObjectStorageHttpError,
KnowledgeFSQueryImageApi,
KnowledgeFSRemoteImageApi,
)
from services.knowledge_fs.object_storage import (
KnowledgeFSObjectList,
@ -23,6 +24,7 @@ from services.knowledge_fs.object_storage import (
KnowledgeFSObjectStorageUnavailableError,
)
from services.knowledge_fs.query_images import KnowledgeFSResolvedQueryImage
from services.knowledge_fs.remote_images import KnowledgeFSRemoteImageError, KnowledgeFSResolvedRemoteImage
@pytest.fixture
@ -40,6 +42,60 @@ def _metadata_header(metadata: dict[str, str]) -> str:
return urlsafe_b64encode(json.dumps(metadata).encode()).decode().rstrip("=")
@patch("controllers.inner_api.knowledge_fs.storage.load_remote_image")
def test_remote_image_endpoint_returns_only_sniffed_bounded_image_bytes(
load_remote_image: MagicMock,
app: Flask,
) -> None:
body = b"\x89PNG\r\n\x1a\nremote"
load_remote_image.return_value = KnowledgeFSResolvedRemoteImage(
byte_size=len(body),
mime_type="image/png",
body=body,
sha256="a" * 64,
)
handler = KnowledgeFSRemoteImageApi()
with app.test_request_context("/?url=https%3A%2F%2Fcdn.example.test%2Fimage.png"):
result = inspect.unwrap(handler.get)(handler)
assert result.get_data() == body
assert result.content_type == "image/png"
assert result.headers["X-Knowledge-FS-Remote-Image-Sha256"] == "a" * 64
load_remote_image.assert_called_once_with("https://cdn.example.test/image.png")
@pytest.mark.parametrize(
("error", "expected_status"),
[
(KnowledgeFSRemoteImageError("REMOTE_IMAGE_BLOCKED", "blocked", retryable=False), 403),
(KnowledgeFSRemoteImageError("REMOTE_IMAGE_NOT_FOUND", "missing", retryable=False), 404),
(KnowledgeFSRemoteImageError("REMOTE_IMAGE_TOO_LARGE", "large", retryable=False), 413),
(
KnowledgeFSRemoteImageError("REMOTE_IMAGE_CONTENT_UNSUPPORTED", "unsupported", retryable=False),
415,
),
(KnowledgeFSRemoteImageError("REMOTE_IMAGE_RATE_LIMITED", "limited", retryable=True), 429),
(KnowledgeFSRemoteImageError("REMOTE_IMAGE_UPSTREAM_UNAVAILABLE", "offline", retryable=True), 502),
],
)
@patch("controllers.inner_api.knowledge_fs.storage.load_remote_image")
def test_remote_image_endpoint_maps_safe_failure_statuses(
load_remote_image: MagicMock,
error: KnowledgeFSRemoteImageError,
expected_status: int,
app: Flask,
) -> None:
load_remote_image.side_effect = error
handler = KnowledgeFSRemoteImageApi()
with app.test_request_context("/?url=https%3A%2F%2Fcdn.example.test%2Fimage.png"):
with pytest.raises(KnowledgeFSObjectStorageHttpError) as exc_info:
inspect.unwrap(handler.get)(handler)
assert exc_info.value.code == expected_status
@patch("controllers.inner_api.knowledge_fs.storage.load_query_image")
def test_query_image_endpoint_returns_only_actor_owned_bounded_bytes(
load_query_image: MagicMock,

View File

@ -0,0 +1,170 @@
"""Unit tests for SSRF-safe KnowledgeFS remote-image loading."""
from unittest.mock import MagicMock, patch
import httpx
import pytest
from core.helper.ssrf_proxy import ResponseTooLargeError, UnsupportedResponseEncodingError
from core.tools.errors import ToolSSRFError
from services.knowledge_fs.remote_images import (
KNOWLEDGE_FS_REMOTE_IMAGE_MAX_BYTES,
KnowledgeFSRemoteImageError,
load_remote_image,
)
def _response(status: int, body: bytes = b"", headers: dict[str, str] | None = None) -> httpx.Response:
return httpx.Response(
status,
content=body,
headers=headers,
request=httpx.Request("GET", "https://cdn.example.test/image"),
)
@patch("services.knowledge_fs.remote_images.remote_fetcher.make_request")
def test_load_remote_image_uses_ssrf_fetcher_and_sniffs_supported_content(
make_request: MagicMock,
) -> None:
body = b"\x89PNG\r\n\x1a\nimage"
make_request.return_value = _response(200, body, {"content-type": "application/octet-stream"})
result = load_remote_image("https://cdn.example.test/image")
assert result.body == body
assert result.mime_type == "image/png"
assert result.sha256 == "3c7474b4239ada3342d87f25ec8849eb8473ee35c5471452482686098b49e81b"
make_request.assert_called_once_with(
"GET",
"https://cdn.example.test/image",
follow_redirects=True,
headers={
"Accept": "image/webp,image/png,image/jpeg,image/gif;q=0.9,*/*;q=0.1",
"Accept-Encoding": "identity",
},
max_retries=1,
stream_response=True,
)
@pytest.mark.parametrize(
("body", "mime_type"),
[
(b"\xff\xd8\xffimage", "image/jpeg"),
(b"GIF89aimage", "image/gif"),
(b"RIFF\x00\x00\x00\x00WEBPimage", "image/webp"),
],
)
@patch("services.knowledge_fs.remote_images.remote_fetcher.make_request")
def test_load_remote_image_sniffs_other_supported_formats(
make_request: MagicMock,
body: bytes,
mime_type: str,
) -> None:
make_request.return_value = _response(200, body, {"content-length": "invalid"})
assert load_remote_image("https://cdn.example.test/image").mime_type == mime_type
@pytest.mark.parametrize(
"url",
[
"",
"https://user:secret@cdn.example.test/private.png",
"file:///etc/passwd",
"data:image/png;base64,AQ==",
"relative.png",
"https://cdn.example.test/" + "x" * 8_192,
],
)
def test_load_remote_image_rejects_non_http_urls(url: str) -> None:
with pytest.raises(KnowledgeFSRemoteImageError) as exc_info:
load_remote_image(url)
assert exc_info.value.code == "REMOTE_IMAGE_URL_INVALID"
assert exc_info.value.retryable is False
@pytest.mark.parametrize(
("status", "code", "retryable"),
[
(404, "REMOTE_IMAGE_NOT_FOUND", False),
(400, "REMOTE_IMAGE_REQUEST_REJECTED", False),
(429, "REMOTE_IMAGE_RATE_LIMITED", True),
(503, "REMOTE_IMAGE_UPSTREAM_UNAVAILABLE", True),
],
)
@patch("services.knowledge_fs.remote_images.remote_fetcher.make_request")
def test_load_remote_image_classifies_upstream_statuses(
make_request: MagicMock,
status: int,
code: str,
retryable: bool,
) -> None:
make_request.return_value = _response(status)
with pytest.raises(KnowledgeFSRemoteImageError) as exc_info:
load_remote_image("https://cdn.example.test/image.png")
assert exc_info.value.code == code
assert exc_info.value.retryable is retryable
@patch("services.knowledge_fs.remote_images.remote_fetcher.make_request")
def test_load_remote_image_rejects_oversized_and_unsupported_bodies(make_request: MagicMock) -> None:
make_request.return_value = _response(
200,
b"x",
{"content-length": str(KNOWLEDGE_FS_REMOTE_IMAGE_MAX_BYTES + 1)},
)
with pytest.raises(KnowledgeFSRemoteImageError) as too_large:
load_remote_image("https://cdn.example.test/large.png")
assert too_large.value.code == "REMOTE_IMAGE_TOO_LARGE"
make_request.return_value = _response(200, b"not-an-image")
with pytest.raises(KnowledgeFSRemoteImageError) as unsupported:
load_remote_image("https://cdn.example.test/not-image")
assert unsupported.value.code == "REMOTE_IMAGE_CONTENT_UNSUPPORTED"
make_request.return_value = _response(200, b"", {"content-length": "-1"})
with pytest.raises(KnowledgeFSRemoteImageError) as empty:
load_remote_image("https://cdn.example.test/empty.png")
assert empty.value.code == "REMOTE_IMAGE_EMPTY"
@patch("services.knowledge_fs.remote_images.ssrf_proxy.buffer_response")
@patch("services.knowledge_fs.remote_images.remote_fetcher.make_request")
def test_load_remote_image_maps_stream_limit_and_ssrf_rejections(
make_request: MagicMock,
buffer_response: MagicMock,
) -> None:
make_request.return_value = _response(200, b"pending")
buffer_response.side_effect = ResponseTooLargeError("large")
with pytest.raises(KnowledgeFSRemoteImageError) as too_large:
load_remote_image("https://cdn.example.test/large.png")
assert too_large.value.code == "REMOTE_IMAGE_TOO_LARGE"
make_request.side_effect = ToolSSRFError("blocked")
with pytest.raises(KnowledgeFSRemoteImageError) as blocked:
load_remote_image("http://127.0.0.1/private.png")
assert blocked.value.code == "REMOTE_IMAGE_BLOCKED"
assert blocked.value.retryable is False
make_request.return_value = _response(200, b"pending")
make_request.side_effect = None
buffer_response.side_effect = UnsupportedResponseEncodingError("gzip")
with pytest.raises(KnowledgeFSRemoteImageError) as unsupported_encoding:
load_remote_image("https://cdn.example.test/compressed.png")
assert unsupported_encoding.value.code == "REMOTE_IMAGE_CONTENT_UNSUPPORTED"
@patch("services.knowledge_fs.remote_images.remote_fetcher.make_request")
def test_load_remote_image_maps_transport_timeouts(make_request: MagicMock) -> None:
make_request.side_effect = httpx.ReadTimeout("stalled")
with pytest.raises(KnowledgeFSRemoteImageError) as unavailable:
load_remote_image("https://cdn.example.test/stalled.png")
assert unavailable.value.code == "REMOTE_IMAGE_UPSTREAM_UNAVAILABLE"
assert unavailable.value.retryable is True

View File

@ -71,3 +71,4 @@ UNSTRUCTURED_MAX_RESPONSE_BYTES=33554432
# Bound authenticated Dify object-storage calls so cleanup cannot hang a compilation lease forever.
DIFY_OBJECT_STORAGE_REQUEST_TIMEOUT_MS=60000
DIFY_REMOTE_IMAGE_REQUEST_TIMEOUT_MS=30000

View File

@ -0,0 +1,35 @@
# Office and remote document image materialization
## What changed
- Kept embedded OpenXML/ODF/EPUB images on the existing archive-media path and added a regression for legacy `.doc` image payload extraction.
- Added a Dify-authenticated, SSRF-protected remote-image bridge for parser-produced HTTP(S) image references.
- Materialized remote images from Markdown, HTML, Office, and other parser formats through the same bounded KnowledgeFS object-storage and thumbnail path as embedded images.
- Included the final image-byte digest in synchronous compilation lineage so a changed visual artifact cannot reuse a stale multimodal publication identity.
- Added deployment configuration for the bounded inner remote-image request timeout.
## Why
- Some parsers return linked document images as HTTP(S) references instead of embedded bytes. Those references previously remained in manifests without an `objectKey`, so the document page could not render them reliably.
- The behavior must be format-independent: DOCX/DOC/PPTX/XLSX images need the same stable storage contract as Markdown and HTML images.
## Safety boundaries
- Only PNG, JPEG, GIF, and WebP payloads up to 10 MiB are accepted after content sniffing.
- External requests use Dify's existing signed-file resolver and SSRF-protected network client; credentials in URLs and unsafe network targets are rejected.
- Missing, blocked, oversized, or unsupported linked images remain as source references and do not block embedded Office images or fail the entire document.
- Transient bridge failures remain retryable, while each KnowledgeFS-to-Dify request has a configurable 30-second default timeout.
## Verification
- `pnpm --dir knowledge-fs --filter @knowledge/parsers test:coverage` (63 passed; package coverage above 90% in every dimension)
- `pnpm --dir knowledge-fs --filter @knowledge/adapters test:coverage` (117 passed; package coverage above 90% in every dimension)
- `pnpm --dir knowledge-fs --filter @knowledge/api-app test` (262 passed)
- `pnpm --dir knowledge-fs --filter @knowledge/api test:coverage` (all tests passed; branch coverage 89.27%, identical to clean-HEAD baseline and below the repository's pre-existing 90% gate)
- Focused KnowledgeFS Python tests (31 passed; remote-image service line/branch coverage 98.8%/96.2%)
- KnowledgeFS TypeScript typechecks, Biome, Ruff, deployment compose tests, and contract-lock verification
## Risks and follow-up
- Linked images are fetched sequentially under the existing per-document extraction cap to keep memory bounded. Large documents with many slow external hosts may take longer than documents whose images are embedded.
- Existing revisions whose manifests have no `objectKey` are not mutated in place; reindexing or creating a new revision is required to materialize their missing linked images.

View File

@ -165,6 +165,7 @@ export interface CreateApiDocumentCompilationRuntimeOptions {
| "documentMultimodalMaxExtractedAssets"
| "documentMultimodalMaxLocalAssetBytes"
| "documentMultimodalMaxPdfRasterizedAssets"
| "documentMultimodalRemoteAssetFetcher"
| "documentPdfRasterizer"
> & { readonly documentMultimodalMaxConcurrency?: number | undefined })
| undefined;
@ -604,6 +605,11 @@ export function createApiDocumentCompilationRuntime({
multimodalMaxPdfRasterizedAssets: multimodal.documentMultimodalMaxPdfRasterizedAssets,
}
: {}),
...(multimodal?.documentMultimodalRemoteAssetFetcher
? {
multimodalRemoteAssetFetcher: multimodal.documentMultimodalRemoteAssetFetcher,
}
: {}),
multimodalManifests: repositories.multimodalManifests,
...(createModelBudget ? { modelBudget: createModelBudget() } : {}),
objectStorage: adapter.objectStorage,

View File

@ -1,6 +1,9 @@
import { randomUUID } from "node:crypto";
import { createNodePlatformAdapter } from "@knowledge/adapters/node";
import {
createNodePlatformAdapter,
createNodeRemoteDocumentImageFetcher,
} from "@knowledge/adapters/node";
import {
type KnowledgeSpaceEmbeddingResolver,
createDatabaseDeletionObjectWriteAdmission,
@ -121,6 +124,7 @@ const researchTaskDirectStream = createApiResearchTaskDirectStreamAssembly({
});
const adapter = createNodePlatformAdapter();
const documentRemoteAssetFetcher = createNodeRemoteDocumentImageFetcher();
const queryImageResolver = createApiQueryImageResolver({ env: process.env });
const queryImageExpansionProvider = createApiQueryImageExpansionProvider(process.env);
const operationalMetrics = createApiKnowledgeFsOperationalMetrics({
@ -165,7 +169,10 @@ const visualEmbeddingOptions = createApiVisualEmbeddingOptions({
modelRequestGate: ingestionModelRuntimeOptions.modelRequestGate,
objectStorage: adapter.objectStorage,
});
const multimodalOptions = createApiMultimodalOptions();
const multimodalOptions = {
...createApiMultimodalOptions(),
documentMultimodalRemoteAssetFetcher: documentRemoteAssetFetcher,
};
const multimodalAnswerOptions = createApiMultimodalAnswerOptions({
objectStorage: adapter.objectStorage,
});

View File

@ -27,6 +27,7 @@ data:
DURABLE_DELETION_ENABLED: "off"
DIFY_INNER_API_URL: http://api:5001
DIFY_OBJECT_STORAGE_REQUEST_TIMEOUT_MS: "60000"
DIFY_REMOTE_IMAGE_REQUEST_TIMEOUT_MS: "30000"
DIFY_DATASOURCE_RUNTIME_MAX_RESPONSE_BYTES: "8388608"
DIFY_DATASOURCE_RUNTIME_REQUEST_TIMEOUT_MS: "60000"
DIFY_MODEL_RUNTIME_MAX_RESPONSE_BYTES: "8388608"

View File

@ -22,6 +22,7 @@ UNSTRUCTURED_RETRY_DELAY_MS=
DIFY_INNER_API_URL=http://host.docker.internal:5001
DIFY_INNER_API_KEY=
DIFY_OBJECT_STORAGE_REQUEST_TIMEOUT_MS=60000
DIFY_REMOTE_IMAGE_REQUEST_TIMEOUT_MS=30000
DIFY_DATASOURCE_RUNTIME_MAX_RESPONSE_BYTES=8388608
DIFY_DATASOURCE_RUNTIME_REQUEST_TIMEOUT_MS=60000
DIFY_MODEL_RUNTIME_MAX_RESPONSE_BYTES=8388608

View File

@ -39,6 +39,7 @@ services:
DIFY_INNER_API_KEY: ${DIFY_INNER_API_KEY:-}
DIFY_INNER_API_URL: ${DIFY_INNER_API_URL:-http://host.docker.internal:5001}
DIFY_OBJECT_STORAGE_REQUEST_TIMEOUT_MS: ${DIFY_OBJECT_STORAGE_REQUEST_TIMEOUT_MS:-60000}
DIFY_REMOTE_IMAGE_REQUEST_TIMEOUT_MS: ${DIFY_REMOTE_IMAGE_REQUEST_TIMEOUT_MS:-30000}
DIFY_DATASOURCE_RUNTIME_MAX_RESPONSE_BYTES: ${DIFY_DATASOURCE_RUNTIME_MAX_RESPONSE_BYTES:-8388608}
DIFY_DATASOURCE_RUNTIME_REQUEST_TIMEOUT_MS: ${DIFY_DATASOURCE_RUNTIME_REQUEST_TIMEOUT_MS:-60000}
DIFY_MODEL_RUNTIME_MAX_RESPONSE_BYTES: ${DIFY_MODEL_RUNTIME_MAX_RESPONSE_BYTES:-8388608}

View File

@ -0,0 +1,112 @@
import { describe, expect, it, vi } from "vitest";
import { DifyRemoteImageRequestError, createDifyRemoteImageFetcher } from "./dify-remote-image";
const pngBytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3]);
describe("Dify remote image fetcher", () => {
it("uses the authenticated SSRF-safe inner endpoint and returns bounded bytes", async () => {
const fetch = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
new Response(pngBytes, {
headers: {
"Content-Length": String(pngBytes.byteLength),
"Content-Type": "image/png",
},
}),
);
const resolver = createDifyRemoteImageFetcher({
apiKey: "inner-key",
baseUrl: "http://api:5001",
fetch,
});
await expect(
resolver.fetch({ maxBytes: 1024, url: "https://cdn.example.test/a b.png?x=1&y=2" }),
).resolves.toEqual({ body: pngBytes, contentType: "image/png" });
const requestUrl = fetch.mock.calls[0]?.[0].toString() ?? "";
expect(requestUrl).toContain("/inner/api/knowledge-fs/remote-image?");
expect(new URL(requestUrl).searchParams.get("url")).toBe(
"https://cdn.example.test/a%20b.png?x=1&y=2",
);
expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get("X-Inner-Api-Key")).toBe("inner-key");
});
it.each([400, 403, 404, 413, 415, 422])(
"leaves terminally unavailable remote images inline for status %s",
async (status) => {
const resolver = createDifyRemoteImageFetcher({
apiKey: "inner-key",
baseUrl: "http://api:5001",
fetch: vi.fn<typeof globalThis.fetch>().mockResolvedValue(new Response(null, { status })),
});
await expect(
resolver.fetch({ maxBytes: 1024, url: "https://cdn.example.test/missing.png" }),
).resolves.toBeNull();
},
);
it("classifies transient responses, transport errors, and timeouts as retryable", async () => {
const unavailable = createDifyRemoteImageFetcher({
apiKey: "inner-key",
baseUrl: "http://api:5001",
fetch: vi
.fn<typeof globalThis.fetch>()
.mockResolvedValue(new Response(null, { status: 503 })),
});
await expect(
unavailable.fetch({ maxBytes: 1024, url: "https://cdn.example.test/image.png" }),
).rejects.toMatchObject({ retryable: true, status: 503 });
const offline = createDifyRemoteImageFetcher({
apiKey: "inner-key",
baseUrl: "http://api:5001",
fetch: vi.fn<typeof globalThis.fetch>().mockRejectedValue(new Error("offline")),
});
await expect(
offline.fetch({ maxBytes: 1024, url: "https://cdn.example.test/image.png" }),
).rejects.toBeInstanceOf(DifyRemoteImageRequestError);
const stalled = createDifyRemoteImageFetcher({
apiKey: "inner-key",
baseUrl: "http://api:5001",
fetch: async (input, init) => {
const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
return await new Promise<Response>((_resolve, reject) => {
signal?.addEventListener("abort", () => reject(signal.reason), { once: true });
});
},
requestTimeoutMs: 10,
});
await expect(
stalled.fetch({ maxBytes: 1024, url: "https://cdn.example.test/image.png" }),
).rejects.toMatchObject({ retryable: true });
});
it("rejects oversized or invalid successful responses", async () => {
const oversized = createDifyRemoteImageFetcher({
apiKey: "inner-key",
baseUrl: "http://api:5001",
fetch: vi.fn<typeof globalThis.fetch>().mockResolvedValue(
new Response(new Uint8Array([1, 2, 3]), {
headers: { "Content-Length": "3", "Content-Type": "image/png" },
}),
),
});
await expect(
oversized.fetch({ maxBytes: 2, url: "https://cdn.example.test/image.png" }),
).rejects.toThrow("exceeds maxBytes=2");
const invalidType = createDifyRemoteImageFetcher({
apiKey: "inner-key",
baseUrl: "http://api:5001",
fetch: vi
.fn<typeof globalThis.fetch>()
.mockResolvedValue(new Response(pngBytes, { headers: { "Content-Type": "text/plain" } })),
});
await expect(
invalidType.fetch({ maxBytes: 1024, url: "https://cdn.example.test/image.png" }),
).rejects.toThrow("content type is invalid");
});
});

View File

@ -0,0 +1,208 @@
export interface RemoteDocumentImageFetchInput {
readonly maxBytes: number;
readonly signal?: AbortSignal | undefined;
readonly url: string;
}
export interface ResolvedRemoteDocumentImage {
readonly body: Uint8Array;
readonly contentType: string;
}
export interface RemoteDocumentImageFetcher {
fetch(input: RemoteDocumentImageFetchInput): Promise<ResolvedRemoteDocumentImage | null>;
}
export interface DifyRemoteImageFetcherOptions {
readonly apiKey: string;
readonly baseUrl: string;
readonly fetch?: typeof globalThis.fetch;
readonly requestTimeoutMs?: number;
}
const defaultRequestTimeoutMs = 30_000;
const terminalUnavailableStatuses = new Set([400, 403, 404, 413, 415, 422]);
const supportedContentTypes = new Set(["image/gif", "image/jpeg", "image/png", "image/webp"]);
export class DifyRemoteImageRequestError extends Error {
readonly code = "dify_remote_image_request_failed";
readonly retryable: boolean;
readonly status?: number;
constructor(
message: string,
options: {
readonly cause?: unknown;
readonly retryable: boolean;
readonly status?: number;
},
) {
super(message, options.cause === undefined ? undefined : { cause: options.cause });
this.name = "DifyRemoteImageRequestError";
this.retryable = options.retryable;
if (options.status !== undefined) this.status = options.status;
}
}
export function createDifyRemoteImageFetcher({
apiKey,
baseUrl,
fetch = globalThis.fetch,
requestTimeoutMs = defaultRequestTimeoutMs,
}: DifyRemoteImageFetcherOptions): RemoteDocumentImageFetcher {
const normalizedBaseUrl = requiredBaseUrl(baseUrl);
const normalizedApiKey = requiredString(apiKey, "Dify inner API key");
positiveSafeInteger(requestTimeoutMs, "requestTimeoutMs");
return {
async fetch({ maxBytes, signal, url }) {
positiveSafeInteger(maxBytes, "maxBytes");
const remoteUrl = validRemoteUrl(url);
const timeout = AbortSignal.timeout(requestTimeoutMs);
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
let response: Response;
try {
response = await fetch(
new URL(
`/inner/api/knowledge-fs/remote-image?${new URLSearchParams({ url: remoteUrl }).toString()}`,
normalizedBaseUrl,
),
{
headers: { "X-Inner-Api-Key": normalizedApiKey },
signal: requestSignal,
},
);
} catch (error) {
if (signal?.aborted) throw signal.reason;
throw new DifyRemoteImageRequestError("Dify remote image request failed", {
cause: error,
retryable: true,
});
}
if (terminalUnavailableStatuses.has(response.status)) {
await response.body?.cancel();
return null;
}
if (!response.ok) {
await response.body?.cancel();
throw new DifyRemoteImageRequestError(
`Dify remote image request failed with status ${response.status}`,
{ retryable: isRetryableStatus(response.status), status: response.status },
);
}
const contentType = normalizedImageContentType(response.headers.get("content-type"));
if (!contentType) {
await response.body?.cancel();
throw new DifyRemoteImageRequestError("Dify remote image content type is invalid", {
retryable: false,
});
}
const body = await readBoundedBody(response, maxBytes, signal);
return { body, contentType };
},
};
}
async function readBoundedBody(
response: Response,
maxBytes: number,
callerSignal: AbortSignal | undefined,
): Promise<Uint8Array> {
const declaredLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
await response.body?.cancel();
throw new DifyRemoteImageRequestError(`Dify remote image exceeds maxBytes=${maxBytes}`, {
retryable: false,
});
}
if (!response.body) return new Uint8Array();
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
throw new DifyRemoteImageRequestError(`Dify remote image exceeds maxBytes=${maxBytes}`, {
retryable: false,
});
}
chunks.push(value);
}
} catch (error) {
if (callerSignal?.aborted) throw callerSignal.reason;
if (error instanceof DifyRemoteImageRequestError) throw error;
throw new DifyRemoteImageRequestError("Dify remote image response failed", {
cause: error,
retryable: true,
});
} finally {
await reader.cancel().catch(() => undefined);
reader.releaseLock();
}
const body = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return body;
}
function normalizedImageContentType(value: string | null): string | null {
const normalized = value?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
return supportedContentTypes.has(normalized) ? normalized : null;
}
function validRemoteUrl(value: string): string {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error("Remote image URL is invalid");
}
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
throw new Error("Remote image URL is invalid");
}
return url.href;
}
function isRetryableStatus(status: number): boolean {
return status === 408 || status === 425 || status === 429 || status >= 500;
}
function requiredBaseUrl(value: string): string {
const normalized = requiredString(value, "Dify inner API URL");
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
throw new Error("Dify inner API URL is invalid");
}
if (
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
parsed.username ||
parsed.password
) {
throw new Error("Dify inner API URL is invalid");
}
return parsed.href.endsWith("/") ? parsed.href : `${parsed.href}/`;
}
function requiredString(value: string, name: string): string {
const normalized = value.trim();
if (!normalized) throw new Error(`${name} is required`);
return normalized;
}
function positiveSafeInteger(value: number, name: string): void {
if (!Number.isSafeInteger(value) || value < 1) {
throw new Error(`${name} must be a positive safe integer`);
}
}

View File

@ -2,6 +2,7 @@ export * from "./cache";
export * from "./cloudflare-job-queue";
export * from "./database";
export * from "./dify-object-storage";
export * from "./dify-remote-image";
export * from "./job-queue";
export * from "./memory-object-storage";
export * from "./migration-runner";

View File

@ -3,6 +3,7 @@ import { type PlatformAdapter, collectPlatformHealth } from "@knowledge/core";
import { createMemoryCacheAdapter } from "./cache";
import { createSchemaDatabaseAdapter } from "./database";
import { createDifyObjectStorageAdapter } from "./dify-object-storage";
import { createDifyRemoteImageFetcher } from "./dify-remote-image";
import { createInlineJobQueueAdapter } from "./job-queue";
import { type PgBossClient, createPgBossJobQueueAdapter } from "./pg-boss-job-queue";
import {
@ -52,6 +53,16 @@ export function createNodePlatformAdapter(
return adapter;
}
export function createNodeRemoteDocumentImageFetcher(options: NodePlatformAdapterOptions = {}) {
const env = options.env ?? process.env;
return createDifyRemoteImageFetcher({
apiKey: env.DIFY_INNER_API_KEY?.trim() || defaultDifyInnerApiKey,
baseUrl: env.DIFY_INNER_API_URL?.trim() || defaultDifyInnerApiUrl,
...(options.difyStorageFetch ? { fetch: options.difyStorageFetch } : {}),
requestTimeoutMs: parsePositiveInteger(env.DIFY_REMOTE_IMAGE_REQUEST_TIMEOUT_MS, 30_000),
});
}
function createNodeDatabaseAdapter(env: RuntimeEnv, databasePool?: PostgresPoolLike) {
const configuredUrl = env.DATABASE_URL?.trim();

View File

@ -26,6 +26,115 @@ const firstArtifactId = "30000000-0000-4000-8000-000000000001";
const retryArtifactId = "30000000-0000-4000-8000-000000000002";
describe("compileDocumentArtifact canonical artifact", () => {
it("materializes parser-provided remote image refs before persisting the manifest", async () => {
const adapter = createNodePlatformAdapter({ env: {} });
const artifacts = createInMemoryParseArtifactRepository({ maxArtifacts: 4 });
const artifactSegments = createInMemoryArtifactSegmentRepository({
maxBatchSize: 10,
maxListLimit: 10,
maxSegments: 10,
});
const documentMultimodalManifests = createInMemoryDocumentMultimodalManifestRepository({
maxManifests: 4,
});
const outlines = createInMemoryDocumentOutlineRepository({ maxOutlines: 4 });
const knowledgePaths = createInMemoryKnowledgePathRepository({
maxListLimit: 20,
maxPaths: 20,
});
const remoteBody = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3]);
const remoteFetches: unknown[] = [];
const artifact = await compileDocumentArtifact(
{
asset: { ...documentAsset(), filename: "linked-images.docx", mimeType: "application/docx" },
body: new Uint8Array([1, 2, 3]),
knowledgeSpaceId,
permissionScope: [],
tenantId: "tenant-1",
traceId: randomUUID(),
},
{
artifacts,
artifactSegments,
documentMultimodalManifests,
documentMultimodalRemoteAssetFetcher: {
fetch: async (input) => {
remoteFetches.push(input);
return { body: remoteBody, contentType: "image/png" };
},
},
documentParser: {
kind: "unstructured",
parse: async (input) =>
ParseArtifactSchema.parse({
artifactHash: "c".repeat(64),
contentType: "mixed",
createdAt: "2026-07-13T00:00:00.000Z",
documentAssetId: input.documentAssetId,
elements: [
{
id: "linked-image",
metadata: {
assetRef: { uri: "https://cdn.example.test/office-linked.png" },
},
sectionPath: [],
type: "image",
},
],
id: firstArtifactId,
metadata: {},
parser: "unstructured",
version: input.version,
}),
},
generateArtifactSegmentId: randomUUID,
generateKnowledgePathId: randomUUID,
knowledgePaths,
now: () => "2026-07-13T00:00:00.000Z",
objectStorage: adapter.objectStorage,
outlineBuilder: createDocumentOutlineBuilder({
generateId: randomUUID,
maxElements: 10,
maxNodes: 10,
maxSummaryChars: 1_000,
now: () => "2026-07-13T00:00:00.000Z",
}),
outlines,
synchronousUploadReindexer: null,
traces: createNoopTraceRecorder(),
},
);
expect(remoteFetches).toEqual([
{ maxBytes: 10 * 1024 * 1024, url: "https://cdn.example.test/office-linked.png" },
]);
expect(artifact.elements[0]?.metadata).toMatchObject({
assetRef: {
contentType: "image/png",
objectKey: expect.any(String),
source: "remote-url",
},
});
expect(artifact.elements[0]?.metadata).not.toHaveProperty("assetRef.uri");
expect(artifact.artifactHash).not.toBe("c".repeat(64));
expect(artifact.metadata).toMatchObject({
multimodalMaterialization: {
assetCount: 1,
sourceArtifactHash: "c".repeat(64),
},
});
await expect(
documentMultimodalManifests.getByDocumentVersion({ documentAssetId, version: 1 }),
).resolves.toMatchObject({
items: [
expect.objectContaining({
assetRef: expect.objectContaining({ objectKey: expect.any(String) }),
}),
],
});
});
it("uses the first persisted artifact id for every derived write on parser retry", async () => {
const adapter = createNodePlatformAdapter({ env: {} });
const artifacts = createInMemoryParseArtifactRepository({ maxArtifacts: 4 });

View File

@ -18,7 +18,9 @@ import {
buildDocumentOutlineKnowledgePath,
buildDocumentSectionKnowledgePaths,
} from "./document-knowledge-paths";
import { finalizeDocumentMultimodalArtifact } from "./document-multimodal-artifact";
import { extractDocumentMultimodalAssets } from "./document-multimodal-asset-extractor";
import type { DocumentRemoteAssetFetcher } from "./document-multimodal-asset-extractor";
import { createDocumentMultimodalManifestBuilder } from "./document-multimodal-manifest-builder";
import type { DocumentMultimodalManifestRepository } from "./document-multimodal-manifest-repository";
import {
@ -70,6 +72,7 @@ export interface CompileDocumentArtifactDeps {
readonly documentMultimodalMaxExtractedAssets?: number | undefined;
readonly documentMultimodalMaxLocalAssetBytes?: number | undefined;
readonly documentMultimodalMaxPdfRasterizedAssets?: number | undefined;
readonly documentMultimodalRemoteAssetFetcher?: DocumentRemoteAssetFetcher | undefined;
readonly documentMultimodalManifests: DocumentMultimodalManifestRepository;
readonly documentParser: ParserAdapter;
readonly documentPdfRasterizer?: DocumentPdfRasterizer | undefined;
@ -111,6 +114,7 @@ export async function compileDocumentArtifact(
documentMultimodalMaxExtractedAssets,
documentMultimodalMaxLocalAssetBytes,
documentMultimodalMaxPdfRasterizedAssets,
documentMultimodalRemoteAssetFetcher,
documentMultimodalManifests,
documentParser,
documentPdfRasterizer,
@ -190,13 +194,17 @@ export async function compileDocumentArtifact(
? { imageVariantGenerator: documentMultimodalImageVariantGenerator }
: {}),
objectStorage,
...(documentMultimodalRemoteAssetFetcher
? { remoteAssetFetcher: documentMultimodalRemoteAssetFetcher }
: {}),
tenantId,
}),
);
const materializedArtifact = finalizeDocumentMultimodalArtifact(assetExtractionResult.artifact);
const artifactToPersist = ParseArtifactSchema.parse({
...assetExtractionResult.artifact,
...materializedArtifact,
metadata: {
...assetExtractionResult.artifact.metadata,
...materializedArtifact.metadata,
...(assetExtractionResult.extractedCount > 0
? { multimodalAssetExtractionCount: assetExtractionResult.extractedCount }
: {}),

View File

@ -49,7 +49,10 @@ import {
} from "./document-knowledge-paths";
import type { DocumentModelBudget } from "./document-model-budget";
import { finalizeDocumentMultimodalArtifact } from "./document-multimodal-artifact";
import { extractDocumentMultimodalAssets } from "./document-multimodal-asset-extractor";
import {
type DocumentRemoteAssetFetcher,
extractDocumentMultimodalAssets,
} from "./document-multimodal-asset-extractor";
import { createDocumentMultimodalManifestBuilder } from "./document-multimodal-manifest-builder";
import type { DocumentMultimodalManifestRepository } from "./document-multimodal-manifest-repository";
import type { DocumentOutlineBuilder } from "./document-outline-builder";
@ -111,6 +114,7 @@ export interface DocumentCompilationWorkerOptions {
readonly multimodalMaxConcurrency?: number | undefined;
readonly multimodalMaxLocalAssetBytes?: number | undefined;
readonly multimodalMaxPdfRasterizedAssets?: number | undefined;
readonly multimodalRemoteAssetFetcher?: DocumentRemoteAssetFetcher | undefined;
readonly multimodalManifests: DocumentMultimodalManifestRepository;
readonly modelBudget?: DocumentModelBudget | undefined;
readonly objectStorage: PlatformAdapter["objectStorage"];
@ -247,6 +251,7 @@ export function createDocumentCompilationWorker({
multimodalMaxConcurrency = 2,
multimodalMaxLocalAssetBytes,
multimodalMaxPdfRasterizedAssets,
multimodalRemoteAssetFetcher,
multimodalManifests,
modelBudget,
objectStorage,
@ -469,6 +474,10 @@ export function createDocumentCompilationWorker({
? { imageVariantGenerator: multimodalImageVariantGenerator }
: {}),
objectStorage: multimodalObjectStorage,
...(multimodalRemoteAssetFetcher
? { remoteAssetFetcher: multimodalRemoteAssetFetcher }
: {}),
...(signal ? { signal } : {}),
tenantId: input.tenantId,
writeOwnerId: multimodalWriteOwnerId,
});

View File

@ -11,6 +11,229 @@ const documentAssetId = "018f0d60-7a49-7cc2-9c1b-5b36f18f2c43";
const parseArtifactId = "018f0d60-7a49-7cc2-9c1b-5b36f18f2c44";
describe("extractDocumentMultimodalAssets", () => {
it("downloads and stores remote images independently of the source document format", async () => {
const adapter = createNodePlatformAdapter({ env: {} });
const remoteBody = new Uint8Array([
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 2, 0, 0, 0, 3, 8, 6, 0,
0, 0, 0, 0, 0, 0,
]);
const fetchCalls: unknown[] = [];
const result = await extractDocumentMultimodalAssets({
artifact: {
artifactHash: "a".repeat(64),
contentType: "mixed",
createdAt: "2026-06-23T00:00:00.000Z",
documentAssetId,
elements: [
{
id: "markdown-image",
metadata: { assetRef: { uri: "https://cdn.example.test/markdown.png" } },
sectionPath: [],
type: "image",
},
{
id: "office-linked-image",
metadata: { assetRef: { uri: "https://cdn.example.test/office.png" } },
sectionPath: [],
type: "image",
},
],
id: parseArtifactId,
metadata: {},
parser: "native-markdown",
version: 1,
},
knowledgeSpaceId,
maxRemoteAssetBytes: 1024,
objectStorage: adapter.objectStorage,
remoteAssetFetcher: {
fetch: async (input) => {
fetchCalls.push(input);
return { body: remoteBody, contentType: "image/png; charset=binary" };
},
},
tenantId: "tenant-1",
});
expect(result.extractedCount).toBe(2);
expect(fetchCalls).toEqual([
{ maxBytes: 1024, url: "https://cdn.example.test/markdown.png" },
{ maxBytes: 1024, url: "https://cdn.example.test/office.png" },
]);
for (const element of result.artifact.elements) {
expect(element.metadata).toMatchObject({
assetRef: {
contentType: "image/png",
height: 3,
objectKey: expect.any(String),
source: "remote-url",
width: 2,
},
});
expect(element.metadata).not.toHaveProperty("assetRef.uri");
}
expect(result.artifact.metadata).toMatchObject({
multimodalAssets: { extractedCount: 2, sources: ["remote-url"] },
});
});
it("leaves unavailable remote images inline without blocking embedded Office images", async () => {
const adapter = createNodePlatformAdapter({ env: {} });
const controller = new AbortController();
const remoteFetches: unknown[] = [];
const result = await extractDocumentMultimodalAssets({
artifact: {
artifactHash: "a".repeat(64),
contentType: "mixed",
createdAt: "2026-06-23T00:00:00.000Z",
documentAssetId,
elements: [
{
id: "remote-missing",
metadata: { assetRef: { uri: "https://cdn.example.test/missing.png" } },
sectionPath: [],
type: "image",
},
{
id: "docx-embedded",
metadata: {
archivePath: "word/media/image1.png",
assetRef: { uri: "data:image/png;base64,AQIDBA==" },
},
sectionPath: [],
type: "image",
},
],
id: parseArtifactId,
metadata: {},
parser: "unstructured",
version: 1,
},
knowledgeSpaceId,
objectStorage: adapter.objectStorage,
remoteAssetFetcher: {
fetch: async (input) => {
remoteFetches.push(input);
return null;
},
},
signal: controller.signal,
tenantId: "tenant-1",
});
expect(result.extractedCount).toBe(1);
expect(remoteFetches).toEqual([
{
maxBytes: 10 * 1024 * 1024,
signal: controller.signal,
url: "https://cdn.example.test/missing.png",
},
]);
expect(result.artifact.elements[0]?.metadata).toEqual({
assetRef: { uri: "https://cdn.example.test/missing.png" },
});
expect(result.artifact.elements[1]?.metadata).toMatchObject({
archivePath: "word/media/image1.png",
assetRef: { objectKey: expect.any(String), source: "data-uri" },
});
});
it("rejects invalid limits and dishonest remote fetcher responses", async () => {
const adapter = createNodePlatformAdapter({ env: {} });
const artifact = remoteImageArtifact("https://cdn.example.test/image.png");
await expect(
extractDocumentMultimodalAssets({
artifact,
knowledgeSpaceId,
maxRemoteAssetBytes: 0,
objectStorage: adapter.objectStorage,
remoteAssetFetcher: { fetch: async () => null },
tenantId: "tenant-1",
}),
).rejects.toThrow("Document multimodal remote asset max bytes must be at least 1");
await expect(
extractDocumentMultimodalAssets({
artifact,
knowledgeSpaceId,
maxRemoteAssetBytes: 1,
objectStorage: adapter.objectStorage,
remoteAssetFetcher: {
fetch: async () => ({ body: new Uint8Array([1, 2]), contentType: "image/png" }),
},
tenantId: "tenant-1",
}),
).rejects.toThrow("Document multimodal remote asset exceeds maxRemoteAssetBytes=1");
await expect(
extractDocumentMultimodalAssets({
artifact,
knowledgeSpaceId,
objectStorage: adapter.objectStorage,
remoteAssetFetcher: {
fetch: async () => ({ body: new Uint8Array([1]), contentType: "text/plain" }),
},
tenantId: "tenant-1",
}),
).rejects.toThrow("Document multimodal remote asset content type is unsupported");
await expect(
extractDocumentMultimodalAssets({
artifact,
knowledgeSpaceId,
objectStorage: adapter.objectStorage,
remoteAssetFetcher: {
fetch: async () => ({ body: new Uint8Array(), contentType: "image/png" }),
},
tenantId: "tenant-1",
}),
).rejects.toThrow("Document multimodal remote asset is empty");
});
it("does not fetch remote refs after the extraction cap or URLs containing credentials", async () => {
const adapter = createNodePlatformAdapter({ env: {} });
const fetchCalls: unknown[] = [];
const artifact = remoteImageArtifact("https://cdn.example.test/second.png");
artifact.elements.unshift({
id: "embedded-first",
metadata: { assetRef: { uri: "data:image/png;base64,AQIDBA==" } },
sectionPath: [],
type: "image",
});
artifact.elements.push({
id: "credential-url",
metadata: { assetRef: { uri: "https://user:secret@cdn.example.test/private.png" } },
sectionPath: [],
type: "image",
});
const result = await extractDocumentMultimodalAssets({
artifact,
knowledgeSpaceId,
maxExtractedAssets: 1,
objectStorage: adapter.objectStorage,
remoteAssetFetcher: {
fetch: async (input) => {
fetchCalls.push(input);
return null;
},
},
tenantId: "tenant-1",
});
expect(result).toMatchObject({ extractedCount: 1, skippedForCapCount: 1 });
expect(fetchCalls).toEqual([]);
expect(result.artifact.elements[1]?.metadata).toEqual({
assetRef: { uri: "https://cdn.example.test/second.png" },
});
expect(result.artifact.elements[2]?.metadata).toEqual({
assetRef: { uri: "https://user:secret@cdn.example.test/private.png" },
});
});
it("stores embedded image data URIs and rewrites image asset refs", async () => {
const adapter = createNodePlatformAdapter({ env: {} });
const dataUri = "data:image/png;base64,AQIDBA==";
@ -413,3 +636,24 @@ describe("extractDocumentMultimodalAssets", () => {
).resolves.toEqual(new Uint8Array([5, 6, 7, 8]));
});
});
function remoteImageArtifact(uri: string) {
return {
artifactHash: "a".repeat(64),
contentType: "mixed" as const,
createdAt: "2026-06-23T00:00:00.000Z",
documentAssetId,
elements: [
{
id: "remote-image",
metadata: { assetRef: { uri } },
sectionPath: [],
type: "image" as const,
},
],
id: parseArtifactId,
metadata: {},
parser: "unstructured" as const,
version: 1,
};
}

View File

@ -22,12 +22,23 @@ export interface ExtractDocumentMultimodalAssetsInput {
readonly maxEmbeddedAssetBytes?: number | undefined;
readonly maxExtractedAssets?: number | undefined;
readonly maxLocalAssetBytes?: number | undefined;
readonly maxRemoteAssetBytes?: number | undefined;
readonly imageVariantGenerator?: DocumentImageVariantGenerator | undefined;
readonly objectStorage: PlatformAdapter["objectStorage"];
readonly remoteAssetFetcher?: DocumentRemoteAssetFetcher | undefined;
readonly signal?: AbortSignal | undefined;
readonly tenantId: string;
readonly writeOwnerId?: string | undefined;
}
export interface DocumentRemoteAssetFetcher {
fetch(input: {
readonly maxBytes: number;
readonly signal?: AbortSignal | undefined;
readonly url: string;
}): Promise<{ readonly body: Uint8Array; readonly contentType: string } | null>;
}
export interface ExtractDocumentMultimodalAssetsResult {
readonly artifact: ParseArtifact;
readonly extractedCount: number;
@ -39,7 +50,7 @@ interface DataUriImage {
readonly body: Uint8Array;
readonly contentType: string;
readonly dimensions?: ImageDimensions | undefined;
readonly source: "data-uri" | "local-file";
readonly source: "data-uri" | "local-file" | "remote-url";
}
interface ImageDimensions {
@ -51,6 +62,13 @@ const dataUriPattern = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\s]+)$/iu;
const defaultMaxEmbeddedAssetBytes = 10 * 1024 * 1024;
const defaultMaxExtractedAssets = 1_000;
const defaultMaxLocalAssetBytes = 50 * 1024 * 1024;
const defaultMaxRemoteAssetBytes = 10 * 1024 * 1024;
const supportedRemoteImageContentTypes = new Set([
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
]);
export async function extractDocumentMultimodalAssets({
allowLocalAssetPaths = [],
@ -59,8 +77,11 @@ export async function extractDocumentMultimodalAssets({
maxEmbeddedAssetBytes = defaultMaxEmbeddedAssetBytes,
maxExtractedAssets = defaultMaxExtractedAssets,
maxLocalAssetBytes = defaultMaxLocalAssetBytes,
maxRemoteAssetBytes = defaultMaxRemoteAssetBytes,
imageVariantGenerator,
objectStorage,
remoteAssetFetcher,
signal,
tenantId,
writeOwnerId,
}: ExtractDocumentMultimodalAssetsInput): Promise<ExtractDocumentMultimodalAssetsResult> {
@ -76,6 +97,10 @@ export async function extractDocumentMultimodalAssets({
throw new Error("Document multimodal max extracted assets must be at least 1");
}
if (!Number.isSafeInteger(maxRemoteAssetBytes) || maxRemoteAssetBytes < 1) {
throw new Error("Document multimodal remote asset max bytes must be at least 1");
}
let extractedCount = 0;
let skippedForCapCount = 0;
const extractionSources = new Set<string>();
@ -90,7 +115,7 @@ export async function extractDocumentMultimodalAssets({
const assetRef = isPlainObject(element.metadata.assetRef) ? element.metadata.assetRef : null;
const uri = typeof assetRef?.uri === "string" ? assetRef.uri.trim() : "";
const image =
let image =
parseDataUriImage(uri, maxEmbeddedAssetBytes) ??
(await readLocalImageAsset({
allowedRoots: allowedLocalRoots,
@ -99,6 +124,40 @@ export async function extractDocumentMultimodalAssets({
uri,
}));
if (!image && remoteAssetFetcher && isRemoteHttpUri(uri)) {
if (extractedCount >= maxExtractedAssets) {
skippedForCapCount += 1;
elements.push(element);
continue;
}
const fetched = await remoteAssetFetcher.fetch({
maxBytes: maxRemoteAssetBytes,
...(signal ? { signal } : {}),
url: uri,
});
if (fetched) {
if (fetched.body.byteLength > maxRemoteAssetBytes) {
throw new Error(
`Document multimodal remote asset exceeds maxRemoteAssetBytes=${maxRemoteAssetBytes}`,
);
}
const contentType = normalizeRemoteImageContentType(fetched.contentType);
if (!contentType) {
throw new Error("Document multimodal remote asset content type is unsupported");
}
if (fetched.body.byteLength === 0) {
throw new Error("Document multimodal remote asset is empty");
}
const dimensions = readImageDimensions(fetched.body, contentType);
image = {
body: fetched.body,
contentType,
...(dimensions ? { dimensions } : {}),
source: "remote-url",
};
}
}
if (!assetRef || !image) {
elements.push(element);
continue;
@ -204,6 +263,24 @@ export async function extractDocumentMultimodalAssets({
};
}
function isRemoteHttpUri(uri: string): boolean {
try {
const parsed = new URL(uri);
return (
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
!parsed.username &&
!parsed.password
);
} catch {
return false;
}
}
function normalizeRemoteImageContentType(value: string): string | null {
const normalized = value.split(";", 1)[0]?.trim().toLowerCase() ?? "";
return supportedRemoteImageContentTypes.has(normalized) ? normalized : null;
}
async function storeGeneratedImageVariants({
assetId,
elementId,

View File

@ -32,6 +32,7 @@ import type { DocumentCompilationJobStateMachine } from "./document-compilation-
import { compileDocumentArtifact } from "./document-compilation-pipeline";
import type { DocumentImageVariantGenerator } from "./document-image-variant-generator";
import { buildDocumentKnowledgePath } from "./document-knowledge-paths";
import type { DocumentRemoteAssetFetcher } from "./document-multimodal-asset-extractor";
import type { DocumentMultimodalManifestRepository } from "./document-multimodal-manifest-repository";
import type { DocumentOutlineBuilder } from "./document-outline-builder";
import type { DocumentOutlineRepository } from "./document-outline-repository";
@ -149,6 +150,7 @@ export interface RegisterDocumentWriteHandlersOptions {
readonly documentMultimodalMaxExtractedAssets?: number | undefined;
readonly documentMultimodalMaxLocalAssetBytes?: number | undefined;
readonly documentMultimodalMaxPdfRasterizedAssets?: number | undefined;
readonly documentMultimodalRemoteAssetFetcher?: DocumentRemoteAssetFetcher | undefined;
readonly documentMultimodalManifests: DocumentMultimodalManifestRepository;
readonly documentParser: ParserAdapter;
readonly documentPdfRasterizer?: DocumentPdfRasterizer | undefined;
@ -220,6 +222,7 @@ export function registerDocumentWriteHandlers({
documentMultimodalMaxExtractedAssets,
documentMultimodalMaxLocalAssetBytes,
documentMultimodalMaxPdfRasterizedAssets,
documentMultimodalRemoteAssetFetcher,
documentMultimodalManifests,
documentParser,
documentPdfRasterizer,
@ -1437,6 +1440,7 @@ export function registerDocumentWriteHandlers({
documentMultimodalMaxExtractedAssets,
documentMultimodalMaxLocalAssetBytes,
documentMultimodalMaxPdfRasterizedAssets,
documentMultimodalRemoteAssetFetcher,
documentMultimodalManifests,
documentParser,
documentPdfRasterizer,

View File

@ -28,6 +28,7 @@ import type {
} from "./document-chunk-repository";
import type { DocumentCompilationJobStateMachine } from "./document-compilation-job";
import type { DocumentImageVariantGenerator } from "./document-image-variant-generator";
import type { DocumentRemoteAssetFetcher } from "./document-multimodal-asset-extractor";
import type { DocumentMultimodalManifestEnhancer } from "./document-multimodal-manifest-enhancer";
import type { DocumentMultimodalManifestRepository } from "./document-multimodal-manifest-repository";
import type { DocumentOutlineRepository } from "./document-outline-repository";
@ -211,6 +212,7 @@ export interface KnowledgeGatewayOptions {
documentMultimodalLocalAssetAllowlist?: readonly string[];
documentMultimodalMaxLocalAssetBytes?: number;
documentMultimodalMaxPdfRasterizedAssets?: number;
documentMultimodalRemoteAssetFetcher?: DocumentRemoteAssetFetcher;
documentPdfRasterizer?: DocumentPdfRasterizer;
documentOutlineSummaryEnhancer?: DocumentOutlineSummaryEnhancer;
documentOutlines?: DocumentOutlineRepository;

View File

@ -105,6 +105,7 @@ export * from "./document-asset-repository";
export * from "./document-asset-embedding-profile-guard";
export * from "./durable-deletion-repository";
export * from "./document-image-variant-generator";
export * from "./document-multimodal-asset-extractor";
export * from "./document-multimodal-enrichment-providers";
export * from "./document-multimodal-candidate-resolver";
export * from "./document-multimodal-evaluation";
@ -735,6 +736,7 @@ export function createKnowledgeGateway({
documentMultimodalMaxExtractedAssets,
documentMultimodalMaxLocalAssetBytes,
documentMultimodalMaxPdfRasterizedAssets,
documentMultimodalRemoteAssetFetcher,
documentPdfRasterizer,
documentOutlineSummaryEnhancer,
documentOutlines,
@ -2012,6 +2014,7 @@ export function createKnowledgeGateway({
...(documentMultimodalMaxExtractedAssets ? { documentMultimodalMaxExtractedAssets } : {}),
...(documentMultimodalImageVariantGenerator ? { documentMultimodalImageVariantGenerator } : {}),
...(documentMultimodalMaxLocalAssetBytes ? { documentMultimodalMaxLocalAssetBytes } : {}),
...(documentMultimodalRemoteAssetFetcher ? { documentMultimodalRemoteAssetFetcher } : {}),
...(documentMultimodalMaxPdfRasterizedAssets
? { documentMultimodalMaxPdfRasterizedAssets }
: {}),
@ -2071,6 +2074,7 @@ export function createKnowledgeGateway({
documentMultimodalMaxExtractedAssets,
documentMultimodalMaxLocalAssetBytes,
documentMultimodalMaxPdfRasterizedAssets,
documentMultimodalRemoteAssetFetcher,
documentMultimodalManifests: multimodalManifestRepository,
documentParser,
documentPdfRasterizer,

View File

@ -1405,6 +1405,56 @@ describe("parser adapters", () => {
});
});
it("requests and preserves provider image payloads for legacy DOC files", async () => {
const parser = createUnstructuredParserClient({
endpoint: "https://unstructured.example.test",
fetch: async (request) => {
const form = await (request instanceof Request ? request : new Request(request)).formData();
expect(form.get("strategy")).toBe("hi_res");
expect(form.getAll("extract_image_block_types")).toEqual(["Image"]);
expect(form.get("extract_image_block_to_payload")).toBe("true");
return new Response(
JSON.stringify([
{
metadata: {
image_base64: "AQIDBA==",
image_mime_type: "image/png",
page_number: 1,
},
type: "Image",
},
]),
{ headers: { "content-type": "application/json" }, status: 200 },
);
},
generateId: () => "018f0d60-7a49-7cc2-9c1b-5b36f18f2c54",
now: () => createdAt,
});
const artifact = await parser.parse({
body: new Uint8Array([0xd0, 0xcf, 0x11, 0xe0]),
documentAssetId,
filename: "legacy.doc",
mimeType: "application/msword",
parserHints: { requiresImages: true },
version: 1,
});
expect(artifact.elements).toEqual([
expect.objectContaining({
metadata: expect.objectContaining({
assetRef: {
contentType: "image/png",
uri: "data:image/png;base64,AQIDBA==",
},
}),
type: "image",
}),
]);
});
it("preserves nested Unstructured title paths from parent ids and category depth", async () => {
const parser = createUnstructuredParserClient({
endpoint: "https://unstructured.example.test",

View File

@ -328,6 +328,7 @@ test("KnowledgeFS deployment env contains only operator-owned runtime inputs", (
"UNSTRUCTURED_REQUEST_TIMEOUT_MS",
"UNSTRUCTURED_MAX_RESPONSE_BYTES",
"DIFY_OBJECT_STORAGE_REQUEST_TIMEOUT_MS",
"DIFY_REMOTE_IMAGE_REQUEST_TIMEOUT_MS",
]);
assert.match(difyKnowledgeFsEnv, /^KNOWLEDGE_DOCUMENT_COMPILATION_RUNTIME=on$/m);
assert.match(difyKnowledgeFsEnv, /^KNOWLEDGE_PDF_RASTERIZER=poppler$/m);