diff --git a/Dockerfile b/Dockerfile index 7289dedb..359a7ba5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,5 +44,8 @@ COPY ./backend . # Copy the frontend build COPY --from=builder /frontend/dist ./ui +COPY --from=builder /frontend/dist/index.html ./ui/404.html -ENTRYPOINT [ "uvicorn", "app.server:app", "--host", "0.0.0.0", "--log-config", "log_config.json" ] +ENV PORT=8000 + +ENTRYPOINT uvicorn app.server:app --host 0.0.0.0 --port $PORT --log-config log_config.json diff --git a/backend/Dockerfile b/backend/Dockerfile index 371e807b..a195bfbd 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -27,6 +27,6 @@ RUN poetry config virtualenvs.create false \ # Copy the rest of application code COPY . . -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --start-interval=1s --retries=3 CMD [ "curl", "-f", "http://localhost:8000/health" ] +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --start-interval=1s --retries=3 CMD [ "curl", "-f", "http://localhost:8000/ok" ] ENTRYPOINT [ "uvicorn", "app.server:app", "--host", "0.0.0.0", "--log-config", "log_config.json" ] diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 47d463e5..93aef80d 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1,5 +1,9 @@ -from fastapi import APIRouter +import orjson +from fastapi import APIRouter, Form, UploadFile, HTTPException +import app.storage as storage +from app.auth.handlers import AuthedUser +from app.upload import convert_ingestion_input_to_blob, ingest_runnable from app.api.assistants import router as assistants_router from app.api.runs import router as runs_router from app.api.threads import router as threads_router @@ -7,9 +11,27 @@ router = APIRouter() -@router.get("/ok") -async def ok(): - return {"ok": True} +@router.post("/ingest", description="Upload files to the given assistant.") +async def ingest_files( + files: list[UploadFile], user: AuthedUser, config: str = Form(...) +) -> None: + """Ingest a list of files.""" + config = orjson.loads(config) + + assistant_id = config["configurable"].get("assistant_id") + if assistant_id is not None: + assistant = await storage.get_assistant(user["user_id"], assistant_id) + if assistant is None: + raise HTTPException(status_code=404, detail="Assistant not found.") + + thread_id = config["configurable"].get("thread_id") + if thread_id is not None: + thread = await storage.get_thread(user["user_id"], thread_id) + if thread is None: + raise HTTPException(status_code=404, detail="Thread not found.") + + file_blobs = [convert_ingestion_input_to_blob(file) for file in files] + return ingest_runnable.batch(file_blobs, config) router.include_router( diff --git a/backend/app/server.py b/backend/app/server.py index 78650b8b..cfe3ab8b 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -1,19 +1,18 @@ import os from pathlib import Path +from typing import Any, MutableMapping -from fastapi.exception_handlers import http_exception_handler import httpx -import orjson import structlog -from fastapi import FastAPI, Form, UploadFile +from fastapi import FastAPI +from fastapi.exception_handlers import http_exception_handler from fastapi.exceptions import HTTPException +from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles +from starlette.responses import Response -import app.storage as storage from app.api import router as api_router -from app.auth.handlers import AuthedUser from app.lifespan import lifespan -from app.upload import convert_ingestion_input_to_blob, ingest_runnable logger = structlog.get_logger(__name__) @@ -31,41 +30,29 @@ async def httpx_http_status_error_handler(request, exc: httpx.HTTPStatusError): ROOT = Path(__file__).parent.parent -app.include_router(api_router) - +@app.get("/ok") +async def ok(): + return {"ok": True} -@app.post("/ingest", description="Upload files to the given assistant.") -async def ingest_files( - files: list[UploadFile], user: AuthedUser, config: str = Form(...) -) -> None: - """Ingest a list of files.""" - config = orjson.loads(config) - assistant_id = config["configurable"].get("assistant_id") - if assistant_id is not None: - assistant = await storage.get_assistant(user["user_id"], assistant_id) - if assistant is None: - raise HTTPException(status_code=404, detail="Assistant not found.") +app.include_router(api_router, prefix="/api") - thread_id = config["configurable"].get("thread_id") - if thread_id is not None: - thread = await storage.get_thread(user["user_id"], thread_id) - if thread is None: - raise HTTPException(status_code=404, detail="Thread not found.") - - file_blobs = [convert_ingestion_input_to_blob(file) for file in files] - return ingest_runnable.batch(file_blobs, config) +ui_dir = str(ROOT / "ui") -@app.get("/health") -async def health() -> dict: - return {"status": "ok"} +class StaticFilesSpa(StaticFiles): + async def get_response( + self, path: str, scope: MutableMapping[str, Any] + ) -> Response: + res = await super().get_response(path, scope) + if isinstance(res, FileResponse) and res.status_code == 404: + res.status_code = 200 + return res -ui_dir = str(ROOT / "ui") if os.path.exists(ui_dir): - app.mount("", StaticFiles(directory=ui_dir, html=True), name="ui") + app.mount("", StaticFilesSpa(directory=ui_dir, html=True), name="ui") else: logger.warn("No UI directory found, serving API only.") diff --git a/backend/app/storage.py b/backend/app/storage.py index 3b6a1ae9..4bad0a17 100644 --- a/backend/app/storage.py +++ b/backend/app/storage.py @@ -176,8 +176,11 @@ async def get_or_create_user(sub: str) -> tuple[User, bool]: async with get_pg_pool().acquire() as conn: if user := await conn.fetchrow('SELECT * FROM "user" WHERE sub = $1', sub): return user, False - user = await conn.fetchrow( + if user := await conn.fetchrow( 'INSERT INTO "user" (sub) VALUES ($1) ON CONFLICT (sub) DO NOTHING RETURNING *', sub, - ) - return user, True + ): + return user, True + if user := await conn.fetchrow('SELECT * FROM "user" WHERE sub = $1', sub): + return user, False + raise RuntimeError("User creation failed.") diff --git a/compose.override.yml b/compose.override.yml index d1f04c8a..b4071df8 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -45,6 +45,7 @@ services: - --reload frontend: container_name: opengpts-frontend + pull_policy: build build: context: frontend depends_on: diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 00000000..547ae671 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,7 @@ +langgraph build -t langchain/opengpts-langgraph:0.1.5 --platform linux/amd64,linux/arm64 +docker push langchain/opengpts-langgraph:0.1.5 +gcloud beta run deploy opengpts-demo-langgraph --image langchain/opengpts-langgraph:0.1.5 --region us-central1 --project langchain-dev --env-vars-file .env.gcp.yaml + +docker build -t langchain/opengpts-backend:0.1.1 --platform linux/amd64,linux/arm64 . +docker push langchain/opengpts-backend:0.1.1 +gcloud beta run deploy opengpts-demo-backend --image langchain/opengpts-backend:0.1.1 --region us-central1 --project langchain-dev --env-vars-file .env.gcp.yaml diff --git a/frontend/src/api/assistants.ts b/frontend/src/api/assistants.ts index aa5a175a..94b41c2f 100644 --- a/frontend/src/api/assistants.ts +++ b/frontend/src/api/assistants.ts @@ -4,7 +4,7 @@ export async function getAssistant( assistantId: string, ): Promise { try { - const response = await fetch(`/assistants/${assistantId}`); + const response = await fetch(`/api/assistants/${assistantId}`); if (!response.ok) { return null; } @@ -17,7 +17,7 @@ export async function getAssistant( export async function getAssistants(): Promise { try { - const response = await fetch(`/assistants/`); + const response = await fetch(`/api/assistants/`); if (!response.ok) { return null; } diff --git a/frontend/src/api/threads.ts b/frontend/src/api/threads.ts index ffa1b879..1837f886 100644 --- a/frontend/src/api/threads.ts +++ b/frontend/src/api/threads.ts @@ -2,7 +2,7 @@ import { Chat } from "../types"; export async function getThread(threadId: string): Promise { try { - const response = await fetch(`/threads/${threadId}`); + const response = await fetch(`/api/threads/${threadId}`); if (!response.ok) { return null; } diff --git a/frontend/src/hooks/useChatList.ts b/frontend/src/hooks/useChatList.ts index 5778b5e4..2c76b065 100644 --- a/frontend/src/hooks/useChatList.ts +++ b/frontend/src/hooks/useChatList.ts @@ -33,7 +33,7 @@ export function useChatList(): ChatListProps { useEffect(() => { async function fetchChats() { - const chats = await fetch("/threads/", { + const chats = await fetch("/api/threads/", { headers: { Accept: "application/json", }, @@ -45,7 +45,7 @@ export function useChatList(): ChatListProps { }, []); const createChat = useCallback(async (name: string, assistant_id: string) => { - const response = await fetch(`/threads`, { + const response = await fetch(`/api/threads`, { method: "POST", body: JSON.stringify({ assistant_id, name }), headers: { @@ -77,7 +77,7 @@ export function useChatList(): ChatListProps { const deleteChat = useCallback( async (thread_id: string) => { - await fetch(`/threads/${thread_id}`, { + await fetch(`/api/threads/${thread_id}`, { method: "DELETE", headers: { Accept: "application/json", diff --git a/frontend/src/hooks/useChatMessages.ts b/frontend/src/hooks/useChatMessages.ts index b830cfa7..c89546ce 100644 --- a/frontend/src/hooks/useChatMessages.ts +++ b/frontend/src/hooks/useChatMessages.ts @@ -3,7 +3,7 @@ import { Message } from "../types"; import { StreamState, mergeMessagesById } from "./useStreamState"; async function getState(threadId: string) { - const { values, next } = await fetch(`/threads/${threadId}/state`, { + const { values, next } = await fetch(`/api/threads/${threadId}/state`, { headers: { Accept: "application/json", }, diff --git a/frontend/src/hooks/useConfigList.ts b/frontend/src/hooks/useConfigList.ts index 91014f05..548ce6a1 100644 --- a/frontend/src/hooks/useConfigList.ts +++ b/frontend/src/hooks/useConfigList.ts @@ -71,7 +71,7 @@ export function useConfigList(): ConfigListProps { assistantId?: string, ): Promise => { const confResponse = await fetch( - assistantId ? `/assistants/${assistantId}` : "/assistants", + assistantId ? `/api/assistants/${assistantId}` : "/api/assistants", { method: assistantId ? "PUT" : "POST", body: JSON.stringify({ name, config, public: isPublic }), @@ -92,7 +92,7 @@ export function useConfigList(): ConfigListProps { "config", JSON.stringify({ configurable: { assistant_id } }), ); - await fetch(`/ingest`, { + await fetch(`/api/ingest`, { method: "POST", body: formData, }); diff --git a/frontend/src/hooks/useMessageEditing.ts b/frontend/src/hooks/useMessageEditing.ts index 91ba3ba9..457bceee 100644 --- a/frontend/src/hooks/useMessageEditing.ts +++ b/frontend/src/hooks/useMessageEditing.ts @@ -13,7 +13,7 @@ export function useMessageEditing( }, []); const commitEdits = useCallback(async () => { if (!threadId) return; - fetch(`/threads/${threadId}/state`, { + fetch(`/api/threads/${threadId}/state`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values: Object.values(editing) }), diff --git a/frontend/src/hooks/useSchemas.ts b/frontend/src/hooks/useSchemas.ts index a6d54ba8..00f98b79 100644 --- a/frontend/src/hooks/useSchemas.ts +++ b/frontend/src/hooks/useSchemas.ts @@ -36,7 +36,7 @@ export function useSchemas() { useEffect(() => { async function save() { - const configSchema = await fetch("/runs/config_schema") + const configSchema = await fetch("/api/runs/config_schema") .then((r) => r.json()) .then(simplifySchema); setSchemas({ diff --git a/frontend/src/hooks/useStreamState.tsx b/frontend/src/hooks/useStreamState.tsx index 36596284..f79aa9c9 100644 --- a/frontend/src/hooks/useStreamState.tsx +++ b/frontend/src/hooks/useStreamState.tsx @@ -33,7 +33,7 @@ export function useStreamState(): StreamStateProps { setController(controller); setCurrent({ status: "inflight", messages: input || [] }); - await fetchEventSource("/runs/stream", { + await fetchEventSource("/api/runs/stream", { signal: controller.signal, method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index a8a086ee..c3ebeba3 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,10 +6,10 @@ export default defineConfig({ plugins: [react()], server: { watch: { - usePolling: true + usePolling: true, }, proxy: { - "^/(assistants|threads|ingest|runs)": { + "/api": { target: process.env.VITE_BACKEND_URL || "http://127.0.0.1:8100", changeOrigin: true, rewrite: (path) => path.replace("/____LANGSERVE_BASE_URL", ""),