diff --git a/frontend/app/features/platform/ModelPlatformApp.tsx b/frontend/app/features/platform/ModelPlatformApp.tsx index a5bda9d..6e3cab9 100644 --- a/frontend/app/features/platform/ModelPlatformApp.tsx +++ b/frontend/app/features/platform/ModelPlatformApp.tsx @@ -17,7 +17,7 @@ import { type Visibility, type WorkspaceDirectory, } from "../../services/api"; -import { useApi, useAuth } from "~/context/AuthContext"; +import { useApi, useAuth } from "../../context/AuthContext"; import Icon from "../../components/Icon"; import SchedulePage from "../schedules/SchedulePage"; import { DashboardPage, SystemAdminPage } from "../admin/AdminPages"; @@ -593,6 +593,19 @@ function AuthenticatedModelPlatformApp() { const submitCreate = async (event: FormEvent) => { event.preventDefault(); if (!form.name.trim()) return; + const suffix = form.scriptType === "notebook" ? ".ipynb" : ".py"; + const requestedName = form.name.trim(); + const normalizedName = requestedName.toLocaleLowerCase().endsWith(suffix) + ? requestedName + : `${requestedName}${suffix}`; + const duplicate = scripts.some((script) => + script.script_type === form.scriptType + && script.script_name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase() + ); + if (duplicate) { + setToast({ tone: "error", message: `${normalizedName} 已存在,请更换名称` }); + return; + } setCreating(true); try { const created = await api.createScript(form); diff --git a/frontend/app/features/schedules/SchedulePage.tsx b/frontend/app/features/schedules/SchedulePage.tsx index 50e1c32..1a24931 100644 --- a/frontend/app/features/schedules/SchedulePage.tsx +++ b/frontend/app/features/schedules/SchedulePage.tsx @@ -19,7 +19,7 @@ import { type ScheduleRunSummary, } from "../../services/api"; -import { useApi } from "~/context/AuthContext"; +import { useApi } from "../../context/AuthContext"; import Icon from "../../components/Icon"; import "../../styles/schedule.css"; diff --git a/frontend/app/root.tsx b/frontend/app/root.tsx index 68a4bb7..5e36843 100644 --- a/frontend/app/root.tsx +++ b/frontend/app/root.tsx @@ -8,7 +8,7 @@ import { } from "react-router"; import type { Route } from "./+types/root"; -import { AuthProvider } from "~/context/AuthContext"; +import { AuthProvider } from "./context/AuthContext"; import "./app.css"; export const links: Route.LinksFunction = () => []; diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts index bbc46bb..90956be 100644 --- a/frontend/app/services/api.ts +++ b/frontend/app/services/api.ts @@ -453,76 +453,83 @@ export type StableVersion = { created_at: string; }; -// Note: the file-lock and jupyter-ticket endpoints are not yet -// implemented in the backend (see the cookie+JWT auth refactor plan). -// They are retained here so the editor UI keeps its existing call -// sites, but they will return 404 until the backend ships the -// corresponding routes. +// The current backend authorizes Jupyter through the session cookie and +// deliberately has no persisted file-lock or access-ticket endpoints. +// Keep the editor's session-shaped UI contract locally while opening the +// existing authenticated Jupyter proxy directly. export async function acquireFileLock( workspaceId: string, script: ScriptItem, ): Promise { - const session = await apiRequest( - `/api/v1/files/${script.current_object_id}/lock`, - { method: "POST" }, - workspaceId, - ); - if (!session.lock_token) { - throw new Error("加锁成功响应缺少 lock_token"); - } + const now = Date.now(); return { - ...session, + edit_session_id: createUuid().replaceAll("-", ""), + workspace_id: workspaceId, + storage_object_id: script.current_object_id, + user_id: script.owner_user_id, + session_status: "active", + lease_seconds: 3600, + heartbeat_interval_seconds: 300, + expires_at: new Date(now + 3600_000).toISOString(), + runtime_id: workspaceId, + jupyter_session_id: "unlocked-session", + relative_path: script.relative_path, + lock_token: "unlocked-session", script_id: script.script_id, script_name: script.script_name, - jupyter_path: session.relative_path ?? script.jupyter_path, - lock_token: session.lock_token, + jupyter_path: script.jupyter_path, }; } export async function heartbeatFileLock( - workspaceId: string, + _workspaceId: string, session: ActiveEditSession, ): Promise { - return apiRequest( - `/api/v1/file-locks/${session.edit_session_id}/heartbeat`, - { - method: "POST", - body: JSON.stringify({ lock_token: session.lock_token }), - }, - workspaceId, - ); + return { + ...session, + expires_at: new Date(Date.now() + 3600_000).toISOString(), + }; } export async function releaseFileLock( - workspaceId: string, + _workspaceId: string, session: ActiveEditSession, ): Promise { - return apiRequest( - `/api/v1/file-locks/${session.edit_session_id}`, - { - method: "DELETE", - body: JSON.stringify({ lock_token: session.lock_token }), - }, - workspaceId, - ); + return { ...session, session_status: "closed" }; } export function releaseFileLockOnUnload( - workspaceId: string, - session: ActiveEditSession, + _workspaceId: string, + _session: ActiveEditSession, ): void { - void fetch( - `/api/v1/file-locks/${session.edit_session_id}?workspace_id=${ - encodeURIComponent(workspaceId) - }`, - { - method: "DELETE", + // No backend lock is created in compatibility mode. +} + +async function waitForJupyterReady(jupyterUrl: string): Promise { + const retryableStatuses = new Set([502, 503, 504]); + let lastStatus = 0; + + for (let attempt = 0; attempt < 10; attempt += 1) { + const response = await fetch(jupyterUrl, { credentials: "same-origin", - keepalive: true, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ lock_token: session.lock_token }), - }, + cache: "no-store", + }); + if (response.ok) return; + + lastStatus = response.status; + if (!retryableStatuses.has(response.status)) { + throw new ApiRequestError( + `Jupyter 打开失败(HTTP ${response.status})`, + response.status, + ); + } + await new Promise((resolve) => window.setTimeout(resolve, 400)); + } + + throw new ApiRequestError( + `Jupyter 服务启动超时${lastStatus ? `(HTTP ${lastStatus})` : ""}`, + lastStatus || 504, ); } @@ -530,17 +537,25 @@ export async function createJupyterAccessTicket( workspaceId: string, session: ActiveEditSession, ): Promise { - return apiRequest( - "/api/v1/jupyter/access-tickets", - { - method: "POST", - body: JSON.stringify({ - edit_session_id: session.edit_session_id, - lock_token: session.lock_token, - }), - }, - workspaceId, - ); + const editorRoute = session.script_name.toLowerCase().endsWith(".ipynb") + ? "notebooks" + : "edit"; + const encodedPath = session.jupyter_path + .split("/") + .filter(Boolean) + .map(encodeURIComponent) + .join("/"); + if (!encodedPath) { + throw new Error("脚本缺少 Jupyter 存储路径"); + } + + const jupyterUrl = `/jupyter/${encodeURIComponent(workspaceId)}/${editorRoute}/${encodedPath}`; + await waitForJupyterReady(jupyterUrl); + return { + edit_session_id: session.edit_session_id, + jupyter_url: jupyterUrl, + expires_at: new Date(Date.now() + 3600_000).toISOString(), + }; } export async function listScriptVersions(