Merge remote-tracking branch 'upstream/dev' into fix/models

This commit is contained in:
Anish Sarkar 2026-07-25 00:32:12 +05:30
commit a2d5467023
1952 changed files with 103406 additions and 35663 deletions

View file

@ -2,19 +2,20 @@
import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, CreditCard, RefreshCw } from "lucide-react";
import { AlertTriangle, Info } from "lucide-react";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
import { AppError } from "@/lib/error";
import { getWorkspaceIdNumber } from "@/lib/route-params";
import { queries } from "@/zero/queries";
const microsToDollars = (micros: number | null | undefined): string => {
@ -38,7 +39,7 @@ export function AutoReloadSettings() {
const pathname = usePathname();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const searchSpaceId = Number(params?.search_space_id);
const workspaceId = getWorkspaceIdNumber(params) ?? 0;
const [enabled, setEnabled] = useState(false);
const [thresholdInput, setThresholdInput] = useState("");
@ -68,7 +69,7 @@ export function AutoReloadSettings() {
const setupResult = searchParams.get("auto_reload_setup");
if (!setupResult) return;
if (setupResult === "success") {
toast.success("Card saved. You can now enable auto-reload.");
toast.success("Card saved. You can now enable top-ups.");
queryClient.invalidateQueries({ queryKey: ["auto-reload-settings"] });
} else if (setupResult === "cancel") {
toast.info("Card setup canceled.");
@ -78,8 +79,7 @@ export function AutoReloadSettings() {
}, [searchParams, router, pathname, queryClient]);
const setupMutation = useMutation({
mutationFn: () =>
stripeApiService.createAutoReloadSetupSession({ search_space_id: searchSpaceId }),
mutationFn: () => stripeApiService.createAutoReloadSetupSession({ workspace_id: workspaceId }),
onSuccess: (response) => {
window.location.assign(response.checkout_url);
},
@ -92,19 +92,19 @@ export function AutoReloadSettings() {
mutationFn: stripeApiService.updateAutoReloadSettings,
onSuccess: (updated) => {
queryClient.setQueryData(["auto-reload-settings"], updated);
toast.success(updated.enabled ? "Auto-reload is on." : "Auto-reload settings saved.");
toast.success(updated.enabled ? "Top-ups are on." : "Top-up settings saved.");
},
onError: (error) => {
if (error instanceof AppError && error.message) {
toast.error(error.message);
return;
}
toast.error("Couldn't save auto-reload settings. Please try again.");
toast.error("Couldn't save top-up settings. Please try again.");
},
});
// Render nothing while loading (avoids a spinner flash on pages where the
// feature flag turns out to be off) and when auto-reload is disabled
// feature flag turns out to be off) and when top-ups are disabled
// server-side.
if (isLoading || !settings || !settings.feature_enabled) {
return null;
@ -131,7 +131,7 @@ export function AutoReloadSettings() {
return;
}
if (amountMicros == null || amountMicros < settings.min_amount_micros) {
toast.error(`Reload amount must be at least $${minAmountDollars}.`);
toast.error(`Top-up amount must be at least $${minAmountDollars}.`);
return;
}
@ -142,135 +142,172 @@ export function AutoReloadSettings() {
});
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RefreshCw className="h-4 w-4 text-amber-500" />
Auto-reload
</CardTitle>
<CardDescription>
Automatically top up your credit balance when it drops below a threshold, using a saved
card. Current balance:{" "}
<span className="font-medium text-foreground">{formatUsd(balanceMicros)}</span>.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
const addCardButton = (
<Button
className="w-full sm:w-auto"
onClick={() => setupMutation.mutate()}
disabled={setupMutation.isPending}
>
{setupMutation.isPending ? (
<>
<Spinner size="xs" />
Redirecting
</>
) : (
"Add a card"
)}
</Button>
);
if (!hasCard) {
return (
<div className="space-y-5">
{settings.failed_at && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Last auto-reload failed</AlertTitle>
<AlertTitle>Last top-up failed</AlertTitle>
<AlertDescription>
Your saved card was declined and auto-reload was turned off. Update your card and
re-enable it below to keep topping up automatically.
Your saved card was declined and top-ups were turned off. Update your card and
re-enable top-ups below.
</AlertDescription>
</Alert>
)}
{!hasCard ? (
<div className="flex flex-col items-start gap-3 rounded-lg border bg-muted/20 p-4">
<div className="flex items-center gap-2 text-sm">
<CreditCard className="h-4 w-4 text-muted-foreground" />
<span>Add a card to enable automatic top-ups.</span>
<div className="space-y-6">
<Alert className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<p className="text-sm leading-relaxed text-muted-foreground">
Automatically top up your credit balance when it drops below a threshold, using a
saved card. Current balance:{" "}
<span className="font-medium text-foreground">{formatUsd(balanceMicros)}</span>.
</p>
</div>
<Button onClick={() => setupMutation.mutate()} disabled={setupMutation.isPending}>
{setupMutation.isPending ? (
<>
<Spinner size="xs" />
Redirecting
</>
) : (
"Add a card"
)}
</Button>
{addCardButton}
</Alert>
<Separator />
</div>
</div>
);
}
return (
<div className="space-y-6">
{settings.failed_at && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Last top-up failed</AlertTitle>
<AlertDescription>
Your saved card was declined and top-ups were turned off. Update your card and re-enable
top-ups below.
</AlertDescription>
</Alert>
)}
<section className="space-y-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<h2 className="text-base font-semibold tracking-tight">Automatic top-ups</h2>
<p className="text-sm text-muted-foreground">
Current balance:{" "}
<span className="font-medium text-foreground">{formatUsd(balanceMicros)}</span>
</p>
</div>
) : (
<>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="auto-reload-toggle" className="text-sm font-medium">
Enable auto-reload
</Label>
<p className="text-xs text-muted-foreground">
Charge your saved card when the balance gets low.
</p>
</div>
<Switch id="auto-reload-toggle" checked={enabled} onCheckedChange={setEnabled} />
</div>
<Button
className="w-full sm:w-auto"
onClick={() => setupMutation.mutate()}
disabled={setupMutation.isPending}
>
{setupMutation.isPending ? (
<>
<Spinner size="xs" />
Redirecting
</>
) : (
"Update card"
)}
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="auto-reload-threshold" className="text-xs">
When balance falls below
</Label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="auto-reload-threshold"
type="number"
min="0"
step="1"
inputMode="decimal"
className="pl-6 tabular-nums"
value={thresholdInput}
onChange={(e) => setThresholdInput(e.target.value)}
disabled={!enabled}
placeholder="5"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="auto-reload-amount" className="text-xs">
Add this much credit
</Label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="auto-reload-amount"
type="number"
min={minAmountDollars}
step="1"
inputMode="decimal"
className="pl-6 tabular-nums"
value={amountInput}
onChange={(e) => setAmountInput(e.target.value)}
disabled={!enabled}
placeholder="10"
/>
</div>
<p className="text-[11px] text-muted-foreground">Minimum ${minAmountDollars}.</p>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="top-ups-toggle" className="text-sm font-medium">
Enable top-ups
</Label>
<p className="text-xs text-muted-foreground">
Charge your saved card when the balance gets low.
</p>
</div>
<Switch id="top-ups-toggle" checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className="flex items-center justify-between gap-3">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => setupMutation.mutate()}
disabled={setupMutation.isPending}
>
<CreditCard className="h-3.5 w-3.5" />
Update card
</Button>
<Button onClick={handleSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? (
<>
<Spinner size="xs" />
Saving
</>
) : (
"Save"
)}
</Button>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="top-ups-threshold" className="text-xs">
When balance falls below
</Label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="top-ups-threshold"
type="number"
min="0"
step="1"
inputMode="decimal"
className="pl-6 tabular-nums"
value={thresholdInput}
onChange={(e) => setThresholdInput(e.target.value)}
disabled={!enabled}
placeholder="5"
/>
</div>
</>
)}
</CardContent>
</Card>
</div>
<div className="space-y-1.5">
<Label htmlFor="top-ups-amount" className="text-xs">
Add this much credit
</Label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="top-ups-amount"
type="number"
min={minAmountDollars}
step="1"
inputMode="decimal"
className="pl-6 tabular-nums"
value={amountInput}
onChange={(e) => setAmountInput(e.target.value)}
disabled={!enabled}
placeholder="10"
/>
</div>
<p className="text-[11px] text-muted-foreground">Minimum ${minAmountDollars}.</p>
</div>
</div>
<div className="flex justify-end">
<Button
className="w-full sm:w-auto"
onClick={handleSave}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<>
<Spinner size="xs" />
Saving
</>
) : (
"Save"
)}
</Button>
</div>
</section>
<Separator />
</div>
);
}

View file

@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
import { AppError } from "@/lib/error";
import { getWorkspaceIdNumber } from "@/lib/route-params";
import { cn } from "@/lib/utils";
import { queries } from "@/zero/queries";
@ -37,7 +38,7 @@ const formatUsd = (micros: number) => {
export function BuyCreditsContent() {
const params = useParams();
const searchSpaceId = Number(params?.search_space_id);
const workspaceId = getWorkspaceIdNumber(params) ?? 0;
const [quantity, setQuantity] = useState(1);
// Raw text of the amount field so the user can clear it while typing;
// committed back to a clamped integer on blur.
@ -176,7 +177,7 @@ export function BuyCreditsContent() {
<Button
className="w-full"
disabled={purchaseMutation.isPending}
onClick={() => purchaseMutation.mutate({ quantity, search_space_id: searchSpaceId })}
onClick={() => purchaseMutation.mutate({ quantity, workspace_id: workspaceId })}
>
{purchaseMutation.isPending ? (
<>

View file

@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner";
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
@ -15,11 +14,8 @@ import { Spinner } from "@/components/ui/spinner";
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
import {
trackIncentivePageViewed,
trackIncentiveTaskClicked,
trackIncentiveTaskCompleted,
} from "@/lib/posthog/events";
import { trackIncentiveTaskClicked } from "@/lib/posthog/events";
import { getWorkspaceIdParam } from "@/lib/route-params";
import { cn } from "@/lib/utils";
// Compact dollar label for a task's reward (e.g. "+$0.03").
@ -32,11 +28,9 @@ const formatRewardUsd = (micros: number) => {
export function EarnCreditsContent() {
const params = useParams();
const queryClient = useQueryClient();
const searchSpaceId = params?.search_space_id ?? "";
const workspaceId = getWorkspaceIdParam(params) ?? "";
useEffect(() => {
trackIncentivePageViewed();
}, []);
// incentive_page_viewed removed — redundant with $pageview.
const { data, isLoading } = useQuery({
queryKey: ["incentive-tasks"],
@ -50,13 +44,11 @@ export function EarnCreditsContent() {
const completeMutation = useMutation({
mutationFn: incentiveTasksApiService.completeTask,
onSuccess: (response, taskType) => {
onSuccess: (response) => {
if (response.success) {
toast.success(response.message);
const task = data?.tasks.find((t) => t.task_type === taskType);
if (task) {
trackIncentiveTaskCompleted(taskType, task.credit_micros_reward);
}
// incentive_task_completed is now emitted server-side
// (incentive_tasks_routes.complete_task) where credit is granted.
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
}
@ -162,7 +154,7 @@ export function EarnCreditsContent() {
<p className="text-sm text-muted-foreground">Need more?</p>
{creditBuyingEnabled ? (
<Button asChild variant="link" className="text-emerald-600 dark:text-emerald-400">
<Link href={`/dashboard/${searchSpaceId}/buy-more`}>Buy credits at $1 per $1</Link>
<Link href={`/dashboard/${workspaceId}/buy-more`}>Buy credits at $1 per $1</Link>
</Button>
) : (
<p className="text-xs text-muted-foreground">

View file

@ -5,59 +5,52 @@ import { useAtomValue } from "jotai";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
updateSearchSpaceApiAccessMutationAtom,
updateSearchSpaceMutationAtom,
} from "@/atoms/search-spaces/search-space-mutation.atoms";
import { updateWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
import { authenticatedFetch } from "@/lib/auth-fetch";
import { buildBackendUrl } from "@/lib/env-config";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { Spinner } from "../ui/spinner";
import { WorkspaceApiAccessControl } from "./workspace-api-access-control";
interface GeneralSettingsManagerProps {
searchSpaceId: number;
workspaceId: number;
}
export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManagerProps) {
const t = useTranslations("searchSpaceSettings");
export function GeneralSettingsManager({ workspaceId }: GeneralSettingsManagerProps) {
const t = useTranslations("workspaceSettings");
const tCommon = useTranslations("common");
const {
data: searchSpace,
data: workspace,
isLoading: loading,
isError,
refetch: fetchSearchSpace,
refetch: fetchWorkspace,
} = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId,
queryKey: cacheKeys.workspaces.detail(workspaceId.toString()),
queryFn: () => workspacesApiService.getWorkspace({ id: workspaceId }),
enabled: !!workspaceId,
});
const { mutateAsync: updateSearchSpace } = useAtomValue(updateSearchSpaceMutationAtom);
const { mutateAsync: updateSearchSpaceApiAccess } = useAtomValue(
updateSearchSpaceApiAccessMutationAtom
);
const { mutateAsync: updateWorkspace } = useAtomValue(updateWorkspaceMutationAtom);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false);
const [savingApiAccess, setSavingApiAccess] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const hasSearchSpace = !!searchSpace;
const searchSpaceName = searchSpace?.name;
const searchSpaceDescription = searchSpace?.description;
const hasWorkspace = !!workspace;
const workspaceName = workspace?.name;
const workspaceDescription = workspace?.description;
const handleExportKB = useCallback(async () => {
if (isExporting) return;
setIsExporting(true);
try {
const response = await authenticatedFetch(
buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/export`),
buildBackendUrl(`/api/v1/workspaces/${workspaceId}/export`),
{ method: "GET" }
);
if (!response.ok) {
@ -80,37 +73,37 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
} finally {
setIsExporting(false);
}
}, [searchSpaceId, isExporting]);
}, [workspaceId, isExporting]);
// Initialize state from fetched search space
// Initialize state from fetched workspace
useEffect(() => {
if (hasSearchSpace) {
setName(searchSpaceName || "");
setDescription(searchSpaceDescription || "");
if (hasWorkspace) {
setName(workspaceName || "");
setDescription(workspaceDescription || "");
}
}, [hasSearchSpace, searchSpaceName, searchSpaceDescription]);
}, [hasWorkspace, workspaceName, workspaceDescription]);
// Derive hasChanges during render
const hasChanges =
!!searchSpace &&
((searchSpace.name || "") !== name || (searchSpace.description || "") !== description);
!!workspace &&
((workspace.name || "") !== name || (workspace.description || "") !== description);
const handleSave = async () => {
try {
setSaving(true);
await updateSearchSpace({
id: searchSpaceId,
await updateWorkspace({
id: workspaceId,
data: {
name: name.trim(),
description: description.trim() || undefined,
},
});
await fetchSearchSpace();
await fetchWorkspace();
} catch (error: unknown) {
console.error("Error saving search space details:", error);
toast.error(error instanceof Error ? error.message : "Failed to save search space details");
console.error("Error saving workspace details:", error);
toast.error(error instanceof Error ? error.message : "Failed to save workspace details");
} finally {
setSaving(false);
}
@ -121,25 +114,6 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
handleSave();
};
const handleApiAccessToggle = useCallback(
async (enabled: boolean) => {
try {
setSavingApiAccess(true);
await updateSearchSpaceApiAccess({
id: searchSpaceId,
api_access_enabled: enabled,
});
await fetchSearchSpace();
} catch (error) {
console.error("Error updating API access:", error);
toast.error(error instanceof Error ? error.message : "Failed to update API access");
} finally {
setSavingApiAccess(false);
}
},
[fetchSearchSpace, searchSpaceId, updateSearchSpaceApiAccess]
);
if (loading) {
return (
<div className="space-y-4 md:space-y-6">
@ -155,7 +129,7 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
return (
<div className="flex flex-col items-center justify-center gap-3 py-8 text-center">
<p className="text-sm text-destructive">Failed to load settings.</p>
<Button variant="outline" size="sm" onClick={() => fetchSearchSpace()}>
<Button variant="outline" size="sm" onClick={() => fetchWorkspace()}>
Retry
</Button>
</div>
@ -167,9 +141,9 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
<form onSubmit={onSubmit} className="space-y-6">
<div className="flex flex-col gap-6">
<div className="space-y-2">
<Label htmlFor="search-space-name">{t("general_name_label")}</Label>
<Label htmlFor="workspace-name">{t("general_name_label")}</Label>
<Input
id="search-space-name"
id="workspace-name"
maxLength={100}
placeholder={t("general_name_placeholder")}
value={name}
@ -179,12 +153,12 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
</div>
<div className="space-y-2">
<Label htmlFor="search-space-description">
<Label htmlFor="workspace-description">
{t("general_description_label")}{" "}
<span className="text-muted-foreground font-normal">({tCommon("optional")})</span>
</Label>
<Input
id="search-space-description"
id="workspace-description"
placeholder={t("general_description_placeholder")}
value={description}
onChange={(e) => setDescription(e.target.value)}
@ -206,26 +180,13 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
</div>
</form>
<div className="border-t pt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<Label htmlFor="api-access-enabled">API key access</Label>
<p className="text-xs text-muted-foreground">
Allow API keys to access this search space.
</p>
</div>
<Switch
id="api-access-enabled"
checked={!!searchSpace?.api_access_enabled}
disabled={savingApiAccess}
onCheckedChange={handleApiAccessToggle}
/>
</div>
<WorkspaceApiAccessControl workspaceId={workspaceId} className="border-t pt-6" />
<div className="border-t pt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<Label>Export knowledge base</Label>
<p className="text-xs text-muted-foreground">
Download all documents in this search space as a ZIP of markdown files.
Download all documents in this workspace as a ZIP of markdown files.
</p>
</div>
<Button

View file

@ -50,7 +50,7 @@ function renderAutoModeOption() {
);
}
export function ModelConnectionsSettings({ searchSpaceId }: { searchSpaceId: number }) {
export function ModelConnectionsSettings({ workspaceId }: { workspaceId: number }) {
const [{ data: globalConnections = [] }] = useAtom(globalModelConnectionsAtom);
const [{ data: connections = [] }] = useAtom(modelConnectionsAtom);
const [{ data: roles }] = useAtom(modelRolesAtom);
@ -147,7 +147,7 @@ export function ModelConnectionsSettings({ searchSpaceId }: { searchSpaceId: num
<Separator />
<ModelProviderConnectionsPanel searchSpaceId={searchSpaceId} connections={connections} />
<ModelProviderConnectionsPanel workspaceId={workspaceId} connections={connections} />
</div>
);
}

View file

@ -66,7 +66,7 @@ export function ConnectionCard({ connection }: { connection: ConnectionRead }) {
<AlertDialogTitle>Delete this provider?</AlertDialogTitle>
<AlertDialogDescription>
<span className="font-medium text-foreground">{providerLabel}</span> and all of
its models will be removed from this search space. This cannot be undone.
its models will be removed from this workspace. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View file

@ -137,7 +137,7 @@ export function ConnectionSettingsDialog({
base_url: data.base_url,
api_key: apiKeyForTest,
scope: "SEARCH_SPACE",
search_space_id: connection.search_space_id,
workspace_id: connection.workspace_id,
extra: connection.extra ?? {},
enabled: connection.enabled,
models: [],

View file

@ -9,9 +9,17 @@ function baseUrlHint(provider: string) {
return "For local servers, use host.docker.internal instead of localhost.";
}
if (provider === "openai_compatible") {
return "Enter the full endpoint URL.";
return "Enter the full endpoint URL. This provider expects a /v1-compatible endpoint.";
}
if (provider === "openai" || provider === "anthropic" || provider === "openrouter") {
if (provider === "openai_compatible_raw") {
return "Enter the exact chat-completions API base URL. SurfSense will not append /v1.";
}
if (
provider === "openai" ||
provider === "anthropic" ||
provider === "openrouter" ||
provider === "requesty"
) {
return "Override only if you route through a proxy or gateway.";
}
return undefined;

View file

@ -23,7 +23,7 @@ import {
} from "./provider-metadata";
interface ModelProviderConnectionsPanelProps {
searchSpaceId: number;
workspaceId: number;
connections: ConnectionRead[];
className?: string;
addProviderTitle?: string;
@ -49,7 +49,7 @@ function toModelSelection(model: SelectableModel): ModelSelection {
}
export function ModelProviderConnectionsPanel({
searchSpaceId,
workspaceId,
connections,
className,
addProviderTitle = "Add Provider",
@ -126,6 +126,11 @@ export function ModelProviderConnectionsPanel({
// Each provider connect form builds its own credential payload; the backend
// resolver (`to_litellm`) forwards `extra.litellm_params` straight to LiteLLM.
function handleCreate(draft: ConnectionDraft) {
if (!Number.isFinite(workspaceId) || workspaceId <= 0) {
toast.error("Workspace is still loading. Please try again.");
return;
}
const models = connectionModelsForDraft(draft);
const testModel = representativeTestModel(models);
if (!testModel) {
@ -138,7 +143,7 @@ export function ModelProviderConnectionsPanel({
base_url: draft.base_url,
api_key: draft.api_key,
scope: "SEARCH_SPACE" as const,
search_space_id: searchSpaceId,
workspace_id: workspaceId,
extra: draft.extra,
enabled: true,
models,
@ -165,13 +170,18 @@ export function ModelProviderConnectionsPanel({
setProvider(providerId);
setIsAddProviderOpen(true);
if (providerId === "vertex_ai") {
if (!Number.isFinite(workspaceId) || workspaceId <= 0) {
toast.error("Workspace is still loading. Please try again.");
return;
}
previewModels.mutate(
{
provider: providerId,
base_url: null,
api_key: null,
scope: "SEARCH_SPACE",
search_space_id: searchSpaceId,
workspace_id: workspaceId,
extra: {},
enabled: true,
models: [],
@ -184,13 +194,18 @@ export function ModelProviderConnectionsPanel({
}
function refreshConnectModels(draft: ConnectionDraft) {
if (!Number.isFinite(workspaceId) || workspaceId <= 0) {
toast.error("Workspace is still loading. Please try again.");
return;
}
previewModels.mutate(
{
provider,
base_url: draft.base_url,
api_key: draft.api_key,
scope: "SEARCH_SPACE",
search_space_id: searchSpaceId,
workspace_id: workspaceId,
extra: draft.extra,
enabled: true,
models: [],

View file

@ -7,9 +7,11 @@ export const PROVIDER_ORDER = [
"bedrock",
"azure",
"openrouter",
"requesty",
"ollama_chat",
"lm_studio",
"openai_compatible",
"openai_compatible_raw",
];
export const PROVIDER_DISPLAY: Record<
@ -37,12 +39,23 @@ export const PROVIDER_DISPLAY: Record<
subtitle: "OpenAI-compatible endpoint",
iconKey: "custom",
},
openai_compatible_raw: {
name: "OpenAI-Compatible Raw",
subtitle: "Use the exact base URL, no /v1 is appended",
iconKey: "custom",
},
openrouter: {
name: "OpenRouter",
subtitle: "OpenRouter",
iconKey: "openrouter",
defaultBaseUrl: "https://openrouter.ai/api/v1",
},
requesty: {
name: "Requesty",
subtitle: "Requesty",
iconKey: "requesty",
defaultBaseUrl: "https://router.requesty.ai/v1",
},
vertex_ai: { name: "Gemini", subtitle: "Google Cloud Vertex AI", iconKey: "vertex_ai" },
};

View file

@ -5,50 +5,50 @@ import { useAtomValue } from "jotai";
import { AlertTriangle, Info } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { updateWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { Spinner } from "../ui/spinner";
interface PromptConfigManagerProps {
searchSpaceId: number;
workspaceId: number;
}
export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) {
const { data: searchSpace, isLoading: loading } = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId,
export function PromptConfigManager({ workspaceId }: PromptConfigManagerProps) {
const { data: workspace, isLoading: loading } = useQuery({
queryKey: cacheKeys.workspaces.detail(workspaceId.toString()),
queryFn: () => workspacesApiService.getWorkspace({ id: workspaceId }),
enabled: !!workspaceId,
});
const { mutateAsync: updateSearchSpace, isPending: isSaving } = useAtomValue(
updateSearchSpaceMutationAtom
const { mutateAsync: updateWorkspace, isPending: isSaving } = useAtomValue(
updateWorkspaceMutationAtom
);
const [customInstructions, setCustomInstructions] = useState("");
const hasSearchSpace = !!searchSpace;
const searchSpaceInstructions = searchSpace?.qna_custom_instructions;
const hasWorkspace = !!workspace;
const workspaceInstructions = workspace?.qna_custom_instructions;
// Initialize state from fetched search space
// Initialize state from fetched workspace
useEffect(() => {
if (hasSearchSpace) {
setCustomInstructions(searchSpaceInstructions || "");
if (hasWorkspace) {
setCustomInstructions(workspaceInstructions || "");
}
}, [hasSearchSpace, searchSpaceInstructions]);
}, [hasWorkspace, workspaceInstructions]);
// Derive hasChanges during render
const hasChanges =
!!searchSpace && (searchSpace.qna_custom_instructions || "") !== customInstructions;
!!workspace && (workspace.qna_custom_instructions || "") !== customInstructions;
const handleSave = async () => {
try {
await updateSearchSpace({
id: searchSpaceId,
await updateWorkspace({
id: workspaceId,
data: { qna_custom_instructions: customInstructions.trim() || "" },
});
toast.success("System instructions saved successfully");
@ -97,8 +97,8 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
<Alert>
<Info />
<AlertDescription>
System instructions apply to all AI interactions in this search space. They guide how the
AI responds, its tone, focus areas, and behavior patterns.
System instructions apply to all AI interactions in this workspace. They guide how the AI
responds, its tone, focus areas, and behavior patterns.
</AlertDescription>
</Alert>
@ -111,7 +111,7 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
</h3>
<p className="text-xs md:text-sm text-muted-foreground">
Provide specific guidelines for how you want the AI to respond. These instructions
will be applied to all answers in this search space.
will be applied to all answers in this workspace.
</p>
</div>
<div className="space-y-3 md:space-y-4">

View file

@ -160,7 +160,7 @@ const CATEGORY_CONFIG: Record<
settings: {
label: "Settings",
icon: Settings,
description: "Manage search space settings",
description: "Manage workspace settings",
order: 10,
},
public_sharing: {
@ -172,7 +172,7 @@ const CATEGORY_CONFIG: Record<
general: {
label: "General",
icon: SlidersHorizontal,
description: "General search space permissions",
description: "General workspace permissions",
order: 12,
},
};
@ -269,7 +269,7 @@ type PermissionWithDescription = PermissionInfo;
// ============ Roles Manager (for Settings page) ============
export function RolesManager({ searchSpaceId }: { searchSpaceId: number }) {
export function RolesManager({ workspaceId }: { workspaceId: number }) {
const { data: access = null } = useAtomValue(myAccessAtom);
const hasPermission = useCallback(
@ -278,9 +278,9 @@ export function RolesManager({ searchSpaceId }: { searchSpaceId: number }) {
);
const { data: roles = [], isLoading: rolesLoading } = useQuery({
queryKey: cacheKeys.roles.all(searchSpaceId.toString()),
queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }),
enabled: !!searchSpaceId,
queryKey: cacheKeys.roles.all(workspaceId.toString()),
queryFn: () => rolesApiService.getRoles({ workspace_id: workspaceId }),
enabled: !!workspaceId,
});
const { data: permissionsData } = useAtomValue(permissionsAtom);
@ -311,36 +311,36 @@ export function RolesManager({ searchSpaceId }: { searchSpaceId: number }) {
}
): Promise<Role> => {
const request: UpdateRoleRequest = {
search_space_id: searchSpaceId,
workspace_id: workspaceId,
role_id: roleId,
data: data,
};
return await updateRole(request);
},
[updateRole, searchSpaceId]
[updateRole, workspaceId]
);
const handleDeleteRole = useCallback(
async (roleId: number): Promise<boolean> => {
const request: DeleteRoleRequest = {
search_space_id: searchSpaceId,
workspace_id: workspaceId,
role_id: roleId,
};
await deleteRole(request);
return true;
},
[deleteRole, searchSpaceId]
[deleteRole, workspaceId]
);
const handleCreateRole = useCallback(
async (roleData: CreateRoleRequest["data"]): Promise<Role> => {
const request: CreateRoleRequest = {
search_space_id: searchSpaceId,
workspace_id: workspaceId,
data: roleData,
};
return await createRole(request);
},
[createRole, searchSpaceId]
[createRole, workspaceId]
);
return (
@ -884,7 +884,7 @@ function CreateRoleDialog({
<DialogHeader className="px-5 pt-5 pb-4 shrink-0">
<DialogTitle className="text-lg">Create Custom Role</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
Define permissions for a new role in this search space
Define permissions for a new role in this workspace
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto">

View file

@ -0,0 +1,115 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { updateWorkspaceApiAccessMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
interface WorkspaceApiAccessControlProps {
workspaceId: number;
className?: string;
}
export function WorkspaceApiAccessControl({
workspaceId,
className,
}: WorkspaceApiAccessControlProps) {
const {
data: workspace,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: cacheKeys.workspaces.detail(workspaceId.toString()),
queryFn: () => workspacesApiService.getWorkspace({ id: workspaceId }),
enabled: !!workspaceId,
});
const { mutateAsync: updateWorkspaceApiAccess } = useAtomValue(
updateWorkspaceApiAccessMutationAtom
);
const [savingApiAccess, setSavingApiAccess] = useState(false);
const handleApiAccessToggle = useCallback(
async (enabled: boolean) => {
try {
setSavingApiAccess(true);
await updateWorkspaceApiAccess({
id: workspaceId,
api_access_enabled: enabled,
});
await refetch();
} catch (error) {
console.error("Error updating API access:", error);
toast.error(error instanceof Error ? error.message : "Failed to update API access");
} finally {
setSavingApiAccess(false);
}
},
[refetch, workspaceId, updateWorkspaceApiAccess]
);
if (isLoading) {
return (
<div
className={cn(
"flex flex-col gap-3 md:flex-row md:items-center md:justify-between",
className
)}
>
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-56" />
</div>
<Skeleton className="h-6 w-11 rounded-full" />
</div>
);
}
if (isError) {
return (
<div
className={cn(
"flex flex-col gap-3 md:flex-row md:items-center md:justify-between",
className
)}
>
<div className="space-y-1">
<Label>API key access</Label>
<p className="text-xs text-destructive">Failed to load workspace API access.</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Retry
</Button>
</div>
);
}
return (
<div
className={cn(
"flex flex-col gap-3 md:flex-row md:items-center md:justify-between",
className
)}
>
<div className="space-y-1">
<Label htmlFor="api-access-enabled">API key access</Label>
<p className="text-xs text-muted-foreground">Allow API keys to access this workspace.</p>
</div>
<Switch
id="api-access-enabled"
checked={!!workspace?.api_access_enabled}
disabled={savingApiAccess}
onCheckedChange={handleApiAccessToggle}
/>
</div>
);
}