Compare commits
No commits in common. "8082fe08233ae49719e6bb828c8ef263aa4c4584" and "8554423f4ee21958e57b2b26b81bc07e3211f06d" have entirely different histories.
8082fe0823
...
8554423f4e
|
|
@ -10,7 +10,6 @@ from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse, PlainTextResponse, Response
|
from fastapi.responses import FileResponse, PlainTextResponse, Response
|
||||||
|
|
||||||
from app.gateway.path_utils import resolve_thread_virtual_path
|
from app.gateway.path_utils import resolve_thread_virtual_path
|
||||||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -63,38 +62,6 @@ def _find_compat_filename_match(missing_path: Path) -> Path | None:
|
||||||
return matches[0] if len(matches) == 1 else None
|
return matches[0] if len(matches) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
def _list_reference_files_in_dir(
|
|
||||||
thread_id: str,
|
|
||||||
root_dir: Path,
|
|
||||||
virtual_prefix: str,
|
|
||||||
source: str,
|
|
||||||
) -> list[dict[str, str]]:
|
|
||||||
if not root_dir.is_dir():
|
|
||||||
return []
|
|
||||||
|
|
||||||
files: list[dict[str, str]] = []
|
|
||||||
for file_path in sorted(root_dir.rglob("*")):
|
|
||||||
if not file_path.is_file():
|
|
||||||
continue
|
|
||||||
relative_path = file_path.relative_to(root_dir).as_posix()
|
|
||||||
# Internal uploaded skills are bootstrap assets, not user-facing references.
|
|
||||||
if source == "upload" and relative_path.startswith("skill/"):
|
|
||||||
continue
|
|
||||||
virtual_path = f"{virtual_prefix}/{relative_path}"
|
|
||||||
encoded_virtual_path = quote(virtual_path, safe="/")
|
|
||||||
files.append(
|
|
||||||
{
|
|
||||||
"filename": file_path.name,
|
|
||||||
"size": str(file_path.stat().st_size),
|
|
||||||
"virtual_path": virtual_path,
|
|
||||||
"artifact_url": f"/api/threads/{thread_id}/artifacts{encoded_virtual_path}",
|
|
||||||
"source": source,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return files
|
|
||||||
|
|
||||||
|
|
||||||
def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
|
def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
|
||||||
"""Check if file is text by examining content for null bytes."""
|
"""Check if file is text by examining content for null bytes."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -139,38 +106,6 @@ def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> byte
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/threads/{thread_id}/artifacts/list",
|
|
||||||
summary="List Reference Files",
|
|
||||||
description="List current files under outputs and uploads for @ references.",
|
|
||||||
)
|
|
||||||
async def list_reference_files(thread_id: str) -> dict:
|
|
||||||
"""List real files from outputs/uploads so mention candidates stay fresh."""
|
|
||||||
paths = get_paths()
|
|
||||||
outputs_dir = paths.sandbox_outputs_dir(thread_id)
|
|
||||||
uploads_dir = paths.sandbox_uploads_dir(thread_id)
|
|
||||||
|
|
||||||
outputs_virtual_prefix = f"{VIRTUAL_PATH_PREFIX}/outputs"
|
|
||||||
uploads_virtual_prefix = f"{VIRTUAL_PATH_PREFIX}/uploads"
|
|
||||||
output_files = _list_reference_files_in_dir(
|
|
||||||
thread_id,
|
|
||||||
outputs_dir,
|
|
||||||
outputs_virtual_prefix,
|
|
||||||
"artifact",
|
|
||||||
)
|
|
||||||
upload_files = _list_reference_files_in_dir(
|
|
||||||
thread_id,
|
|
||||||
uploads_dir,
|
|
||||||
uploads_virtual_prefix,
|
|
||||||
"upload",
|
|
||||||
)
|
|
||||||
files = [*output_files, *upload_files]
|
|
||||||
return {
|
|
||||||
"files": files,
|
|
||||||
"count": len(files),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/threads/{thread_id}/artifacts/{path:path}",
|
"/threads/{thread_id}/artifacts/{path:path}",
|
||||||
summary="Get Artifact File",
|
summary="Get Artifact File",
|
||||||
|
|
|
||||||
|
|
@ -87,9 +87,6 @@ class ArtifactReconcileMiddleware(AgentMiddleware[ArtifactReconcileState]):
|
||||||
if not isinstance(artifact, str):
|
if not isinstance(artifact, str):
|
||||||
changed = True
|
changed = True
|
||||||
continue
|
continue
|
||||||
if artifact == ARTIFACTS_REPLACE_SENTINEL:
|
|
||||||
changed = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
actual_path = self._to_outputs_file(artifact, outputs_dir)
|
actual_path = self._to_outputs_file(artifact, outputs_dir)
|
||||||
if actual_path is None:
|
if actual_path is None:
|
||||||
|
|
|
||||||
|
|
@ -22,22 +22,14 @@ class ViewedImageData(TypedDict):
|
||||||
|
|
||||||
def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]:
|
def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]:
|
||||||
"""Reducer for artifacts list - merges and deduplicates artifacts."""
|
"""Reducer for artifacts list - merges and deduplicates artifacts."""
|
||||||
def _clean(values: list[str] | None) -> list[str]:
|
|
||||||
if not values:
|
|
||||||
return []
|
|
||||||
return [v for v in values if isinstance(v, str) and v != ARTIFACTS_REPLACE_SENTINEL]
|
|
||||||
|
|
||||||
cleaned_existing = _clean(existing)
|
|
||||||
cleaned_new = _clean(new)
|
|
||||||
|
|
||||||
if new and new[0] == ARTIFACTS_REPLACE_SENTINEL:
|
if new and new[0] == ARTIFACTS_REPLACE_SENTINEL:
|
||||||
return list(dict.fromkeys(cleaned_new))
|
return list(dict.fromkeys(new[1:]))
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return cleaned_new
|
return new or []
|
||||||
if new is None:
|
if new is None:
|
||||||
return cleaned_existing
|
return existing
|
||||||
# Use dict.fromkeys to deduplicate while preserving order
|
# Use dict.fromkeys to deduplicate while preserving order
|
||||||
return list(dict.fromkeys(cleaned_existing + cleaned_new))
|
return list(dict.fromkeys(existing + new))
|
||||||
|
|
||||||
|
|
||||||
def merge_viewed_images(existing: dict[str, ViewedImageData] | None, new: dict[str, ViewedImageData] | None) -> dict[str, ViewedImageData]:
|
def merge_viewed_images(existing: dict[str, ViewedImageData] | None, new: dict[str, ViewedImageData] | None) -> dict[str, ViewedImageData]:
|
||||||
|
|
|
||||||
|
|
@ -87,25 +87,3 @@ def test_before_model_discovers_outputs_when_artifacts_empty(tmp_path):
|
||||||
assert result == {
|
assert result == {
|
||||||
"artifacts": [ARTIFACTS_REPLACE_SENTINEL, "/mnt/user-data/outputs/report.md"]
|
"artifacts": [ARTIFACTS_REPLACE_SENTINEL, "/mnt/user-data/outputs/report.md"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_before_model_drops_leaked_replace_sentinel(tmp_path):
|
|
||||||
outputs_dir = tmp_path / "outputs"
|
|
||||||
outputs_dir.mkdir()
|
|
||||||
keep = outputs_dir / "keep.md"
|
|
||||||
keep.write_text("ok", encoding="utf-8")
|
|
||||||
|
|
||||||
middleware = ArtifactReconcileMiddleware()
|
|
||||||
state = {
|
|
||||||
"thread_data": {"outputs_path": str(outputs_dir)},
|
|
||||||
"artifacts": [
|
|
||||||
ARTIFACTS_REPLACE_SENTINEL,
|
|
||||||
"/mnt/user-data/outputs/keep.md",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
result = middleware.before_model(state, runtime=SimpleNamespace(context={}))
|
|
||||||
|
|
||||||
assert result == {
|
|
||||||
"artifacts": [ARTIFACTS_REPLACE_SENTINEL, "/mnt/user-data/outputs/keep.md"]
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -130,43 +130,3 @@ def test_get_artifact_compat_fallback_for_dash_spacing(tmp_path, monkeypatch) ->
|
||||||
|
|
||||||
assert bytes(response.body).decode("utf-8") == "ok"
|
assert bytes(response.body).decode("utf-8") == "ok"
|
||||||
assert response.media_type == "text/markdown"
|
assert response.media_type == "text/markdown"
|
||||||
|
|
||||||
|
|
||||||
def test_list_reference_files_returns_outputs_and_uploads(tmp_path, monkeypatch) -> None:
|
|
||||||
outputs_dir = tmp_path / "outputs"
|
|
||||||
uploads_dir = tmp_path / "uploads"
|
|
||||||
outputs_dir.mkdir()
|
|
||||||
uploads_dir.mkdir()
|
|
||||||
(outputs_dir / "notes.md").write_text("hello", encoding="utf-8")
|
|
||||||
(outputs_dir / "figures").mkdir()
|
|
||||||
(outputs_dir / "figures" / "plot.png").write_bytes(b"png")
|
|
||||||
(uploads_dir / "dataset.csv").write_text("a,b\n1,2\n", encoding="utf-8")
|
|
||||||
(uploads_dir / "skill").mkdir()
|
|
||||||
(uploads_dir / "skill" / "internal.txt").write_text("hidden", encoding="utf-8")
|
|
||||||
|
|
||||||
class _FakePaths:
|
|
||||||
def sandbox_outputs_dir(self, _thread_id: str) -> Path:
|
|
||||||
return outputs_dir
|
|
||||||
|
|
||||||
def sandbox_uploads_dir(self, _thread_id: str) -> Path:
|
|
||||||
return uploads_dir
|
|
||||||
|
|
||||||
monkeypatch.setattr(artifacts_router, "get_paths", lambda: _FakePaths())
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
app.include_router(artifacts_router.router)
|
|
||||||
|
|
||||||
with TestClient(app) as client:
|
|
||||||
response = client.get("/api/threads/thread-1/artifacts/list")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
payload = response.json()
|
|
||||||
assert payload["count"] == 3
|
|
||||||
by_path = {item["virtual_path"]: item for item in payload["files"]}
|
|
||||||
|
|
||||||
assert "/mnt/user-data/outputs/notes.md" in by_path
|
|
||||||
assert "/mnt/user-data/outputs/figures/plot.png" in by_path
|
|
||||||
assert "/mnt/user-data/uploads/dataset.csv" in by_path
|
|
||||||
assert "/mnt/user-data/uploads/skill/internal.txt" not in by_path
|
|
||||||
assert by_path["/mnt/user-data/outputs/notes.md"]["source"] == "artifact"
|
|
||||||
assert by_path["/mnt/user-data/uploads/dataset.csv"]["source"] == "upload"
|
|
||||||
|
|
|
||||||
|
|
@ -32,35 +32,3 @@ def test_merge_artifacts_supports_replace_sentinel():
|
||||||
"/mnt/user-data/outputs/b.md",
|
"/mnt/user-data/outputs/b.md",
|
||||||
"/mnt/user-data/outputs/c.md",
|
"/mnt/user-data/outputs/c.md",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_merge_artifacts_always_strips_sentinel_from_existing():
|
|
||||||
existing = [
|
|
||||||
"/mnt/user-data/outputs/a.md",
|
|
||||||
ARTIFACTS_REPLACE_SENTINEL,
|
|
||||||
"/mnt/user-data/outputs/b.md",
|
|
||||||
]
|
|
||||||
|
|
||||||
result = merge_artifacts(existing, None)
|
|
||||||
|
|
||||||
assert result == [
|
|
||||||
"/mnt/user-data/outputs/a.md",
|
|
||||||
"/mnt/user-data/outputs/b.md",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_artifacts_strips_sentinel_from_non_replace_payload():
|
|
||||||
existing = ["/mnt/user-data/outputs/a.md"]
|
|
||||||
new = [
|
|
||||||
"/mnt/user-data/outputs/b.md",
|
|
||||||
ARTIFACTS_REPLACE_SENTINEL,
|
|
||||||
"/mnt/user-data/outputs/c.md",
|
|
||||||
]
|
|
||||||
|
|
||||||
result = merge_artifacts(existing, new)
|
|
||||||
|
|
||||||
assert result == [
|
|
||||||
"/mnt/user-data/outputs/a.md",
|
|
||||||
"/mnt/user-data/outputs/b.md",
|
|
||||||
"/mnt/user-data/outputs/c.md",
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ import { Tooltip } from "@/components/workspace/tooltip";
|
||||||
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
|
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
|
||||||
import { Welcome } from "@/components/workspace/welcome";
|
import { Welcome } from "@/components/workspace/welcome";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
|
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
||||||
import { useNotification } from "@/core/notification/hooks";
|
import { useNotification } from "@/core/notification/hooks";
|
||||||
|
|
@ -211,10 +210,6 @@ export default function ChatPage() {
|
||||||
const result = thread.values?.title ?? "";
|
const result = thread.values?.title ?? "";
|
||||||
return result === "Untitled" ? "" : result;
|
return result === "Untitled" ? "" : result;
|
||||||
}, [thread.values?.title]);
|
}, [thread.values?.title]);
|
||||||
const sanitizedArtifacts = useMemo(
|
|
||||||
() => sanitizeArtifactPaths(thread.values.artifacts),
|
|
||||||
[thread.values.artifacts],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [hasSubmitted, setHasSubmitted] = useState(false);
|
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||||||
const [historyCutoff, setHistoryCutoff] = useState<number | null>(null);
|
const [historyCutoff, setHistoryCutoff] = useState<number | null>(null);
|
||||||
|
|
@ -263,21 +258,21 @@ export default function ChatPage() {
|
||||||
|
|
||||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setArtifacts(sanitizedArtifacts);
|
setArtifacts(thread.values.artifacts);
|
||||||
if (
|
if (
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||||
autoSelectFirstArtifact
|
autoSelectFirstArtifact
|
||||||
) {
|
) {
|
||||||
if (sanitizedArtifacts.length > 0) {
|
if (thread?.values?.artifacts?.length > 0) {
|
||||||
setAutoSelectFirstArtifact(false);
|
setAutoSelectFirstArtifact(false);
|
||||||
selectArtifact(sanitizedArtifacts[0]!);
|
selectArtifact(thread.values.artifacts[0]!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
autoSelectFirstArtifact,
|
autoSelectFirstArtifact,
|
||||||
sanitizedArtifacts,
|
|
||||||
selectArtifact,
|
selectArtifact,
|
||||||
setArtifacts,
|
setArtifacts,
|
||||||
|
thread.values.artifacts,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const artifactPanelOpen = useMemo(() => {
|
const artifactPanelOpen = useMemo(() => {
|
||||||
|
|
@ -497,7 +492,7 @@ export default function ChatPage() {
|
||||||
) : (
|
) : (
|
||||||
<div className="relative flex size-full justify-center px-[20px]">
|
<div className="relative flex size-full justify-center px-[20px]">
|
||||||
<div className="z-30"></div>
|
<div className="z-30"></div>
|
||||||
{sanitizedArtifacts.length === 0 ? (
|
{thread.values.artifacts?.length === 0 ? (
|
||||||
<ConversationEmptyState
|
<ConversationEmptyState
|
||||||
icon={<FilesIcon />}
|
icon={<FilesIcon />}
|
||||||
title={t.chatPage.noArtifactSelectedTitle}
|
title={t.chatPage.noArtifactSelectedTitle}
|
||||||
|
|
@ -523,7 +518,7 @@ export default function ChatPage() {
|
||||||
<main className="min-h-0 grow overflow-auto">
|
<main className="min-h-0 grow overflow-auto">
|
||||||
<ArtifactFileList
|
<ArtifactFileList
|
||||||
className="mb-[207px] max-w-(--container-width-sm) pt-[20px]"
|
className="mb-[207px] max-w-(--container-width-sm) pt-[20px]"
|
||||||
files={sanitizedArtifacts}
|
files={thread.values.artifacts ?? []}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||||
|
|
||||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
|
|
||||||
import {
|
import {
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
|
|
@ -44,10 +43,6 @@ const ChatBox: React.FC<{
|
||||||
deselect,
|
deselect,
|
||||||
selectedArtifact,
|
selectedArtifact,
|
||||||
} = useArtifacts();
|
} = useArtifacts();
|
||||||
const sanitizedArtifacts = useMemo(
|
|
||||||
() => sanitizeArtifactPaths(thread.values.artifacts),
|
|
||||||
[thread.values.artifacts],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -57,7 +52,7 @@ const ChatBox: React.FC<{
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update artifacts from the current thread
|
// Update artifacts from the current thread
|
||||||
setArtifacts(sanitizedArtifacts);
|
setArtifacts(thread.values.artifacts);
|
||||||
|
|
||||||
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||||
// if (
|
// if (
|
||||||
|
|
@ -71,19 +66,19 @@ const ChatBox: React.FC<{
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||||
autoSelectFirstArtifact
|
autoSelectFirstArtifact
|
||||||
) {
|
) {
|
||||||
if (sanitizedArtifacts.length > 0) {
|
if (thread?.values?.artifacts?.length > 0) {
|
||||||
setAutoSelectFirstArtifact(false);
|
setAutoSelectFirstArtifact(false);
|
||||||
selectArtifact(sanitizedArtifacts[0]!);
|
selectArtifact(thread.values.artifacts[0]!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
threadId,
|
threadId,
|
||||||
autoSelectFirstArtifact,
|
autoSelectFirstArtifact,
|
||||||
deselect,
|
deselect,
|
||||||
sanitizedArtifacts,
|
|
||||||
selectArtifact,
|
selectArtifact,
|
||||||
selectedArtifact,
|
selectedArtifact,
|
||||||
setArtifacts,
|
setArtifacts,
|
||||||
|
thread.values.artifacts,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const artifactPanelOpen = useMemo(() => {
|
const artifactPanelOpen = useMemo(() => {
|
||||||
|
|
@ -156,7 +151,7 @@ const ChatBox: React.FC<{
|
||||||
<XIcon />
|
<XIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{sanitizedArtifacts.length === 0 ? (
|
{thread.values.artifacts?.length === 0 ? (
|
||||||
<ConversationEmptyState
|
<ConversationEmptyState
|
||||||
icon={<FilesIcon />}
|
icon={<FilesIcon />}
|
||||||
title={t.chatPage.noArtifactSelectedTitle}
|
title={t.chatPage.noArtifactSelectedTitle}
|
||||||
|
|
@ -172,7 +167,7 @@ const ChatBox: React.FC<{
|
||||||
<main className="min-h-0 grow">
|
<main className="min-h-0 grow">
|
||||||
<ArtifactFileList
|
<ArtifactFileList
|
||||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||||
files={sanitizedArtifacts}
|
files={thread.values.artifacts ?? []}
|
||||||
threadId={threadId ?? ""}
|
threadId={threadId ?? ""}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,6 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Tag } from "@/components/ui/tag";
|
import { Tag } from "@/components/ui/tag";
|
||||||
import { useReferenceFiles } from "@/core/artifacts/references";
|
|
||||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import type { SelectedSkillPayloadItem } from "@/core/i18n/locales/types";
|
import type { SelectedSkillPayloadItem } from "@/core/i18n/locales/types";
|
||||||
|
|
@ -81,6 +80,7 @@ import {
|
||||||
MENTION_REFERENCE_EVENT,
|
MENTION_REFERENCE_EVENT,
|
||||||
type MentionReferenceEventDetail,
|
type MentionReferenceEventDetail,
|
||||||
} from "@/core/threads/reference-events";
|
} from "@/core/threads/reference-events";
|
||||||
|
import { useUploadedFiles } from "@/core/uploads/hooks";
|
||||||
import { useIframeSkill } from "@/hooks/use-iframe-skill";
|
import { useIframeSkill } from "@/hooks/use-iframe-skill";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -96,6 +96,7 @@ import {
|
||||||
import { Suggestion, Suggestions } from "../ai-elements/suggestion";
|
import { Suggestion, Suggestions } from "../ai-elements/suggestion";
|
||||||
import { ScrollArea } from "../ui/scroll-area";
|
import { ScrollArea } from "../ui/scroll-area";
|
||||||
|
|
||||||
|
import { useThread } from "./messages/context";
|
||||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||||
import { Tooltip } from "./tooltip";
|
import { Tooltip } from "./tooltip";
|
||||||
|
|
||||||
|
|
@ -259,6 +260,7 @@ export function InputBox({
|
||||||
}),
|
}),
|
||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
const { thread } = useThread();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const iframeSkill = useIframeSkill({ threadId: threadIdFromProps });
|
const iframeSkill = useIframeSkill({ threadId: threadIdFromProps });
|
||||||
const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping;
|
const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping;
|
||||||
|
|
@ -292,7 +294,7 @@ export function InputBox({
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false);
|
const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false);
|
||||||
const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false);
|
const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false);
|
||||||
const { data: referenceFilesData } = useReferenceFiles(threadIdFromProps);
|
const { data: uploadedFilesData } = useUploadedFiles(threadIdFromProps);
|
||||||
|
|
||||||
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
|
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
|
||||||
const effectiveIsFocused =
|
const effectiveIsFocused =
|
||||||
|
|
@ -437,41 +439,49 @@ export function InputBox({
|
||||||
);
|
);
|
||||||
|
|
||||||
const mentionCandidates = useMemo<MentionCandidate[]>(() => {
|
const mentionCandidates = useMemo<MentionCandidate[]>(() => {
|
||||||
const deduped = new Map<string, MentionCandidate>();
|
const artifactCandidates = (thread.values.artifacts ?? []).map((path) => {
|
||||||
(referenceFilesData?.files ?? []).forEach((file) => {
|
const filename = path.split("/").pop() ?? path;
|
||||||
const path = file.virtual_path || "";
|
return {
|
||||||
const filename = file.filename ?? path.split("/").pop() ?? path;
|
key: `artifact:${path}`,
|
||||||
const refSource = file.source === "upload" ? "upload" : "artifact";
|
filename,
|
||||||
const typeLabel =
|
path,
|
||||||
refSource === "upload"
|
pathTail: getPathTail(path),
|
||||||
? referenceSourceLabels.upload
|
ref_source: "artifact" as const,
|
||||||
: referenceSourceLabels.artifact;
|
ref_kind: "mention" as const,
|
||||||
const previewUrl =
|
typeLabel: referenceSourceLabels.artifact,
|
||||||
file.artifact_url ||
|
isImage: isImageFilename(filename),
|
||||||
(threadId
|
previewUrl: threadId
|
||||||
? urlOfArtifact({
|
? urlOfArtifact({
|
||||||
filepath: path,
|
filepath: path,
|
||||||
threadId,
|
threadId,
|
||||||
})
|
})
|
||||||
: undefined);
|
: undefined,
|
||||||
|
};
|
||||||
deduped.set(`${refSource}:${path || filename}`, {
|
|
||||||
key: `${refSource}:${path || filename}`,
|
|
||||||
filename,
|
|
||||||
path,
|
|
||||||
pathTail: getPathTail(path),
|
|
||||||
ref_source: refSource,
|
|
||||||
ref_kind: "mention",
|
|
||||||
typeLabel,
|
|
||||||
isImage: isImageFilename(filename),
|
|
||||||
previewUrl,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const uploadCandidates =
|
||||||
|
uploadedFilesData?.files.map((file) => ({
|
||||||
|
key: `upload:${file.virtual_path || file.filename}`,
|
||||||
|
filename: file.filename,
|
||||||
|
path: file.virtual_path,
|
||||||
|
pathTail: getPathTail(file.virtual_path),
|
||||||
|
ref_source: "upload" as const,
|
||||||
|
ref_kind: "mention" as const,
|
||||||
|
typeLabel: referenceSourceLabels.upload,
|
||||||
|
isImage: isImageFilename(file.filename),
|
||||||
|
previewUrl: file.artifact_url,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
const deduped = new Map<string, MentionCandidate>();
|
||||||
|
[...artifactCandidates, ...uploadCandidates].forEach((candidate) => {
|
||||||
|
deduped.set(candidate.key, candidate);
|
||||||
});
|
});
|
||||||
return [...deduped.values()];
|
return [...deduped.values()];
|
||||||
}, [
|
}, [
|
||||||
referenceFilesData?.files,
|
|
||||||
referenceSourceLabels.artifact,
|
referenceSourceLabels.artifact,
|
||||||
referenceSourceLabels.upload,
|
referenceSourceLabels.upload,
|
||||||
|
thread.values.artifacts,
|
||||||
|
uploadedFilesData?.files,
|
||||||
threadId,
|
threadId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { getBackendBaseURL } from "../config";
|
|
||||||
|
|
||||||
export type ReferenceFileInfo = {
|
|
||||||
filename: string;
|
|
||||||
size: string;
|
|
||||||
virtual_path: string;
|
|
||||||
artifact_url: string;
|
|
||||||
source: "artifact" | "upload";
|
|
||||||
};
|
|
||||||
|
|
||||||
type ListReferenceFilesResponse = {
|
|
||||||
files: ReferenceFileInfo[];
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function listReferenceFiles(
|
|
||||||
threadId: string,
|
|
||||||
): Promise<ListReferenceFilesResponse> {
|
|
||||||
const response = await fetch(
|
|
||||||
`${getBackendBaseURL()}/api/threads/${threadId}/artifacts/list`,
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to list reference files");
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useReferenceFiles(threadId: string | undefined) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["references", "list", threadId],
|
|
||||||
queryFn: () => listReferenceFiles(threadId ?? ""),
|
|
||||||
enabled: Boolean(threadId),
|
|
||||||
refetchInterval: 5000,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { getBackendBaseURL } from "../config";
|
import { getBackendBaseURL } from "../config";
|
||||||
import type { AgentThread } from "../threads";
|
import type { AgentThread } from "../threads";
|
||||||
|
|
||||||
const ARTIFACTS_REPLACE_SENTINEL = "__deerflow_replace_artifacts__";
|
|
||||||
|
|
||||||
export function urlOfArtifact({
|
export function urlOfArtifact({
|
||||||
filepath,
|
filepath,
|
||||||
threadId,
|
threadId,
|
||||||
|
|
@ -21,13 +19,9 @@ export function urlOfArtifact({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractArtifactsFromThread(thread: AgentThread) {
|
export function extractArtifactsFromThread(thread: AgentThread) {
|
||||||
return sanitizeArtifactPaths(thread.values.artifacts);
|
return thread.values.artifacts ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveArtifactURL(absolutePath: string, threadId: string) {
|
export function resolveArtifactURL(absolutePath: string, threadId: string) {
|
||||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
|
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeArtifactPaths(paths: string[] | undefined | null) {
|
|
||||||
return (paths ?? []).filter((path) => path !== ARTIFACTS_REPLACE_SENTINEL);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue