mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-19 12:24:46 +08:00
Implement optimistic UI for file uploads and enhance message handling (#967)
* feat(upload): implement optimistic UI for file uploads and enhance message handling * feat(middleware): enhance file handling by collecting historical uploads from directory * feat(thread-title): update page title handling for new threads and improve loading state * feat(uploads-middleware): enhance file extraction by verifying file existence in uploads directory * feat(thread-stream): update file path reference to use virtual_path for uploads * feat(tests): add core behaviour tests for UploadsMiddleware * feat(tests): remove unused pytest import from test_uploads_middleware_core_logic.py * feat: enhance file upload handling and localization support - Update UploadsMiddleware to validate filenames more robustly. - Modify MessageListItem to parse uploaded files from raw content for backward compatibility. - Add localization for uploading messages in English and Chinese. - Introduce parseUploadedFiles utility to extract uploaded files from message content.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"""Middleware to inject uploaded files information into agent context."""
|
"""Middleware to inject uploaded files information into agent context."""
|
||||||
|
|
||||||
import re
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import NotRequired, override
|
from typing import NotRequired, override
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@ from langgraph.runtime import Runtime
|
|||||||
|
|
||||||
from src.config.paths import Paths, get_paths
|
from src.config.paths import Paths, get_paths
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class UploadsMiddlewareState(AgentState):
|
class UploadsMiddlewareState(AgentState):
|
||||||
"""State schema for uploads middleware."""
|
"""State schema for uploads middleware."""
|
||||||
@@ -21,8 +23,9 @@ class UploadsMiddlewareState(AgentState):
|
|||||||
class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
||||||
"""Middleware to inject uploaded files information into the agent context.
|
"""Middleware to inject uploaded files information into the agent context.
|
||||||
|
|
||||||
This middleware lists all files in the thread's uploads directory and
|
Reads file metadata from the current message's additional_kwargs.files
|
||||||
adds a system message with the file list before the agent processes the request.
|
(set by the frontend after upload) and prepends an <uploaded_files> block
|
||||||
|
to the last human message so the model knows which files are available.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
state_schema = UploadsMiddlewareState
|
state_schema = UploadsMiddlewareState
|
||||||
@@ -36,111 +39,91 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self._paths = Paths(base_dir) if base_dir else get_paths()
|
self._paths = Paths(base_dir) if base_dir else get_paths()
|
||||||
|
|
||||||
def _get_uploads_dir(self, thread_id: str) -> Path:
|
def _create_files_message(self, new_files: list[dict], historical_files: list[dict]) -> str:
|
||||||
"""Get the uploads directory for a thread.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
thread_id: The thread ID.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to the uploads directory.
|
|
||||||
"""
|
|
||||||
return self._paths.sandbox_uploads_dir(thread_id)
|
|
||||||
|
|
||||||
def _list_newly_uploaded_files(self, thread_id: str, last_message_files: set[str]) -> list[dict]:
|
|
||||||
"""List only newly uploaded files that weren't in the last message.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
thread_id: The thread ID.
|
|
||||||
last_message_files: Set of filenames that were already shown in previous messages.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of new file information dictionaries.
|
|
||||||
"""
|
|
||||||
uploads_dir = self._get_uploads_dir(thread_id)
|
|
||||||
|
|
||||||
if not uploads_dir.exists():
|
|
||||||
return []
|
|
||||||
|
|
||||||
files = []
|
|
||||||
for file_path in sorted(uploads_dir.iterdir()):
|
|
||||||
if file_path.is_file() and file_path.name not in last_message_files:
|
|
||||||
stat = file_path.stat()
|
|
||||||
files.append(
|
|
||||||
{
|
|
||||||
"filename": file_path.name,
|
|
||||||
"size": stat.st_size,
|
|
||||||
"path": f"/mnt/user-data/uploads/{file_path.name}",
|
|
||||||
"extension": file_path.suffix,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return files
|
|
||||||
|
|
||||||
def _create_files_message(self, files: list[dict]) -> str:
|
|
||||||
"""Create a formatted message listing uploaded files.
|
"""Create a formatted message listing uploaded files.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
files: List of file information dictionaries.
|
new_files: Files uploaded in the current message.
|
||||||
|
historical_files: Files uploaded in previous messages.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Formatted string listing the files.
|
Formatted string inside <uploaded_files> tags.
|
||||||
"""
|
"""
|
||||||
if not files:
|
lines = ["<uploaded_files>"]
|
||||||
return "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>"
|
|
||||||
|
|
||||||
lines = ["<uploaded_files>", "The following files have been uploaded and are available for use:", ""]
|
lines.append("The following files were uploaded in this message:")
|
||||||
|
lines.append("")
|
||||||
for file in files:
|
for file in new_files:
|
||||||
size_kb = file["size"] / 1024
|
size_kb = file["size"] / 1024
|
||||||
if size_kb < 1024:
|
size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB"
|
||||||
size_str = f"{size_kb:.1f} KB"
|
|
||||||
else:
|
|
||||||
size_str = f"{size_kb / 1024:.1f} MB"
|
|
||||||
|
|
||||||
lines.append(f"- {file['filename']} ({size_str})")
|
lines.append(f"- {file['filename']} ({size_str})")
|
||||||
lines.append(f" Path: {file['path']}")
|
lines.append(f" Path: {file['path']}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
|
if historical_files:
|
||||||
|
lines.append("The following files were uploaded in previous messages and are still available:")
|
||||||
|
lines.append("")
|
||||||
|
for file in historical_files:
|
||||||
|
size_kb = file["size"] / 1024
|
||||||
|
size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB"
|
||||||
|
lines.append(f"- {file['filename']} ({size_str})")
|
||||||
|
lines.append(f" Path: {file['path']}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
lines.append("You can read these files using the `read_file` tool with the paths shown above.")
|
lines.append("You can read these files using the `read_file` tool with the paths shown above.")
|
||||||
lines.append("</uploaded_files>")
|
lines.append("</uploaded_files>")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _extract_files_from_message(self, content: str) -> set[str]:
|
def _files_from_kwargs(self, message: HumanMessage, uploads_dir: Path | None = None) -> list[dict] | None:
|
||||||
"""Extract filenames from uploaded_files tag in message content.
|
"""Extract file info from message additional_kwargs.files.
|
||||||
|
|
||||||
|
The frontend sends uploaded file metadata in additional_kwargs.files
|
||||||
|
after a successful upload. Each entry has: filename, size (bytes),
|
||||||
|
path (virtual path), status.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
content: Message content that may contain <uploaded_files> tag.
|
message: The human message to inspect.
|
||||||
|
uploads_dir: Physical uploads directory used to verify file existence.
|
||||||
|
When provided, entries whose files no longer exist are skipped.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Set of filenames mentioned in the tag.
|
List of file dicts with virtual paths, or None if the field is absent or empty.
|
||||||
"""
|
"""
|
||||||
# Match <uploaded_files>...</uploaded_files> tag
|
kwargs_files = (message.additional_kwargs or {}).get("files")
|
||||||
match = re.search(r"<uploaded_files>([\s\S]*?)</uploaded_files>", content)
|
if not isinstance(kwargs_files, list) or not kwargs_files:
|
||||||
if not match:
|
return None
|
||||||
return set()
|
|
||||||
|
|
||||||
files_content = match.group(1)
|
files = []
|
||||||
|
for f in kwargs_files:
|
||||||
# Extract filenames from lines like "- filename.ext (size)"
|
if not isinstance(f, dict):
|
||||||
# Need to capture everything before the opening parenthesis, including spaces
|
continue
|
||||||
filenames = set()
|
filename = f.get("filename") or ""
|
||||||
for line in files_content.split("\n"):
|
if not filename or Path(filename).name != filename:
|
||||||
# Match pattern: - filename with spaces.ext (size)
|
continue
|
||||||
# Changed from [^\s(]+ to [^(]+ to allow spaces in filename
|
if uploads_dir is not None and not (uploads_dir / filename).is_file():
|
||||||
file_match = re.match(r"^-\s+(.+?)\s*\(", line.strip())
|
continue
|
||||||
if file_match:
|
files.append(
|
||||||
filenames.add(file_match.group(1).strip())
|
{
|
||||||
|
"filename": filename,
|
||||||
return filenames
|
"size": int(f.get("size") or 0),
|
||||||
|
"path": f"/mnt/user-data/uploads/{filename}",
|
||||||
|
"extension": Path(filename).suffix,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return files if files else None
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def before_agent(self, state: UploadsMiddlewareState, runtime: Runtime) -> dict | None:
|
def before_agent(self, state: UploadsMiddlewareState, runtime: Runtime) -> dict | None:
|
||||||
"""Inject uploaded files information before agent execution.
|
"""Inject uploaded files information before agent execution.
|
||||||
|
|
||||||
Only injects files that weren't already shown in previous messages.
|
New files come from the current message's additional_kwargs.files.
|
||||||
Prepends file info to the last human message content.
|
Historical files are scanned from the thread's uploads directory,
|
||||||
|
excluding the new ones.
|
||||||
|
|
||||||
|
Prepends <uploaded_files> context to the last human message content.
|
||||||
|
The original additional_kwargs (including files metadata) is preserved
|
||||||
|
on the updated message so the frontend can read it from the stream.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
state: Current agent state.
|
state: Current agent state.
|
||||||
@@ -149,72 +132,70 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
|||||||
Returns:
|
Returns:
|
||||||
State updates including uploaded files list.
|
State updates including uploaded files list.
|
||||||
"""
|
"""
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
thread_id = runtime.context.get("thread_id")
|
|
||||||
if thread_id is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
messages = list(state.get("messages", []))
|
messages = list(state.get("messages", []))
|
||||||
if not messages:
|
if not messages:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Track all filenames that have been shown in previous messages (EXCEPT the last one)
|
|
||||||
shown_files: set[str] = set()
|
|
||||||
for msg in messages[:-1]: # Scan all messages except the last one
|
|
||||||
if isinstance(msg, HumanMessage):
|
|
||||||
content = msg.content if isinstance(msg.content, str) else ""
|
|
||||||
extracted = self._extract_files_from_message(content)
|
|
||||||
shown_files.update(extracted)
|
|
||||||
if extracted:
|
|
||||||
logger.info(f"Found previously shown files: {extracted}")
|
|
||||||
|
|
||||||
logger.info(f"Total shown files from history: {shown_files}")
|
|
||||||
|
|
||||||
# List only newly uploaded files
|
|
||||||
files = self._list_newly_uploaded_files(thread_id, shown_files)
|
|
||||||
logger.info(f"Newly uploaded files to inject: {[f['filename'] for f in files]}")
|
|
||||||
|
|
||||||
if not files:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Find the last human message and prepend file info to it
|
|
||||||
last_message_index = len(messages) - 1
|
last_message_index = len(messages) - 1
|
||||||
last_message = messages[last_message_index]
|
last_message = messages[last_message_index]
|
||||||
|
|
||||||
if not isinstance(last_message, HumanMessage):
|
if not isinstance(last_message, HumanMessage):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Resolve uploads directory for existence checks
|
||||||
|
thread_id = runtime.context.get("thread_id")
|
||||||
|
uploads_dir = self._paths.sandbox_uploads_dir(thread_id) if thread_id else None
|
||||||
|
|
||||||
|
# Get newly uploaded files from the current message's additional_kwargs.files
|
||||||
|
new_files = self._files_from_kwargs(last_message, uploads_dir) or []
|
||||||
|
|
||||||
|
# Collect historical files from the uploads directory (all except the new ones)
|
||||||
|
new_filenames = {f["filename"] for f in new_files}
|
||||||
|
historical_files: list[dict] = []
|
||||||
|
if uploads_dir and uploads_dir.exists():
|
||||||
|
for file_path in sorted(uploads_dir.iterdir()):
|
||||||
|
if file_path.is_file() and file_path.name not in new_filenames:
|
||||||
|
stat = file_path.stat()
|
||||||
|
historical_files.append(
|
||||||
|
{
|
||||||
|
"filename": file_path.name,
|
||||||
|
"size": stat.st_size,
|
||||||
|
"path": f"/mnt/user-data/uploads/{file_path.name}",
|
||||||
|
"extension": file_path.suffix,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not new_files and not historical_files:
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.debug(f"New files: {[f['filename'] for f in new_files]}, historical: {[f['filename'] for f in historical_files]}")
|
||||||
|
|
||||||
# Create files message and prepend to the last human message content
|
# Create files message and prepend to the last human message content
|
||||||
files_message = self._create_files_message(files)
|
files_message = self._create_files_message(new_files, historical_files)
|
||||||
|
|
||||||
# Extract original content - handle both string and list formats
|
# Extract original content - handle both string and list formats
|
||||||
original_content = ""
|
original_content = ""
|
||||||
if isinstance(last_message.content, str):
|
if isinstance(last_message.content, str):
|
||||||
original_content = last_message.content
|
original_content = last_message.content
|
||||||
elif isinstance(last_message.content, list):
|
elif isinstance(last_message.content, list):
|
||||||
# Content is a list of content blocks (e.g., [{"type": "text", "text": "..."}])
|
|
||||||
text_parts = []
|
text_parts = []
|
||||||
for block in last_message.content:
|
for block in last_message.content:
|
||||||
if isinstance(block, dict) and block.get("type") == "text":
|
if isinstance(block, dict) and block.get("type") == "text":
|
||||||
text_parts.append(block.get("text", ""))
|
text_parts.append(block.get("text", ""))
|
||||||
original_content = "\n".join(text_parts)
|
original_content = "\n".join(text_parts)
|
||||||
|
|
||||||
logger.info(f"Original message content: {original_content[:100] if original_content else '(empty)'}")
|
# Create new message with combined content.
|
||||||
|
# Preserve additional_kwargs (including files metadata) so the frontend
|
||||||
# Create new message with combined content
|
# can read structured file info from the streamed message.
|
||||||
updated_message = HumanMessage(
|
updated_message = HumanMessage(
|
||||||
content=f"{files_message}\n\n{original_content}",
|
content=f"{files_message}\n\n{original_content}",
|
||||||
id=last_message.id,
|
id=last_message.id,
|
||||||
additional_kwargs=last_message.additional_kwargs,
|
additional_kwargs=last_message.additional_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Replace the last message
|
|
||||||
messages[last_message_index] = updated_message
|
messages[last_message_index] = updated_message
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"uploaded_files": files,
|
"uploaded_files": new_files,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
}
|
}
|
||||||
|
|||||||
334
backend/tests/test_uploads_middleware_core_logic.py
Normal file
334
backend/tests/test_uploads_middleware_core_logic.py
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
"""Core behaviour tests for UploadsMiddleware.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- _files_from_kwargs: parsing, validation, existence check, virtual-path construction
|
||||||
|
- _create_files_message: output format with new-only and new+historical files
|
||||||
|
- before_agent: full injection pipeline (string & list content, preserved
|
||||||
|
additional_kwargs, historical files from uploads dir, edge-cases)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from langchain_core.messages import AIMessage, HumanMessage
|
||||||
|
|
||||||
|
from src.agents.middlewares.uploads_middleware import UploadsMiddleware
|
||||||
|
from src.config.paths import Paths
|
||||||
|
|
||||||
|
THREAD_ID = "thread-abc123"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _middleware(tmp_path: Path) -> UploadsMiddleware:
|
||||||
|
return UploadsMiddleware(base_dir=str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime(thread_id: str | None = THREAD_ID) -> MagicMock:
|
||||||
|
rt = MagicMock()
|
||||||
|
rt.context = {"thread_id": thread_id}
|
||||||
|
return rt
|
||||||
|
|
||||||
|
|
||||||
|
def _uploads_dir(tmp_path: Path, thread_id: str = THREAD_ID) -> Path:
|
||||||
|
d = Paths(str(tmp_path)).sandbox_uploads_dir(thread_id)
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _human(content, files=None, **extra_kwargs):
|
||||||
|
additional_kwargs = dict(extra_kwargs)
|
||||||
|
if files is not None:
|
||||||
|
additional_kwargs["files"] = files
|
||||||
|
return HumanMessage(content=content, additional_kwargs=additional_kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _files_from_kwargs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilesFromKwargs:
|
||||||
|
def test_returns_none_when_files_field_absent(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
msg = HumanMessage(content="hello")
|
||||||
|
assert mw._files_from_kwargs(msg) is None
|
||||||
|
|
||||||
|
def test_returns_none_for_empty_files_list(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
msg = _human("hello", files=[])
|
||||||
|
assert mw._files_from_kwargs(msg) is None
|
||||||
|
|
||||||
|
def test_returns_none_for_non_list_files(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
msg = _human("hello", files="not-a-list")
|
||||||
|
assert mw._files_from_kwargs(msg) is None
|
||||||
|
|
||||||
|
def test_skips_non_dict_entries(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
msg = _human("hi", files=["bad", 42, None])
|
||||||
|
assert mw._files_from_kwargs(msg) is None
|
||||||
|
|
||||||
|
def test_skips_entries_with_empty_filename(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
msg = _human("hi", files=[{"filename": "", "size": 100, "path": "/mnt/user-data/uploads/x"}])
|
||||||
|
assert mw._files_from_kwargs(msg) is None
|
||||||
|
|
||||||
|
def test_always_uses_virtual_path(self, tmp_path):
|
||||||
|
"""path field must be /mnt/user-data/uploads/<filename> regardless of what the frontend sent."""
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
msg = _human(
|
||||||
|
"hi",
|
||||||
|
files=[{"filename": "report.pdf", "size": 1024, "path": "/some/arbitrary/path/report.pdf"}],
|
||||||
|
)
|
||||||
|
result = mw._files_from_kwargs(msg)
|
||||||
|
assert result is not None
|
||||||
|
assert result[0]["path"] == "/mnt/user-data/uploads/report.pdf"
|
||||||
|
|
||||||
|
def test_skips_file_that_does_not_exist_on_disk(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
uploads_dir = _uploads_dir(tmp_path)
|
||||||
|
# file is NOT written to disk
|
||||||
|
msg = _human("hi", files=[{"filename": "missing.txt", "size": 50, "path": "/mnt/user-data/uploads/missing.txt"}])
|
||||||
|
assert mw._files_from_kwargs(msg, uploads_dir) is None
|
||||||
|
|
||||||
|
def test_accepts_file_that_exists_on_disk(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
uploads_dir = _uploads_dir(tmp_path)
|
||||||
|
(uploads_dir / "data.csv").write_text("a,b,c")
|
||||||
|
msg = _human("hi", files=[{"filename": "data.csv", "size": 5, "path": "/mnt/user-data/uploads/data.csv"}])
|
||||||
|
result = mw._files_from_kwargs(msg, uploads_dir)
|
||||||
|
assert result is not None
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["filename"] == "data.csv"
|
||||||
|
assert result[0]["path"] == "/mnt/user-data/uploads/data.csv"
|
||||||
|
|
||||||
|
def test_skips_nonexistent_but_accepts_existing_in_mixed_list(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
uploads_dir = _uploads_dir(tmp_path)
|
||||||
|
(uploads_dir / "present.txt").write_text("here")
|
||||||
|
msg = _human(
|
||||||
|
"hi",
|
||||||
|
files=[
|
||||||
|
{"filename": "present.txt", "size": 4, "path": "/mnt/user-data/uploads/present.txt"},
|
||||||
|
{"filename": "gone.txt", "size": 4, "path": "/mnt/user-data/uploads/gone.txt"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = mw._files_from_kwargs(msg, uploads_dir)
|
||||||
|
assert result is not None
|
||||||
|
assert [f["filename"] for f in result] == ["present.txt"]
|
||||||
|
|
||||||
|
def test_no_existence_check_when_uploads_dir_is_none(self, tmp_path):
|
||||||
|
"""Without an uploads_dir argument the existence check is skipped entirely."""
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
msg = _human("hi", files=[{"filename": "phantom.txt", "size": 10, "path": "/mnt/user-data/uploads/phantom.txt"}])
|
||||||
|
result = mw._files_from_kwargs(msg, uploads_dir=None)
|
||||||
|
assert result is not None
|
||||||
|
assert result[0]["filename"] == "phantom.txt"
|
||||||
|
|
||||||
|
def test_size_is_coerced_to_int(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
msg = _human("hi", files=[{"filename": "f.txt", "size": "2048", "path": "/mnt/user-data/uploads/f.txt"}])
|
||||||
|
result = mw._files_from_kwargs(msg)
|
||||||
|
assert result is not None
|
||||||
|
assert result[0]["size"] == 2048
|
||||||
|
|
||||||
|
def test_missing_size_defaults_to_zero(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
msg = _human("hi", files=[{"filename": "f.txt", "path": "/mnt/user-data/uploads/f.txt"}])
|
||||||
|
result = mw._files_from_kwargs(msg)
|
||||||
|
assert result is not None
|
||||||
|
assert result[0]["size"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _create_files_message
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateFilesMessage:
|
||||||
|
def _new_file(self, filename="notes.txt", size=1024):
|
||||||
|
return {"filename": filename, "size": size, "path": f"/mnt/user-data/uploads/{filename}"}
|
||||||
|
|
||||||
|
def test_new_files_section_always_present(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
msg = mw._create_files_message([self._new_file()], [])
|
||||||
|
assert "<uploaded_files>" in msg
|
||||||
|
assert "</uploaded_files>" in msg
|
||||||
|
assert "uploaded in this message" in msg
|
||||||
|
assert "notes.txt" in msg
|
||||||
|
assert "/mnt/user-data/uploads/notes.txt" in msg
|
||||||
|
|
||||||
|
def test_historical_section_present_only_when_non_empty(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
|
||||||
|
msg_no_hist = mw._create_files_message([self._new_file()], [])
|
||||||
|
assert "previous messages" not in msg_no_hist
|
||||||
|
|
||||||
|
hist = self._new_file("old.txt")
|
||||||
|
msg_with_hist = mw._create_files_message([self._new_file()], [hist])
|
||||||
|
assert "previous messages" in msg_with_hist
|
||||||
|
assert "old.txt" in msg_with_hist
|
||||||
|
|
||||||
|
def test_size_formatting_kb(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
msg = mw._create_files_message([self._new_file(size=2048)], [])
|
||||||
|
assert "2.0 KB" in msg
|
||||||
|
|
||||||
|
def test_size_formatting_mb(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
msg = mw._create_files_message([self._new_file(size=2 * 1024 * 1024)], [])
|
||||||
|
assert "2.0 MB" in msg
|
||||||
|
|
||||||
|
def test_read_file_instruction_included(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
msg = mw._create_files_message([self._new_file()], [])
|
||||||
|
assert "read_file" in msg
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# before_agent
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBeforeAgent:
|
||||||
|
def _state(self, *messages):
|
||||||
|
return {"messages": list(messages)}
|
||||||
|
|
||||||
|
def test_returns_none_when_messages_empty(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
assert mw.before_agent({"messages": []}, _runtime()) is None
|
||||||
|
|
||||||
|
def test_returns_none_when_last_message_is_not_human(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
state = self._state(HumanMessage(content="q"), AIMessage(content="a"))
|
||||||
|
assert mw.before_agent(state, _runtime()) is None
|
||||||
|
|
||||||
|
def test_returns_none_when_no_files_in_kwargs(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
state = self._state(_human("plain message"))
|
||||||
|
assert mw.before_agent(state, _runtime()) is None
|
||||||
|
|
||||||
|
def test_returns_none_when_all_files_missing_from_disk(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
_uploads_dir(tmp_path) # directory exists but is empty
|
||||||
|
msg = _human("hi", files=[{"filename": "ghost.txt", "size": 10, "path": "/mnt/user-data/uploads/ghost.txt"}])
|
||||||
|
state = self._state(msg)
|
||||||
|
assert mw.before_agent(state, _runtime()) is None
|
||||||
|
|
||||||
|
def test_injects_uploaded_files_tag_into_string_content(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
uploads_dir = _uploads_dir(tmp_path)
|
||||||
|
(uploads_dir / "report.pdf").write_bytes(b"pdf")
|
||||||
|
|
||||||
|
msg = _human("please analyse", files=[{"filename": "report.pdf", "size": 3, "path": "/mnt/user-data/uploads/report.pdf"}])
|
||||||
|
state = self._state(msg)
|
||||||
|
result = mw.before_agent(state, _runtime())
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
updated_msg = result["messages"][-1]
|
||||||
|
assert isinstance(updated_msg.content, str)
|
||||||
|
assert "<uploaded_files>" in updated_msg.content
|
||||||
|
assert "report.pdf" in updated_msg.content
|
||||||
|
assert "please analyse" in updated_msg.content
|
||||||
|
|
||||||
|
def test_injects_uploaded_files_tag_into_list_content(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
uploads_dir = _uploads_dir(tmp_path)
|
||||||
|
(uploads_dir / "data.csv").write_bytes(b"a,b")
|
||||||
|
|
||||||
|
msg = _human(
|
||||||
|
[{"type": "text", "text": "analyse this"}],
|
||||||
|
files=[{"filename": "data.csv", "size": 3, "path": "/mnt/user-data/uploads/data.csv"}],
|
||||||
|
)
|
||||||
|
state = self._state(msg)
|
||||||
|
result = mw.before_agent(state, _runtime())
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
updated_msg = result["messages"][-1]
|
||||||
|
assert "<uploaded_files>" in updated_msg.content
|
||||||
|
assert "analyse this" in updated_msg.content
|
||||||
|
|
||||||
|
def test_preserves_additional_kwargs_on_updated_message(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
uploads_dir = _uploads_dir(tmp_path)
|
||||||
|
(uploads_dir / "img.png").write_bytes(b"png")
|
||||||
|
|
||||||
|
files_meta = [{"filename": "img.png", "size": 3, "path": "/mnt/user-data/uploads/img.png", "status": "uploaded"}]
|
||||||
|
msg = _human("check image", files=files_meta, element="task")
|
||||||
|
state = self._state(msg)
|
||||||
|
result = mw.before_agent(state, _runtime())
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
updated_kwargs = result["messages"][-1].additional_kwargs
|
||||||
|
assert updated_kwargs.get("files") == files_meta
|
||||||
|
assert updated_kwargs.get("element") == "task"
|
||||||
|
|
||||||
|
def test_uploaded_files_returned_in_state_update(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
uploads_dir = _uploads_dir(tmp_path)
|
||||||
|
(uploads_dir / "notes.txt").write_bytes(b"hello")
|
||||||
|
|
||||||
|
msg = _human("review", files=[{"filename": "notes.txt", "size": 5, "path": "/mnt/user-data/uploads/notes.txt"}])
|
||||||
|
result = mw.before_agent(self._state(msg), _runtime())
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result["uploaded_files"] == [
|
||||||
|
{
|
||||||
|
"filename": "notes.txt",
|
||||||
|
"size": 5,
|
||||||
|
"path": "/mnt/user-data/uploads/notes.txt",
|
||||||
|
"extension": ".txt",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_historical_files_from_uploads_dir_excluding_new(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
uploads_dir = _uploads_dir(tmp_path)
|
||||||
|
(uploads_dir / "old.txt").write_bytes(b"old")
|
||||||
|
(uploads_dir / "new.txt").write_bytes(b"new")
|
||||||
|
|
||||||
|
msg = _human("go", files=[{"filename": "new.txt", "size": 3, "path": "/mnt/user-data/uploads/new.txt"}])
|
||||||
|
result = mw.before_agent(self._state(msg), _runtime())
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
content = result["messages"][-1].content
|
||||||
|
assert "uploaded in this message" in content
|
||||||
|
assert "new.txt" in content
|
||||||
|
assert "previous messages" in content
|
||||||
|
assert "old.txt" in content
|
||||||
|
|
||||||
|
def test_no_historical_section_when_upload_dir_is_empty(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
uploads_dir = _uploads_dir(tmp_path)
|
||||||
|
(uploads_dir / "only.txt").write_bytes(b"x")
|
||||||
|
|
||||||
|
msg = _human("go", files=[{"filename": "only.txt", "size": 1, "path": "/mnt/user-data/uploads/only.txt"}])
|
||||||
|
result = mw.before_agent(self._state(msg), _runtime())
|
||||||
|
|
||||||
|
content = result["messages"][-1].content
|
||||||
|
assert "previous messages" not in content
|
||||||
|
|
||||||
|
def test_no_historical_scan_when_thread_id_is_none(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
msg = _human("go", files=[{"filename": "f.txt", "size": 1, "path": "/mnt/user-data/uploads/f.txt"}])
|
||||||
|
# thread_id=None → _files_from_kwargs skips existence check, no dir scan
|
||||||
|
result = mw.before_agent(self._state(msg), _runtime(thread_id=None))
|
||||||
|
# With no existence check, the file passes through and injection happens
|
||||||
|
assert result is not None
|
||||||
|
content = result["messages"][-1].content
|
||||||
|
assert "previous messages" not in content
|
||||||
|
|
||||||
|
def test_message_id_preserved_on_updated_message(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
uploads_dir = _uploads_dir(tmp_path)
|
||||||
|
(uploads_dir / "f.txt").write_bytes(b"x")
|
||||||
|
|
||||||
|
msg = _human("go", files=[{"filename": "f.txt", "size": 1, "path": "/mnt/user-data/uploads/f.txt"}])
|
||||||
|
msg.id = "original-id-42"
|
||||||
|
result = mw.before_agent(self._state(msg), _runtime())
|
||||||
|
|
||||||
|
assert result["messages"][-1].id == "original-id-42"
|
||||||
@@ -1,22 +1,31 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
import { FileIcon } from "lucide-react";
|
import { FileIcon, Loader2Icon } from "lucide-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { memo, useMemo, type ImgHTMLAttributes } from "react";
|
import { memo, useMemo, type ImgHTMLAttributes } from "react";
|
||||||
import rehypeKatex from "rehype-katex";
|
import rehypeKatex from "rehype-katex";
|
||||||
|
|
||||||
|
import { Loader } from "@/components/ai-elements/loader";
|
||||||
import {
|
import {
|
||||||
Message as AIElementMessage,
|
Message as AIElementMessage,
|
||||||
MessageContent as AIElementMessageContent,
|
MessageContent as AIElementMessageContent,
|
||||||
MessageResponse as AIElementMessageResponse,
|
MessageResponse as AIElementMessageResponse,
|
||||||
MessageToolbar,
|
MessageToolbar,
|
||||||
} from "@/components/ai-elements/message";
|
} from "@/components/ai-elements/message";
|
||||||
|
import {
|
||||||
|
Reasoning,
|
||||||
|
ReasoningContent,
|
||||||
|
ReasoningTrigger,
|
||||||
|
} from "@/components/ai-elements/reasoning";
|
||||||
|
import { Task, TaskTrigger } from "@/components/ai-elements/task";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||||
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
extractContentFromMessage,
|
extractContentFromMessage,
|
||||||
extractReasoningContentFromMessage,
|
extractReasoningContentFromMessage,
|
||||||
parseUploadedFiles,
|
parseUploadedFiles,
|
||||||
type UploadedFile,
|
stripUploadedFilesTag,
|
||||||
|
type FileInMessage,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
import { humanMessagePlugins } from "@/core/streamdown";
|
import { humanMessagePlugins } from "@/core/streamdown";
|
||||||
@@ -121,37 +130,67 @@ function MessageContent_({
|
|||||||
|
|
||||||
const rawContent = extractContentFromMessage(message);
|
const rawContent = extractContentFromMessage(message);
|
||||||
const reasoningContent = extractReasoningContentFromMessage(message);
|
const reasoningContent = extractReasoningContentFromMessage(message);
|
||||||
const { contentToParse, uploadedFiles } = useMemo(() => {
|
|
||||||
if (!isLoading && reasoningContent && !rawContent) {
|
const files = useMemo(() => {
|
||||||
return {
|
const files = message.additional_kwargs?.files;
|
||||||
contentToParse: reasoningContent,
|
if (!Array.isArray(files) || files.length === 0) {
|
||||||
uploadedFiles: [] as UploadedFile[],
|
if (rawContent.includes("<uploaded_files>")) {
|
||||||
};
|
// If the content contains the <uploaded_files> tag, we return the parsed files from the content for backward compatibility.
|
||||||
|
return parseUploadedFiles(rawContent);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
if (isHuman && rawContent) {
|
return files as FileInMessage[];
|
||||||
const { files, cleanContent: contentWithoutFiles } =
|
}, [message.additional_kwargs?.files, rawContent]);
|
||||||
parseUploadedFiles(rawContent);
|
|
||||||
return { contentToParse: contentWithoutFiles, uploadedFiles: files };
|
const contentToDisplay = useMemo(() => {
|
||||||
|
if (isHuman) {
|
||||||
|
return rawContent ? stripUploadedFilesTag(rawContent) : "";
|
||||||
}
|
}
|
||||||
return {
|
return rawContent ?? "";
|
||||||
contentToParse: rawContent ?? "",
|
}, [rawContent, isHuman]);
|
||||||
uploadedFiles: [] as UploadedFile[],
|
|
||||||
};
|
|
||||||
}, [isLoading, rawContent, reasoningContent, isHuman]);
|
|
||||||
|
|
||||||
const filesList =
|
const filesList =
|
||||||
uploadedFiles.length > 0 && thread_id ? (
|
files && files.length > 0 && thread_id ? (
|
||||||
<UploadedFilesList files={uploadedFiles} threadId={thread_id} />
|
<RichFilesList files={files} threadId={thread_id} />
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
// Uploading state: mock AI message shown while files upload
|
||||||
|
if (message.additional_kwargs?.element === "task") {
|
||||||
|
return (
|
||||||
|
<AIElementMessageContent className={className}>
|
||||||
|
<Task defaultOpen={false}>
|
||||||
|
<TaskTrigger title="">
|
||||||
|
<div className="text-muted-foreground flex w-full cursor-default items-center gap-2 text-sm select-none">
|
||||||
|
<Loader className="size-4" />
|
||||||
|
<span>{contentToDisplay}</span>
|
||||||
|
</div>
|
||||||
|
</TaskTrigger>
|
||||||
|
</Task>
|
||||||
|
</AIElementMessageContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reasoning-only AI message (no main response content yet)
|
||||||
|
if (!isHuman && reasoningContent && !rawContent) {
|
||||||
|
return (
|
||||||
|
<AIElementMessageContent className={className}>
|
||||||
|
<Reasoning isStreaming={isLoading}>
|
||||||
|
<ReasoningTrigger />
|
||||||
|
<ReasoningContent>{reasoningContent}</ReasoningContent>
|
||||||
|
</Reasoning>
|
||||||
|
</AIElementMessageContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isHuman) {
|
if (isHuman) {
|
||||||
const messageResponse = contentToParse ? (
|
const messageResponse = contentToDisplay ? (
|
||||||
<AIElementMessageResponse
|
<AIElementMessageResponse
|
||||||
remarkPlugins={humanMessagePlugins.remarkPlugins}
|
remarkPlugins={humanMessagePlugins.remarkPlugins}
|
||||||
rehypePlugins={humanMessagePlugins.rehypePlugins}
|
rehypePlugins={humanMessagePlugins.rehypePlugins}
|
||||||
components={components}
|
components={components}
|
||||||
>
|
>
|
||||||
{contentToParse}
|
{contentToDisplay}
|
||||||
</AIElementMessageResponse>
|
</AIElementMessageResponse>
|
||||||
) : null;
|
) : null;
|
||||||
return (
|
return (
|
||||||
@@ -170,7 +209,7 @@ function MessageContent_({
|
|||||||
<AIElementMessageContent className={className}>
|
<AIElementMessageContent className={className}>
|
||||||
{filesList}
|
{filesList}
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
content={contentToParse}
|
content={contentToDisplay}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
||||||
className="my-3"
|
className="my-3"
|
||||||
@@ -224,22 +263,31 @@ function isImageFile(filename: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploaded files list component
|
* Format bytes to human-readable size string
|
||||||
*/
|
*/
|
||||||
function UploadedFilesList({
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return "—";
|
||||||
|
const kb = bytes / 1024;
|
||||||
|
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
||||||
|
return `${(kb / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of files from additional_kwargs.files (with optional upload status)
|
||||||
|
*/
|
||||||
|
function RichFilesList({
|
||||||
files,
|
files,
|
||||||
threadId,
|
threadId,
|
||||||
}: {
|
}: {
|
||||||
files: UploadedFile[];
|
files: FileInMessage[];
|
||||||
threadId: string;
|
threadId: string;
|
||||||
}) {
|
}) {
|
||||||
if (files.length === 0) return null;
|
if (files.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-2 flex flex-wrap justify-end gap-2">
|
<div className="mb-2 flex flex-wrap justify-end gap-2">
|
||||||
{files.map((file, index) => (
|
{files.map((file, index) => (
|
||||||
<UploadedFileCard
|
<RichFileCard
|
||||||
key={`${file.path}-${index}`}
|
key={`${file.filename}-${index}`}
|
||||||
file={file}
|
file={file}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
/>
|
/>
|
||||||
@@ -249,18 +297,48 @@ function UploadedFilesList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single uploaded file card component
|
* Single file card that handles FileInMessage (supports uploading state)
|
||||||
*/
|
*/
|
||||||
function UploadedFileCard({
|
function RichFileCard({
|
||||||
file,
|
file,
|
||||||
threadId,
|
threadId,
|
||||||
}: {
|
}: {
|
||||||
file: UploadedFile;
|
file: FileInMessage;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
}) {
|
}) {
|
||||||
if (!threadId) return null;
|
const { t } = useI18n();
|
||||||
|
const isUploading = file.status === "uploading";
|
||||||
const isImage = isImageFile(file.filename);
|
const isImage = isImageFile(file.filename);
|
||||||
|
|
||||||
|
if (isUploading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background border-border/40 flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border p-3 opacity-60 shadow-sm">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Loader2Icon className="text-muted-foreground mt-0.5 size-4 shrink-0 animate-spin" />
|
||||||
|
<span
|
||||||
|
className="text-foreground truncate text-sm font-medium"
|
||||||
|
title={file.filename}
|
||||||
|
>
|
||||||
|
{file.filename}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
|
||||||
|
>
|
||||||
|
{getFileTypeLabel(file.filename)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
{t.uploads.uploading}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.path) return null;
|
||||||
|
|
||||||
const fileUrl = resolveArtifactURL(file.path, threadId);
|
const fileUrl = resolveArtifactURL(file.path, threadId);
|
||||||
|
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
@@ -274,14 +352,14 @@ function UploadedFileCard({
|
|||||||
<img
|
<img
|
||||||
src={fileUrl}
|
src={fileUrl}
|
||||||
alt={file.filename}
|
alt={file.filename}
|
||||||
className="h-32 w-auto max-w-[240px] object-cover transition-transform group-hover:scale-105"
|
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background border-border/40 flex max-w-[200px] min-w-[120px] flex-col gap-1 rounded-lg border p-3 shadow-sm">
|
<div className="bg-background border-border/40 flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border p-3 shadow-sm">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||||
<span
|
<span
|
||||||
@@ -298,7 +376,9 @@ function UploadedFileCard({
|
|||||||
>
|
>
|
||||||
{getFileTypeLabel(file.filename)}
|
{getFileTypeLabel(file.filename)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-muted-foreground text-[10px]">{file.size}</span>
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
{formatBytes(file.size)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect } from "react";
|
|||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import type { AgentThreadState } from "@/core/threads";
|
import type { AgentThreadState } from "@/core/threads";
|
||||||
|
|
||||||
|
import { useThreadChat } from "./chats";
|
||||||
import { FlipDisplay } from "./flip-display";
|
import { FlipDisplay } from "./flip-display";
|
||||||
|
|
||||||
export function ThreadTitle({
|
export function ThreadTitle({
|
||||||
@@ -15,8 +16,9 @@ export function ThreadTitle({
|
|||||||
thread: BaseStream<AgentThreadState>;
|
thread: BaseStream<AgentThreadState>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { isNewThread } = useThreadChat();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pageTitle = !thread.values
|
const pageTitle = isNewThread
|
||||||
? t.pages.newChat
|
? t.pages.newChat
|
||||||
: thread.values?.title && thread.values.title !== "Untitled"
|
: thread.values?.title && thread.values.title !== "Untitled"
|
||||||
? thread.values.title
|
? thread.values.title
|
||||||
@@ -27,6 +29,7 @@ export function ThreadTitle({
|
|||||||
document.title = `${pageTitle} - ${t.pages.appName}`;
|
document.title = `${pageTitle} - ${t.pages.appName}`;
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
isNewThread,
|
||||||
t.pages.newChat,
|
t.pages.newChat,
|
||||||
t.pages.untitled,
|
t.pages.untitled,
|
||||||
t.pages.appName,
|
t.pages.appName,
|
||||||
|
|||||||
@@ -88,9 +88,11 @@ export const enUS: Translations = {
|
|||||||
reasoningEffortLow: "Low",
|
reasoningEffortLow: "Low",
|
||||||
reasoningEffortLowDescription: "Simple Logic Check + Shallow Deduction",
|
reasoningEffortLowDescription: "Simple Logic Check + Shallow Deduction",
|
||||||
reasoningEffortMedium: "Medium",
|
reasoningEffortMedium: "Medium",
|
||||||
reasoningEffortMediumDescription: "Multi-layer Logic Analysis + Basic Verification",
|
reasoningEffortMediumDescription:
|
||||||
|
"Multi-layer Logic Analysis + Basic Verification",
|
||||||
reasoningEffortHigh: "High",
|
reasoningEffortHigh: "High",
|
||||||
reasoningEffortHighDescription: "Full-dimensional Logic Deduction + Multi-path Verification + Backward Check",
|
reasoningEffortHighDescription:
|
||||||
|
"Full-dimensional Logic Deduction + Multi-path Verification + Backward Check",
|
||||||
searchModels: "Search models...",
|
searchModels: "Search models...",
|
||||||
surpriseMe: "Surprise",
|
surpriseMe: "Surprise",
|
||||||
surpriseMePrompt: "Surprise me",
|
surpriseMePrompt: "Surprise me",
|
||||||
@@ -248,6 +250,11 @@ export const enUS: Translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Subtasks
|
// Subtasks
|
||||||
|
uploads: {
|
||||||
|
uploading: "Uploading...",
|
||||||
|
uploadingFiles: "Uploading files, please wait...",
|
||||||
|
},
|
||||||
|
|
||||||
subtasks: {
|
subtasks: {
|
||||||
subtask: "Subtask",
|
subtask: "Subtask",
|
||||||
executing: (count: number) =>
|
executing: (count: number) =>
|
||||||
|
|||||||
@@ -187,6 +187,12 @@ export interface Translations {
|
|||||||
skillInstallTooltip: string;
|
skillInstallTooltip: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Uploads
|
||||||
|
uploads: {
|
||||||
|
uploading: string;
|
||||||
|
uploadingFiles: string;
|
||||||
|
};
|
||||||
|
|
||||||
// Subtasks
|
// Subtasks
|
||||||
subtasks: {
|
subtasks: {
|
||||||
subtask: string;
|
subtask: string;
|
||||||
|
|||||||
@@ -238,6 +238,11 @@ export const zhCN: Translations = {
|
|||||||
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
|
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
uploads: {
|
||||||
|
uploading: "上传中...",
|
||||||
|
uploadingFiles: "文件上传中,请稍候...",
|
||||||
|
},
|
||||||
|
|
||||||
subtasks: {
|
subtasks: {
|
||||||
subtask: "子任务",
|
subtask: "子任务",
|
||||||
executing: (count: number) =>
|
executing: (count: number) =>
|
||||||
|
|||||||
@@ -263,57 +263,56 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents an uploaded file parsed from the <uploaded_files> tag
|
* Represents a file stored in message additional_kwargs.files.
|
||||||
|
* Used for optimistic UI (uploading state) and structured file metadata.
|
||||||
*/
|
*/
|
||||||
export interface UploadedFile {
|
export interface FileInMessage {
|
||||||
filename: string;
|
filename: string;
|
||||||
size: string;
|
size: number; // bytes
|
||||||
path: string;
|
path?: string; // virtual path, may not be set during upload
|
||||||
|
status?: "uploading" | "uploaded";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of parsing uploaded files from message content
|
* Strip <uploaded_files> tag from message content.
|
||||||
|
* Returns the content with the tag removed.
|
||||||
*/
|
*/
|
||||||
export interface ParsedUploadedFiles {
|
export function stripUploadedFilesTag(content: string): string {
|
||||||
files: UploadedFile[];
|
return content
|
||||||
cleanContent: string;
|
.replace(/<uploaded_files>[\s\S]*?<\/uploaded_files>/g, "")
|
||||||
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function parseUploadedFiles(content: string): FileInMessage[] {
|
||||||
* Parse <uploaded_files> tag from message content and extract file information.
|
|
||||||
* Returns the list of uploaded files and the content with the tag removed.
|
|
||||||
*/
|
|
||||||
export function parseUploadedFiles(content: string): ParsedUploadedFiles {
|
|
||||||
// Match <uploaded_files>...</uploaded_files> tag
|
// Match <uploaded_files>...</uploaded_files> tag
|
||||||
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;
|
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;
|
||||||
// eslint-disable-next-line @typescript-eslint/prefer-regexp-exec
|
// eslint-disable-next-line @typescript-eslint/prefer-regexp-exec
|
||||||
const match = content.match(uploadedFilesRegex);
|
const match = content.match(uploadedFilesRegex);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return { files: [], cleanContent: content };
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadedFilesContent = match[1];
|
const uploadedFilesContent = match[1];
|
||||||
const cleanContent = content.replace(uploadedFilesRegex, "").trim();
|
|
||||||
|
|
||||||
// Check if it's "No files have been uploaded yet."
|
// Check if it's "No files have been uploaded yet."
|
||||||
if (uploadedFilesContent?.includes("No files have been uploaded yet.")) {
|
if (uploadedFilesContent?.includes("No files have been uploaded yet.")) {
|
||||||
return { files: [], cleanContent };
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse file list
|
// Parse file list
|
||||||
// Format: - filename (size)\n Path: /path/to/file
|
// Format: - filename (size)\n Path: /path/to/file
|
||||||
const fileRegex = /- ([^\n(]+)\s*\(([^)]+)\)\s*\n\s*Path:\s*([^\n]+)/g;
|
const fileRegex = /- ([^\n(]+)\s*\(([^)]+)\)\s*\n\s*Path:\s*([^\n]+)/g;
|
||||||
const files: UploadedFile[] = [];
|
const files: FileInMessage[] = [];
|
||||||
let fileMatch;
|
let fileMatch;
|
||||||
|
|
||||||
while ((fileMatch = fileRegex.exec(uploadedFilesContent ?? "")) !== null) {
|
while ((fileMatch = fileRegex.exec(uploadedFilesContent ?? "")) !== null) {
|
||||||
files.push({
|
files.push({
|
||||||
filename: fileMatch[1].trim(),
|
filename: fileMatch[1].trim(),
|
||||||
size: fileMatch[2].trim(),
|
size: parseInt(fileMatch[2].trim(), 10) ?? 0,
|
||||||
path: fileMatch[3].trim(),
|
path: fileMatch[3].trim(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { files, cleanContent };
|
return files;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import type { AIMessage } from "@langchain/langgraph-sdk";
|
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
|
||||||
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
|
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
|
||||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
|
|
||||||
import { getAPIClient } from "../api";
|
import { getAPIClient } from "../api";
|
||||||
|
import { useI18n } from "../i18n/hooks";
|
||||||
|
import type { FileInMessage } from "../messages/utils";
|
||||||
import type { LocalSettings } from "../settings";
|
import type { LocalSettings } from "../settings";
|
||||||
import { useUpdateSubtask } from "../tasks/context";
|
import { useUpdateSubtask } from "../tasks/context";
|
||||||
|
import type { UploadedFileInfo } from "../uploads";
|
||||||
import { uploadFiles } from "../uploads";
|
import { uploadFiles } from "../uploads";
|
||||||
|
|
||||||
import type { AgentThread, AgentThreadState } from "./types";
|
import type { AgentThread, AgentThreadState } from "./types";
|
||||||
@@ -36,11 +39,14 @@ export function useThreadStream({
|
|||||||
onFinish,
|
onFinish,
|
||||||
onToolEnd,
|
onToolEnd,
|
||||||
}: ThreadStreamOptions) {
|
}: ThreadStreamOptions) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [_threadId, setThreadId] = useState<string | null>(threadId ?? null);
|
const [_threadId, setThreadId] = useState<string | null>(threadId ?? null);
|
||||||
|
const startedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (_threadId && _threadId !== threadId) {
|
if (_threadId && _threadId !== threadId) {
|
||||||
setThreadId(threadId ?? null);
|
setThreadId(threadId ?? null);
|
||||||
|
startedRef.current = false; // Reset for new thread
|
||||||
}
|
}
|
||||||
}, [threadId, _threadId]);
|
}, [threadId, _threadId]);
|
||||||
|
|
||||||
@@ -54,7 +60,10 @@ export function useThreadStream({
|
|||||||
fetchStateHistory: { limit: 1 },
|
fetchStateHistory: { limit: 1 },
|
||||||
onCreated(meta) {
|
onCreated(meta) {
|
||||||
setThreadId(meta.thread_id);
|
setThreadId(meta.thread_id);
|
||||||
onStart?.(meta.thread_id);
|
if (!startedRef.current) {
|
||||||
|
onStart?.(meta.thread_id);
|
||||||
|
startedRef.current = true;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onLangChainEvent(event) {
|
onLangChainEvent(event) {
|
||||||
if (event.event === "on_tool_end") {
|
if (event.event === "on_tool_end") {
|
||||||
@@ -85,6 +94,21 @@ export function useThreadStream({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Optimistic messages shown before the server stream responds
|
||||||
|
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
|
||||||
|
// Track message count before sending so we know when server has responded
|
||||||
|
const prevMsgCountRef = useRef(thread.messages.length);
|
||||||
|
|
||||||
|
// Clear optimistic when server messages arrive (count increases)
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
optimisticMessages.length > 0 &&
|
||||||
|
thread.messages.length > prevMsgCountRef.current
|
||||||
|
) {
|
||||||
|
setOptimisticMessages([]);
|
||||||
|
}
|
||||||
|
}, [thread.messages.length, optimisticMessages.length]);
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
async (
|
async (
|
||||||
threadId: string,
|
threadId: string,
|
||||||
@@ -93,98 +117,191 @@ export function useThreadStream({
|
|||||||
) => {
|
) => {
|
||||||
const text = message.text.trim();
|
const text = message.text.trim();
|
||||||
|
|
||||||
// Upload files first if any
|
// Capture current count before showing optimistic messages
|
||||||
if (message.files && message.files.length > 0) {
|
prevMsgCountRef.current = thread.messages.length;
|
||||||
try {
|
|
||||||
// Convert FileUIPart to File objects by fetching blob URLs
|
|
||||||
const filePromises = message.files.map(async (fileUIPart) => {
|
|
||||||
if (fileUIPart.url && fileUIPart.filename) {
|
|
||||||
try {
|
|
||||||
// Fetch the blob URL to get the file data
|
|
||||||
const response = await fetch(fileUIPart.url);
|
|
||||||
const blob = await response.blob();
|
|
||||||
|
|
||||||
// Create a File object from the blob
|
// Build optimistic files list with uploading status
|
||||||
return new File([blob], fileUIPart.filename, {
|
const optimisticFiles: FileInMessage[] = (message.files ?? []).map(
|
||||||
type: fileUIPart.mediaType || blob.type,
|
(f) => ({
|
||||||
});
|
filename: f.filename ?? "",
|
||||||
} catch (error) {
|
size: 0,
|
||||||
console.error(
|
status: "uploading" as const,
|
||||||
`Failed to fetch file ${fileUIPart.filename}:`,
|
}),
|
||||||
error,
|
);
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const conversionResults = await Promise.all(filePromises);
|
// Create optimistic human message (shown immediately)
|
||||||
const files = conversionResults.filter(
|
const optimisticHumanMsg: Message = {
|
||||||
(file): file is File => file !== null,
|
type: "human",
|
||||||
);
|
id: `opt-human-${Date.now()}`,
|
||||||
const failedConversions = conversionResults.length - files.length;
|
content: text ? [{ type: "text", text }] : "",
|
||||||
|
additional_kwargs:
|
||||||
|
optimisticFiles.length > 0 ? { files: optimisticFiles } : {},
|
||||||
|
};
|
||||||
|
|
||||||
if (failedConversions > 0) {
|
const newOptimistic: Message[] = [optimisticHumanMsg];
|
||||||
throw new Error(
|
if (optimisticFiles.length > 0) {
|
||||||
`Failed to prepare ${failedConversions} attachment(s) for upload. Please retry.`,
|
// Mock AI message while files are being uploaded
|
||||||
);
|
newOptimistic.push({
|
||||||
}
|
type: "ai",
|
||||||
|
id: `opt-ai-${Date.now()}`,
|
||||||
|
content: t.uploads.uploadingFiles,
|
||||||
|
additional_kwargs: { element: "task" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setOptimisticMessages(newOptimistic);
|
||||||
|
|
||||||
if (!threadId) {
|
if (!startedRef.current) {
|
||||||
throw new Error("Thread is not ready for file upload.");
|
onStart?.(threadId);
|
||||||
}
|
startedRef.current = true;
|
||||||
|
|
||||||
if (files.length > 0) {
|
|
||||||
await uploadFiles(threadId, files);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to upload files:", error);
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : "Failed to upload files.";
|
|
||||||
toast.error(errorMessage);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await thread.submit(
|
let uploadedFileInfo: UploadedFileInfo[] = [];
|
||||||
{
|
|
||||||
messages: [
|
try {
|
||||||
{
|
// Upload files first if any
|
||||||
type: "human",
|
if (message.files && message.files.length > 0) {
|
||||||
content: [
|
try {
|
||||||
{
|
// Convert FileUIPart to File objects by fetching blob URLs
|
||||||
type: "text",
|
const filePromises = message.files.map(async (fileUIPart) => {
|
||||||
text,
|
if (fileUIPart.url && fileUIPart.filename) {
|
||||||
},
|
try {
|
||||||
],
|
// Fetch the blob URL to get the file data
|
||||||
|
const response = await fetch(fileUIPart.url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// Create a File object from the blob
|
||||||
|
return new File([blob], fileUIPart.filename, {
|
||||||
|
type: fileUIPart.mediaType || blob.type,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to fetch file ${fileUIPart.filename}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const conversionResults = await Promise.all(filePromises);
|
||||||
|
const files = conversionResults.filter(
|
||||||
|
(file): file is File => file !== null,
|
||||||
|
);
|
||||||
|
const failedConversions = conversionResults.length - files.length;
|
||||||
|
|
||||||
|
if (failedConversions > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to prepare ${failedConversions} attachment(s) for upload. Please retry.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!threadId) {
|
||||||
|
throw new Error("Thread is not ready for file upload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
const uploadResponse = await uploadFiles(threadId, files);
|
||||||
|
uploadedFileInfo = uploadResponse.files;
|
||||||
|
|
||||||
|
// Update optimistic human message with uploaded status + paths
|
||||||
|
const uploadedFiles: FileInMessage[] = uploadedFileInfo.map(
|
||||||
|
(info) => ({
|
||||||
|
filename: info.filename,
|
||||||
|
size: info.size,
|
||||||
|
path: info.virtual_path,
|
||||||
|
status: "uploaded" as const,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setOptimisticMessages((messages) => {
|
||||||
|
if (messages.length > 1 && messages[0]) {
|
||||||
|
const humanMessage: Message = messages[0];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...humanMessage,
|
||||||
|
additional_kwargs: { files: uploadedFiles },
|
||||||
|
},
|
||||||
|
...messages.slice(1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to upload files:", error);
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to upload files.";
|
||||||
|
toast.error(errorMessage);
|
||||||
|
setOptimisticMessages([]);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build files metadata for submission (included in additional_kwargs)
|
||||||
|
const filesForSubmit: FileInMessage[] = uploadedFileInfo.map(
|
||||||
|
(info) => ({
|
||||||
|
filename: info.filename,
|
||||||
|
size: info.size,
|
||||||
|
path: info.virtual_path,
|
||||||
|
status: "uploaded" as const,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await thread.submit(
|
||||||
|
{
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "human",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
additional_kwargs:
|
||||||
|
filesForSubmit.length > 0 ? { files: filesForSubmit } : {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
threadId: threadId,
|
||||||
|
streamSubgraphs: true,
|
||||||
|
streamResumable: true,
|
||||||
|
streamMode: ["values", "messages-tuple", "custom"],
|
||||||
|
config: {
|
||||||
|
recursion_limit: 1000,
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
...extraContext,
|
||||||
|
...context,
|
||||||
|
thinking_enabled: context.mode !== "flash",
|
||||||
|
is_plan_mode: context.mode === "pro" || context.mode === "ultra",
|
||||||
|
subagent_enabled: context.mode === "ultra",
|
||||||
|
thread_id: threadId,
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
threadId: threadId,
|
|
||||||
streamSubgraphs: true,
|
|
||||||
streamResumable: true,
|
|
||||||
streamMode: ["values", "messages-tuple", "custom"],
|
|
||||||
config: {
|
|
||||||
recursion_limit: 1000,
|
|
||||||
},
|
},
|
||||||
context: {
|
);
|
||||||
...extraContext,
|
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||||
...context,
|
} catch (error) {
|
||||||
thinking_enabled: context.mode !== "flash",
|
setOptimisticMessages([]);
|
||||||
is_plan_mode: context.mode === "pro" || context.mode === "ultra",
|
throw error;
|
||||||
subagent_enabled: context.mode === "ultra",
|
}
|
||||||
thread_id: threadId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
|
||||||
// afterSubmit?.();
|
|
||||||
},
|
},
|
||||||
[thread, context, queryClient],
|
[thread, t.uploads.uploadingFiles, onStart, context, queryClient],
|
||||||
);
|
);
|
||||||
return [thread, sendMessage] as const;
|
|
||||||
|
// Merge thread with optimistic messages for display
|
||||||
|
const mergedThread =
|
||||||
|
optimisticMessages.length > 0
|
||||||
|
? ({
|
||||||
|
...thread,
|
||||||
|
messages: [...thread.messages, ...optimisticMessages],
|
||||||
|
} as typeof thread)
|
||||||
|
: thread;
|
||||||
|
|
||||||
|
return [mergedThread, sendMessage] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useThreads(
|
export function useThreads(
|
||||||
|
|||||||
Reference in New Issue
Block a user