feat: iframe传 文件进入

This commit is contained in:
ZyGuo committed 2026-06-05 14:15:42 +08:00
1 parent c766e3fa3b
commit 5f6ab41798
3 files changed
+139 -2

No files matched your search

@@ -100,3 +100,37 @@ test("maps iframe model selection into stream context model_name", () => {
model_name: "OpenAI3", 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; value?: string;
}; };
type BrainyUploadedFile = {
filename?: string;
size?: string | number;
path?: string;
status?: string;
artifact_url?: string;
};
export type BrainyChatSubmitMessage = { export type BrainyChatSubmitMessage = {
source?: string; source?: string;
type?: string; type?: string;
@@ -35,6 +43,7 @@ export type BrainyChatSubmitMessage = {
pageContext?: BrainyPageContext; pageContext?: BrainyPageContext;
screenshot?: BrainyScreenshot; screenshot?: BrainyScreenshot;
model?: BrainyModelSelection; model?: BrainyModelSelection;
uploadedFiles?: BrainyUploadedFile[];
}; };
requestId?: string; requestId?: string;
}; };
@@ -253,6 +262,36 @@ function buildScreenshotFileParts(
return []; 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( export function parseBrainyChatSubmitMessage(
data: BrainyChatSubmitMessage, data: BrainyChatSubmitMessage,
): ParsedBrainyChatSubmitMessage { ): ParsedBrainyChatSubmitMessage {
@@ -268,6 +307,7 @@ export function parseBrainyChatSubmitMessage(
}); });
const requestId = trimToUndefined(data.requestId); const requestId = trimToUndefined(data.requestId);
const modelName = trimToUndefined(data.payload?.model?.value); const modelName = trimToUndefined(data.payload?.model?.value);
const uploadedFiles = normalizeUploadedFiles(data.payload?.uploadedFiles);
const iframeContext: Record<string, unknown> = {}; const iframeContext: Record<string, unknown> = {};
if (requestId) { if (requestId) {
@@ -281,6 +321,13 @@ export function parseBrainyChatSubmitMessage(
submitText, submitText,
files, files,
...(Object.keys(iframeContext).length > 0 ? { iframeContext } : {}), ...(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.""" """Middleware to inject uploaded files information into agent context."""
import logging import logging
import shutil
from pathlib import Path from pathlib import Path
from typing import NotRequired, override from typing import NotRequired, override
from urllib.parse import unquote
from langchain.agents import AgentState from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware import AgentMiddleware
@@ -23,6 +25,7 @@ _OUTLINE_PREVIEW_LINES = 5
# generic read_file/grep guidance made the agent try to parse image binaries # generic read_file/grep guidance made the agent try to parse image binaries
# with bash/python, which fails and wastes turns. # 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]]: def _extract_outline_for_file(file_path: Path) -> tuple[list[dict], list[str]]:
@@ -162,6 +165,58 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
return "\n".join(lines) 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: def _files_from_kwargs(self, message: HumanMessage, uploads_dir: Path | None = None) -> list[dict] | None:
"""Extract file info from message additional_kwargs.files. """Extract file info from message additional_kwargs.files.
@@ -189,7 +244,8 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
if not filename or Path(filename).name != filename: if not filename or Path(filename).name != filename:
continue continue
if uploads_dir is not None and not (uploads_dir / filename).is_file(): if uploads_dir is not None and not (uploads_dir / filename).is_file():
continue if not self._import_file_from_artifact_url(f, uploads_dir):
continue
files.append( files.append(
{ {
"filename": filename, "filename": filename,