已实现由外部创建会话

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 logging
import os import os
import shutil
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, File, HTTPException, UploadFile from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from pydantic import BaseModel from pydantic import BaseModel
from src.agents.middlewares.thread_data_middleware import THREAD_DATA_BASE_DIR 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"]) router = APIRouter(prefix="/api/threads/{thread_id}/uploads", tags=["uploads"])
SKILL_UPLOAD_TARGET = "skill"
# File extensions that should be converted to markdown # File extensions that should be converted to markdown
CONVERTIBLE_EXTENSIONS = { CONVERTIBLE_EXTENSIONS = {
".pdf", ".pdf",
@ -78,6 +81,7 @@ async def convert_file_to_markdown(file_path: Path) -> Path | None:
async def upload_files( async def upload_files(
thread_id: str, thread_id: str,
files: list[UploadFile] = File(...), files: list[UploadFile] = File(...),
upload_target: str | None = Form(default=None),
) -> UploadResponse: ) -> UploadResponse:
"""Upload multiple files to a thread's uploads directory. """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") raise HTTPException(status_code=400, detail="No files provided")
uploads_dir = get_uploads_dir(thread_id) 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 = [] uploaded_files = []
sandbox_provider = get_sandbox_provider() sandbox_provider = get_sandbox_provider()
@ -107,12 +124,12 @@ async def upload_files(
try: try:
# Save the original file # Save the original file
file_path = uploads_dir / file.filename file_path = target_dir / file.filename
content = await file.read() content = await file.read()
# Build relative path from backend root # Build relative path from backend root
relative_path = f".deer-flow/threads/{thread_id}/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/uploads/{file.filename}" virtual_path = f"/mnt/user-data/{relative_upload_prefix}/{file.filename}"
sandbox.update_file(virtual_path, content) sandbox.update_file(virtual_path, content)
file_info = { file_info = {
@ -120,7 +137,7 @@ async def upload_files(
"size": str(len(content)), "size": str(len(content)),
"path": relative_path, # Actual filesystem path (relative to backend/) "path": relative_path, # Actual filesystem path (relative to backend/)
"virtual_path": virtual_path, # Path for Agent in sandbox "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}") 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: if file_ext in CONVERTIBLE_EXTENSIONS:
md_path = await convert_file_to_markdown(file_path) md_path = await convert_file_to_markdown(file_path)
if md_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_file"] = md_path.name
file_info["markdown_path"] = md_relative_path file_info["markdown_path"] = md_relative_path
file_info["markdown_virtual_path"] = f"/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/uploads/{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) uploaded_files.append(file_info)
@ -165,17 +184,18 @@ async def list_uploaded_files(thread_id: str) -> dict:
return {"files": [], "count": 0} return {"files": [], "count": 0}
files = [] files = []
for file_path in sorted(uploads_dir.iterdir()): for file_path in sorted(uploads_dir.rglob("*")):
if file_path.is_file(): if file_path.is_file():
stat = file_path.stat() 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( files.append(
{ {
"filename": file_path.name, "filename": path_in_uploads,
"size": stat.st_size, "size": stat.st_size,
"path": relative_path, # Actual filesystem path (relative to backend/) "path": relative_path, # Actual filesystem path (relative to backend/)
"virtual_path": f"/mnt/user-data/uploads/{file_path.name}", # Path for Agent in sandbox "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/{file_path.name}", # HTTP URL "artifact_url": f"/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/{path_in_uploads}", # HTTP URL
"extension": file_path.suffix, "extension": file_path.suffix,
"modified": stat.st_mtime, "modified": stat.st_mtime,
} }

View File

@ -81,17 +81,34 @@ export default function ChatPage() {
} }
}, [inputInitialValue]); }, [inputInitialValue]);
const isNewThread = useMemo( 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); const [threadId, setThreadId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (threadIdFromPath !== "new") { if (threadIdFromPath !== "new") {
setThreadId(threadIdFromPath); setThreadId(threadIdFromPath);
} else { } else {
setThreadId(uuid()); const queryThreadId = searchParams.get("thread_id")?.trim();
setThreadId(queryThreadId || uuid());
} }
}, [threadIdFromPath]); }, [threadIdFromPath, searchParams]);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const [finalState, setFinalState] = useState<AgentThreadState | null>(null); const [finalState, setFinalState] = useState<AgentThreadState | null>(null);
@ -185,6 +202,7 @@ export default function ChatPage() {
isNewThread, isNewThread,
threadId, threadId,
thread, thread,
uploadTarget,
threadContext: { threadContext: {
...settings.context, ...settings.context,
thinking_enabled: settings.context.mode !== "flash", 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 { getAPIClient } from "../api";
import { useUpdateSubtask } from "../tasks/context"; import { useUpdateSubtask } from "../tasks/context";
import { uploadFiles } from "../uploads"; import { uploadFiles } from "../uploads";
import type { UploadTarget } from "../uploads/api";
import type { import type {
AgentThread, AgentThread,
@ -83,12 +84,14 @@ export function useSubmitThread({
thread, thread,
threadContext, threadContext,
isNewThread, isNewThread,
uploadTarget,
afterSubmit, afterSubmit,
}: { }: {
isNewThread: boolean; isNewThread: boolean;
threadId: string | null | undefined; threadId: string | null | undefined;
thread: UseStream<AgentThreadState>; thread: UseStream<AgentThreadState>;
threadContext: Omit<AgentThreadContext, "thread_id">; threadContext: Omit<AgentThreadContext, "thread_id">;
uploadTarget?: UploadTarget;
afterSubmit?: () => void; afterSubmit?: () => void;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -127,7 +130,7 @@ export function useSubmitThread({
); );
if (files.length > 0 && threadId) { if (files.length > 0 && threadId) {
await uploadFiles(threadId, files); await uploadFiles(threadId, files, { target: uploadTarget });
} }
} catch (error) { } catch (error) {
console.error("Failed to upload files:", error); console.error("Failed to upload files:", error);
@ -167,7 +170,7 @@ export function useSubmitThread({
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
afterSubmit?.(); afterSubmit?.();
}, },
[thread, isNewThread, threadId, threadContext, queryClient, afterSubmit], [thread, isNewThread, threadId, threadContext, uploadTarget, queryClient, afterSubmit],
); );
return callback; return callback;
} }

View File

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