Skip to content

Commit bd00c69

Browse files
Vector store file upload functionality on setup page
1 parent 3bef9e1 commit bd00c69

File tree

8 files changed

+1068
-831
lines changed

8 files changed

+1068
-831
lines changed

main.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
from fastapi import FastAPI, Request
77
from fastapi.staticfiles import StaticFiles
88
from fastapi.templating import Jinja2Templates
9-
from fastapi.responses import RedirectResponse, Response
9+
from fastapi.responses import RedirectResponse, Response, HTMLResponse
1010
from routers import chat, files, setup
1111
from utils.threads import create_thread
12-
from fastapi.exceptions import HTTPException
12+
from fastapi.exceptions import HTTPException, RequestValidationError
1313

1414

1515
logger = logging.getLogger("uvicorn.error")
@@ -42,6 +42,25 @@ async def general_exception_handler(request: Request, exc: Exception) -> Respons
4242
status_code=500
4343
)
4444

45+
@app.exception_handler(RequestValidationError)
46+
async def validation_exception_handler(request: Request, exc: RequestValidationError):
47+
# Log the detailed validation errors
48+
logger.error(f"Validation error: {exc.errors()}")
49+
error_details = "; ".join([f"{err['loc'][-1]}: {err['msg']}" for err in exc.errors()])
50+
51+
# Check if it's an htmx request
52+
if request.headers.get("hx-request") == "true":
53+
# Return an HTML fragment suitable for htmx swapping
54+
error_html = f'<div id="file-list-container"><p class="errorMessage">Validation Error: {error_details}</p></div>' # Assuming target is file-list-container
55+
return HTMLResponse(content=error_html, status_code=200)
56+
else:
57+
# Return the full error page for standard requests
58+
return templates.TemplateResponse(
59+
"error.html",
60+
{"request": request, "error_message": f"Invalid input: {error_details}"},
61+
status_code=422,
62+
)
63+
4564
@app.exception_handler(HTTPException)
4665
async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
4766
logger.error(f"HTTP error: {exc.detail}")

routers/files.py

Lines changed: 220 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,128 +1,220 @@
1-
import os
2-
import logging
3-
from typing import List, Dict
4-
from dotenv import load_dotenv
5-
from fastapi import APIRouter, Request, UploadFile, File, HTTPException, Depends, Form, Path
6-
from fastapi.responses import StreamingResponse
7-
from openai import AsyncOpenAI
8-
from openai.types.file_purpose import FilePurpose
9-
from utils.files import get_or_create_vector_store
10-
from utils.streaming import stream_file_content
11-
12-
logger = logging.getLogger("uvicorn.error")
13-
14-
# Get assistant ID from environment variables
15-
load_dotenv(override=True)
16-
assistant_id_env = os.getenv("ASSISTANT_ID")
17-
if not assistant_id_env:
18-
raise ValueError("ASSISTANT_ID environment variable not set")
19-
assistant_id: str = assistant_id_env
20-
21-
router = APIRouter(
22-
prefix="/assistants/{assistant_id}/files",
23-
tags=["assistants_files"]
24-
)
25-
26-
27-
#TODO: Correctly return HTML, not JSON, from the routes below
28-
29-
@router.get("/")
30-
async def list_files(client: AsyncOpenAI = Depends(lambda: AsyncOpenAI())) -> List[Dict[str, str]]:
31-
# List files in the vector store
32-
vector_store_id = await get_or_create_vector_store(assistant_id, client)
33-
file_list = await client.vector_stores.files.list(vector_store_id)
34-
files_array: List[Dict[str, str]] = []
35-
36-
if file_list.data:
37-
for file in file_list.data:
38-
file_details = await client.files.retrieve(file.id)
39-
vector_file_details = await client.vector_stores.files.retrieve(
40-
vector_store_id=vector_store_id,
41-
file_id=file.id
42-
)
43-
files_array.append({
44-
"file_id": file.id,
45-
"filename": file_details.filename or "unknown_filename",
46-
"status": vector_file_details.status or "unknown_status",
47-
})
48-
return files_array
49-
50-
51-
# Take a purpose parameter, defaulting to "assistants"
52-
@router.post("/")
53-
async def upload_file(file: UploadFile = File(...), purpose: FilePurpose = Form(default="assistants")) -> Dict[str, str]:
54-
try:
55-
client = AsyncOpenAI()
56-
vector_store_id = await get_or_create_vector_store(assistant_id)
57-
openai_file = await client.files.create(
58-
file=file.file,
59-
purpose=purpose
60-
)
61-
await client.vector_stores.files.create(
62-
vector_store_id=vector_store_id,
63-
file_id=openai_file.id
64-
)
65-
return {"message": "File uploaded successfully"}
66-
except Exception as e:
67-
raise HTTPException(status_code=500, detail=str(e))
68-
69-
70-
@router.delete("/delete")
71-
async def delete_file(
72-
request: Request,
73-
fileId: str = Form(...),
74-
client: AsyncOpenAI = Depends(lambda: AsyncOpenAI())
75-
) -> Dict[str, str]:
76-
vector_store_id = await get_or_create_vector_store(assistant_id, client)
77-
await client.vector_stores.files.delete(vector_store_id=vector_store_id, file_id=fileId)
78-
return {"message": "File deleted successfully"}
79-
80-
81-
# --- Streaming file content ---
82-
83-
84-
85-
86-
@router.get("/{file_id}")
87-
async def download_assistant_file(
88-
file_id: str = Path(..., description="The ID of the file to retrieve"),
89-
client: AsyncOpenAI = Depends(lambda: AsyncOpenAI())
90-
) -> StreamingResponse:
91-
try:
92-
file = await client.files.retrieve(file_id)
93-
file_content = await client.files.content(file_id)
94-
95-
if not hasattr(file_content, 'content'):
96-
raise HTTPException(status_code=500, detail="File content not available")
97-
98-
return StreamingResponse(
99-
stream_file_content(file_content.content),
100-
headers={"Content-Disposition": f'attachment; filename=\"{file.filename or "download"}\"'}
101-
)
102-
except Exception as e:
103-
raise HTTPException(status_code=500, detail=str(e))
104-
105-
106-
@router.get("/{file_id}/content")
107-
async def get_assistant_image_content(
108-
file_id: str,
109-
client: AsyncOpenAI = Depends(lambda: AsyncOpenAI())
110-
) -> StreamingResponse:
111-
"""
112-
Streams file content from OpenAI API.
113-
This route is used to serve images and other files generated by the code interpreter.
114-
"""
115-
try:
116-
# Get the file content from OpenAI
117-
file_content = await client.files.content(file_id)
118-
file_bytes = file_content.read() # Remove await since read() returns bytes directly
119-
120-
# Return the file content as a streaming response
121-
# Note: In a production environment, you might want to add caching
122-
return StreamingResponse(
123-
content=iter([file_bytes]),
124-
media_type="image/png" # You might want to make this dynamic based on file type
125-
)
126-
except Exception as e:
127-
logger.error(f"Error getting file content: {e}")
128-
raise HTTPException(status_code=500, detail=str(e))
1+
import os
2+
import logging
3+
from typing import Literal
4+
from dotenv import load_dotenv
5+
from fastapi import (
6+
APIRouter, Request, UploadFile, File, HTTPException, Depends, Path, Form
7+
)
8+
from fastapi.responses import HTMLResponse, StreamingResponse
9+
from fastapi.templating import Jinja2Templates
10+
from openai import AsyncOpenAI
11+
from utils.files import get_or_create_vector_store, get_files_for_vector_store
12+
from utils.streaming import stream_file_content
13+
14+
logger = logging.getLogger("uvicorn.error")
15+
16+
# Setup Jinja2 templates
17+
templates = Jinja2Templates(directory="templates")
18+
19+
# Get assistant ID from environment variables
20+
load_dotenv(override=True)
21+
assistant_id_env = os.getenv("ASSISTANT_ID")
22+
if not assistant_id_env:
23+
raise ValueError("ASSISTANT_ID environment variable not set")
24+
default_assistant_id: str = assistant_id_env
25+
26+
router = APIRouter(
27+
prefix="/assistants/{assistant_id}/files",
28+
tags=["assistants_files"]
29+
)
30+
31+
32+
@router.get("/list", response_class=HTMLResponse)
33+
async def list_files(
34+
request: Request,
35+
assistant_id: str = Path(..., description="The ID of the Assistant"),
36+
client: AsyncOpenAI = Depends(lambda: AsyncOpenAI())
37+
) -> HTMLResponse:
38+
"""Lists files and returns an HTML partial."""
39+
try:
40+
vector_store_id = await get_or_create_vector_store(assistant_id, client)
41+
files = await get_files_for_vector_store(vector_store_id, client)
42+
return templates.TemplateResponse(
43+
"components/file-list.html",
44+
{"request": request, "files": files}
45+
)
46+
except Exception as e:
47+
logger.error(f"Error generating file list HTML for assistant {assistant_id}: {e}")
48+
# Return an error message within the HTML structure
49+
return HTMLResponse(content=f'<div id="file-list-container"><p class="error-message">Error loading files: {e}</p></div>')
50+
51+
52+
# Modified upload_file
53+
@router.post("/", response_class=HTMLResponse)
54+
async def upload_file(
55+
request: Request,
56+
assistant_id: str = Path(..., description="The ID of the Assistant"),
57+
file: UploadFile = File(...),
58+
purpose: Literal["assistants", "vision"] = Form(default="assistants"),
59+
client: AsyncOpenAI = Depends(lambda: AsyncOpenAI())
60+
) -> HTMLResponse:
61+
"""Uploads a file, adds it to the vector store, and returns the updated file list HTML."""
62+
try:
63+
vector_store_id = await get_or_create_vector_store(assistant_id, client)
64+
except Exception as e:
65+
logger.error(f"Error getting or creating vector store for assistant {assistant_id}: {e}")
66+
return templates.TemplateResponse(
67+
"components/file-list.html",
68+
{"request": request, "error_message": f"Error getting or creating vector store for assistant"}
69+
)
70+
71+
error_message = None
72+
try:
73+
# Upload the file to OpenAI
74+
openai_file = await client.files.create(
75+
file=(file.filename, file.file),
76+
purpose=purpose
77+
)
78+
79+
# Add the uploaded file to the vector store
80+
await client.vector_stores.files.create(
81+
vector_store_id=vector_store_id,
82+
file_id=openai_file.id
83+
)
84+
except Exception as e:
85+
logger.error(f"Error uploading file for assistant {assistant_id}: {e}")
86+
error_message = f"Error uploading file for assistant"
87+
88+
# Fetch the updated list of files and render the partial
89+
files = []
90+
try:
91+
if vector_store_id:
92+
files = await get_files_for_vector_store(vector_store_id, client)
93+
except Exception as e:
94+
logger.error(f"Error fetching files for assistant {assistant_id}: {e}")
95+
error_message = f"Error fetching files for assistant"
96+
97+
# Return the response, conditionally including error message
98+
return templates.TemplateResponse(
99+
"components/file-list.html",
100+
{
101+
"request": request,
102+
"files": files,
103+
**({"error_message": error_message} if error_message else {})
104+
}
105+
)
106+
107+
108+
# Modified delete_file
109+
@router.delete("/{file_id}", response_class=HTMLResponse) # Changed path to include file_id
110+
async def delete_file(
111+
request: Request, # Add request for template rendering
112+
assistant_id: str = Path(..., description="The ID of the Assistant"),
113+
file_id: str = Path(..., description="The ID of the file to delete"),
114+
client: AsyncOpenAI = Depends(lambda: AsyncOpenAI())
115+
) -> HTMLResponse:
116+
"""Deletes a file from the vector store and OpenAI account, then returns the updated file list HTML."""
117+
error_message = None
118+
files = []
119+
vector_store_id = None
120+
121+
try:
122+
vector_store_id = await get_or_create_vector_store(assistant_id, client)
123+
124+
# 1. Delete the file association from the vector store
125+
try:
126+
deleted_vs_file = await client.vector_stores.files.delete(
127+
vector_store_id=vector_store_id,
128+
file_id=file_id
129+
)
130+
131+
# 2. If vector store deletion was successful, attempt to delete the base file object
132+
if deleted_vs_file.deleted:
133+
try:
134+
await client.files.delete(file_id=file_id)
135+
except Exception as file_delete_error:
136+
# Log the warning but don't set error_message, as VS deletion succeeded
137+
logger.warning(f"File {file_id} removed from vector store {vector_store_id}, but failed to delete base file object: {file_delete_error}")
138+
else:
139+
# Log the warning and potentially set an error if VS deletion failed
140+
logger.warning(f"Failed to remove file {file_id} association from vector store {vector_store_id}")
141+
# Decide if this constitutes a full error for the user
142+
error_message = f"Failed to remove file from vector store."
143+
144+
except Exception as delete_error:
145+
logger.error(f"Error during file deletion process for file {file_id}, assistant {assistant_id}: {delete_error}")
146+
error_message = f"Error deleting file: {delete_error}"
147+
148+
except Exception as vs_error:
149+
logger.error(f"Error getting or creating vector store for assistant {assistant_id}: {vs_error}")
150+
error_message = f"Error accessing vector store: {vs_error}"
151+
152+
# Always try to fetch the current list of files, even if deletion had issues
153+
try:
154+
if vector_store_id: # Only fetch if we got the ID
155+
files = await get_files_for_vector_store(vector_store_id, client)
156+
elif not error_message: # If we couldn't get VS ID and have no other error, set one
157+
error_message = "Could not retrieve vector store information."
158+
159+
except Exception as fetch_error:
160+
logger.error(f"Error fetching file list after delete attempt for assistant {assistant_id}: {fetch_error}")
161+
# If an error message wasn't already set, set one now. Otherwise, keep the original error.
162+
if not error_message:
163+
error_message = f"Error fetching file list: {fetch_error}"
164+
165+
# Return the response, conditionally including error message
166+
return templates.TemplateResponse(
167+
"components/file-list.html",
168+
{
169+
"request": request,
170+
"files": files,
171+
**({"error_message": error_message} if error_message else {})
172+
}
173+
)
174+
175+
176+
# --- Streaming file content routes ---
177+
178+
@router.get("/{file_id}")
179+
async def download_assistant_file(
180+
file_id: str = Path(..., description="The ID of the file to retrieve"),
181+
client: AsyncOpenAI = Depends(lambda: AsyncOpenAI())
182+
) -> StreamingResponse:
183+
try:
184+
file = await client.files.retrieve(file_id)
185+
file_content = await client.files.content(file_id)
186+
187+
if not hasattr(file_content, 'content'):
188+
raise HTTPException(status_code=500, detail="File content not available")
189+
190+
return StreamingResponse(
191+
stream_file_content(file_content.content),
192+
headers={"Content-Disposition": f'attachment; filename=\"{file.filename or "download"}\"'}
193+
)
194+
except Exception as e:
195+
raise HTTPException(status_code=500, detail=str(e))
196+
197+
198+
@router.get("/{file_id}/content")
199+
async def get_assistant_image_content(
200+
file_id: str,
201+
client: AsyncOpenAI = Depends(lambda: AsyncOpenAI())
202+
) -> StreamingResponse:
203+
"""
204+
Streams file content from OpenAI API.
205+
This route is used to serve images and other files generated by the code interpreter.
206+
"""
207+
try:
208+
# Get the file content from OpenAI
209+
file_content = await client.files.content(file_id)
210+
file_bytes = file_content.read() # Remove await since read() returns bytes directly
211+
212+
# Return the file content as a streaming response
213+
# Note: In a production environment, you might want to add caching
214+
return StreamingResponse(
215+
content=iter([file_bytes]),
216+
media_type="image/png" # You might want to make this dynamic based on file type
217+
)
218+
except Exception as e:
219+
logger.error(f"Error getting file content: {e}")
220+
raise HTTPException(status_code=500, detail=str(e))

0 commit comments

Comments
 (0)