Merge branch 'gzy_iframedf' into 'master'

feat: iframe传 文件进入

See merge request knowledge-base-bj/deerflow-server!93
This commit is contained in:
g zy committed 2026-06-05 07:10:59 +00:00
commit 9ebde3b315
3 files changed
+158 -21

No files matched your search

@@ -100,3 +100,37 @@ test("maps iframe model selection into stream context model_name", () => {
model_name: "OpenAI3",
});
});
test("maps externally uploaded files into stream additional_kwargs.files", () => {
const result = parseBrainyChatSubmitMessage({
source: "brainy-extension",
type: "brainy:chat-submit",
requestId: "iframe-789",
payload: {
text: "网摘",
prompt: "网摘",
uploadedFiles: [
{
filename: "LLM Wiki 接口说明.md",
size: "7002",
path: "/mnt/user-data/uploads/LLM Wiki 接口说明.md",
status: "uploaded",
artifact_url:
"/api/threads/445ca7a7-9737-44f0-8eea-6887776f4f9c/artifacts/mnt/user-data/uploads/LLM%20Wiki%20%E6%8E%A5%E5%8F%A3%E8%AF%B4%E6%98%8E.md",
},
],
},
});
assert.equal(result.submitText, "网摘");
assert.deepEqual(result.additionalKwargs?.files, [
{
filename: "LLM Wiki 接口说明.md",
size: "7002",
path: "/mnt/user-data/uploads/LLM Wiki 接口说明.md",
status: "uploaded",
artifact_url:
"/api/threads/445ca7a7-9737-44f0-8eea-6887776f4f9c/artifacts/mnt/user-data/uploads/LLM%20Wiki%20%E6%8E%A5%E5%8F%A3%E8%AF%B4%E6%98%8E.md",
},
]);
});
+48 -1
View File
@@ -26,6 +26,14 @@ type BrainyModelSelection = {
value?: string;
};
type BrainyUploadedFile = {
filename?: string;
size?: string | number;
path?: string;
status?: string;
artifact_url?: string;
};
export type BrainyChatSubmitMessage = {
source?: string;
type?: string;
@@ -35,6 +43,7 @@ export type BrainyChatSubmitMessage = {
pageContext?: BrainyPageContext;
screenshot?: BrainyScreenshot;
model?: BrainyModelSelection;
uploadedFiles?: BrainyUploadedFile[];
};
requestId?: string;
};
@@ -253,6 +262,36 @@ function buildScreenshotFileParts(
return [];
}
function normalizeUploadedFiles(
uploadedFiles: BrainyUploadedFile[] | undefined,
): Record<string, unknown>[] {
if (!Array.isArray(uploadedFiles)) {
return [];
}
return uploadedFiles
.map((file) => {
const filename = trimToUndefined(file.filename);
if (!filename) {
return null;
}
const size = String(file.size ?? "0");
const path = trimToUndefined(file.path);
const status = trimToUndefined(file.status);
const artifactUrl = trimToUndefined(file.artifact_url);
return {
filename,
size,
...(path ? { path } : {}),
...(status ? { status } : {}),
...(artifactUrl ? { artifact_url: artifactUrl } : {}),
};
})
.filter((file): file is Record<string, unknown> => file !== null);
}
export function parseBrainyChatSubmitMessage(
data: BrainyChatSubmitMessage,
): ParsedBrainyChatSubmitMessage {
@@ -268,6 +307,7 @@ export function parseBrainyChatSubmitMessage(
});
const requestId = trimToUndefined(data.requestId);
const modelName = trimToUndefined(data.payload?.model?.value);
const uploadedFiles = normalizeUploadedFiles(data.payload?.uploadedFiles);
const iframeContext: Record<string, unknown> = {};
if (requestId) {
@@ -281,6 +321,13 @@ export function parseBrainyChatSubmitMessage(
submitText,
files,
...(Object.keys(iframeContext).length > 0 ? { iframeContext } : {}),
...(promptPrefix ? { additionalKwargs: { prompt_prefix: promptPrefix } } : {}),
...(promptPrefix || uploadedFiles.length > 0
? {
additionalKwargs: {
...(promptPrefix ? { prompt_prefix: promptPrefix } : {}),
...(uploadedFiles.length > 0 ? { files: uploadedFiles } : {}),
},
}
: {}),
};
}
@@ -1,8 +1,10 @@
"""Middleware to inject uploaded files information into agent context."""
import logging
from pathlib import Path
from typing import NotRequired, override
import logging
import shutil
from pathlib import Path
from typing import NotRequired, override
from urllib.parse import unquote
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
@@ -13,7 +15,7 @@ from deerflow.config.paths import Paths, get_paths
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.file_conversion import extract_outline
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
_OUTLINE_PREVIEW_LINES = 5
@@ -22,7 +24,8 @@ _OUTLINE_PREVIEW_LINES = 5
# configured vision model), never by reading their bytes. Listing them with the
# generic read_file/grep guidance made the agent try to parse image binaries
# with bash/python, which fails and wastes turns.
_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
_ARTIFACT_URL_MARKER = "/artifacts/"
def _extract_outline_for_file(file_path: Path) -> tuple[list[dict], list[str]]:
@@ -69,7 +72,7 @@ class UploadsMiddlewareState(AgentState):
uploaded_files: NotRequired[list[dict] | None]
class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
"""Middleware to inject uploaded files information into the agent context.
Reads file metadata from the current message's additional_kwargs.files
@@ -122,7 +125,7 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
lines.append(" Use `grep` to search for keywords (e.g. `grep(pattern='keyword', path='/mnt/user-data/uploads/')`).")
lines.append("")
def _create_files_message(self, new_files: list[dict], historical_files: list[dict]) -> str:
def _create_files_message(self, new_files: list[dict], historical_files: list[dict]) -> str:
"""Create a formatted message listing uploaded files.
Args:
@@ -160,9 +163,61 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
lines.append("- Only fall back to web search if the file content is clearly insufficient to answer the question.")
lines.append("</uploaded_files>")
return "\n".join(lines)
def _files_from_kwargs(self, message: HumanMessage, uploads_dir: Path | None = None) -> list[dict] | None:
return "\n".join(lines)
def _import_file_from_artifact_url(
self,
file_info: dict,
uploads_dir: Path | None,
) -> bool:
"""Copy a previously uploaded file into the current thread when possible.
iframe-style callers may pass file metadata that points at an upload
from another DeerFlow thread. The current run thread still needs a real
file under its own ``/mnt/user-data/uploads`` directory, otherwise this
middleware will filter the entry out during existence checks.
"""
filename = file_info.get("filename") or ""
artifact_url = str(file_info.get("artifact_url") or "").strip()
if not filename or uploads_dir is None or not artifact_url:
return False
if not artifact_url.startswith("/api/threads/") or _ARTIFACT_URL_MARKER not in artifact_url:
return False
prefix, encoded_virtual_path = artifact_url.split(_ARTIFACT_URL_MARKER, 1)
source_thread_id = prefix.removeprefix("/api/threads/").strip("/")
if not source_thread_id or not encoded_virtual_path:
return False
virtual_path = "/" + unquote(encoded_virtual_path.lstrip("/"))
if Path(virtual_path).name != filename:
return False
try:
source_path = self._paths.resolve_virtual_path(
source_thread_id,
virtual_path,
user_id=get_effective_user_id(),
)
except ValueError:
logger.debug("Failed to resolve uploaded artifact path: %s", artifact_url, exc_info=True)
return False
if not source_path.is_file():
return False
uploads_dir.mkdir(parents=True, exist_ok=True)
target_path = uploads_dir / filename
shutil.copy2(source_path, target_path)
source_markdown = source_path.with_suffix(".md")
if source_markdown.is_file() and source_markdown != source_path:
shutil.copy2(source_markdown, target_path.with_suffix(".md"))
return True
def _files_from_kwargs(self, message: HumanMessage, uploads_dir: Path | None = None) -> list[dict] | None:
"""Extract file info from message additional_kwargs.files.
The frontend sends uploaded file metadata in additional_kwargs.files
@@ -183,16 +238,17 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
files = []
for f in kwargs_files:
if not isinstance(f, dict):
continue
filename = f.get("filename") or ""
if not filename or Path(filename).name != filename:
continue
if uploads_dir is not None and not (uploads_dir / filename).is_file():
continue
files.append(
{
"filename": filename,
if not isinstance(f, dict):
continue
filename = f.get("filename") or ""
if not filename or Path(filename).name != filename:
continue
if uploads_dir is not None and not (uploads_dir / filename).is_file():
if not self._import_file_from_artifact_url(f, uploads_dir):
continue
files.append(
{
"filename": filename,
"size": int(f.get("size") or 0),
"path": f"/mnt/user-data/uploads/{filename}",
"extension": Path(filename).suffix,