已实现由外部创建会话

This commit is contained in:
Titan 2026-03-12 10:43:56 +08:00
parent c669b3bb24
commit 4119fdcba7
4 changed files with 67 additions and 19 deletions

View File

@ -2,9 +2,10 @@
import logging
import os
import shutil
from pathlib import Path
from fastapi import APIRouter, File, HTTPException, UploadFile
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from pydantic import BaseModel
from src.agents.middlewares.thread_data_middleware import THREAD_DATA_BASE_DIR
@ -14,6 +15,8 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/threads/{thread_id}/uploads", tags=["uploads"])
SKILL_UPLOAD_TARGET = "skill"
# File extensions that should be converted to markdown
CONVERTIBLE_EXTENSIONS = {
".pdf",
@ -78,6 +81,7 @@ async def convert_file_to_markdown(file_path: Path) -> Path | None:
async def upload_files(
thread_id: str,
files: list[UploadFile] = File(...),
upload_target: str | None = Form(default=None),
) -> UploadResponse:
"""Upload multiple files to a thread's uploads directory.
@ -95,6 +99,19 @@ async def upload_files(
raise HTTPException(status_code=400, detail="No files provided")
uploads_dir = get_uploads_dir(thread_id)
target_dir = uploads_dir
relative_upload_prefix = "uploads"
if upload_target == SKILL_UPLOAD_TARGET:
target_dir = uploads_dir / SKILL_UPLOAD_TARGET
# Clean existing uploads/skill directory to keep only the latest skill package
if target_dir.exists():
shutil.rmtree(target_dir)
target_dir.mkdir(parents=True, exist_ok=True)
relative_upload_prefix = f"uploads/{SKILL_UPLOAD_TARGET}"
uploaded_files = []
sandbox_provider = get_sandbox_provider()
@ -107,12 +124,12 @@ async def upload_files(
try:
# Save the original file
file_path = uploads_dir / file.filename
file_path = target_dir / file.filename
content = await file.read()
# Build relative path from backend root
relative_path = f".deer-flow/threads/{thread_id}/user-data/uploads/{file.filename}"
virtual_path = f"/mnt/user-data/uploads/{file.filename}"
relative_path = f".deer-flow/threads/{thread_id}/user-data/{relative_upload_prefix}/{file.filename}"
virtual_path = f"/mnt/user-data/{relative_upload_prefix}/{file.filename}"
sandbox.update_file(virtual_path, content)
file_info = {
@ -120,7 +137,7 @@ async def upload_files(
"size": str(len(content)),
"path": relative_path, # Actual filesystem path (relative to backend/)
"virtual_path": virtual_path, # Path for Agent in sandbox
"artifact_url": f"/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/{file.filename}", # HTTP URL
"artifact_url": f"/api/threads/{thread_id}/artifacts/mnt/user-data/{relative_upload_prefix}/{file.filename}", # HTTP URL
}
logger.info(f"Saved file: {file.filename} ({len(content)} bytes) to {relative_path}")
@ -130,11 +147,13 @@ async def upload_files(
if file_ext in CONVERTIBLE_EXTENSIONS:
md_path = await convert_file_to_markdown(file_path)
if md_path:
md_relative_path = f".deer-flow/threads/{thread_id}/user-data/uploads/{md_path.name}"
md_relative_path = f".deer-flow/threads/{thread_id}/user-data/{relative_upload_prefix}/{md_path.name}"
file_info["markdown_file"] = md_path.name
file_info["markdown_path"] = md_relative_path
file_info["markdown_virtual_path"] = f"/mnt/user-data/uploads/{md_path.name}"
file_info["markdown_artifact_url"] = f"/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/{md_path.name}"
file_info["markdown_virtual_path"] = f"/mnt/user-data/{relative_upload_prefix}/{md_path.name}"
file_info["markdown_artifact_url"] = (
f"/api/threads/{thread_id}/artifacts/mnt/user-data/{relative_upload_prefix}/{md_path.name}"
)
uploaded_files.append(file_info)
@ -165,17 +184,18 @@ async def list_uploaded_files(thread_id: str) -> dict:
return {"files": [], "count": 0}
files = []
for file_path in sorted(uploads_dir.iterdir()):
for file_path in sorted(uploads_dir.rglob("*")):
if file_path.is_file():
stat = file_path.stat()
relative_path = f".deer-flow/threads/{thread_id}/user-data/uploads/{file_path.name}"
path_in_uploads = file_path.relative_to(uploads_dir).as_posix()
relative_path = f".deer-flow/threads/{thread_id}/user-data/uploads/{path_in_uploads}"
files.append(
{
"filename": file_path.name,
"filename": path_in_uploads,
"size": stat.st_size,
"path": relative_path, # Actual filesystem path (relative to backend/)
"virtual_path": f"/mnt/user-data/uploads/{file_path.name}", # Path for Agent in sandbox
"artifact_url": f"/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/{file_path.name}", # HTTP URL
"virtual_path": f"/mnt/user-data/uploads/{path_in_uploads}", # Path for Agent in sandbox
"artifact_url": f"/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/{path_in_uploads}", # HTTP URL
"extension": file_path.suffix,
"modified": stat.st_mtime,
}

View File

@ -81,17 +81,34 @@ export default function ChatPage() {
}
}, [inputInitialValue]);
const isNewThread = useMemo(
() => threadIdFromPath === "new",
[threadIdFromPath],
() => {
if (threadIdFromPath !== "new") {
return false;
}
const queryThreadId = searchParams.get("thread_id")?.trim();
const queryIsNew = searchParams.get("isnew")?.trim().toLowerCase();
const shouldReuseExisting = queryIsNew === "false" && !!queryThreadId;
return !shouldReuseExisting;
},
[threadIdFromPath, searchParams],
);
const uploadTarget = useMemo(() => {
const target = searchParams.get("upload_target")?.trim().toLowerCase();
return target === "skill" ? "skill" : undefined;
}, [searchParams]);
const [threadId, setThreadId] = useState<string | null>(null);
useEffect(() => {
if (threadIdFromPath !== "new") {
setThreadId(threadIdFromPath);
} else {
setThreadId(uuid());
const queryThreadId = searchParams.get("thread_id")?.trim();
setThreadId(queryThreadId || uuid());
}
}, [threadIdFromPath]);
}, [threadIdFromPath, searchParams]);
const { showNotification } = useNotification();
const [finalState, setFinalState] = useState<AgentThreadState | null>(null);
@ -185,6 +202,7 @@ export default function ChatPage() {
isNewThread,
threadId,
thread,
uploadTarget,
threadContext: {
...settings.context,
thinking_enabled: settings.context.mode !== "flash",

View File

@ -10,6 +10,7 @@ import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { getAPIClient } from "../api";
import { useUpdateSubtask } from "../tasks/context";
import { uploadFiles } from "../uploads";
import type { UploadTarget } from "../uploads/api";
import type {
AgentThread,
@ -83,12 +84,14 @@ export function useSubmitThread({
thread,
threadContext,
isNewThread,
uploadTarget,
afterSubmit,
}: {
isNewThread: boolean;
threadId: string | null | undefined;
thread: UseStream<AgentThreadState>;
threadContext: Omit<AgentThreadContext, "thread_id">;
uploadTarget?: UploadTarget;
afterSubmit?: () => void;
}) {
const queryClient = useQueryClient();
@ -127,7 +130,7 @@ export function useSubmitThread({
);
if (files.length > 0 && threadId) {
await uploadFiles(threadId, files);
await uploadFiles(threadId, files, { target: uploadTarget });
}
} catch (error) {
console.error("Failed to upload files:", error);
@ -167,7 +170,7 @@ export function useSubmitThread({
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
afterSubmit?.();
},
[thread, isNewThread, threadId, threadContext, queryClient, afterSubmit],
[thread, isNewThread, threadId, threadContext, uploadTarget, queryClient, afterSubmit],
);
return callback;
}

View File

@ -29,12 +29,15 @@ export interface ListFilesResponse {
count: number;
}
export type UploadTarget = "skill";
/**
* Upload files to a thread
*/
export async function uploadFiles(
threadId: string,
files: File[],
options?: { target?: UploadTarget },
): Promise<UploadResponse> {
const formData = new FormData();
@ -42,6 +45,10 @@ export async function uploadFiles(
formData.append("files", file);
});
if (options?.target) {
formData.append("upload_target", options.target);
}
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${threadId}/uploads`,
{