diff options
Diffstat (limited to 'backend/app/main.py')
| -rw-r--r-- | backend/app/main.py | 286 |
1 files changed, 285 insertions, 1 deletions
diff --git a/backend/app/main.py b/backend/app/main.py index 65fa3a3..261b45a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,11 +1,14 @@ from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse +from fastapi.responses import StreamingResponse, FileResponse from pydantic import BaseModel from app.schemas import NodeRunRequest, NodeRunResponse, MergeStrategy, Role, Message, Context, LLMConfig, ModelProvider, ReasoningEffort from app.services.llm import llm_streamer, generate_title from dotenv import load_dotenv import os +import json +import shutil +from typing import List, Literal, Optional load_dotenv() @@ -19,6 +22,131 @@ app.add_middleware( allow_headers=["*"], ) +# --------- Project / Blueprint storage --------- +DATA_ROOT = os.path.abspath(os.getenv("DATA_ROOT", os.path.join(os.getcwd(), "data"))) +DEFAULT_USER = "test" +ARCHIVE_FILENAME = "archived_nodes.json" + +def ensure_user_root(user: str) -> str: + """ + Ensures the new data root structure: + data/<user>/projects + data/<user>/archive + """ + user_root = os.path.join(DATA_ROOT, user) + projects_root = os.path.join(user_root, "projects") + archive_root = os.path.join(user_root, "archive") + os.makedirs(projects_root, exist_ok=True) + os.makedirs(archive_root, exist_ok=True) + return user_root + + +def projects_root(user: str) -> str: + return os.path.join(ensure_user_root(user), "projects") + + +def archive_root(user: str) -> str: + return os.path.join(ensure_user_root(user), "archive") + + +def migrate_legacy_layout(user: str): + """ + Migrate from legacy ./projects/<user> and legacy archive folders to the new data/<user>/ structure. + """ + legacy_root = os.path.abspath(os.path.join(os.getcwd(), "projects", user)) + new_projects = projects_root(user) + if os.path.exists(legacy_root) and not os.listdir(new_projects): + try: + for name in os.listdir(legacy_root): + src = os.path.join(legacy_root, name) + dst = os.path.join(new_projects, name) + if not os.path.exists(dst): + shutil.move(src, dst) + except Exception: + pass + # migrate legacy archive (archived/ or .cf_archived/) + legacy_archives = [ + os.path.join(legacy_root, "archived", ARCHIVE_FILENAME), + os.path.join(legacy_root, ".cf_archived", ARCHIVE_FILENAME), + ] + new_archive_file = archived_path(user) + if not os.path.exists(new_archive_file): + for legacy in legacy_archives: + if os.path.exists(legacy): + os.makedirs(os.path.dirname(new_archive_file), exist_ok=True) + try: + shutil.move(legacy, new_archive_file) + except Exception: + pass + +def safe_path(user: str, relative_path: str) -> str: + root = projects_root(user) + norm = os.path.normpath(relative_path).lstrip(os.sep) + full = os.path.abspath(os.path.join(root, norm)) + if not full.startswith(root): + raise HTTPException(status_code=400, detail="Invalid path") + return full + +class FSItem(BaseModel): + name: str + path: str # path relative to user root + type: Literal["file", "folder"] + size: Optional[int] = None + mtime: Optional[float] = None + children: Optional[List["FSItem"]] = None + +FSItem.model_rebuild() + +def list_tree(user: str, relative_path: str = ".") -> List[FSItem]: + migrate_legacy_layout(user) + root = safe_path(user, relative_path) + items: List[FSItem] = [] + for name in sorted(os.listdir(root)): + full = os.path.join(root, name) + rel = os.path.relpath(full, projects_root(user)) + stat = os.stat(full) + if os.path.isdir(full): + items.append(FSItem( + name=name, + path=rel, + type="folder", + size=None, + mtime=stat.st_mtime, + children=list_tree(user, rel) + )) + else: + items.append(FSItem( + name=name, + path=rel, + type="file", + size=stat.st_size, + mtime=stat.st_mtime, + children=None + )) + return items + +class SaveBlueprintRequest(BaseModel): + user: str = DEFAULT_USER + path: str # relative path including filename.json + content: dict + +class RenameRequest(BaseModel): + user: str = DEFAULT_USER + path: str + new_name: Optional[str] = None + new_path: Optional[str] = None + +class FolderRequest(BaseModel): + user: str = DEFAULT_USER + path: str # relative folder path + +class DeleteRequest(BaseModel): + user: str = DEFAULT_USER + path: str + is_folder: bool = False + +# ----------------------------------------------- + @app.get("/") def read_root(): return {"message": "ContextFlow Backend is running"} @@ -117,3 +245,159 @@ async def summarize_endpoint(request: SummarizeRequest): from app.services.llm import summarize_content summary = await summarize_content(request.content, request.model) return SummarizeResponse(summary=summary) + +# ---------------- Project / Blueprint APIs ---------------- +@app.get("/api/projects/tree", response_model=List[FSItem]) +def get_project_tree(user: str = DEFAULT_USER): + """ + List all files/folders for the user under the projects root. + """ + ensure_user_root(user) + return list_tree(user) + + +@app.post("/api/projects/create_folder") +def create_folder(req: FolderRequest): + """ + Create a folder (and parents) under the user's project root. + """ + try: + folder_path = safe_path(req.user, req.path) + os.makedirs(folder_path, exist_ok=True) + return {"ok": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/projects/save_blueprint") +def save_blueprint(req: SaveBlueprintRequest): + """ + Save a blueprint JSON to disk. + """ + try: + full_path = safe_path(req.user, req.path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + with open(full_path, "w", encoding="utf-8") as f: + json.dump(req.content, f, ensure_ascii=False, indent=2) + return {"ok": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/projects/file") +def read_blueprint(user: str = DEFAULT_USER, path: str = ""): + """ + Read a blueprint JSON file. + """ + if not path: + raise HTTPException(status_code=400, detail="path is required") + full_path = safe_path(user, path) + if not os.path.isfile(full_path): + raise HTTPException(status_code=404, detail="file not found") + try: + with open(full_path, "r", encoding="utf-8") as f: + data = json.load(f) + return {"content": data} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/projects/download") +def download_blueprint(user: str = DEFAULT_USER, path: str = ""): + """ + Download a blueprint file. + """ + if not path: + raise HTTPException(status_code=400, detail="path is required") + full_path = safe_path(user, path) + if not os.path.isfile(full_path): + raise HTTPException(status_code=404, detail="file not found") + return FileResponse(full_path, filename=os.path.basename(full_path), media_type="application/json") + + +@app.post("/api/projects/rename") +def rename_item(req: RenameRequest): + """ + Rename or move a file or folder. + - If new_path is provided, it is treated as the target relative path (move). + - Else, new_name is used within the same directory. + """ + try: + src = safe_path(req.user, req.path) + if not os.path.exists(src): + raise HTTPException(status_code=404, detail="source not found") + if req.new_path: + dst = safe_path(req.user, req.new_path) + else: + if not req.new_name: + raise HTTPException(status_code=400, detail="new_name or new_path required") + base_dir = os.path.dirname(src) + dst = os.path.join(base_dir, req.new_name) + # Ensure still inside user root + safe_path(req.user, os.path.relpath(dst, ensure_user_root(req.user))) + os.rename(src, dst) + return {"ok": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/projects/delete") +def delete_item(req: DeleteRequest): + """ + Delete a file or folder. + """ + try: + target = safe_path(req.user, req.path) + if not os.path.exists(target): + raise HTTPException(status_code=404, detail="not found") + if os.path.isdir(target): + if not req.is_folder: + # Prevent deleting folder accidentally unless flagged + raise HTTPException(status_code=400, detail="set is_folder=True to delete folder") + shutil.rmtree(target) + else: + os.remove(target) + return {"ok": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) +# ---------------------------------------------------------- + +# --------------- Archived Nodes APIs ---------------------- +def archived_path(user: str) -> str: + root = archive_root(user) + return os.path.join(root, ARCHIVE_FILENAME) + + +@app.get("/api/projects/archived") +def get_archived_nodes(user: str = DEFAULT_USER): + migrate_legacy_layout(user) + path = archived_path(user) + if not os.path.exists(path): + return {"archived": []} + try: + with open(path, "r", encoding="utf-8") as f: + return {"archived": json.load(f)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/projects/archived") +def save_archived_nodes(payload: dict): + user = payload.get("user", DEFAULT_USER) + data = payload.get("archived", []) + try: + path = archived_path(user) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) |
