fix: improve semantics and structure of settings forms in GeneralSettingsManager and PromptConfigManager

This commit is contained in:
JoeMakuta 2026-03-26 15:11:39 +02:00
parent 420eed01ea
commit f00f7826ed
2 changed files with 350 additions and 294 deletions

View file

@ -9,160 +9,190 @@ import { toast } from "sonner";
import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { Spinner } from "../ui/spinner";
interface GeneralSettingsManagerProps { interface GeneralSettingsManagerProps {
searchSpaceId: number; searchSpaceId: number;
} }
export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManagerProps) { export function GeneralSettingsManager({
const t = useTranslations("searchSpaceSettings"); searchSpaceId,
const tCommon = useTranslations("common"); }: GeneralSettingsManagerProps) {
const { const t = useTranslations("searchSpaceSettings");
data: searchSpace, const tCommon = useTranslations("common");
isLoading: loading, const {
refetch: fetchSearchSpace, data: searchSpace,
} = useQuery({ isLoading: loading,
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()), refetch: fetchSearchSpace,
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }), } = useQuery({
enabled: !!searchSpaceId, queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
}); queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId,
});
const { mutateAsync: updateSearchSpace } = useAtomValue(updateSearchSpaceMutationAtom); const { mutateAsync: updateSearchSpace } = useAtomValue(
updateSearchSpaceMutationAtom,
);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
// Initialize state from fetched search space // Initialize state from fetched search space
useEffect(() => { useEffect(() => {
if (searchSpace) { if (searchSpace) {
setName(searchSpace.name || ""); setName(searchSpace.name || "");
setDescription(searchSpace.description || ""); setDescription(searchSpace.description || "");
setHasChanges(false); setHasChanges(false);
} }
}, [searchSpace]); }, [searchSpace]);
// Track changes // Track changes
useEffect(() => { useEffect(() => {
if (searchSpace) { if (searchSpace) {
const currentName = searchSpace.name || ""; const currentName = searchSpace.name || "";
const currentDescription = searchSpace.description || ""; const currentDescription = searchSpace.description || "";
const changed = currentName !== name || currentDescription !== description; const changed =
setHasChanges(changed); currentName !== name || currentDescription !== description;
} setHasChanges(changed);
}, [searchSpace, name, description]); }
}, [searchSpace, name, description]);
const handleSave = async () => { const handleSave = async () => {
try { try {
setSaving(true); setSaving(true);
await updateSearchSpace({ await updateSearchSpace({
id: searchSpaceId, id: searchSpaceId,
data: { data: {
name: name.trim(), name: name.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
}, },
}); });
setHasChanges(false); setHasChanges(false);
await fetchSearchSpace(); await fetchSearchSpace();
} catch (error: any) { } catch (error: any) {
console.error("Error saving search space details:", error); console.error("Error saving search space details:", error);
toast.error(error.message || "Failed to save search space details"); toast.error(error.message || "Failed to save search space details");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
if (loading) { const onSubmit = (e: React.FormEvent) => {
return ( e.preventDefault();
<div className="space-y-4 md:space-y-6"> handleSave();
<Card> };
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<Skeleton className="h-5 md:h-6 w-36 md:w-48" />
<Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
</CardHeader>
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<Skeleton className="h-10 md:h-12 w-full" />
<Skeleton className="h-10 md:h-12 w-full" />
</CardContent>
</Card>
</div>
);
}
return ( if (loading) {
<div className="space-y-4 md:space-y-6"> return (
<Alert className="bg-muted/50 py-3 md:py-4"> <div className="space-y-4 md:space-y-6">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <Card>
<AlertDescription className="text-xs md:text-sm"> <CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
Update your search space name and description. These details help identify and organize <Skeleton className="h-5 md:h-6 w-36 md:w-48" />
your workspace. <Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
</AlertDescription> </CardHeader>
</Alert> <CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<Skeleton className="h-10 md:h-12 w-full" />
<Skeleton className="h-10 md:h-12 w-full" />
</CardContent>
</Card>
</div>
);
}
{/* Search Space Details Card */} return (
<Card> <div className="space-y-4 md:space-y-6">
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3"> <Alert className="bg-muted/50 py-3 md:py-4">
<CardTitle className="text-base md:text-lg">Search Space Details</CardTitle> <Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<CardDescription className="text-xs md:text-sm"> <AlertDescription className="text-xs md:text-sm">
Manage the basic information for this search space. Update your search space name and description. These details help
</CardDescription> identify and organize your workspace.
</CardHeader> </AlertDescription>
<CardContent className="space-y-4 md:space-y-5 px-3 md:px-6 pb-3 md:pb-6"> </Alert>
<div className="space-y-1.5 md:space-y-2">
<Label htmlFor="search-space-name" className="text-sm md:text-base font-medium">
{t("general_name_label")}
</Label>
<Input
id="search-space-name"
placeholder={t("general_name_placeholder")}
value={name}
onChange={(e) => setName(e.target.value)}
className="text-sm md:text-base h-9 md:h-10"
/>
<p className="text-[10px] md:text-xs text-muted-foreground">
{t("general_name_description")}
</p>
</div>
<div className="space-y-1.5 md:space-y-2"> {/* Search Space Details Card */}
<Label htmlFor="search-space-description" className="text-sm md:text-base font-medium"> <form onSubmit={onSubmit} className="space-y-4 md:space-y-6">
{t("general_description_label")}{" "} <Card>
<span className="text-muted-foreground font-normal">({tCommon("optional")})</span> <CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
</Label> <CardTitle className="text-base md:text-lg">
<Input Search Space Details
id="search-space-description" </CardTitle>
placeholder={t("general_description_placeholder")} <CardDescription className="text-xs md:text-sm">
value={description} Manage the basic information for this search space.
onChange={(e) => setDescription(e.target.value)} </CardDescription>
className="text-sm md:text-base h-9 md:h-10" </CardHeader>
/> <CardContent className="space-y-4 md:space-y-5 px-3 md:px-6 pb-3 md:pb-6">
<p className="text-[10px] md:text-xs text-muted-foreground"> <div className="space-y-1.5 md:space-y-2">
{t("general_description_description")} <Label
</p> htmlFor="search-space-name"
</div> className="text-sm md:text-base font-medium"
</CardContent> >
</Card> {t("general_name_label")}
</Label>
<Input
id="search-space-name"
placeholder={t("general_name_placeholder")}
value={name}
onChange={(e) => setName(e.target.value)}
className="text-sm md:text-base h-9 md:h-10"
/>
<p className="text-[10px] md:text-xs text-muted-foreground">
{t("general_name_description")}
</p>
</div>
{/* Action Buttons */} <div className="space-y-1.5 md:space-y-2">
<div className="flex justify-end pt-3 md:pt-4"> <Label
<Button htmlFor="search-space-description"
variant="outline" className="text-sm md:text-base font-medium"
onClick={handleSave} >
disabled={!hasChanges || saving || !name.trim()} {t("general_description_label")}{" "}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200" <span className="text-muted-foreground font-normal">
> ({tCommon("optional")})
{saving ? t("general_saving") : t("general_save")} </span>
</Button> </Label>
</div> <Input
</div> id="search-space-description"
); placeholder={t("general_description_placeholder")}
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-sm md:text-base h-9 md:h-10"
/>
<p className="text-[10px] md:text-xs text-muted-foreground">
{t("general_description_description")}
</p>
</div>
</CardContent>
</Card>
{/* Action Buttons */}
<div className="flex justify-end pt-3 md:pt-4">
<Button
type="submit"
variant="outline"
disabled={!hasChanges || saving || !name.trim()}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
{saving ? <Spinner size="sm" /> : null}
{saving ? t("general_saving") : t("general_save")}
</Button>
</div>
</form>
</div>
);
} }

View file

@ -6,187 +6,213 @@ import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { Spinner } from "../ui/spinner";
interface PromptConfigManagerProps { interface PromptConfigManagerProps {
searchSpaceId: number; searchSpaceId: number;
} }
export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) { export function PromptConfigManager({
const { searchSpaceId,
data: searchSpace, }: PromptConfigManagerProps) {
isLoading: loading, const {
refetch: fetchSearchSpace, data: searchSpace,
} = useQuery({ isLoading: loading,
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()), refetch: fetchSearchSpace,
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }), } = useQuery({
enabled: !!searchSpaceId, queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
}); queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId,
});
const [customInstructions, setCustomInstructions] = useState(""); const [customInstructions, setCustomInstructions] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
// Initialize state from fetched search space // Initialize state from fetched search space
useEffect(() => { useEffect(() => {
if (searchSpace) { if (searchSpace) {
setCustomInstructions(searchSpace.qna_custom_instructions || ""); setCustomInstructions(searchSpace.qna_custom_instructions || "");
setHasChanges(false); setHasChanges(false);
} }
}, [searchSpace]); }, [searchSpace]);
// Track changes // Track changes
useEffect(() => { useEffect(() => {
if (searchSpace) { if (searchSpace) {
const currentCustom = searchSpace.qna_custom_instructions || ""; const currentCustom = searchSpace.qna_custom_instructions || "";
const changed = currentCustom !== customInstructions; const changed = currentCustom !== customInstructions;
setHasChanges(changed); setHasChanges(changed);
} }
}, [searchSpace, customInstructions]); }, [searchSpace, customInstructions]);
const handleSave = async () => { const handleSave = async () => {
try { try {
setSaving(true); setSaving(true);
const payload = { const payload = {
qna_custom_instructions: customInstructions.trim() || "", qna_custom_instructions: customInstructions.trim() || "",
}; };
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`, `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`,
{ {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
} },
); );
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || "Failed to save system instructions"); throw new Error(
} errorData.detail || "Failed to save system instructions",
);
}
toast.success("System instructions saved successfully"); toast.success("System instructions saved successfully");
setHasChanges(false); setHasChanges(false);
await fetchSearchSpace(); await fetchSearchSpace();
} catch (error: any) { } catch (error: any) {
console.error("Error saving system instructions:", error); console.error("Error saving system instructions:", error);
toast.error(error.message || "Failed to save system instructions"); toast.error(error.message || "Failed to save system instructions");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
if (loading) { const onSubmit = (e: React.FormEvent) => {
return ( e.preventDefault();
<div className="space-y-4 md:space-y-6"> handleSave();
<Card> };
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<Skeleton className="h-5 md:h-6 w-36 md:w-48" />
<Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
</CardHeader>
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<Skeleton className="h-16 md:h-20 w-full" />
<Skeleton className="h-24 md:h-32 w-full" />
</CardContent>
</Card>
</div>
);
}
return ( if (loading) {
<div className="space-y-4 md:space-y-6"> return (
{/* Work in Progress Notice */} <div className="space-y-4 md:space-y-6">
<Alert <Card>
variant="default" <CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
className="bg-amber-50 dark:bg-amber-950/30 border-amber-300 dark:border-amber-700 py-3 md:py-4" <Skeleton className="h-5 md:h-6 w-36 md:w-48" />
> <Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
<AlertTriangle className="h-3 w-3 md:h-4 md:w-4 text-amber-600 dark:text-amber-500 shrink-0" /> </CardHeader>
<AlertDescription className="text-amber-800 dark:text-amber-300 text-xs md:text-sm"> <CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<span className="font-semibold">Work in Progress:</span> This functionality is currently <Skeleton className="h-16 md:h-20 w-full" />
under development and not yet connected to the backend. Your instructions will be saved <Skeleton className="h-24 md:h-32 w-full" />
but won't affect AI behavior until the feature is fully implemented. </CardContent>
</AlertDescription> </Card>
</Alert> </div>
);
}
<Alert className="bg-muted/50 py-3 md:py-4"> return (
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <div className="space-y-4 md:space-y-6">
<AlertDescription className="text-xs md:text-sm"> {/* Work in Progress Notice */}
System instructions apply to all AI interactions in this search space. They guide how the <Alert
AI responds, its tone, focus areas, and behavior patterns. variant="default"
</AlertDescription> className="bg-amber-50 dark:bg-amber-950/30 border-amber-300 dark:border-amber-700 py-3 md:py-4"
</Alert> >
<AlertTriangle className="h-3 w-3 md:h-4 md:w-4 text-amber-600 dark:text-amber-500 shrink-0" />
<AlertDescription className="text-amber-800 dark:text-amber-300 text-xs md:text-sm">
<span className="font-semibold">Work in Progress:</span> This
functionality is currently under development and not yet connected to
the backend. Your instructions will be saved but won't affect AI
behavior until the feature is fully implemented.
</AlertDescription>
</Alert>
{/* System Instructions Card */} <Alert className="bg-muted/50 py-3 md:py-4">
<Card> <Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3"> <AlertDescription className="text-xs md:text-sm">
<CardTitle className="text-base md:text-lg">Custom System Instructions</CardTitle> System instructions apply to all AI interactions in this search space.
<CardDescription className="text-xs md:text-sm"> They guide how the AI responds, its tone, focus areas, and behavior
Provide specific guidelines for how you want the AI to respond. These instructions will patterns.
be applied to all answers in this search space. </AlertDescription>
</CardDescription> </Alert>
</CardHeader>
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<div className="space-y-1.5 md:space-y-2">
<Label
htmlFor="custom-instructions-settings"
className="text-sm md:text-base font-medium"
>
Your Instructions
</Label>
<Textarea
id="custom-instructions-settings"
placeholder="E.g., Always provide practical examples, be concise, focus on technical details, use simple language, respond in a specific format..."
value={customInstructions}
onChange={(e) => setCustomInstructions(e.target.value)}
rows={10}
className="resize-none font-mono text-xs md:text-sm"
/>
<div className="flex items-center justify-between">
<p className="text-[10px] md:text-xs text-muted-foreground">
{customInstructions.length} characters
</p>
{customInstructions.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setCustomInstructions("")}
className="h-auto py-0.5 md:py-1 px-1.5 md:px-2 text-[10px] md:text-xs"
>
Clear
</Button>
)}
</div>
</div>
{customInstructions.trim().length === 0 && ( {/* System Instructions Card */}
<Alert className="py-2 md:py-3"> <form onSubmit={onSubmit} className="space-y-4 md:space-y-6">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <Card>
<AlertDescription className="text-xs md:text-sm"> <CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
No system instructions are currently set. The AI will use default behavior. <CardTitle className="text-base md:text-lg">
</AlertDescription> Custom System Instructions
</Alert> </CardTitle>
)} <CardDescription className="text-xs md:text-sm">
</CardContent> Provide specific guidelines for how you want the AI to respond.
</Card> These instructions will be applied to all answers in this search
space.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<div className="space-y-1.5 md:space-y-2">
<Label
htmlFor="custom-instructions-settings"
className="text-sm md:text-base font-medium"
>
Your Instructions
</Label>
<Textarea
id="custom-instructions-settings"
placeholder="E.g., Always provide practical examples, be concise, focus on technical details, use simple language, respond in a specific format..."
value={customInstructions}
onChange={(e) => setCustomInstructions(e.target.value)}
rows={10}
className="resize-none font-mono text-xs md:text-sm"
/>
<div className="flex items-center justify-between">
<p className="text-[10px] md:text-xs text-muted-foreground">
{customInstructions.length} characters
</p>
{customInstructions.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setCustomInstructions("")}
className="h-auto py-0.5 md:py-1 px-1.5 md:px-2 text-[10px] md:text-xs"
>
Clear
</Button>
)}
</div>
</div>
{/* Action Buttons */} {customInstructions.trim().length === 0 && (
<div className="flex justify-end pt-3 md:pt-4"> <Alert className="py-2 md:py-3">
<Button <Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
variant="outline" <AlertDescription className="text-xs md:text-sm">
onClick={handleSave} No system instructions are currently set. The AI will use
disabled={!hasChanges || saving} default behavior.
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200" </AlertDescription>
> </Alert>
{saving ? "Saving" : "Save Instructions"} )}
</Button> </CardContent>
</div> </Card>
</div>
); {/* Action Buttons */}
<div className="flex justify-end pt-3 md:pt-4">
<Button
type="submit"
variant="outline"
disabled={!hasChanges || saving}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
{saving ? <Spinner size="sm" /> : null}
{saving ? "Saving" : "Save Instructions"}
</Button>
</div>
</form>
</div>
);
} }