Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ jobs:
- name: OAuth unit + endpoint tests
run: python -m pytest tests/unit/test_oauth_linking.py tests/functional/test_oauth_endpoints.py -v

# Plan limits: which models a plan may run, the rolling daily quota, the
# concurrency slot, and the assistant allowance. These gate real work, so a
# regression here silently hands out free GPU time.
- name: Plan-limit unit tests
run: python -m pytest tests/unit/test_plan_store.py -v

# All migrations must apply cleanly against a fresh SQLite DB and round-trip.
- name: Alembic migration smoke
env:
Expand Down
28 changes: 23 additions & 5 deletions PanTS-Demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ import ScrollToTopButton from "./components/ScrollToTopButton";
const VisualizationPage = lazy(() => import("./routes/VisualizationPage"));
const CompareViewerPage = lazy(() => import("./routes/CompareViewerPage"));
const UploadPage = lazy(() => import("./routes/UploadPage"));
const AccountPage = lazy(() => import("./routes/AccountPage"));
const SettingsPage = lazy(() => import("./routes/Settings"));
const ProfileSettings = lazy(() => import("./routes/Settings/ProfileSettings"));
const PlanSettings = lazy(() => import("./routes/Settings/PlanSettings"));
const HistorySettings = lazy(() => import("./routes/Settings/HistorySettings"));
const PrivacySettings = lazy(() => import("./routes/Settings/PrivacySettings"));
const SignupRedirect = lazy(() => import("./routes/SignupRedirect"));
const LegalPage = lazy(() => import("./routes/LegalPage"));
const RotatingHeartLoader = lazy(() => import("./components/Loading"));

const BASENAME = import.meta.env.VITE_BASENAME;
Expand Down Expand Up @@ -80,9 +86,20 @@ function App() {
/>
<Route path="/test" element={<RotatingHeartLoader />} />
<Route path="/upload" element={<UploadPage />} />
{/* Sign in/up is a popup, so old /login links just land on home. */}
{/* Both sign in and sign up are the popup now. /login and
/signup stay routable so old links don't 404. */}
<Route path="/login" element={<Navigate to="/" replace />} />
<Route path="/account" element={<AccountPage />} />
<Route path="/signup" element={<SignupRedirect />} />
{/* Settings is a shell with a left nav; each section is its
own URL so a link can point straight at one. */}
<Route path="/account" element={<SettingsPage />}>
<Route index element={<ProfileSettings />} />
<Route path="plan" element={<PlanSettings />} />
<Route path="history" element={<HistorySettings />} />
<Route path="privacy" element={<PrivacySettings />} />
</Route>
<Route path="/terms" element={<LegalPage kind="terms" />} />
<Route path="/privacy" element={<LegalPage kind="privacy" />} />
<Route
path="/api"
element={<Navigate to="/upload" replace />}
Expand All @@ -95,10 +112,11 @@ function App() {
/>
</Routes>
</Suspense>
{/* Global auth popup, above all routes. Inside the router so it
can link to the legal pages. */}
<AuthModal />
</BrowserRouter>
</div>
{/* Global sign-in / sign-up popup, above all routes. */}
<AuthModal />
</AnnotationProvider>
</FileProvider>
</AuthProvider>
Expand Down
65 changes: 64 additions & 1 deletion PanTS-Demo/src/components/AIAssistant/AISidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useAuth } from "../../contexts/authContext";
import { API_BASE } from "../../helpers/constants";
import type {
AIAction,
Expand All @@ -10,6 +11,15 @@ import type {
} from "./types";
import "./AISidebar.css";

// The plan's daily message allowance is spent (HTTP 402). Distinguished from a
// transport error so the streaming path doesn't retry on the non-streaming one,
// which would be refused for the same reason.
class PlanLimitError extends Error {}

// Signed out (HTTP 401). The assistant needs an account, same as inference.
// Also its own type, for the same no-pointless-retry reason.
class AuthRequiredError extends Error {}

// Bumped to v2 so a previously-stored reasoning model (e.g. qwen3) is reset —
// the default now prefers a non-reasoning model that never leaks "thinking".
const MODEL_STORAGE_KEY = "bodymaps-ai-model-v2";
Expand Down Expand Up @@ -274,6 +284,10 @@ export default function AISidebar({
const [capturing, setCapturing] = useState(false);
const [models, setModels] = useState<AIModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState("");
// The assistant runs on the server and is metered per account, so it needs a
// signed-in user — the same rule the Upload page applies to inference.
const { isAuthenticated, promptAuth } = useAuth();

const [modelState, setModelState] = useState<ModelState>("loading");
const [modelMenuOpen, setModelMenuOpen] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
Expand Down Expand Up @@ -622,8 +636,19 @@ export default function AISidebar({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "include",
signal,
});
// 402 = the plan's daily message allowance is spent. Surfaced as the
// assistant's own reply rather than a modal: the sidebar is a
// conversation, and a dialog over it would lose the thread.
if (response.status === 401) {
throw new AuthRequiredError("Sign in to use the assistant.");
}
if (response.status === 402) {
const limit = await response.json().catch(() => ({}));
throw new PlanLimitError(limit.message || "You've reached today's message limit.");
}
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);

const reader = response.body.getReader();
Expand Down Expand Up @@ -720,9 +745,16 @@ export default function AISidebar({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "include",
signal,
});
const data = await response.json();
if (response.status === 401) {
throw new AuthRequiredError(data.reply || "Sign in to use the assistant.");
}
if (response.status === 402) {
throw new PlanLimitError(data.message || "You've reached today's message limit.");
}
if (!response.ok) throw new Error(data.reply || `HTTP ${response.status}`);
const returnedActions: AIAction[] = Array.isArray(data.actions) ? data.actions : [];
if (returnedActions.length) void applyReturnedActions(returnedActions);
Expand All @@ -741,6 +773,13 @@ export default function AISidebar({
const outgoingAttachments = attachments;
if ((!text && outgoingAttachments.length === 0) || loading) return;

// Caught here as well as server-side: no point sending a request that can
// only come back 401, and the popup is the useful response either way.
if (!isAuthenticated) {
promptAuth();
return;
}

const conversation = messages
.filter((message) => message.role === "user" || message.role === "assistant")
.slice(-12)
Expand Down Expand Up @@ -818,12 +857,32 @@ export default function AISidebar({
} catch (streamError) {
if (isAbort(streamError)) {
// User pressed Stop — keep whatever was streamed, no error.
} else if (streamError instanceof AuthRequiredError) {
updateMessage(assistantId, (m) => ({
...m, content: streamError.message, status: undefined,
}));
promptAuth();
} else if (streamError instanceof PlanLimitError) {
// A spent allowance is an answer, not a transport failure: retrying
// on the non-streaming endpoint would just be refused again.
updateMessage(assistantId, (m) => ({
...m, content: streamError.message, status: undefined,
}));
} else {
console.warn("[BodyMaps AI stream] falling back:", streamError);
try {
await sendNonStreaming(assistantId, payload, controller.signal);
} catch (error) {
if (!isAbort(error)) {
if (error instanceof AuthRequiredError) {
updateMessage(assistantId, (m) => ({
...m, content: error.message, status: undefined,
}));
promptAuth();
} else if (error instanceof PlanLimitError) {
updateMessage(assistantId, (m) => ({
...m, content: error.message, status: undefined,
}));
} else if (!isAbort(error)) {
console.error("[BodyMaps AI send error]", error);
updateMessage(assistantId, (m) => ({
...m,
Expand All @@ -846,6 +905,10 @@ export default function AISidebar({
attachments,
loading,
messages,
// Without these the guard closes over a stale auth state, and signing in
// mid-session would leave the composer still refusing to send.
isAuthenticated,
promptAuth,
caseId,
sessionId,
availableOrgans,
Expand Down
2 changes: 1 addition & 1 deletion PanTS-Demo/src/components/AuthButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default function AuthButton() {

if (!isAuthenticated || !user) {
return (
<button type="button" className={styles.signInBtn} onClick={() => promptAuth("signin")}>
<button type="button" className={styles.signInBtn} onClick={() => promptAuth()}>
Sign in
</button>
);
Expand Down
20 changes: 20 additions & 0 deletions PanTS-Demo/src/components/AuthModal.css
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,28 @@
color: #111111;
}

/* Signup only — consent by continuing, in place of a checkbox. */
.authm-fineprint {
width: 100%;
margin: 16px 0 0;
font-size: 11.5px;
line-height: 1.55;
color: #8f8f8f;
text-align: center;
}
.authm-fineprint a {
color: #6a6a6a;
text-decoration: underline;
}
.authm-fineprint a:hover {
color: #002d72;
}

.authm-toggle {
width: 100%;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.07);
text-align: center;
font-size: 13px;
color: #6a6a6a;
Expand Down
Loading
Loading