mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-29 19:06:24 +02:00
Merge remote-tracking branch 'upstream/dev' into pr-611
This commit is contained in:
commit
6f330e7b8d
92 changed files with 5331 additions and 6029 deletions
|
|
@ -7,8 +7,11 @@ import {
|
|||
MessagePrimitive,
|
||||
ThreadPrimitive,
|
||||
useAssistantState,
|
||||
useThreadViewport,
|
||||
} from "@assistant-ui/react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
Brain,
|
||||
|
|
@ -40,7 +43,14 @@ import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
|||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
llmPreferencesAtom,
|
||||
newLLMConfigsAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import {
|
||||
ComposerAddAttachment,
|
||||
ComposerAttachments,
|
||||
|
|
@ -57,11 +67,13 @@ import {
|
|||
ChainOfThoughtTrigger,
|
||||
} from "@/components/prompt-kit/chain-of-thought";
|
||||
import { DocumentsDataTable, type DocumentsDataTableRef } from "@/components/new-chat/DocumentsDataTable";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
|
||||
/**
|
||||
* Props for the Thread component
|
||||
|
|
@ -78,35 +90,38 @@ const ThinkingStepsContext = createContext<Map<string, ThinkingStep[]>>(new Map(
|
|||
*/
|
||||
function getStepIcon(status: "pending" | "in_progress" | "completed", title: string) {
|
||||
const titleLower = title.toLowerCase();
|
||||
|
||||
|
||||
if (status === "in_progress") {
|
||||
return <Loader2 className="size-4 animate-spin text-primary" />;
|
||||
}
|
||||
|
||||
|
||||
if (status === "completed") {
|
||||
return <CheckCircle2 className="size-4 text-emerald-500" />;
|
||||
}
|
||||
|
||||
|
||||
if (titleLower.includes("search") || titleLower.includes("knowledge")) {
|
||||
return <Search className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
|
||||
if (titleLower.includes("analy") || titleLower.includes("understand")) {
|
||||
return <Brain className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
|
||||
return <Sparkles className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain of thought display component with smart expand/collapse behavior
|
||||
*/
|
||||
const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolean }> = ({ steps, isThreadRunning = true }) => {
|
||||
const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolean }> = ({
|
||||
steps,
|
||||
isThreadRunning = true,
|
||||
}) => {
|
||||
// Track which steps the user has manually toggled (overrides auto behavior)
|
||||
const [manualOverrides, setManualOverrides] = useState<Record<string, boolean>>({});
|
||||
// Track previous step statuses to detect changes
|
||||
const prevStatusesRef = useRef<Record<string, string>>({});
|
||||
|
||||
|
||||
// Derive effective status: if thread stopped and step is in_progress, treat as completed
|
||||
const getEffectiveStatus = (step: ThinkingStep): "pending" | "in_progress" | "completed" => {
|
||||
if (step.status === "in_progress" && !isThreadRunning) {
|
||||
|
|
@ -114,24 +129,24 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea
|
|||
}
|
||||
return step.status;
|
||||
};
|
||||
|
||||
|
||||
// Check if any step is effectively in progress
|
||||
const hasInProgressStep = steps.some(step => getEffectiveStatus(step) === "in_progress");
|
||||
|
||||
const hasInProgressStep = steps.some((step) => getEffectiveStatus(step) === "in_progress");
|
||||
|
||||
// Find the last completed step index (using effective status)
|
||||
const lastCompletedIndex = steps
|
||||
.map((s, i) => getEffectiveStatus(s) === "completed" ? i : -1)
|
||||
.filter(i => i !== -1)
|
||||
.map((s, i) => (getEffectiveStatus(s) === "completed" ? i : -1))
|
||||
.filter((i) => i !== -1)
|
||||
.pop();
|
||||
|
||||
|
||||
// Clear manual overrides when a step's status changes
|
||||
useEffect(() => {
|
||||
const currentStatuses: Record<string, string> = {};
|
||||
steps.forEach(step => {
|
||||
steps.forEach((step) => {
|
||||
currentStatuses[step.id] = step.status;
|
||||
// If status changed, clear any manual override for this step
|
||||
if (prevStatusesRef.current[step.id] && prevStatusesRef.current[step.id] !== step.status) {
|
||||
setManualOverrides(prev => {
|
||||
setManualOverrides((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[step.id];
|
||||
return next;
|
||||
|
|
@ -140,9 +155,9 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea
|
|||
});
|
||||
prevStatusesRef.current = currentStatuses;
|
||||
}, [steps]);
|
||||
|
||||
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
|
||||
const getStepOpenState = (step: ThinkingStep, index: number): boolean => {
|
||||
const effectiveStatus = getEffectiveStatus(step);
|
||||
// If user has manually toggled, respect that
|
||||
|
|
@ -160,14 +175,14 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea
|
|||
// Default: collapsed
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
const handleToggle = (stepId: string, currentOpen: boolean) => {
|
||||
setManualOverrides(prev => ({
|
||||
setManualOverrides((prev) => ({
|
||||
...prev,
|
||||
[stepId]: !currentOpen,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
|
||||
<ChainOfThought>
|
||||
|
|
@ -176,8 +191,8 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea
|
|||
const icon = getStepIcon(effectiveStatus, step.title);
|
||||
const isOpen = getStepOpenState(step, index);
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={step.id}
|
||||
<ChainOfThoughtStep
|
||||
key={step.id}
|
||||
open={isOpen}
|
||||
onOpenChange={() => handleToggle(step.id, isOpen)}
|
||||
>
|
||||
|
|
@ -194,9 +209,7 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea
|
|||
{step.items && step.items.length > 0 && (
|
||||
<ChainOfThoughtContent>
|
||||
{step.items.map((item, idx) => (
|
||||
<ChainOfThoughtItem key={`${step.id}-item-${idx}`}>
|
||||
{item}
|
||||
</ChainOfThoughtItem>
|
||||
<ChainOfThoughtItem key={`${step.id}-item-${idx}`}>{item}</ChainOfThoughtItem>
|
||||
))}
|
||||
</ChainOfThoughtContent>
|
||||
)}
|
||||
|
|
@ -208,6 +221,56 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Component that handles auto-scroll when thinking steps update.
|
||||
* Uses useThreadViewport to scroll to bottom when thinking steps change,
|
||||
* ensuring the user always sees the latest content during streaming.
|
||||
*/
|
||||
const ThinkingStepsScrollHandler: FC = () => {
|
||||
const thinkingStepsMap = useContext(ThinkingStepsContext);
|
||||
const viewport = useThreadViewport();
|
||||
const isRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
// Track the serialized state to detect any changes
|
||||
const prevStateRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
// Only act during streaming
|
||||
if (!isRunning) {
|
||||
prevStateRef.current = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Serialize the thinking steps state to detect any changes
|
||||
// This catches new steps, status changes, and item additions
|
||||
let stateString = "";
|
||||
thinkingStepsMap.forEach((steps, msgId) => {
|
||||
steps.forEach((step) => {
|
||||
stateString += `${msgId}:${step.id}:${step.status}:${step.items?.length || 0};`;
|
||||
});
|
||||
});
|
||||
|
||||
// If state changed at all during streaming, scroll
|
||||
if (stateString !== prevStateRef.current && stateString !== "") {
|
||||
prevStateRef.current = stateString;
|
||||
|
||||
// Multiple attempts to ensure scroll happens after DOM updates
|
||||
const scrollAttempt = () => {
|
||||
try {
|
||||
viewport.scrollToBottom();
|
||||
} catch (e) {
|
||||
// Ignore errors - viewport might not be ready
|
||||
}
|
||||
};
|
||||
|
||||
// Delayed attempts to handle async DOM updates
|
||||
requestAnimationFrame(scrollAttempt);
|
||||
setTimeout(scrollAttempt, 100);
|
||||
}
|
||||
}, [thinkingStepsMap, viewport, isRunning]);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
};
|
||||
|
||||
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map() }) => {
|
||||
return (
|
||||
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
|
||||
|
|
@ -221,6 +284,9 @@ export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map() }) =>
|
|||
turnAnchor="top"
|
||||
className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
|
||||
>
|
||||
{/* Auto-scroll handler for thinking steps - must be inside Viewport */}
|
||||
<ThinkingStepsScrollHandler />
|
||||
|
||||
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
||||
<ThreadWelcome />
|
||||
</AssistantIf>
|
||||
|
|
@ -263,49 +329,24 @@ const ThreadScrollToBottom: FC = () => {
|
|||
|
||||
const getTimeBasedGreeting = (userEmail?: string): string => {
|
||||
const hour = new Date().getHours();
|
||||
|
||||
|
||||
// Extract first name from email if available
|
||||
const firstName = userEmail
|
||||
? userEmail.split("@")[0].split(".")[0].charAt(0).toUpperCase() +
|
||||
userEmail.split("@")[0].split(".")[0].slice(1)
|
||||
? userEmail.split("@")[0].split(".")[0].charAt(0).toUpperCase() +
|
||||
userEmail.split("@")[0].split(".")[0].slice(1)
|
||||
: null;
|
||||
|
||||
|
||||
// Array of greeting variations for each time period
|
||||
const morningGreetings = [
|
||||
"Good morning",
|
||||
"Rise and shine",
|
||||
"Morning",
|
||||
"Hey there",
|
||||
];
|
||||
|
||||
const afternoonGreetings = [
|
||||
"Good afternoon",
|
||||
"Afternoon",
|
||||
"Hey there",
|
||||
"Hi there",
|
||||
];
|
||||
|
||||
const eveningGreetings = [
|
||||
"Good evening",
|
||||
"Evening",
|
||||
"Hey there",
|
||||
"Hi there",
|
||||
];
|
||||
|
||||
const nightGreetings = [
|
||||
"Good night",
|
||||
"Evening",
|
||||
"Hey there",
|
||||
"Winding down",
|
||||
];
|
||||
|
||||
const lateNightGreetings = [
|
||||
"Still up",
|
||||
"Night owl mode",
|
||||
"The night is young",
|
||||
"Hi there",
|
||||
];
|
||||
|
||||
const morningGreetings = ["Good morning", "Rise and shine", "Morning", "Hey there"];
|
||||
|
||||
const afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there", "Hi there"];
|
||||
|
||||
const eveningGreetings = ["Good evening", "Evening", "Hey there", "Hi there"];
|
||||
|
||||
const nightGreetings = ["Good night", "Evening", "Hey there", "Winding down"];
|
||||
|
||||
const lateNightGreetings = ["Still up", "Night owl mode", "The night is young", "Hi there"];
|
||||
|
||||
// Select a random greeting based on time
|
||||
let greeting: string;
|
||||
if (hour < 5) {
|
||||
|
|
@ -321,12 +362,12 @@ const getTimeBasedGreeting = (userEmail?: string): string => {
|
|||
// Night: 10 PM to midnight
|
||||
greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)];
|
||||
}
|
||||
|
||||
|
||||
// Add personalization with first name if available
|
||||
if (firstName) {
|
||||
return `${greeting}, ${firstName}!`;
|
||||
}
|
||||
|
||||
|
||||
return `${greeting}!`;
|
||||
};
|
||||
|
||||
|
|
@ -335,14 +376,14 @@ const ThreadWelcome: FC = () => {
|
|||
|
||||
// Memoize greeting so it doesn't change on re-renders (only on user change)
|
||||
const greeting = useMemo(() => getTimeBasedGreeting(user?.email), [user?.email]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
|
||||
{/* Greeting positioned above the composer - fixed position */}
|
||||
<div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center z-10">
|
||||
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-2 animate-in text-5xl delay-100 duration-500 ease-out fill-mode-both">
|
||||
{greeting}
|
||||
</h1>
|
||||
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-2 animate-in text-5xl delay-100 duration-500 ease-out fill-mode-both">
|
||||
{greeting}
|
||||
</h1>
|
||||
</div>
|
||||
{/* Composer - top edge fixed, expands downward only */}
|
||||
<div className="fade-in slide-in-from-bottom-3 animate-in delay-200 duration-500 ease-out fill-mode-both w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
|
||||
|
|
@ -490,6 +531,23 @@ const Composer: FC = () => {
|
|||
setMentionedDocuments((prev) => prev.filter((doc) => doc.id !== docId));
|
||||
};
|
||||
|
||||
// Check if a model is configured - needed to disable input
|
||||
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
|
||||
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
|
||||
const { data: preferences } = useAtomValue(llmPreferencesAtom);
|
||||
|
||||
const hasModelConfigured = useMemo(() => {
|
||||
if (!preferences) return false;
|
||||
const agentLlmId = preferences.agent_llm_id;
|
||||
if (agentLlmId === null || agentLlmId === undefined) return false;
|
||||
|
||||
// Check if the configured model actually exists
|
||||
if (agentLlmId < 0) {
|
||||
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||
}
|
||||
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||
}, [preferences, globalConfigs, userConfigs]);
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
|
||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
|
||||
|
|
@ -571,22 +629,26 @@ const Composer: FC = () => {
|
|||
|
||||
const ConnectorIndicator: FC = () => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors(false, searchSpaceId ? Number(searchSpaceId) : undefined);
|
||||
const { data: documentTypeCounts, isLoading: documentTypesLoading } = useAtomValue(documentTypeCountsAtom);
|
||||
const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors(
|
||||
false,
|
||||
searchSpaceId ? Number(searchSpaceId) : undefined
|
||||
);
|
||||
const { data: documentTypeCounts, isLoading: documentTypesLoading } =
|
||||
useAtomValue(documentTypeCountsAtom);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
|
||||
const isLoading = connectorsLoading || documentTypesLoading;
|
||||
|
||||
|
||||
// Get document types that have documents in the search space
|
||||
const activeDocumentTypes = documentTypeCounts
|
||||
const activeDocumentTypes = documentTypeCounts
|
||||
? Object.entries(documentTypeCounts).filter(([_, count]) => count > 0)
|
||||
: [];
|
||||
|
||||
|
||||
const hasConnectors = connectors.length > 0;
|
||||
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
|
||||
const totalSourceCount = connectors.length + activeDocumentTypes.length;
|
||||
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
// Clear any pending close timeout
|
||||
if (closeTimeoutRef.current) {
|
||||
|
|
@ -595,16 +657,16 @@ const ConnectorIndicator: FC = () => {
|
|||
}
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
// Delay closing by 150ms for better UX
|
||||
closeTimeoutRef.current = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 150);
|
||||
}, []);
|
||||
|
||||
|
||||
if (!searchSpaceId) return null;
|
||||
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
|
|
@ -618,7 +680,9 @@ const ConnectorIndicator: FC = () => {
|
|||
"data-[state=open]:bg-transparent data-[state=open]:shadow-none data-[state=open]:ring-0",
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
aria-label={hasSources ? `View ${totalSourceCount} connected sources` : "Add your first connector"}
|
||||
aria-label={
|
||||
hasSources ? `View ${totalSourceCount} connected sources` : "Add your first connector"
|
||||
}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
|
|
@ -640,9 +704,9 @@ const ConnectorIndicator: FC = () => {
|
|||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-64 p-3"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
|
|
@ -650,9 +714,7 @@ const ConnectorIndicator: FC = () => {
|
|||
{hasSources ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Connected Sources
|
||||
</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">Connected Sources</p>
|
||||
<span className="text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
|
||||
{totalSourceCount}
|
||||
</span>
|
||||
|
|
@ -681,11 +743,11 @@ const ConnectorIndicator: FC = () => {
|
|||
</div>
|
||||
<div className="pt-1 border-t border-border/50">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors`}
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add`}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plug2 className="size-3" />
|
||||
Manage connectors
|
||||
<Plus className="size-3" />
|
||||
Add more sources
|
||||
<ChevronRightIcon className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -728,7 +790,24 @@ const ComposerAction: FC = () => {
|
|||
return text.length === 0;
|
||||
});
|
||||
|
||||
const isSendDisabled = hasProcessingAttachments || isComposerEmpty;
|
||||
// Check if a model is configured
|
||||
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
|
||||
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
|
||||
const { data: preferences } = useAtomValue(llmPreferencesAtom);
|
||||
|
||||
const hasModelConfigured = useMemo(() => {
|
||||
if (!preferences) return false;
|
||||
const agentLlmId = preferences.agent_llm_id;
|
||||
if (agentLlmId === null || agentLlmId === undefined) return false;
|
||||
|
||||
// Check if the configured model actually exists
|
||||
if (agentLlmId < 0) {
|
||||
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||
}
|
||||
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||
}, [preferences, globalConfigs, userConfigs]);
|
||||
|
||||
const isSendDisabled = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured;
|
||||
|
||||
return (
|
||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||
|
|
@ -745,15 +824,25 @@ const ComposerAction: FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Show warning when no model is configured */}
|
||||
{!hasModelConfigured && !hasProcessingAttachments && (
|
||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
|
||||
<AlertCircle className="size-3" />
|
||||
<span>Select a model</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
||||
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
||||
<TooltipIconButton
|
||||
tooltip={
|
||||
hasProcessingAttachments
|
||||
? "Wait for attachments to process"
|
||||
: isComposerEmpty
|
||||
? "Enter a message to send"
|
||||
: "Send message"
|
||||
!hasModelConfigured
|
||||
? "Please select a model from the header to start chatting"
|
||||
: hasProcessingAttachments
|
||||
? "Wait for attachments to process"
|
||||
: isComposerEmpty
|
||||
? "Enter a message to send"
|
||||
: "Send message"
|
||||
}
|
||||
side="bottom"
|
||||
type="submit"
|
||||
|
|
@ -798,25 +887,34 @@ const MessageError: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const AssistantMessageInner: FC = () => {
|
||||
/**
|
||||
* Custom component to render thinking steps from Context
|
||||
*/
|
||||
const ThinkingStepsPart: FC = () => {
|
||||
const thinkingStepsMap = useContext(ThinkingStepsContext);
|
||||
|
||||
|
||||
// Get the current message ID to look up thinking steps
|
||||
const messageId = useAssistantState(({ message }) => message?.id);
|
||||
const thinkingSteps = thinkingStepsMap.get(messageId) || [];
|
||||
|
||||
|
||||
// Check if thread is still running (for stopping the spinner when cancelled)
|
||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
|
||||
|
||||
if (thinkingSteps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<ThinkingStepsDisplay steps={thinkingSteps} isThreadRunning={isThreadRunning} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantMessageInner: FC = () => {
|
||||
return (
|
||||
<>
|
||||
{/* Show thinking steps BEFORE the text response */}
|
||||
{thinkingSteps.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<ThinkingStepsDisplay steps={thinkingSteps} isThreadRunning={isThreadRunning} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render thinking steps from message content - this ensures proper scroll tracking */}
|
||||
<ThinkingStepsPart />
|
||||
|
||||
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@ import {
|
|||
IconBrandLinkedin,
|
||||
IconBrandTwitter,
|
||||
} from "@tabler/icons-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
|
||||
export function FooterNew() {
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
"use client";
|
||||
import {
|
||||
IconBrandDiscord,
|
||||
IconBrandGithub,
|
||||
IconBrandLinkedin,
|
||||
IconBrandTwitter,
|
||||
} from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Footer() {
|
||||
const pages = [
|
||||
{
|
||||
title: "Privacy",
|
||||
href: "/privacy",
|
||||
},
|
||||
{
|
||||
title: "Terms",
|
||||
href: "/terms",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="border-t border-neutral-100 dark:border-white/[0.1] px-8 py-20 w-full relative overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto text-sm text-neutral-500 justify-between items-start md:px-8">
|
||||
<div className="flex flex-col items-center justify-center w-full relative">
|
||||
<div className="mr-0 md:mr-4 md:flex mb-4">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium text-black dark:text-white ml-2">SurfSense</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="transition-colors flex sm:flex-row flex-col hover:text-text-neutral-800 text-neutral-600 dark:text-neutral-300 list-none gap-4">
|
||||
{pages.map((page) => (
|
||||
<li key={`pages-${page.title}`} className="list-none">
|
||||
<Link className="transition-colors hover:text-text-neutral-800" href={page.href}>
|
||||
{page.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<GridLineHorizontal className="max-w-7xl mx-auto mt-8" />
|
||||
</div>
|
||||
<div className="flex sm:flex-row flex-col justify-between mt-8 items-center w-full">
|
||||
<p className="text-neutral-500 dark:text-neutral-400 mb-8 sm:mb-0">
|
||||
© SurfSense 2025
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Link href="https://x.com/mod_setter">
|
||||
<IconBrandTwitter className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
<Link href="https://www.linkedin.com/in/rohan-verma-sde/">
|
||||
<IconBrandLinkedin className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
<Link href="https://github.com/MODSetter">
|
||||
<IconBrandGithub className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
<Link href="https://discord.gg/ejRNvftDp9">
|
||||
<IconBrandDiscord className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GridLineHorizontal = ({ className, offset }: { className?: string; offset?: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--background": "#ffffff",
|
||||
"--color": "rgba(0, 0, 0, 0.2)",
|
||||
"--height": "1px",
|
||||
"--width": "5px",
|
||||
"--fade-stop": "90%",
|
||||
"--offset": offset || "200px", //-100px if you want to keep the line inside
|
||||
"--color-dark": "rgba(255, 255, 255, 0.2)",
|
||||
maskComposite: "exclude",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"w-[calc(100%+var(--offset))] h-[var(--height)]",
|
||||
"bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]",
|
||||
"[background-size:var(--width)_var(--height)]",
|
||||
"[mask:linear-gradient(to_left,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_right,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
|
||||
"[mask-composite:exclude]",
|
||||
"z-30",
|
||||
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
||||
className
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
66
surfsense_web/components/new-chat/chat-header.tsx
Normal file
66
surfsense_web/components/new-chat/chat-header.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type {
|
||||
GlobalNewLLMConfig,
|
||||
NewLLMConfigPublic,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { ModelConfigSidebar } from "./model-config-sidebar";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [selectedConfig, setSelectedConfig] = useState<
|
||||
NewLLMConfigPublic | GlobalNewLLMConfig | null
|
||||
>(null);
|
||||
const [isGlobal, setIsGlobal] = useState(false);
|
||||
const [sidebarMode, setSidebarMode] = useState<"create" | "edit" | "view">("view");
|
||||
|
||||
const handleEditConfig = useCallback(
|
||||
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
|
||||
setSelectedConfig(config);
|
||||
setIsGlobal(global);
|
||||
setSidebarMode(global ? "view" : "edit");
|
||||
setSidebarOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleAddNew = useCallback(() => {
|
||||
setSelectedConfig(null);
|
||||
setIsGlobal(false);
|
||||
setSidebarMode("create");
|
||||
setSidebarOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleSidebarClose = useCallback((open: boolean) => {
|
||||
setSidebarOpen(open);
|
||||
if (!open) {
|
||||
// Reset state when closing
|
||||
setSelectedConfig(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header Bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/30 bg-background/80 backdrop-blur-sm">
|
||||
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
|
||||
</div>
|
||||
|
||||
{/* Config Sidebar */}
|
||||
<ModelConfigSidebar
|
||||
open={sidebarOpen}
|
||||
onOpenChange={handleSidebarClose}
|
||||
config={selectedConfig}
|
||||
isGlobal={isGlobal}
|
||||
searchSpaceId={searchSpaceId}
|
||||
mode={sidebarMode}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
369
surfsense_web/components/new-chat/model-config-sidebar.tsx
Normal file
369
surfsense_web/components/new-chat/model-config-sidebar.tsx
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Bot, ChevronRight, Globe, User, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createNewLLMConfigMutationAtom,
|
||||
updateLLMPreferencesMutationAtom,
|
||||
updateNewLLMConfigMutationAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type {
|
||||
GlobalNewLLMConfig,
|
||||
NewLLMConfigPublic,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModelConfigSidebarProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
|
||||
isGlobal: boolean;
|
||||
searchSpaceId: number;
|
||||
mode: "create" | "edit" | "view";
|
||||
}
|
||||
|
||||
export function ModelConfigSidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
config,
|
||||
isGlobal,
|
||||
searchSpaceId,
|
||||
mode,
|
||||
}: ModelConfigSidebarProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Mutations - use mutateAsync from the atom value
|
||||
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
|
||||
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
// Get title based on mode
|
||||
const getTitle = () => {
|
||||
if (mode === "create") return "Add New Configuration";
|
||||
if (isGlobal) return "View Global Configuration";
|
||||
return "Edit Configuration";
|
||||
};
|
||||
|
||||
// Handle form submit
|
||||
const handleSubmit = useCallback(
|
||||
async (data: LLMConfigFormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
// Create new config
|
||||
const result = await createConfig({
|
||||
...data,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
|
||||
// Assign the new config to the agent role
|
||||
if (result?.id) {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: {
|
||||
agent_llm_id: result.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toast.success("Configuration created and assigned!");
|
||||
onOpenChange(false);
|
||||
} else if (!isGlobal && config) {
|
||||
// Update existing user config
|
||||
await updateConfig({
|
||||
id: config.id,
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
provider: data.provider,
|
||||
custom_provider: data.custom_provider,
|
||||
model_name: data.model_name,
|
||||
api_key: data.api_key,
|
||||
api_base: data.api_base,
|
||||
litellm_params: data.litellm_params,
|
||||
system_instructions: data.system_instructions,
|
||||
use_default_system_instructions: data.use_default_system_instructions,
|
||||
citations_enabled: data.citations_enabled,
|
||||
},
|
||||
});
|
||||
toast.success("Configuration updated!");
|
||||
onOpenChange(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save configuration:", error);
|
||||
toast.error("Failed to save configuration");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
mode,
|
||||
isGlobal,
|
||||
config,
|
||||
searchSpaceId,
|
||||
createConfig,
|
||||
updateConfig,
|
||||
updatePreferences,
|
||||
onOpenChange,
|
||||
]
|
||||
);
|
||||
|
||||
// Handle "Use this model" for global configs
|
||||
const handleUseGlobalConfig = useCallback(async () => {
|
||||
if (!config || !isGlobal) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: {
|
||||
agent_llm_id: config.id,
|
||||
},
|
||||
});
|
||||
toast.success(`Now using ${config.name}`);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to set model:", error);
|
||||
toast.error("Failed to set model");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{/* Sidebar Panel */}
|
||||
<motion.div
|
||||
initial={{ x: "100%", opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: "100%", opacity: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
damping: 30,
|
||||
stiffness: 300,
|
||||
}}
|
||||
className={cn(
|
||||
"fixed right-0 top-0 z-50 h-full w-full sm:w-[480px] lg:w-[540px]",
|
||||
"bg-background border-l border-border/50 shadow-2xl",
|
||||
"flex flex-col"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-muted/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-10 rounded-xl bg-primary/10">
|
||||
<Bot className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{getTitle()}</h2>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{isGlobal ? (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Globe className="size-3" />
|
||||
Global
|
||||
</Badge>
|
||||
) : mode !== "create" ? (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<User className="size-3" />
|
||||
Custom
|
||||
</Badge>
|
||||
) : null}
|
||||
{config && (
|
||||
<span className="text-xs text-muted-foreground">{config.model_name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-xl hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content - use overflow-y-auto instead of ScrollArea for better compatibility */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-6">
|
||||
{/* Global config notice */}
|
||||
{isGlobal && mode !== "create" && (
|
||||
<Alert className="mb-6 border-amber-500/30 bg-amber-500/5">
|
||||
<AlertCircle className="size-4 text-amber-500" />
|
||||
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
||||
Global configurations are read-only. To customize settings, create a new
|
||||
configuration based on this template.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{mode === "create" ? (
|
||||
<LLMConfigForm
|
||||
searchSpaceId={searchSpaceId}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
isSubmitting={isSubmitting}
|
||||
mode="create"
|
||||
submitLabel="Create & Use"
|
||||
/>
|
||||
) : isGlobal && config ? (
|
||||
// Read-only view for global configs
|
||||
<div className="space-y-6">
|
||||
{/* Config Details */}
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Configuration Name
|
||||
</label>
|
||||
<p className="text-sm font-medium">{config.name}</p>
|
||||
</div>
|
||||
{config.description && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Description
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/50" />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Provider
|
||||
</label>
|
||||
<p className="text-sm font-medium">{config.provider}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Model
|
||||
</label>
|
||||
<p className="text-sm font-medium font-mono">{config.model_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/50" />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Citations
|
||||
</label>
|
||||
<Badge
|
||||
variant={config.citations_enabled ? "default" : "secondary"}
|
||||
className="w-fit"
|
||||
>
|
||||
{config.citations_enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.system_instructions && (
|
||||
<>
|
||||
<div className="h-px bg-border/50" />
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
System Instructions
|
||||
</label>
|
||||
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
|
||||
<p className="text-xs font-mono text-muted-foreground whitespace-pre-wrap line-clamp-10">
|
||||
{config.system_instructions}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4 border-t border-border/50">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 gap-2"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>Loading...</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="size-4" />
|
||||
Use This Model
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : config ? (
|
||||
// Edit form for user configs
|
||||
<LLMConfigForm
|
||||
searchSpaceId={searchSpaceId}
|
||||
initialData={{
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
provider: config.provider,
|
||||
custom_provider: config.custom_provider,
|
||||
model_name: config.model_name,
|
||||
api_key: config.api_key,
|
||||
api_base: config.api_base,
|
||||
litellm_params: config.litellm_params,
|
||||
system_instructions: config.system_instructions,
|
||||
use_default_system_instructions: config.use_default_system_instructions,
|
||||
citations_enabled: config.citations_enabled,
|
||||
search_space_id: searchSpaceId,
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
isSubmitting={isSubmitting}
|
||||
mode="edit"
|
||||
submitLabel="Save Changes"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
384
surfsense_web/components/new-chat/model-selector.tsx
Normal file
384
surfsense_web/components/new-chat/model-selector.tsx
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Cloud,
|
||||
Edit3,
|
||||
Globe,
|
||||
Loader2,
|
||||
Plus,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
User,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
llmPreferencesAtom,
|
||||
newLLMConfigsAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import type {
|
||||
GlobalNewLLMConfig,
|
||||
NewLLMConfigPublic,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Provider icons mapping
|
||||
const getProviderIcon = (provider: string) => {
|
||||
const iconClass = "size-4";
|
||||
switch (provider?.toUpperCase()) {
|
||||
case "OPENAI":
|
||||
return <Sparkles className={cn(iconClass, "text-emerald-500")} />;
|
||||
case "ANTHROPIC":
|
||||
return <Bot className={cn(iconClass, "text-amber-600")} />;
|
||||
case "GOOGLE":
|
||||
return <Cloud className={cn(iconClass, "text-blue-500")} />;
|
||||
case "GROQ":
|
||||
return <Zap className={cn(iconClass, "text-orange-500")} />;
|
||||
case "OLLAMA":
|
||||
return <Settings2 className={cn(iconClass, "text-gray-500")} />;
|
||||
case "XAI":
|
||||
return <Bot className={cn(iconClass, "text-violet-500")} />;
|
||||
default:
|
||||
return <Bot className={cn(iconClass, "text-muted-foreground")} />;
|
||||
}
|
||||
};
|
||||
|
||||
interface ModelSelectorProps {
|
||||
onEdit: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void;
|
||||
onAddNew: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isSwitching, setIsSwitching] = useState(false);
|
||||
|
||||
// Fetch configs
|
||||
const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom);
|
||||
const { data: globalConfigs, isLoading: globalConfigsLoading } =
|
||||
useAtomValue(globalNewLLMConfigsAtom);
|
||||
const { data: preferences, isLoading: preferencesLoading } = useAtomValue(llmPreferencesAtom);
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const isLoading = userConfigsLoading || globalConfigsLoading || preferencesLoading;
|
||||
|
||||
// Get current agent LLM config
|
||||
const currentConfig = useMemo(() => {
|
||||
if (!preferences) return null;
|
||||
|
||||
const agentLlmId = preferences.agent_llm_id;
|
||||
if (agentLlmId === null || agentLlmId === undefined) return null;
|
||||
|
||||
// Check if it's a global config (negative ID)
|
||||
if (agentLlmId < 0) {
|
||||
return globalConfigs?.find((c) => c.id === agentLlmId) ?? null;
|
||||
}
|
||||
// Otherwise, check user configs
|
||||
return userConfigs?.find((c) => c.id === agentLlmId) ?? null;
|
||||
}, [preferences, globalConfigs, userConfigs]);
|
||||
|
||||
// Filter configs based on search
|
||||
const filteredGlobalConfigs = useMemo(() => {
|
||||
if (!globalConfigs) return [];
|
||||
if (!searchQuery) return globalConfigs;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return globalConfigs.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(query) ||
|
||||
c.model_name.toLowerCase().includes(query) ||
|
||||
c.provider.toLowerCase().includes(query)
|
||||
);
|
||||
}, [globalConfigs, searchQuery]);
|
||||
|
||||
const filteredUserConfigs = useMemo(() => {
|
||||
if (!userConfigs) return [];
|
||||
if (!searchQuery) return userConfigs;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return userConfigs.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(query) ||
|
||||
c.model_name.toLowerCase().includes(query) ||
|
||||
c.provider.toLowerCase().includes(query)
|
||||
);
|
||||
}, [userConfigs, searchQuery]);
|
||||
|
||||
const handleSelectConfig = useCallback(
|
||||
async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => {
|
||||
// If already selected, just close
|
||||
if (currentConfig?.id === config.id) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!searchSpaceId) {
|
||||
toast.error("No search space selected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSwitching(true);
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
data: {
|
||||
agent_llm_id: config.id,
|
||||
},
|
||||
});
|
||||
toast.success(`Switched to ${config.name}`);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to switch model:", error);
|
||||
toast.error("Failed to switch model");
|
||||
} finally {
|
||||
setIsSwitching(false);
|
||||
}
|
||||
},
|
||||
[currentConfig, searchSpaceId, updatePreferences]
|
||||
);
|
||||
|
||||
const handleEditConfig = useCallback(
|
||||
(e: React.MouseEvent, config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => {
|
||||
e.stopPropagation();
|
||||
onEdit(config, isGlobal);
|
||||
setOpen(false);
|
||||
},
|
||||
[onEdit]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"h-9 gap-2 px-3 rounded-xl border border-border/50 bg-background/50 backdrop-blur-sm",
|
||||
"hover:bg-muted/80 hover:border-border transition-all duration-200",
|
||||
"text-sm font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Loading...</span>
|
||||
</>
|
||||
) : currentConfig ? (
|
||||
<>
|
||||
{getProviderIcon(currentConfig.provider)}
|
||||
<span className="max-w-[150px] truncate">{currentConfig.name}</span>
|
||||
<Badge variant="secondary" className="ml-1 text-[10px] px-1.5 py-0 h-4 bg-muted/80">
|
||||
{currentConfig.model_name.split("/").pop()?.slice(0, 15) ||
|
||||
currentConfig.model_name.slice(0, 15)}
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bot className="size-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Select Model</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronDown className="size-3.5 text-muted-foreground ml-1 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className="w-[360px] p-0 rounded-xl shadow-lg border-border/50"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command shouldFilter={false} className="rounded-xl relative">
|
||||
{/* Switching overlay */}
|
||||
{isSwitching && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Switching model...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2 bg-muted/30">
|
||||
<Bot className="size-4 text-muted-foreground" />
|
||||
<CommandInput
|
||||
placeholder="Search models..."
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
className="h-8 border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
|
||||
disabled={isSwitching}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CommandList className="max-h-[400px] overflow-y-auto">
|
||||
<CommandEmpty className="py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Bot className="size-8 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">No models found</p>
|
||||
<p className="text-xs text-muted-foreground/60">Try a different search term</p>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Global Configs Section */}
|
||||
{filteredGlobalConfigs.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
<Globe className="size-3.5" />
|
||||
Global Models
|
||||
</div>
|
||||
{filteredGlobalConfigs.map((config) => {
|
||||
const isSelected = currentConfig?.id === config.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`global-${config.id}`}
|
||||
value={`global-${config.id}`}
|
||||
onSelect={() => handleSelectConfig(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer",
|
||||
"aria-selected:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">{getProviderIcon(config.provider)}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{config.name}</span>
|
||||
{isSelected && <Check className="size-3.5 text-primary shrink-0" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{config.model_name}
|
||||
</span>
|
||||
{config.citations_enabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-primary/10 text-primary border-primary/20"
|
||||
>
|
||||
Citations
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 rounded-md hover:bg-muted"
|
||||
onClick={(e) => handleEditConfig(e, config, true)}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{filteredGlobalConfigs.length > 0 && filteredUserConfigs.length > 0 && (
|
||||
<CommandSeparator className="my-1" />
|
||||
)}
|
||||
|
||||
{/* User Configs Section */}
|
||||
{filteredUserConfigs.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
<User className="size-3.5" />
|
||||
Your Configurations
|
||||
</div>
|
||||
{filteredUserConfigs.map((config) => {
|
||||
const isSelected = currentConfig?.id === config.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`user-${config.id}`}
|
||||
value={`user-${config.id}`}
|
||||
onSelect={() => handleSelectConfig(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer",
|
||||
"aria-selected:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">{getProviderIcon(config.provider)}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{config.name}</span>
|
||||
{isSelected && <Check className="size-3.5 text-primary shrink-0" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{config.model_name}
|
||||
</span>
|
||||
{config.citations_enabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-primary/10 text-primary border-primary/20"
|
||||
>
|
||||
Citations
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 rounded-md hover:bg-muted"
|
||||
onClick={(e) => handleEditConfig(e, config, false)}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Add New Config Button */}
|
||||
<div className="p-2 border-t border-border/50 bg-muted/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onAddNew();
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium">Add New Configuration</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export { OnboardActionCard } from "./onboard-action-card";
|
||||
export { OnboardAdvancedSettings } from "./onboard-advanced-settings";
|
||||
export { OnboardHeader } from "./onboard-header";
|
||||
export { OnboardLLMSetup } from "./onboard-llm-setup";
|
||||
export { OnboardLoading } from "./onboard-loading";
|
||||
export { OnboardStats } from "./onboard-stats";
|
||||
export { SetupLLMStep } from "./setup-llm-step";
|
||||
export { SetupPromptStep } from "./setup-prompt-step";
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowRight, CheckCircle, type LucideIcon } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OnboardActionCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
features: string[];
|
||||
buttonText: string;
|
||||
onClick: () => void;
|
||||
colorScheme: "emerald" | "blue" | "violet";
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
const colorSchemes = {
|
||||
emerald: {
|
||||
iconBg: "bg-emerald-500/10 dark:bg-emerald-500/20",
|
||||
iconRing: "ring-emerald-500/20 dark:ring-emerald-500/30",
|
||||
iconColor: "text-emerald-600 dark:text-emerald-400",
|
||||
checkColor: "text-emerald-500",
|
||||
buttonBg: "bg-emerald-600 hover:bg-emerald-500",
|
||||
hoverBorder: "hover:border-emerald-500/50",
|
||||
},
|
||||
blue: {
|
||||
iconBg: "bg-blue-500/10 dark:bg-blue-500/20",
|
||||
iconRing: "ring-blue-500/20 dark:ring-blue-500/30",
|
||||
iconColor: "text-blue-600 dark:text-blue-400",
|
||||
checkColor: "text-blue-500",
|
||||
buttonBg: "bg-blue-600 hover:bg-blue-500",
|
||||
hoverBorder: "hover:border-blue-500/50",
|
||||
},
|
||||
violet: {
|
||||
iconBg: "bg-violet-500/10 dark:bg-violet-500/20",
|
||||
iconRing: "ring-violet-500/20 dark:ring-violet-500/30",
|
||||
iconColor: "text-violet-600 dark:text-violet-400",
|
||||
checkColor: "text-violet-500",
|
||||
buttonBg: "bg-violet-600 hover:bg-violet-500",
|
||||
hoverBorder: "hover:border-violet-500/50",
|
||||
},
|
||||
};
|
||||
|
||||
export function OnboardActionCard({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
features,
|
||||
buttonText,
|
||||
onClick,
|
||||
colorScheme,
|
||||
delay = 0,
|
||||
}: OnboardActionCardProps) {
|
||||
const colors = colorSchemes[colorScheme];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay, type: "spring", stiffness: 200 }}
|
||||
whileHover={{ y: -6, transition: { duration: 0.2 } }}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"h-full cursor-pointer group relative overflow-hidden transition-all duration-300",
|
||||
"border bg-card hover:shadow-lg",
|
||||
colors.hoverBorder
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="relative pb-4">
|
||||
<motion.div
|
||||
className={cn(
|
||||
"w-14 h-14 rounded-2xl flex items-center justify-center mb-4 ring-1 transition-all duration-300",
|
||||
colors.iconBg,
|
||||
colors.iconRing,
|
||||
"group-hover:scale-110"
|
||||
)}
|
||||
whileHover={{ rotate: [0, -5, 5, 0] }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Icon className={cn("w-7 h-7", colors.iconColor)} />
|
||||
</motion.div>
|
||||
<CardTitle className="text-xl">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative space-y-4">
|
||||
<div className="space-y-2.5 text-sm text-muted-foreground">
|
||||
{features.map((feature, index) => (
|
||||
<div key={index} className="flex items-center gap-2.5">
|
||||
<CheckCircle className={cn("w-4 h-4", colors.checkColor)} />
|
||||
<span>{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className={cn(
|
||||
"w-full text-white border-0 transition-all duration-300",
|
||||
colors.buttonBg
|
||||
)}
|
||||
>
|
||||
{buttonText}
|
||||
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, MessageSquare, Settings2 } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { SetupLLMStep } from "@/components/onboard/setup-llm-step";
|
||||
import { SetupPromptStep } from "@/components/onboard/setup-prompt-step";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OnboardAdvancedSettingsProps {
|
||||
searchSpaceId: number;
|
||||
showLLMSettings: boolean;
|
||||
setShowLLMSettings: (show: boolean) => void;
|
||||
showPromptSettings: boolean;
|
||||
setShowPromptSettings: (show: boolean) => void;
|
||||
onConfigCreated: () => void;
|
||||
onConfigDeleted: () => void;
|
||||
onPreferencesUpdated: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function OnboardAdvancedSettings({
|
||||
searchSpaceId,
|
||||
showLLMSettings,
|
||||
setShowLLMSettings,
|
||||
showPromptSettings,
|
||||
setShowPromptSettings,
|
||||
onConfigCreated,
|
||||
onConfigDeleted,
|
||||
onPreferencesUpdated,
|
||||
}: OnboardAdvancedSettingsProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 1 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* LLM Configuration */}
|
||||
<Collapsible open={showLLMSettings} onOpenChange={setShowLLMSettings}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-xl bg-fuchsia-500/10 dark:bg-fuchsia-500/20 border border-fuchsia-500/20">
|
||||
<Settings2 className="w-5 h-5 text-fuchsia-600 dark:text-fuchsia-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">LLM Configuration</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize AI models and role assignments
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: showLLMSettings ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<AnimatePresence>
|
||||
{showLLMSettings && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card className="mt-2">
|
||||
<CardContent className="pt-6">
|
||||
<SetupLLMStep
|
||||
searchSpaceId={searchSpaceId}
|
||||
onConfigCreated={onConfigCreated}
|
||||
onConfigDeleted={onConfigDeleted}
|
||||
onPreferencesUpdated={onPreferencesUpdated}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Prompt Configuration */}
|
||||
<Collapsible open={showPromptSettings} onOpenChange={setShowPromptSettings}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-xl bg-cyan-500/10 dark:bg-cyan-500/20 border border-cyan-500/20">
|
||||
<MessageSquare className="w-5 h-5 text-cyan-600 dark:text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">AI Response Settings</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure citations and custom instructions (Optional)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: showPromptSettings ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<AnimatePresence>
|
||||
{showPromptSettings && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card className="mt-2">
|
||||
<CardContent className="pt-6">
|
||||
<SetupPromptStep
|
||||
searchSpaceId={searchSpaceId}
|
||||
onComplete={() => setShowPromptSettings(false)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { CheckCircle } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface OnboardHeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
isReady?: boolean;
|
||||
}
|
||||
|
||||
export function OnboardHeader({ title, subtitle, isReady }: OnboardHeaderProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="text-center mb-10"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, delay: 0.2 }}
|
||||
className="inline-flex items-center justify-center mb-6"
|
||||
>
|
||||
<Logo className="w-20 h-20 rounded-2xl shadow-lg" />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-foreground">{title}</h1>
|
||||
<p className="text-muted-foreground text-lg md:text-xl max-w-2xl mx-auto">{subtitle}</p>
|
||||
</motion.div>
|
||||
|
||||
{isReady && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.4, type: "spring" }}
|
||||
className="mt-4"
|
||||
>
|
||||
<Badge className="px-4 py-2 text-sm bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
AI Configuration Complete
|
||||
</Badge>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Bot } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { SetupLLMStep } from "@/components/onboard/setup-llm-step";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface OnboardLLMSetupProps {
|
||||
searchSpaceId: number;
|
||||
title: string;
|
||||
configTitle: string;
|
||||
configDescription: string;
|
||||
onConfigCreated: () => void;
|
||||
onConfigDeleted: () => void;
|
||||
onPreferencesUpdated: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function OnboardLLMSetup({
|
||||
searchSpaceId,
|
||||
title,
|
||||
configTitle,
|
||||
configDescription,
|
||||
onConfigCreated,
|
||||
onConfigDeleted,
|
||||
onPreferencesUpdated,
|
||||
}: OnboardLLMSetupProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-4xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, delay: 0.1 }}
|
||||
className="inline-flex items-center justify-center mb-6"
|
||||
>
|
||||
<Logo className="w-16 h-16 rounded-2xl shadow-lg" />
|
||||
</motion.div>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-4xl font-bold text-foreground mb-3"
|
||||
>
|
||||
{title}
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="text-muted-foreground text-lg"
|
||||
>
|
||||
Configure your AI model to get started
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* LLM Setup Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="text-center border-b pb-6">
|
||||
<div className="flex items-center justify-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-xl bg-primary/10 border border-primary/20">
|
||||
<Bot className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{configTitle}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{configDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<SetupLLMStep
|
||||
searchSpaceId={searchSpaceId}
|
||||
onConfigCreated={onConfigCreated}
|
||||
onConfigDeleted={onConfigDeleted}
|
||||
onPreferencesUpdated={onPreferencesUpdated}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Wand2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
interface OnboardLoadingProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export function OnboardLoading({ title, subtitle }: OnboardLoadingProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="relative mb-8 flex justify-center">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<Wand2 className="w-16 h-16 text-primary" />
|
||||
</motion.div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">{title}</h2>
|
||||
<p className="text-muted-foreground">{subtitle}</p>
|
||||
<div className="mt-6 flex justify-center gap-1.5">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="w-2 h-2 rounded-full bg-primary"
|
||||
animate={{ scale: [1, 1.5, 1], opacity: [0.5, 1, 0.5] }}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Bot, Brain, Sparkles } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface OnboardStatsProps {
|
||||
globalConfigsCount: number;
|
||||
userConfigsCount: number;
|
||||
}
|
||||
|
||||
export function OnboardStats({ globalConfigsCount, userConfigsCount }: OnboardStatsProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="flex flex-wrap justify-center gap-3 mb-10"
|
||||
>
|
||||
{globalConfigsCount > 0 && (
|
||||
<Badge variant="secondary" className="px-3 py-1.5">
|
||||
<Sparkles className="w-3 h-3 mr-1.5 text-violet-500" />
|
||||
{globalConfigsCount} Global Model{globalConfigsCount > 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
{userConfigsCount > 0 && (
|
||||
<Badge variant="secondary" className="px-3 py-1.5">
|
||||
<Bot className="w-3 h-3 mr-1.5 text-blue-500" />
|
||||
{userConfigsCount} Custom Config{userConfigsCount > 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className="px-3 py-1.5">
|
||||
<Brain className="w-3 h-3 mr-1.5 text-fuchsia-500" />
|
||||
All Roles Assigned
|
||||
</Badge>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,813 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
Brain,
|
||||
Check,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
ChevronUp,
|
||||
Plus,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createLLMConfigMutationAtom,
|
||||
deleteLLMConfigMutationAtom,
|
||||
updateLLMPreferencesMutationAtom,
|
||||
} from "@/atoms/llm-config/llm-config-mutation.atoms";
|
||||
import {
|
||||
globalLLMConfigsAtom,
|
||||
llmConfigsAtom,
|
||||
llmPreferencesAtom,
|
||||
} from "@/atoms/llm-config/llm-config-query.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { LANGUAGES } from "@/contracts/enums/languages";
|
||||
import { getModelsByProvider } from "@/contracts/enums/llm-models";
|
||||
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
|
||||
import { type CreateLLMConfigRequest, LLMConfig } from "@/contracts/types/llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import InferenceParamsEditor from "../inference-params-editor";
|
||||
|
||||
interface SetupLLMStepProps {
|
||||
searchSpaceId: number;
|
||||
onConfigCreated?: () => void;
|
||||
onConfigDeleted?: () => void;
|
||||
onPreferencesUpdated?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ROLE_DESCRIPTIONS = {
|
||||
long_context: {
|
||||
icon: Brain,
|
||||
key: "long_context_llm_id" as const,
|
||||
titleKey: "long_context_llm_title",
|
||||
descKey: "long_context_llm_desc",
|
||||
examplesKey: "long_context_llm_examples",
|
||||
color:
|
||||
"bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-950 dark:text-blue-200 dark:border-blue-800",
|
||||
},
|
||||
fast: {
|
||||
icon: Zap,
|
||||
key: "fast_llm_id" as const,
|
||||
titleKey: "fast_llm_title",
|
||||
descKey: "fast_llm_desc",
|
||||
examplesKey: "fast_llm_examples",
|
||||
color:
|
||||
"bg-green-100 text-green-800 border-green-200 dark:bg-green-950 dark:text-green-200 dark:border-green-800",
|
||||
},
|
||||
strategic: {
|
||||
icon: Bot,
|
||||
key: "strategic_llm_id" as const,
|
||||
titleKey: "strategic_llm_title",
|
||||
descKey: "strategic_llm_desc",
|
||||
examplesKey: "strategic_llm_examples",
|
||||
color:
|
||||
"bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-950 dark:text-purple-200 dark:border-purple-800",
|
||||
},
|
||||
};
|
||||
|
||||
export function SetupLLMStep({
|
||||
searchSpaceId,
|
||||
onConfigCreated,
|
||||
onConfigDeleted,
|
||||
onPreferencesUpdated,
|
||||
}: SetupLLMStepProps) {
|
||||
const { mutate: createLLMConfig, isPending: isCreatingLlmConfig } = useAtomValue(
|
||||
createLLMConfigMutationAtom
|
||||
);
|
||||
const t = useTranslations("onboard");
|
||||
const { mutateAsync: deleteLLMConfig } = useAtomValue(deleteLLMConfigMutationAtom);
|
||||
const { data: llmConfigs = [] } = useAtomValue(llmConfigsAtom);
|
||||
const { data: globalConfigs = [] } = useAtomValue(globalLLMConfigsAtom);
|
||||
const { data: preferences = {} } = useAtomValue(llmPreferencesAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||
const [formData, setFormData] = useState<CreateLLMConfigRequest>({
|
||||
name: "",
|
||||
provider: "" as CreateLLMConfigRequest["provider"], // Allow it as Default
|
||||
custom_provider: "",
|
||||
model_name: "",
|
||||
api_key: "",
|
||||
api_base: "",
|
||||
language: "English",
|
||||
litellm_params: {},
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
||||
const [showProviderForm, setShowProviderForm] = useState(false);
|
||||
|
||||
// Role assignments state
|
||||
const [assignments, setAssignments] = useState({
|
||||
long_context_llm_id: preferences.long_context_llm_id || "",
|
||||
fast_llm_id: preferences.fast_llm_id || "",
|
||||
strategic_llm_id: preferences.strategic_llm_id || "",
|
||||
});
|
||||
|
||||
// Combine global and user-specific configs
|
||||
const allConfigs = [...globalConfigs, ...llmConfigs];
|
||||
|
||||
useEffect(() => {
|
||||
setAssignments({
|
||||
long_context_llm_id: preferences.long_context_llm_id || "",
|
||||
fast_llm_id: preferences.fast_llm_id || "",
|
||||
strategic_llm_id: preferences.strategic_llm_id || "",
|
||||
});
|
||||
}, [preferences]);
|
||||
|
||||
const handleInputChange = (field: keyof CreateLLMConfigRequest, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name || !formData.provider || !formData.model_name || !formData.api_key) {
|
||||
toast.error("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
|
||||
createLLMConfig(formData, {
|
||||
onError: (error) => {
|
||||
console.error("Error creating LLM config:", error);
|
||||
if (error instanceof Error) {
|
||||
toast.error(error?.message || "Failed to create LLM config");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("LLM config created successfully");
|
||||
setFormData({
|
||||
name: "",
|
||||
provider: "" as CreateLLMConfigRequest["provider"],
|
||||
custom_provider: "",
|
||||
model_name: "",
|
||||
api_key: "",
|
||||
api_base: "",
|
||||
language: "English",
|
||||
litellm_params: {},
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
onConfigCreated?.();
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsAddingNew(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRoleAssignment = async (role: string, configId: string) => {
|
||||
const newAssignments = {
|
||||
...assignments,
|
||||
[role]: configId === "" ? "" : parseInt(configId),
|
||||
};
|
||||
|
||||
setAssignments(newAssignments);
|
||||
|
||||
// Auto-save if this assignment completes all roles
|
||||
const hasAllAssignments =
|
||||
newAssignments.long_context_llm_id &&
|
||||
newAssignments.fast_llm_id &&
|
||||
newAssignments.strategic_llm_id;
|
||||
|
||||
if (hasAllAssignments) {
|
||||
const numericAssignments = {
|
||||
long_context_llm_id:
|
||||
typeof newAssignments.long_context_llm_id === "string"
|
||||
? parseInt(newAssignments.long_context_llm_id)
|
||||
: newAssignments.long_context_llm_id,
|
||||
fast_llm_id:
|
||||
typeof newAssignments.fast_llm_id === "string"
|
||||
? parseInt(newAssignments.fast_llm_id)
|
||||
: newAssignments.fast_llm_id,
|
||||
strategic_llm_id:
|
||||
typeof newAssignments.strategic_llm_id === "string"
|
||||
? parseInt(newAssignments.strategic_llm_id)
|
||||
: newAssignments.strategic_llm_id,
|
||||
};
|
||||
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: numericAssignments,
|
||||
});
|
||||
|
||||
if (onPreferencesUpdated) {
|
||||
await onPreferencesUpdated();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectedProvider = LLM_PROVIDERS.find((p) => p.value === formData.provider);
|
||||
const availableModels = formData.provider ? getModelsByProvider(formData.provider) : [];
|
||||
|
||||
const handleParamsChange = (newParams: Record<string, number | string>) => {
|
||||
setFormData((prev) => ({ ...prev, litellm_params: newParams }));
|
||||
};
|
||||
|
||||
const handleProviderChange = (value: string) => {
|
||||
handleInputChange("provider", value);
|
||||
setFormData((prev) => ({ ...prev, model_name: "" }));
|
||||
};
|
||||
|
||||
const isAssignmentComplete =
|
||||
assignments.long_context_llm_id && assignments.fast_llm_id && assignments.strategic_llm_id;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Global Configs Notice - Prominent at top */}
|
||||
{globalConfigs.length > 0 && (
|
||||
<Alert className="bg-blue-50 border-blue-200 dark:bg-blue-950 dark:border-blue-800">
|
||||
<CheckCircle className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<AlertDescription className="text-blue-800 dark:text-blue-200">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-base">
|
||||
{globalConfigs.length} global configuration(s) available!
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
You can skip adding your own LLM provider and use our pre-configured models in the
|
||||
role assignment section below.
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Or expand "Add LLM Provider" to add your own custom configurations.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Section 1: Add LLM Providers */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Bot className="w-5 h-5" />
|
||||
{t("add_llm_provider")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t("configure_first_provider")}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowProviderForm(!showProviderForm)}
|
||||
className="gap-2"
|
||||
>
|
||||
{showProviderForm ? (
|
||||
<>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
Collapse
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
Expand
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showProviderForm && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* Info Alert */}
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{t("add_provider_instruction")}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Existing Configurations */}
|
||||
{llmConfigs.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-muted-foreground">
|
||||
{t("your_llm_configs")}
|
||||
</h4>
|
||||
<div className="grid gap-3">
|
||||
{llmConfigs.map((config) => (
|
||||
<motion.div
|
||||
key={config.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
>
|
||||
<Card className="border-l-4 border-l-primary">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Bot className="w-4 h-4" />
|
||||
<h4 className="font-medium">{config.name}</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{config.provider}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("model")}: {config.model_name}
|
||||
{config.language && ` • ${t("language")}: ${config.language}`}
|
||||
{config.api_base && ` • ${t("base")}: ${config.api_base}`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteLLMConfig({ id: config.id });
|
||||
onConfigDeleted?.();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete config:", error);
|
||||
}
|
||||
}}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add New Provider */}
|
||||
{!isAddingNew ? (
|
||||
<Card className="border-dashed border-2 hover:border-primary/50 transition-colors">
|
||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||
<Plus className="w-8 h-8 text-muted-foreground mb-3" />
|
||||
<h4 className="font-semibold mb-1">{t("add_provider_title")}</h4>
|
||||
<p className="text-sm text-muted-foreground text-center mb-3">
|
||||
{t("add_provider_subtitle")}
|
||||
</p>
|
||||
<Button onClick={() => setIsAddingNew(true)} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("add_provider_button")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t("add_new_llm_provider")}</CardTitle>
|
||||
<CardDescription>{t("configure_new_provider")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">{t("config_name_required")}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder={t("config_name_placeholder")}
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange("name", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider">{t("provider_required")}</Label>
|
||||
<Select value={formData.provider} onValueChange={handleProviderChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("provider_placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{LLM_PROVIDERS.map((provider) => (
|
||||
<SelectItem key={provider.value} value={provider.value}>
|
||||
{provider.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="language">{t("language_optional")}</Label>
|
||||
<Select
|
||||
value={formData.language || "English"}
|
||||
onValueChange={(value) => handleInputChange("language", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("language_placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGES.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.provider === "CUSTOM" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom_provider">{t("custom_provider_name")}</Label>
|
||||
<Input
|
||||
id="custom_provider"
|
||||
placeholder={t("custom_provider_placeholder")}
|
||||
value={formData.custom_provider ?? ""}
|
||||
onChange={(e) => handleInputChange("custom_provider", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model_name">{t("model_name_required")}</Label>
|
||||
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-expanded={modelComboboxOpen}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className={cn(!formData.model_name && "text-muted-foreground")}>
|
||||
{formData.model_name || t("model_name_placeholder")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start" side="bottom">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={
|
||||
selectedProvider?.example ||
|
||||
t("model_name_placeholder") ||
|
||||
"Type model name..."
|
||||
}
|
||||
value={formData.model_name}
|
||||
onValueChange={(value) => handleInputChange("model_name", value)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="py-2 text-center text-sm text-muted-foreground">
|
||||
{formData.model_name
|
||||
? `Using custom model: "${formData.model_name}"`
|
||||
: "Type your model name above"}
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
{availableModels.length > 0 && (
|
||||
<CommandGroup heading="Suggested Models">
|
||||
{availableModels
|
||||
.filter(
|
||||
(model) =>
|
||||
!formData.model_name ||
|
||||
model.value
|
||||
.toLowerCase()
|
||||
.includes(formData.model_name.toLowerCase()) ||
|
||||
model.label
|
||||
.toLowerCase()
|
||||
.includes(formData.model_name.toLowerCase())
|
||||
)
|
||||
.map((model) => (
|
||||
<CommandItem
|
||||
key={model.value}
|
||||
value={model.value}
|
||||
onSelect={(currentValue) => {
|
||||
handleInputChange("model_name", currentValue);
|
||||
setModelComboboxOpen(false);
|
||||
}}
|
||||
className="flex flex-col items-start py-3"
|
||||
>
|
||||
<div className="flex w-full items-center">
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
formData.model_name === model.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{model.label}</div>
|
||||
{model.contextWindow && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Context: {model.contextWindow}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{availableModels.length > 0
|
||||
? `Type freely or select from ${availableModels.length} model suggestions`
|
||||
: selectedProvider?.example
|
||||
? `${t("examples")}: ${selectedProvider.example}`
|
||||
: "Type your model name freely"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api_key">{t("api_key_required")}</Label>
|
||||
<Input
|
||||
id="api_key"
|
||||
type="password"
|
||||
placeholder={
|
||||
formData.provider === "OLLAMA"
|
||||
? "Any value (e.g., ollama)"
|
||||
: t("api_key_placeholder")
|
||||
}
|
||||
value={formData.api_key}
|
||||
onChange={(e) => handleInputChange("api_key", e.target.value)}
|
||||
required
|
||||
/>
|
||||
{formData.provider === "OLLAMA" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 Ollama doesn't require authentication — enter any value (e.g.,
|
||||
"ollama")
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api_base">{t("api_base_optional")}</Label>
|
||||
<Input
|
||||
id="api_base"
|
||||
placeholder={selectedProvider?.apiBase || t("api_base_placeholder")}
|
||||
value={formData.api_base ?? ""}
|
||||
onChange={(e) => handleInputChange("api_base", e.target.value)}
|
||||
/>
|
||||
{/* Ollama-specific help */}
|
||||
{formData.provider === "OLLAMA" && (
|
||||
<div className="mt-2 p-3 bg-muted/50 rounded-lg border border-muted">
|
||||
<p className="text-xs font-medium mb-2">
|
||||
💡 Ollama API Base URL Examples:
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() =>
|
||||
handleInputChange("api_base", "http://localhost:11434")
|
||||
}
|
||||
>
|
||||
<code className="px-1.5 py-0.5 bg-background rounded border">
|
||||
http://localhost:11434
|
||||
</code>
|
||||
<span>— Standard local installation</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() =>
|
||||
handleInputChange("api_base", "http://host.docker.internal:11434")
|
||||
}
|
||||
>
|
||||
<code className="px-1.5 py-0.5 bg-background rounded border">
|
||||
http://host.docker.internal:11434
|
||||
</code>
|
||||
<span>— If using SurfSense Docker image</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<InferenceParamsEditor
|
||||
params={formData.litellm_params || {}}
|
||||
setParams={handleParamsChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" disabled={isCreatingLlmConfig} size="sm">
|
||||
{isCreatingLlmConfig ? t("adding") : t("add_provider")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsAddingNew(false)}
|
||||
disabled={isCreatingLlmConfig}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="my-8" />
|
||||
|
||||
{/* Section 2: Assign Roles */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Brain className="w-5 h-5" />
|
||||
{t("assign_llm_roles")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t("assign_specific_roles")}</p>
|
||||
</div>
|
||||
|
||||
{allConfigs.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{t("add_provider_before_roles")}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{t("assign_roles_instruction")}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{Object.entries(ROLE_DESCRIPTIONS).map(([roleKey, role]) => {
|
||||
const IconComponent = role.icon;
|
||||
const currentAssignment = assignments[role.key];
|
||||
const assignedConfig = allConfigs.find((config) => config.id === currentAssignment);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={roleKey}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Object.keys(ROLE_DESCRIPTIONS).indexOf(roleKey) * 0.1 }}
|
||||
>
|
||||
<Card
|
||||
className={`border-l-4 ${currentAssignment ? "border-l-primary" : "border-l-muted"}`}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${role.color}`}>
|
||||
<IconComponent className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{t(role.titleKey)}</CardTitle>
|
||||
<CardDescription className="mt-1 text-xs">
|
||||
{t(role.descKey)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{currentAssignment && <CheckCircle className="w-5 h-5 text-green-500" />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{t("assign_llm_config")}:</Label>
|
||||
<Select
|
||||
value={currentAssignment?.toString() || ""}
|
||||
onValueChange={(value) => handleRoleAssignment(role.key, value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("select_llm_config")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{globalConfigs.length > 0 && (
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
{t("global_configs") || "Global Configurations"}
|
||||
</div>
|
||||
)}
|
||||
{globalConfigs
|
||||
.filter((config) => config.id && config.id.toString().trim() !== "")
|
||||
.map((config) => (
|
||||
<SelectItem key={config.id} value={config.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
🌐 Global
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.provider}
|
||||
</Badge>
|
||||
<span className="text-sm">{config.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({config.model_name})
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
{llmConfigs.length > 0 && globalConfigs.length > 0 && (
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground border-t mt-1">
|
||||
{t("your_configs") || "Your Configurations"}
|
||||
</div>
|
||||
)}
|
||||
{llmConfigs
|
||||
.filter((config) => config.id && config.id.toString().trim() !== "")
|
||||
.map((config) => (
|
||||
<SelectItem key={config.id} value={config.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.provider}
|
||||
</Badge>
|
||||
<span className="text-sm">{config.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({config.model_name})
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{assignedConfig && (
|
||||
<div className="mt-2 p-3 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Bot className="w-4 h-4" />
|
||||
<span className="font-medium">{t("assigned")}:</span>
|
||||
{"is_global" in assignedConfig && assignedConfig.is_global && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
🌐 Global
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{assignedConfig.provider}
|
||||
</Badge>
|
||||
<span className="text-sm">{assignedConfig.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t("model")}: {assignedConfig.model_name}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Status Indicators */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 pt-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("progress")}:</span>
|
||||
<div className="flex gap-1">
|
||||
{Object.keys(ROLE_DESCRIPTIONS).map((key) => {
|
||||
const roleKey = ROLE_DESCRIPTIONS[key as keyof typeof ROLE_DESCRIPTIONS].key;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
assignments[roleKey] ? "bg-primary" : "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span>
|
||||
{t("roles_assigned", {
|
||||
assigned: Object.values(assignments).filter(Boolean).length,
|
||||
total: Object.keys(ROLE_DESCRIPTIONS).length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isAssignmentComplete && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-200 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{t("all_roles_assigned_saved")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,340 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ChevronDown, ChevronUp, ExternalLink, Info, Sparkles, User } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
interface SetupPromptStepProps {
|
||||
searchSpaceId: number;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function SetupPromptStep({ searchSpaceId, onComplete }: SetupPromptStepProps) {
|
||||
const { data: prompts = [], isPending: loadingPrompts } = useAtomValue(communityPromptsAtom);
|
||||
const [enableCitations, setEnableCitations] = useState(true);
|
||||
const [customInstructions, setCustomInstructions] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [selectedPromptKey, setSelectedPromptKey] = useState<string | null>(null);
|
||||
const [expandedPrompts, setExpandedPrompts] = useState<Set<string>>(new Set());
|
||||
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||
|
||||
// Mark that we have changes when user modifies anything
|
||||
useEffect(() => {
|
||||
setHasChanges(true);
|
||||
}, [enableCitations, customInstructions]);
|
||||
|
||||
const handleSelectCommunityPrompt = (promptKey: string, promptValue: string) => {
|
||||
setCustomInstructions(promptValue);
|
||||
setSelectedPromptKey(promptKey);
|
||||
toast.success("Community prompt applied");
|
||||
};
|
||||
|
||||
const toggleExpand = (promptKey: string) => {
|
||||
const newExpanded = new Set(expandedPrompts);
|
||||
if (newExpanded.has(promptKey)) {
|
||||
newExpanded.delete(promptKey);
|
||||
} else {
|
||||
newExpanded.add(promptKey);
|
||||
}
|
||||
setExpandedPrompts(newExpanded);
|
||||
};
|
||||
|
||||
// Get unique categories
|
||||
const categories = Array.from(new Set(prompts.map((p) => p.category || "general")));
|
||||
const filteredPrompts =
|
||||
selectedCategory === "all"
|
||||
? prompts
|
||||
: prompts.filter((p) => (p.category || "general") === selectedCategory);
|
||||
|
||||
const truncateText = (text: string, maxLength: number = 150) => {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + "...";
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Prepare the update payload with simplified schema
|
||||
const payload: any = {
|
||||
citations_enabled: enableCitations,
|
||||
qna_custom_instructions: customInstructions.trim() || "",
|
||||
};
|
||||
|
||||
// Only send update if there's something to update
|
||||
if (Object.keys(payload).length > 0) {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.detail || `Failed to save prompt configuration (${response.status})`
|
||||
);
|
||||
}
|
||||
|
||||
toast.success("Prompt configuration saved successfully");
|
||||
}
|
||||
|
||||
setHasChanges(false);
|
||||
onComplete?.();
|
||||
} catch (error: any) {
|
||||
console.error("Error saving prompt configuration:", error);
|
||||
toast.error(error.message || "Failed to save prompt configuration");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
// Skip without saving - use defaults
|
||||
onComplete?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
These settings are optional. You can skip this step and configure them later in settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Citation Toggle */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between space-x-4 p-4 rounded-lg border bg-card">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label htmlFor="enable-citations" className="text-base font-medium">
|
||||
Enable Citations
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When enabled, AI responses will include citations to source documents using
|
||||
[citation:id] format.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-citations"
|
||||
checked={enableCitations}
|
||||
onCheckedChange={setEnableCitations}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!enableCitations && (
|
||||
<Alert
|
||||
variant="default"
|
||||
className="bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-800"
|
||||
>
|
||||
<Info className="h-4 w-4 text-yellow-600 dark:text-yellow-500" />
|
||||
<AlertDescription className="text-yellow-800 dark:text-yellow-300">
|
||||
Disabling citations means AI responses won't include source references. You can
|
||||
re-enable this anytime in settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SearchSpace System Instructions */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-instructions" className="text-base font-medium">
|
||||
SearchSpace System Instructions (Optional)
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add system instructions to guide how the AI should respond. Choose from community
|
||||
prompts below or write your own.
|
||||
</p>
|
||||
|
||||
{/* Community Prompts Section */}
|
||||
{!loadingPrompts && prompts.length > 0 && (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Community Prompts Library
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Browse {prompts.length} curated prompts. Click to preview or apply directly
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs
|
||||
value={selectedCategory}
|
||||
onValueChange={setSelectedCategory}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-5 mb-4">
|
||||
<TabsTrigger value="all" className="text-xs">
|
||||
All ({prompts.length})
|
||||
</TabsTrigger>
|
||||
{categories.map((category) => (
|
||||
<TabsTrigger key={category} value={category} className="text-xs capitalize">
|
||||
{category} (
|
||||
{prompts.filter((p) => (p.category || "general") === category).length})
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<ScrollArea className="h-[300px] pr-4">
|
||||
<div className="space-y-3">
|
||||
{filteredPrompts.map((prompt) => {
|
||||
const isExpanded = expandedPrompts.has(prompt.key);
|
||||
const isSelected = selectedPromptKey === prompt.key;
|
||||
const displayText = isExpanded
|
||||
? prompt.value
|
||||
: truncateText(prompt.value, 120);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={prompt.key}
|
||||
className={`p-4 rounded-lg border transition-all ${
|
||||
isSelected
|
||||
? "border-primary bg-accent/50"
|
||||
: "border-border hover:border-primary/50 hover:bg-accent/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap flex-1">
|
||||
<Badge variant="outline" className="text-xs font-medium">
|
||||
{prompt.key.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
{prompt.category && (
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{prompt.category}
|
||||
</Badge>
|
||||
)}
|
||||
{isSelected && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
✓ Selected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{prompt.link && (
|
||||
<a
|
||||
href={prompt.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-primary shrink-0"
|
||||
title="View source"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-foreground mb-3 whitespace-pre-wrap">
|
||||
{displayText}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{prompt.author}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{prompt.value.length > 120 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleExpand(prompt.key)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3 mr-1" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3 mr-1" />
|
||||
Read more
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant={isSelected ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleSelectCommunityPrompt(prompt.key, prompt.value)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isSelected ? "Applied" : "Use This"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
id="custom-instructions"
|
||||
placeholder="E.g., Always provide practical examples, be concise, focus on technical details..."
|
||||
value={customInstructions}
|
||||
onChange={(e) => {
|
||||
setCustomInstructions(e.target.value);
|
||||
setSelectedPromptKey(null);
|
||||
}}
|
||||
rows={6}
|
||||
className="resize-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">{customInstructions.length} characters</p>
|
||||
{customInstructions.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCustomInstructions("");
|
||||
setSelectedPromptKey(null);
|
||||
}}
|
||||
className="h-auto py-1 px-2 text-xs"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<Button variant="ghost" onClick={handleSkip} disabled={saving}>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !hasChanges}>
|
||||
{saving ? "Saving..." : "Save Configuration"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,148 +1,133 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Brain, ChevronDown, Circle, Loader2, Search, Sparkles, Lightbulb, CheckCircle2 } from "lucide-react"
|
||||
import React from "react"
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Circle,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ChainOfThoughtItemProps = React.ComponentProps<"div">
|
||||
export type ChainOfThoughtItemProps = React.ComponentProps<"div">;
|
||||
|
||||
export const ChainOfThoughtItem = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ChainOfThoughtItemProps) => (
|
||||
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
export const ChainOfThoughtItem = ({ children, className, ...props }: ChainOfThoughtItemProps) => (
|
||||
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ChainOfThoughtTriggerProps = React.ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
leftIcon?: React.ReactNode
|
||||
swapIconOnHover?: boolean
|
||||
}
|
||||
export type ChainOfThoughtTriggerProps = React.ComponentProps<typeof CollapsibleTrigger> & {
|
||||
leftIcon?: React.ReactNode;
|
||||
swapIconOnHover?: boolean;
|
||||
};
|
||||
|
||||
export const ChainOfThoughtTrigger = ({
|
||||
children,
|
||||
className,
|
||||
leftIcon,
|
||||
swapIconOnHover = true,
|
||||
...props
|
||||
children,
|
||||
className,
|
||||
leftIcon,
|
||||
swapIconOnHover = true,
|
||||
...props
|
||||
}: ChainOfThoughtTriggerProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"group text-muted-foreground hover:text-foreground flex cursor-pointer items-center justify-start gap-1 text-left text-sm transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{leftIcon ? (
|
||||
<span className="relative inline-flex size-4 items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
"transition-opacity",
|
||||
swapIconOnHover && "group-hover:opacity-0"
|
||||
)}
|
||||
>
|
||||
{leftIcon}
|
||||
</span>
|
||||
{swapIconOnHover && (
|
||||
<ChevronDown className="absolute size-4 opacity-0 transition-opacity group-hover:opacity-100 group-data-[state=open]:rotate-180" />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="relative inline-flex size-4 items-center justify-center">
|
||||
<Circle className="size-2 fill-current" />
|
||||
</span>
|
||||
)}
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
{!leftIcon && (
|
||||
<ChevronDown className="size-4 transition-transform group-data-[state=open]:rotate-180" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"group text-muted-foreground hover:text-foreground flex cursor-pointer items-center justify-start gap-1 text-left text-sm transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{leftIcon ? (
|
||||
<span className="relative inline-flex size-4 items-center justify-center">
|
||||
<span className={cn("transition-opacity", swapIconOnHover && "group-hover:opacity-0")}>
|
||||
{leftIcon}
|
||||
</span>
|
||||
{swapIconOnHover && (
|
||||
<ChevronDown className="absolute size-4 opacity-0 transition-opacity group-hover:opacity-100 group-data-[state=open]:rotate-180" />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="relative inline-flex size-4 items-center justify-center">
|
||||
<Circle className="size-2 fill-current" />
|
||||
</span>
|
||||
)}
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
{!leftIcon && (
|
||||
<ChevronDown className="size-4 transition-transform group-data-[state=open]:rotate-180" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type ChainOfThoughtContentProps = React.ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>
|
||||
export type ChainOfThoughtContentProps = React.ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const ChainOfThoughtContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ChainOfThoughtContentProps) => {
|
||||
return (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"text-popover-foreground data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="grid grid-cols-[min-content_minmax(0,1fr)] gap-x-4">
|
||||
<div className="bg-primary/20 ml-1.75 h-full w-px group-data-[last=true]:hidden" />
|
||||
<div className="ml-1.75 h-full w-px bg-transparent group-data-[last=false]:hidden" />
|
||||
<div className="mt-2 space-y-2">{children}</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"text-popover-foreground data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="grid grid-cols-[min-content_minmax(0,1fr)] gap-x-4">
|
||||
<div className="bg-primary/20 ml-1.75 h-full w-px group-data-[last=true]:hidden" />
|
||||
<div className="ml-1.75 h-full w-px bg-transparent group-data-[last=false]:hidden" />
|
||||
<div className="mt-2 space-y-2">{children}</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
};
|
||||
|
||||
export type ChainOfThoughtProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ChainOfThought({ children, className }: ChainOfThoughtProps) {
|
||||
const childrenArray = React.Children.toArray(children)
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-0", className)}>
|
||||
{childrenArray.map((child, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{React.isValidElement(child) &&
|
||||
React.cloneElement(
|
||||
child as React.ReactElement<ChainOfThoughtStepProps>,
|
||||
{
|
||||
isLast: index === childrenArray.length - 1,
|
||||
}
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className={cn("space-y-0", className)}>
|
||||
{childrenArray.map((child, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{React.isValidElement(child) &&
|
||||
React.cloneElement(child as React.ReactElement<ChainOfThoughtStepProps>, {
|
||||
isLast: index === childrenArray.length - 1,
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type ChainOfThoughtStepProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
isLast?: boolean
|
||||
}
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
export const ChainOfThoughtStep = ({
|
||||
children,
|
||||
className,
|
||||
isLast = false,
|
||||
...props
|
||||
children,
|
||||
className,
|
||||
isLast = false,
|
||||
...props
|
||||
}: ChainOfThoughtStepProps & React.ComponentProps<typeof Collapsible>) => {
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn("group", className)}
|
||||
data-last={isLast}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<div className="flex justify-start group-data-[last=true]:hidden">
|
||||
<div className="bg-primary/20 ml-1.75 h-4 w-px" />
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Collapsible className={cn("group", className)} data-last={isLast} {...props}>
|
||||
{children}
|
||||
<div className="flex justify-start group-data-[last=true]:hidden">
|
||||
<div className="bg-primary/20 ml-1.75 h-4 w-px" />
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -121,11 +121,6 @@ export function SearchSpaceForm({
|
|||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
{isEditing ? "Edit Search Space" : "Create Search Space"}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{isEditing
|
||||
? "Update your search space details"
|
||||
: "Create a new search space to organize your documents, chats, and podcasts."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -198,8 +193,8 @@ export function SearchSpaceForm({
|
|||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
A search space allows you to organize and search through your documents, generate
|
||||
podcasts, and have AI-powered conversations about your content.
|
||||
A search space is your personal workspace. Connect external sources, upload documents,
|
||||
take notes, and get work done with AI agents.
|
||||
</p>
|
||||
</div>
|
||||
</Tilt>
|
||||
|
|
|
|||
|
|
@ -4,24 +4,22 @@ import { useAtomValue } from "jotai";
|
|||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
Brain,
|
||||
CheckCircle,
|
||||
FileText,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Settings2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/llm-config/llm-config-mutation.atoms";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import {
|
||||
globalLLMConfigsAtom,
|
||||
llmConfigsAtom,
|
||||
globalNewLLMConfigsAtom,
|
||||
llmPreferencesAtom,
|
||||
} from "@/atoms/llm-config/llm-config-query.atoms";
|
||||
newLLMConfigsAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -36,29 +34,21 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
|
||||
const ROLE_DESCRIPTIONS = {
|
||||
long_context: {
|
||||
icon: Brain,
|
||||
title: "Long Context LLM",
|
||||
description: "Handles summarization of long documents and complex Q&A",
|
||||
color: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
examples: "Document analysis, research synthesis, complex Q&A",
|
||||
characteristics: ["Large context window", "Deep reasoning", "Complex analysis"],
|
||||
},
|
||||
fast: {
|
||||
icon: Zap,
|
||||
title: "Fast LLM",
|
||||
description: "Optimized for quick responses and real-time interactions",
|
||||
color: "bg-green-100 text-green-800 border-green-200",
|
||||
examples: "Quick searches, simple questions, instant responses",
|
||||
characteristics: ["Low latency", "Quick responses", "Real-time chat"],
|
||||
},
|
||||
strategic: {
|
||||
agent: {
|
||||
icon: Bot,
|
||||
title: "Strategic LLM",
|
||||
description: "Advanced reasoning for planning and strategic decision making",
|
||||
title: "Agent LLM",
|
||||
description: "Primary LLM for chat interactions and agent operations",
|
||||
color: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
examples: "Chat responses, agent tasks, real-time interactions",
|
||||
characteristics: ["Fast responses", "Conversational", "Agent operations"],
|
||||
},
|
||||
document_summary: {
|
||||
icon: FileText,
|
||||
title: "Document Summary LLM",
|
||||
description: "Handles document summarization, long context analysis, and query reformulation",
|
||||
color: "bg-purple-100 text-purple-800 border-purple-200",
|
||||
examples: "Planning workflows, strategic analysis, complex problem solving",
|
||||
characteristics: ["Strategic thinking", "Long-term planning", "Complex reasoning"],
|
||||
examples: "Document analysis, podcasts, research synthesis",
|
||||
characteristics: ["Large context window", "Deep reasoning", "Summarization"],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -67,18 +57,19 @@ interface LLMRoleManagerProps {
|
|||
}
|
||||
|
||||
export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||
// Use new LLM config system
|
||||
const {
|
||||
data: llmConfigs = [],
|
||||
data: newLLMConfigs = [],
|
||||
isFetching: configsLoading,
|
||||
error: configsError,
|
||||
refetch: refreshConfigs,
|
||||
} = useAtomValue(llmConfigsAtom);
|
||||
} = useAtomValue(newLLMConfigsAtom);
|
||||
const {
|
||||
data: globalConfigs = [],
|
||||
isFetching: globalConfigsLoading,
|
||||
error: globalConfigsError,
|
||||
refetch: refreshGlobalConfigs,
|
||||
} = useAtomValue(globalLLMConfigsAtom);
|
||||
} = useAtomValue(globalNewLLMConfigsAtom);
|
||||
const {
|
||||
data: preferences = {},
|
||||
isFetching: preferencesLoading,
|
||||
|
|
@ -89,9 +80,8 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const [assignments, setAssignments] = useState({
|
||||
long_context_llm_id: preferences.long_context_llm_id || "",
|
||||
fast_llm_id: preferences.fast_llm_id || "",
|
||||
strategic_llm_id: preferences.strategic_llm_id || "",
|
||||
agent_llm_id: preferences.agent_llm_id || "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id || "",
|
||||
});
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
|
@ -99,9 +89,8 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
|
||||
useEffect(() => {
|
||||
const newAssignments = {
|
||||
long_context_llm_id: preferences.long_context_llm_id || "",
|
||||
fast_llm_id: preferences.fast_llm_id || "",
|
||||
strategic_llm_id: preferences.strategic_llm_id || "",
|
||||
agent_llm_id: preferences.agent_llm_id || "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id || "",
|
||||
};
|
||||
setAssignments(newAssignments);
|
||||
setHasChanges(false);
|
||||
|
|
@ -117,9 +106,8 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
|
||||
// Check if there are changes compared to current preferences
|
||||
const currentPrefs = {
|
||||
long_context_llm_id: preferences.long_context_llm_id || "",
|
||||
fast_llm_id: preferences.fast_llm_id || "",
|
||||
strategic_llm_id: preferences.strategic_llm_id || "",
|
||||
agent_llm_id: preferences.agent_llm_id || "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id || "",
|
||||
};
|
||||
|
||||
const hasChangesNow = Object.keys(newAssignments).some(
|
||||
|
|
@ -135,24 +123,18 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
setIsSaving(true);
|
||||
|
||||
const numericAssignments = {
|
||||
long_context_llm_id:
|
||||
typeof assignments.long_context_llm_id === "string"
|
||||
? assignments.long_context_llm_id
|
||||
? parseInt(assignments.long_context_llm_id)
|
||||
agent_llm_id:
|
||||
typeof assignments.agent_llm_id === "string"
|
||||
? assignments.agent_llm_id
|
||||
? parseInt(assignments.agent_llm_id)
|
||||
: undefined
|
||||
: assignments.long_context_llm_id,
|
||||
fast_llm_id:
|
||||
typeof assignments.fast_llm_id === "string"
|
||||
? assignments.fast_llm_id
|
||||
? parseInt(assignments.fast_llm_id)
|
||||
: assignments.agent_llm_id,
|
||||
document_summary_llm_id:
|
||||
typeof assignments.document_summary_llm_id === "string"
|
||||
? assignments.document_summary_llm_id
|
||||
? parseInt(assignments.document_summary_llm_id)
|
||||
: undefined
|
||||
: assignments.fast_llm_id,
|
||||
strategic_llm_id:
|
||||
typeof assignments.strategic_llm_id === "string"
|
||||
? assignments.strategic_llm_id
|
||||
? parseInt(assignments.strategic_llm_id)
|
||||
: undefined
|
||||
: assignments.strategic_llm_id,
|
||||
: assignments.document_summary_llm_id,
|
||||
};
|
||||
|
||||
await updatePreferences({
|
||||
|
|
@ -168,21 +150,18 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
|
||||
const handleReset = () => {
|
||||
setAssignments({
|
||||
long_context_llm_id: preferences.long_context_llm_id || "",
|
||||
fast_llm_id: preferences.fast_llm_id || "",
|
||||
strategic_llm_id: preferences.strategic_llm_id || "",
|
||||
agent_llm_id: preferences.agent_llm_id || "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id || "",
|
||||
});
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const isAssignmentComplete =
|
||||
assignments.long_context_llm_id && assignments.fast_llm_id && assignments.strategic_llm_id;
|
||||
const assignedConfigIds = Object.values(assignments).filter((id) => id !== "");
|
||||
const isAssignmentComplete = assignments.agent_llm_id && assignments.document_summary_llm_id;
|
||||
|
||||
// Combine global and custom configs
|
||||
// Combine global and custom configs (new system)
|
||||
const allConfigs = [
|
||||
...globalConfigs.map((config) => ({ ...config, is_global: true })),
|
||||
...llmConfigs.filter((config) => config.id && config.id.toString().trim() !== ""),
|
||||
...newLLMConfigs.filter((config) => config.id && config.id.toString().trim() !== ""),
|
||||
];
|
||||
|
||||
const availableConfigs = allConfigs;
|
||||
|
|
@ -194,19 +173,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10">
|
||||
<Settings2 className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">LLM Role Management</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Assign your LLM configurations to specific roles for different purposes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -263,99 +229,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats Overview */}
|
||||
{!isLoading && !hasError && (
|
||||
<div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="h-1 bg-blue-500" />
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p className="text-2xl font-bold tracking-tight">{availableConfigs.length}</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">Available Models</p>
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-0.5 text-[10px] text-muted-foreground">
|
||||
<span>{globalConfigs.length} Global</span>
|
||||
<span>{llmConfigs.length} Custom</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-500/10">
|
||||
<Bot className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="h-1 bg-purple-500" />
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p className="text-2xl font-bold tracking-tight">{assignedConfigIds.length}</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">Assigned Roles</p>
|
||||
</div>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-purple-500/10">
|
||||
<CheckCircle className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className={`h-1 ${isAssignmentComplete ? "bg-green-500" : "bg-yellow-500"}`} />
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p className="text-2xl font-bold tracking-tight">
|
||||
{Math.round((assignedConfigIds.length / 3) * 100)}%
|
||||
</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">Completion</p>
|
||||
</div>
|
||||
<div
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
|
||||
isAssignmentComplete ? "bg-green-500/10" : "bg-yellow-500/10"
|
||||
}`}
|
||||
>
|
||||
{isAssignmentComplete ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className={`h-1 ${isAssignmentComplete ? "bg-emerald-500" : "bg-orange-500"}`} />
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p
|
||||
className={`text-2xl font-bold tracking-tight ${
|
||||
isAssignmentComplete ? "text-emerald-600" : "text-orange-600"
|
||||
}`}
|
||||
>
|
||||
{isAssignmentComplete ? "Ready" : "Setup"}
|
||||
</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">Status</p>
|
||||
</div>
|
||||
<div
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
|
||||
isAssignmentComplete ? "bg-emerald-500/10" : "bg-orange-500/10"
|
||||
}`}
|
||||
>
|
||||
{isAssignmentComplete ? (
|
||||
<CheckCircle className="h-4 w-4 text-emerald-600" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 text-orange-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Alert */}
|
||||
{!isLoading && !hasError && (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -363,7 +236,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
No LLM configurations found. Please add at least one LLM provider in the Model
|
||||
No LLM configurations found. Please add at least one LLM provider in the Agent
|
||||
Configs tab before assigning roles.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
@ -459,12 +332,12 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
)}
|
||||
|
||||
{/* Custom Configurations */}
|
||||
{llmConfigs.length > 0 && (
|
||||
{newLLMConfigs.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Your Configurations
|
||||
</div>
|
||||
{llmConfigs
|
||||
{newLLMConfigs
|
||||
.filter(
|
||||
(config) => config.id && config.id.toString().trim() !== ""
|
||||
)
|
||||
|
|
@ -536,38 +409,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Indicator */}
|
||||
{isAssignmentComplete && !hasChanges && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-green-50 text-green-700 rounded-lg border border-green-200">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">All roles assigned and saved!</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<div className="flex justify-center">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Progress:</span>
|
||||
<div className="flex gap-1">
|
||||
{Object.keys(ROLE_DESCRIPTIONS).map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
assignments[`${key}_llm_id` as keyof typeof assignments]
|
||||
? "bg-primary"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span>
|
||||
{assignedConfigIds.length} of {Object.keys(ROLE_DESCRIPTIONS).length} roles assigned
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,30 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Info,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Sparkles,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { AlertTriangle, Info, RotateCcw, Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
|
@ -44,20 +28,14 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
|
||||
enabled: !!searchSpaceId,
|
||||
});
|
||||
const { data: prompts = [], isPending: loadingPrompts } = useAtomValue(communityPromptsAtom);
|
||||
|
||||
const [enableCitations, setEnableCitations] = useState(true);
|
||||
const [customInstructions, setCustomInstructions] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [selectedPromptKey, setSelectedPromptKey] = useState<string | null>(null);
|
||||
const [expandedPrompts, setExpandedPrompts] = useState<Set<string>>(new Set());
|
||||
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||
|
||||
// Initialize state from fetched search space
|
||||
useEffect(() => {
|
||||
if (searchSpace) {
|
||||
setEnableCitations(searchSpace.citations_enabled);
|
||||
setCustomInstructions(searchSpace.qna_custom_instructions || "");
|
||||
setHasChanges(false);
|
||||
}
|
||||
|
|
@ -67,50 +45,39 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
useEffect(() => {
|
||||
if (searchSpace) {
|
||||
const currentCustom = searchSpace.qna_custom_instructions || "";
|
||||
|
||||
const changed =
|
||||
searchSpace.citations_enabled !== enableCitations || currentCustom !== customInstructions;
|
||||
|
||||
const changed = currentCustom !== customInstructions;
|
||||
setHasChanges(changed);
|
||||
}
|
||||
}, [searchSpace, enableCitations, customInstructions]);
|
||||
}, [searchSpace, customInstructions]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Prepare payload with simplified schema
|
||||
const payload: any = {
|
||||
citations_enabled: enableCitations,
|
||||
const payload = {
|
||||
qna_custom_instructions: customInstructions.trim() || "",
|
||||
};
|
||||
|
||||
// Only send request if we have something to update
|
||||
if (Object.keys(payload).length > 0) {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to save prompt configuration");
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
|
||||
toast.success("Prompt configuration saved successfully");
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to save system instructions");
|
||||
}
|
||||
|
||||
toast.success("System instructions saved successfully");
|
||||
setHasChanges(false);
|
||||
|
||||
// Refresh to get updated data
|
||||
await fetchSearchSpace();
|
||||
} catch (error: any) {
|
||||
console.error("Error saving prompt configuration:", error);
|
||||
toast.error(error.message || "Failed to save prompt configuration");
|
||||
console.error("Error saving system instructions:", error);
|
||||
toast.error(error.message || "Failed to save system instructions");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -118,41 +85,11 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
|
||||
const handleReset = () => {
|
||||
if (searchSpace) {
|
||||
setEnableCitations(searchSpace.citations_enabled);
|
||||
setCustomInstructions(searchSpace.qna_custom_instructions || "");
|
||||
setSelectedPromptKey(null);
|
||||
setHasChanges(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectCommunityPrompt = (promptKey: string, promptValue: string) => {
|
||||
setCustomInstructions(promptValue);
|
||||
setSelectedPromptKey(promptKey);
|
||||
toast.success("Community prompt applied");
|
||||
};
|
||||
|
||||
const toggleExpand = (promptKey: string) => {
|
||||
const newExpanded = new Set(expandedPrompts);
|
||||
if (newExpanded.has(promptKey)) {
|
||||
newExpanded.delete(promptKey);
|
||||
} else {
|
||||
newExpanded.add(promptKey);
|
||||
}
|
||||
setExpandedPrompts(newExpanded);
|
||||
};
|
||||
|
||||
// Get unique categories
|
||||
const categories = Array.from(new Set(prompts.map((p) => p.category || "general")));
|
||||
const filteredPrompts =
|
||||
selectedCategory === "all"
|
||||
? prompts
|
||||
: prompts.filter((p) => (p.category || "general") === selectedCategory);
|
||||
|
||||
const truncateText = (text: string, maxLength: number = 150) => {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + "...";
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -172,225 +109,47 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Configure how the AI responds to your queries. Citations add source references, and the
|
||||
system instructions personalize the response style.
|
||||
{/* Work in Progress Notice */}
|
||||
<Alert
|
||||
variant="default"
|
||||
className="bg-amber-50 dark:bg-amber-950/30 border-amber-300 dark:border-amber-700"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-500" />
|
||||
<AlertDescription className="text-amber-800 dark:text-amber-300">
|
||||
<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>
|
||||
|
||||
{/* Citations Card */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<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.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* System Instructions Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Citation Configuration</CardTitle>
|
||||
<CardTitle>Custom System Instructions</CardTitle>
|
||||
<CardDescription>
|
||||
Control whether AI responses include citations to source documents
|
||||
Provide specific guidelines for how you want the AI to respond. These instructions will
|
||||
be applied to all answers in this search space.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between space-x-4 p-4 rounded-lg border bg-card">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label htmlFor="enable-citations-settings" className="text-base font-medium">
|
||||
Enable Citations
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When enabled, AI responses will include citations in [citation:id] format linking to
|
||||
source documents.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-citations-settings"
|
||||
checked={enableCitations}
|
||||
onCheckedChange={setEnableCitations}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!enableCitations && (
|
||||
<Alert
|
||||
variant="default"
|
||||
className="bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-800"
|
||||
>
|
||||
<Info className="h-4 w-4 text-yellow-600 dark:text-yellow-500" />
|
||||
<AlertDescription className="text-yellow-800 dark:text-yellow-300">
|
||||
Citations are currently disabled. AI responses will not include source references.
|
||||
You can re-enable this anytime.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{enableCitations && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Citations are enabled. When answering questions, the AI will reference source
|
||||
documents using the [citation:id] format.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SearchSpace System Instructions Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>SearchSpace System Instructions</CardTitle>
|
||||
<CardDescription>
|
||||
Add system instructions to guide the AI's response style and behavior
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Community Prompts Section */}
|
||||
{!loadingPrompts && prompts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base font-medium flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Community Prompts Library
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browse {prompts.length} curated prompts from the community
|
||||
</p>
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="pt-4">
|
||||
<Tabs
|
||||
value={selectedCategory}
|
||||
onValueChange={setSelectedCategory}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-5 mb-4">
|
||||
<TabsTrigger value="all" className="text-xs">
|
||||
All ({prompts.length})
|
||||
</TabsTrigger>
|
||||
{categories.map((category) => (
|
||||
<TabsTrigger key={category} value={category} className="text-xs capitalize">
|
||||
{category} (
|
||||
{prompts.filter((p) => (p.category || "general") === category).length})
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<ScrollArea className="h-[350px] pr-4">
|
||||
<div className="space-y-3">
|
||||
{filteredPrompts.map((prompt) => {
|
||||
const isExpanded = expandedPrompts.has(prompt.key);
|
||||
const isSelected = selectedPromptKey === prompt.key;
|
||||
const displayText = isExpanded
|
||||
? prompt.value
|
||||
: truncateText(prompt.value, 120);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={prompt.key}
|
||||
className={`p-4 rounded-lg border transition-all ${
|
||||
isSelected
|
||||
? "border-primary bg-accent/50"
|
||||
: "border-border hover:border-primary/50 hover:bg-accent/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap flex-1">
|
||||
<Badge variant="outline" className="text-xs font-medium">
|
||||
{prompt.key.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
{prompt.category && (
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{prompt.category}
|
||||
</Badge>
|
||||
)}
|
||||
{isSelected && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
✓ Selected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{prompt.link && (
|
||||
<a
|
||||
href={prompt.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-primary shrink-0"
|
||||
title="View source"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-foreground mb-3 whitespace-pre-wrap">
|
||||
{displayText}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{prompt.author}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{prompt.value.length > 120 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleExpand(prompt.key)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3 mr-1" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3 mr-1" />
|
||||
Read more
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant={isSelected ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleSelectCommunityPrompt(prompt.key, prompt.value)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isSelected ? "Applied" : "Use This"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-instructions-settings" className="text-base font-medium">
|
||||
Your System Instructions
|
||||
Your Instructions
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Provide specific guidelines for how you want the AI to respond. These instructions
|
||||
will be applied to all answers.
|
||||
</p>
|
||||
<Textarea
|
||||
id="custom-instructions-settings"
|
||||
placeholder="E.g., Always provide practical examples, be concise, focus on technical details, use simple language..."
|
||||
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);
|
||||
setSelectedPromptKey(null);
|
||||
}}
|
||||
rows={8}
|
||||
onChange={(e) => setCustomInstructions(e.target.value)}
|
||||
rows={12}
|
||||
className="resize-none font-mono text-sm"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -401,10 +160,7 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCustomInstructions("");
|
||||
setSelectedPromptKey(null);
|
||||
}}
|
||||
onClick={() => setCustomInstructions("")}
|
||||
className="h-auto py-1 px-2 text-xs"
|
||||
>
|
||||
Clear
|
||||
|
|
@ -441,7 +197,7 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
className="flex items-center gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? "Saving..." : "Save Configuration"}
|
||||
{saving ? "Saving..." : "Save Instructions"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -452,7 +208,7 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
>
|
||||
<Info className="h-4 w-4 text-blue-600 dark:text-blue-500" />
|
||||
<AlertDescription className="text-blue-800 dark:text-blue-300">
|
||||
You have unsaved changes. Click "Save Configuration" to apply them.
|
||||
You have unsaved changes. Click "Save Instructions" to apply them.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
|
|||
566
surfsense_web/components/shared/llm-config-form.tsx
Normal file
566
surfsense_web/components/shared/llm-config-form.tsx
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Key,
|
||||
Loader2,
|
||||
MessageSquareQuote,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { defaultSystemInstructionsAtom } from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getModelsByProvider } from "@/contracts/enums/llm-models";
|
||||
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
|
||||
import type { CreateNewLLMConfigRequest } from "@/contracts/types/new-llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import InferenceParamsEditor from "../inference-params-editor";
|
||||
|
||||
// Form schema with zod
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100),
|
||||
description: z.string().max(500).optional().nullable(),
|
||||
provider: z.string().min(1, "Provider is required"),
|
||||
custom_provider: z.string().max(100).optional().nullable(),
|
||||
model_name: z.string().min(1, "Model name is required").max(100),
|
||||
api_key: z.string().min(1, "API key is required"),
|
||||
api_base: z.string().max(500).optional().nullable(),
|
||||
litellm_params: z.record(z.string(), z.any()).optional().nullable(),
|
||||
system_instructions: z.string().optional().default(""),
|
||||
use_default_system_instructions: z.boolean().default(true),
|
||||
citations_enabled: z.boolean().default(true),
|
||||
search_space_id: z.number(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export interface LLMConfigFormData extends CreateNewLLMConfigRequest {}
|
||||
|
||||
interface LLMConfigFormProps {
|
||||
initialData?: Partial<LLMConfigFormData>;
|
||||
searchSpaceId: number;
|
||||
onSubmit: (data: LLMConfigFormData) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
isSubmitting?: boolean;
|
||||
mode?: "create" | "edit";
|
||||
submitLabel?: string;
|
||||
showAdvanced?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function LLMConfigForm({
|
||||
initialData,
|
||||
searchSpaceId,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isSubmitting = false,
|
||||
mode = "create",
|
||||
submitLabel,
|
||||
showAdvanced = true,
|
||||
compact = false,
|
||||
}: LLMConfigFormProps) {
|
||||
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
|
||||
defaultSystemInstructionsAtom
|
||||
);
|
||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: initialData?.name ?? "",
|
||||
description: initialData?.description ?? "",
|
||||
provider: initialData?.provider ?? "",
|
||||
custom_provider: initialData?.custom_provider ?? "",
|
||||
model_name: initialData?.model_name ?? "",
|
||||
api_key: initialData?.api_key ?? "",
|
||||
api_base: initialData?.api_base ?? "",
|
||||
litellm_params: initialData?.litellm_params ?? {},
|
||||
system_instructions: initialData?.system_instructions ?? "",
|
||||
use_default_system_instructions: initialData?.use_default_system_instructions ?? true,
|
||||
citations_enabled: initialData?.citations_enabled ?? true,
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
// Load default instructions when available (only for new configs)
|
||||
useEffect(() => {
|
||||
if (
|
||||
mode === "create" &&
|
||||
defaultInstructionsLoaded &&
|
||||
defaultInstructions?.default_system_instructions &&
|
||||
!form.getValues("system_instructions")
|
||||
) {
|
||||
form.setValue("system_instructions", defaultInstructions.default_system_instructions);
|
||||
}
|
||||
}, [defaultInstructionsLoaded, defaultInstructions, mode, form]);
|
||||
|
||||
const watchProvider = form.watch("provider");
|
||||
const selectedProvider = LLM_PROVIDERS.find((p) => p.value === watchProvider);
|
||||
const availableModels = watchProvider ? getModelsByProvider(watchProvider) : [];
|
||||
|
||||
const handleProviderChange = (value: string) => {
|
||||
form.setValue("provider", value);
|
||||
form.setValue("model_name", "");
|
||||
|
||||
// Auto-fill API base for certain providers
|
||||
const provider = LLM_PROVIDERS.find((p) => p.value === value);
|
||||
if (provider?.apiBase) {
|
||||
form.setValue("api_base", provider.apiBase);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (values: FormValues) => {
|
||||
await onSubmit(values as LLMConfigFormData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||
{/* System Instructions & Citations Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<MessageSquareQuote className="h-4 w-4" />
|
||||
System Instructions
|
||||
</div>
|
||||
|
||||
{/* System Instructions */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="system_instructions"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Instructions for the AI</FormLabel>
|
||||
{defaultInstructions && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
field.onChange(defaultInstructions.default_system_instructions)
|
||||
}
|
||||
className="h-7 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Reset to Default
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter system instructions for the AI..."
|
||||
rows={6}
|
||||
className="font-mono text-xs resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
Use {"{resolved_today}"} to include today's date dynamically
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Citations Toggle */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="citations_enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-sm font-medium">Enable Citations</FormLabel>
|
||||
<FormDescription className="text-xs">
|
||||
Include [citation:id] references to source documents
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Model Configuration Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Bot className="h-4 w-4" />
|
||||
Model Configuration
|
||||
</div>
|
||||
|
||||
{/* Name & Description */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<Sparkles className="h-3.5 w-3.5 text-violet-500" />
|
||||
Configuration Name
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., My GPT-4 Agent"
|
||||
className="transition-all focus-visible:ring-violet-500/50"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">
|
||||
Description
|
||||
<Badge variant="outline" className="ml-2 text-[10px]">
|
||||
Optional
|
||||
</Badge>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Brief description" {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Provider Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provider"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>LLM Provider</FormLabel>
|
||||
<Select value={field.value} onValueChange={handleProviderChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="transition-all focus:ring-violet-500/50">
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{LLM_PROVIDERS.map((provider) => (
|
||||
<SelectItem key={provider.value} value={provider.value}>
|
||||
<div className="flex flex-col py-0.5">
|
||||
<span className="font-medium">{provider.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{provider.description}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Custom Provider (conditional) */}
|
||||
<AnimatePresence>
|
||||
{watchProvider === "CUSTOM" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="custom_provider"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Custom Provider Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="my-custom-provider"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Model Name with Combobox */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model_name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Model Name</FormLabel>
|
||||
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={modelComboboxOpen}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value || "Select or type model name"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={selectedProvider?.example || "Type model name..."}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="py-3 text-center text-sm text-muted-foreground">
|
||||
{field.value ? `Using: "${field.value}"` : "Type your model name"}
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
{availableModels.length > 0 && (
|
||||
<CommandGroup heading="Suggested Models">
|
||||
{availableModels
|
||||
.filter(
|
||||
(model) =>
|
||||
!field.value ||
|
||||
model.value.toLowerCase().includes(field.value.toLowerCase())
|
||||
)
|
||||
.slice(0, 8)
|
||||
.map((model) => (
|
||||
<CommandItem
|
||||
key={model.value}
|
||||
value={model.value}
|
||||
onSelect={(value) => {
|
||||
field.onChange(value);
|
||||
setModelComboboxOpen(false);
|
||||
}}
|
||||
className="py-2"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
field.value === model.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{model.label}</div>
|
||||
{model.contextWindow && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Context: {model.contextWindow}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{selectedProvider?.example && (
|
||||
<FormDescription className="text-xs">
|
||||
Example: {selectedProvider.example}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* API Credentials */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<Key className="h-3.5 w-3.5 text-amber-500" />
|
||||
API Key
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={watchProvider === "OLLAMA" ? "Any value" : "sk-..."}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{watchProvider === "OLLAMA" && (
|
||||
<FormDescription className="text-xs">
|
||||
Ollama doesn't require auth — enter any value
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_base"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
API Base URL
|
||||
{selectedProvider?.apiBase && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Auto-filled
|
||||
</Badge>
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={selectedProvider?.apiBase || "https://api.example.com/v1"}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ollama Quick Actions */}
|
||||
<AnimatePresence>
|
||||
{watchProvider === "OLLAMA" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => form.setValue("api_base", "http://localhost:11434")}
|
||||
>
|
||||
localhost:11434
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => form.setValue("api_base", "http://host.docker.internal:11434")}
|
||||
>
|
||||
Docker
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Advanced Parameters */}
|
||||
{showAdvanced && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Advanced Parameters
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="litellm_params"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<InferenceParamsEditor
|
||||
params={field.value || {}}
|
||||
setParams={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-3 pt-4",
|
||||
compact ? "justify-end" : "justify-center sm:justify-end"
|
||||
)}
|
||||
>
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={isSubmitting} className="gap-2 min-w-[160px]">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{mode === "edit" ? "Updating..." : "Creating..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!compact && <Rocket className="h-4 w-4" />}
|
||||
{submitLabel ?? (mode === "edit" ? "Update Configuration" : "Create Configuration")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
|
@ -221,7 +221,7 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
|
|||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<ScrollArea className="flex-1 min-h-0 overflow-hidden">
|
||||
<div className="p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ export function AllNotesSidebar({
|
|||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<ScrollArea className="flex-1 min-h-0 overflow-hidden">
|
||||
<div className="p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
|
|
|
|||
|
|
@ -41,19 +41,13 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
|
|||
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={usagePercentage} className="h-2" />
|
||||
<div className="flex items-start gap-2 pt-1">
|
||||
<Mail className="h-3 w-3 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
Contact{" "}
|
||||
<a
|
||||
href="mailto:rohan@surfsense.com"
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
rohan@surfsense.com
|
||||
</a>{" "}
|
||||
to increase limits
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="mailto:rohan@surfsense.com?subject=Request%20to%20Increase%20Page%20Limits"
|
||||
className="flex items-center gap-1.5 text-[10px] text-muted-foreground hover:text-primary transition-colors pt-1"
|
||||
>
|
||||
<Mail className="h-3 w-3 flex-shrink-0" />
|
||||
<span>Contact to increase limits</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
BookOpenIcon,
|
||||
|
|
@ -18,6 +10,9 @@ import {
|
|||
} from "lucide-react";
|
||||
import { Component, type ReactNode, useCallback } from "react";
|
||||
import { z } from "zod";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Zod schema for serializable article data (from backend)
|
||||
|
|
@ -92,7 +87,7 @@ export interface ArticleProps {
|
|||
*/
|
||||
export function parseSerializableArticle(data: unknown): ArticleProps {
|
||||
const result = SerializableArticleSchema.safeParse(data);
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
console.warn("Invalid article data:", result.error.issues);
|
||||
// Return fallback with basic info
|
||||
|
|
@ -103,7 +98,7 @@ export function parseSerializableArticle(data: unknown): ArticleProps {
|
|||
error: "Failed to parse article data",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const parsed = result.data;
|
||||
return {
|
||||
id: parsed.id,
|
||||
|
|
@ -162,10 +157,7 @@ export function Article({
|
|||
return (
|
||||
<Card
|
||||
id={id}
|
||||
className={cn(
|
||||
"overflow-hidden border-destructive/20 bg-destructive/5",
|
||||
className
|
||||
)}
|
||||
className={cn("overflow-hidden border-destructive/20 bg-destructive/5", className)}
|
||||
style={{ maxWidth }}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
|
|
@ -174,14 +166,8 @@ export function Article({
|
|||
<AlertCircleIcon className="size-5 text-destructive" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-destructive text-sm">
|
||||
Failed to scrape webpage
|
||||
</p>
|
||||
{href && (
|
||||
<p className="text-muted-foreground text-xs mt-0.5 truncate">
|
||||
{href}
|
||||
</p>
|
||||
)}
|
||||
<p className="font-medium text-destructive text-sm">Failed to scrape webpage</p>
|
||||
{href && <p className="text-muted-foreground text-xs mt-0.5 truncate">{href}</p>}
|
||||
<p className="text-muted-foreground text-xs mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -228,9 +214,7 @@ export function Article({
|
|||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-xs mt-1 line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs mt-1 line-clamp-2">{description}</p>
|
||||
)}
|
||||
|
||||
{/* Metadata row */}
|
||||
|
|
@ -276,9 +260,7 @@ export function Article({
|
|||
<span className="flex items-center gap-1">
|
||||
<FileTextIcon className="size-3" />
|
||||
<span>{formatWordCount(wordCount)}</span>
|
||||
{wasTruncated && (
|
||||
<span className="text-warning">(truncated)</span>
|
||||
)}
|
||||
{wasTruncated && <span className="text-warning">(truncated)</span>}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
|
@ -333,9 +315,7 @@ export function Article({
|
|||
/**
|
||||
* Loading state for article component
|
||||
*/
|
||||
export function ArticleLoading({
|
||||
title = "Loading article...",
|
||||
}: { title?: string }) {
|
||||
export function ArticleLoading({ title = "Loading article..." }: { title?: string }) {
|
||||
return (
|
||||
<Card className="overflow-hidden animate-pulse">
|
||||
<CardContent className="p-4">
|
||||
|
|
@ -388,10 +368,7 @@ interface ErrorBoundaryState {
|
|||
/**
|
||||
* Error boundary for article component
|
||||
*/
|
||||
export class ArticleErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
export class ArticleErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
|
|
@ -409,9 +386,7 @@ export class ArticleErrorBoundary extends Component<
|
|||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircleIcon className="size-5 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to render article
|
||||
</p>
|
||||
<p className="text-sm text-destructive">Failed to render article</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -422,4 +397,3 @@ export class ArticleErrorBoundary extends Component<
|
|||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { Brain, CheckCircle2, Loader2, Search, Sparkles } from "lucide-react";
|
||||
import { useMemo, useState, useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ChainOfThought,
|
||||
ChainOfThoughtContent,
|
||||
ChainOfThoughtItem,
|
||||
ChainOfThoughtStep,
|
||||
ChainOfThoughtTrigger,
|
||||
ChainOfThought,
|
||||
ChainOfThoughtContent,
|
||||
ChainOfThoughtItem,
|
||||
ChainOfThoughtStep,
|
||||
ChainOfThoughtTrigger,
|
||||
} from "@/components/prompt-kit/chain-of-thought";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -17,21 +17,21 @@ import { cn } from "@/lib/utils";
|
|||
* Zod schemas for runtime validation
|
||||
*/
|
||||
const ThinkingStepSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
items: z.array(z.string()).default([]),
|
||||
status: z.enum(["pending", "in_progress", "completed"]).default("pending"),
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
items: z.array(z.string()).default([]),
|
||||
status: z.enum(["pending", "in_progress", "completed"]).default("pending"),
|
||||
});
|
||||
|
||||
const DeepAgentThinkingArgsSchema = z.object({
|
||||
query: z.string().optional(),
|
||||
context: z.string().optional(),
|
||||
query: z.string().optional(),
|
||||
context: z.string().optional(),
|
||||
});
|
||||
|
||||
const DeepAgentThinkingResultSchema = z.object({
|
||||
steps: z.array(ThinkingStepSchema).optional(),
|
||||
status: z.enum(["thinking", "searching", "synthesizing", "completed"]).optional(),
|
||||
summary: z.string().optional(),
|
||||
steps: z.array(ThinkingStepSchema).optional(),
|
||||
status: z.enum(["thinking", "searching", "synthesizing", "completed"]).optional(),
|
||||
summary: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -45,200 +45,198 @@ type DeepAgentThinkingResult = z.infer<typeof DeepAgentThinkingResultSchema>;
|
|||
* Parse and validate a single thinking step
|
||||
*/
|
||||
export function parseThinkingStep(data: unknown): ThinkingStep {
|
||||
const result = ThinkingStepSchema.safeParse(data);
|
||||
if (!result.success) {
|
||||
console.warn("Invalid thinking step data:", result.error.issues);
|
||||
// Return a fallback step
|
||||
return {
|
||||
id: "unknown",
|
||||
title: "Processing...",
|
||||
items: [],
|
||||
status: "pending",
|
||||
};
|
||||
}
|
||||
return result.data;
|
||||
const result = ThinkingStepSchema.safeParse(data);
|
||||
if (!result.success) {
|
||||
console.warn("Invalid thinking step data:", result.error.issues);
|
||||
// Return a fallback step
|
||||
return {
|
||||
id: "unknown",
|
||||
title: "Processing...",
|
||||
items: [],
|
||||
status: "pending",
|
||||
};
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate thinking result
|
||||
*/
|
||||
export function parseThinkingResult(data: unknown): DeepAgentThinkingResult {
|
||||
const result = DeepAgentThinkingResultSchema.safeParse(data);
|
||||
if (!result.success) {
|
||||
console.warn("Invalid thinking result data:", result.error.issues);
|
||||
return {};
|
||||
}
|
||||
return result.data;
|
||||
const result = DeepAgentThinkingResultSchema.safeParse(data);
|
||||
if (!result.success) {
|
||||
console.warn("Invalid thinking result data:", result.error.issues);
|
||||
return {};
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon based on step status and type
|
||||
*/
|
||||
function getStepIcon(status: "pending" | "in_progress" | "completed", title: string) {
|
||||
// Check for specific step types based on title keywords
|
||||
const titleLower = title.toLowerCase();
|
||||
|
||||
if (status === "in_progress") {
|
||||
return <Loader2 className="size-4 animate-spin text-primary" />;
|
||||
}
|
||||
|
||||
if (status === "completed") {
|
||||
return <CheckCircle2 className="size-4 text-emerald-500" />;
|
||||
}
|
||||
|
||||
// Default icons based on step type
|
||||
if (titleLower.includes("search") || titleLower.includes("knowledge")) {
|
||||
return <Search className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
if (titleLower.includes("analy") || titleLower.includes("understand")) {
|
||||
return <Brain className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
return <Sparkles className="size-4 text-muted-foreground" />;
|
||||
// Check for specific step types based on title keywords
|
||||
const titleLower = title.toLowerCase();
|
||||
|
||||
if (status === "in_progress") {
|
||||
return <Loader2 className="size-4 animate-spin text-primary" />;
|
||||
}
|
||||
|
||||
if (status === "completed") {
|
||||
return <CheckCircle2 className="size-4 text-emerald-500" />;
|
||||
}
|
||||
|
||||
// Default icons based on step type
|
||||
if (titleLower.includes("search") || titleLower.includes("knowledge")) {
|
||||
return <Search className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
if (titleLower.includes("analy") || titleLower.includes("understand")) {
|
||||
return <Brain className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
return <Sparkles className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to display a single thinking step with controlled open state
|
||||
*/
|
||||
function ThinkingStepDisplay({
|
||||
step,
|
||||
isOpen,
|
||||
onToggle
|
||||
}: {
|
||||
step: ThinkingStep;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
function ThinkingStepDisplay({
|
||||
step,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
step: ThinkingStep;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const icon = useMemo(() => getStepIcon(step.status, step.title), [step.status, step.title]);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep open={isOpen} onOpenChange={onToggle}>
|
||||
<ChainOfThoughtTrigger
|
||||
leftIcon={icon}
|
||||
swapIconOnHover={step.status !== "in_progress"}
|
||||
className={cn(
|
||||
step.status === "in_progress" && "text-foreground font-medium",
|
||||
step.status === "completed" && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</ChainOfThoughtTrigger>
|
||||
<ChainOfThoughtContent>
|
||||
{step.items.map((item, index) => (
|
||||
<ChainOfThoughtItem key={`${step.id}-item-${index}`}>
|
||||
{item}
|
||||
</ChainOfThoughtItem>
|
||||
))}
|
||||
</ChainOfThoughtContent>
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
const icon = useMemo(() => getStepIcon(step.status, step.title), [step.status, step.title]);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep open={isOpen} onOpenChange={onToggle}>
|
||||
<ChainOfThoughtTrigger
|
||||
leftIcon={icon}
|
||||
swapIconOnHover={step.status !== "in_progress"}
|
||||
className={cn(
|
||||
step.status === "in_progress" && "text-foreground font-medium",
|
||||
step.status === "completed" && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</ChainOfThoughtTrigger>
|
||||
<ChainOfThoughtContent>
|
||||
{step.items.map((item, index) => (
|
||||
<ChainOfThoughtItem key={`${step.id}-item-${index}`}>{item}</ChainOfThoughtItem>
|
||||
))}
|
||||
</ChainOfThoughtContent>
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading state with animated thinking indicator
|
||||
*/
|
||||
function ThinkingLoadingState({ status }: { status?: string }) {
|
||||
const statusText = useMemo(() => {
|
||||
switch (status) {
|
||||
case "searching":
|
||||
return "Searching knowledge base...";
|
||||
case "synthesizing":
|
||||
return "Synthesizing response...";
|
||||
case "thinking":
|
||||
default:
|
||||
return "Thinking...";
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<div className="my-3 flex items-center gap-2 rounded-lg border border-border/50 bg-muted/30 px-4 py-3">
|
||||
<div className="relative">
|
||||
<Brain className="size-5 text-primary" />
|
||||
<span className="absolute -right-0.5 -top-0.5 flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">{statusText}</span>
|
||||
</div>
|
||||
);
|
||||
const statusText = useMemo(() => {
|
||||
switch (status) {
|
||||
case "searching":
|
||||
return "Searching knowledge base...";
|
||||
case "synthesizing":
|
||||
return "Synthesizing response...";
|
||||
case "thinking":
|
||||
default:
|
||||
return "Thinking...";
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<div className="my-3 flex items-center gap-2 rounded-lg border border-border/50 bg-muted/30 px-4 py-3">
|
||||
<div className="relative">
|
||||
<Brain className="size-5 text-primary" />
|
||||
<span className="absolute -right-0.5 -top-0.5 flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">{statusText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart chain of thought renderer with state management
|
||||
*/
|
||||
function SmartChainOfThought({ steps }: { steps: ThinkingStep[] }) {
|
||||
// Track which steps the user has manually toggled
|
||||
const [manualOverrides, setManualOverrides] = useState<Record<string, boolean>>({});
|
||||
// Track previous step statuses to detect changes
|
||||
const prevStatusesRef = useRef<Record<string, string>>({});
|
||||
|
||||
// Check if any step is currently in progress
|
||||
const hasInProgressStep = steps.some(step => step.status === "in_progress");
|
||||
|
||||
// Find the last completed step index
|
||||
const lastCompletedIndex = steps
|
||||
.map((s, i) => s.status === "completed" ? i : -1)
|
||||
.filter(i => i !== -1)
|
||||
.pop();
|
||||
|
||||
// Clear manual overrides when a step's status changes
|
||||
useEffect(() => {
|
||||
const currentStatuses: Record<string, string> = {};
|
||||
steps.forEach(step => {
|
||||
currentStatuses[step.id] = step.status;
|
||||
// If status changed, clear any manual override for this step
|
||||
if (prevStatusesRef.current[step.id] && prevStatusesRef.current[step.id] !== step.status) {
|
||||
setManualOverrides(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[step.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
prevStatusesRef.current = currentStatuses;
|
||||
}, [steps]);
|
||||
|
||||
const getStepOpenState = (step: ThinkingStep, index: number): boolean => {
|
||||
// If user has manually toggled, respect that
|
||||
if (manualOverrides[step.id] !== undefined) {
|
||||
return manualOverrides[step.id];
|
||||
}
|
||||
// Auto behavior: open if in progress
|
||||
if (step.status === "in_progress") {
|
||||
return true;
|
||||
}
|
||||
// Auto behavior: keep last completed step open if no in-progress step
|
||||
if (!hasInProgressStep && index === lastCompletedIndex) {
|
||||
return true;
|
||||
}
|
||||
// Default: collapsed
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleToggle = (stepId: string, currentOpen: boolean) => {
|
||||
setManualOverrides(prev => ({
|
||||
...prev,
|
||||
[stepId]: !currentOpen,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ChainOfThought>
|
||||
{steps.map((step, index) => {
|
||||
const isOpen = getStepOpenState(step, index);
|
||||
return (
|
||||
<ThinkingStepDisplay
|
||||
key={step.id}
|
||||
step={step}
|
||||
isOpen={isOpen}
|
||||
onToggle={() => handleToggle(step.id, isOpen)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ChainOfThought>
|
||||
);
|
||||
// Track which steps the user has manually toggled
|
||||
const [manualOverrides, setManualOverrides] = useState<Record<string, boolean>>({});
|
||||
// Track previous step statuses to detect changes
|
||||
const prevStatusesRef = useRef<Record<string, string>>({});
|
||||
|
||||
// Check if any step is currently in progress
|
||||
const hasInProgressStep = steps.some((step) => step.status === "in_progress");
|
||||
|
||||
// Find the last completed step index
|
||||
const lastCompletedIndex = steps
|
||||
.map((s, i) => (s.status === "completed" ? i : -1))
|
||||
.filter((i) => i !== -1)
|
||||
.pop();
|
||||
|
||||
// Clear manual overrides when a step's status changes
|
||||
useEffect(() => {
|
||||
const currentStatuses: Record<string, string> = {};
|
||||
steps.forEach((step) => {
|
||||
currentStatuses[step.id] = step.status;
|
||||
// If status changed, clear any manual override for this step
|
||||
if (prevStatusesRef.current[step.id] && prevStatusesRef.current[step.id] !== step.status) {
|
||||
setManualOverrides((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[step.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
prevStatusesRef.current = currentStatuses;
|
||||
}, [steps]);
|
||||
|
||||
const getStepOpenState = (step: ThinkingStep, index: number): boolean => {
|
||||
// If user has manually toggled, respect that
|
||||
if (manualOverrides[step.id] !== undefined) {
|
||||
return manualOverrides[step.id];
|
||||
}
|
||||
// Auto behavior: open if in progress
|
||||
if (step.status === "in_progress") {
|
||||
return true;
|
||||
}
|
||||
// Auto behavior: keep last completed step open if no in-progress step
|
||||
if (!hasInProgressStep && index === lastCompletedIndex) {
|
||||
return true;
|
||||
}
|
||||
// Default: collapsed
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleToggle = (stepId: string, currentOpen: boolean) => {
|
||||
setManualOverrides((prev) => ({
|
||||
...prev,
|
||||
[stepId]: !currentOpen,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ChainOfThought>
|
||||
{steps.map((step, index) => {
|
||||
const isOpen = getStepOpenState(step, index);
|
||||
return (
|
||||
<ThinkingStepDisplay
|
||||
key={step.id}
|
||||
step={step}
|
||||
isOpen={isOpen}
|
||||
onToggle={() => handleToggle(step.id, isOpen)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ChainOfThought>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -249,69 +247,68 @@ function SmartChainOfThought({ steps }: { steps: ThinkingStep[] }) {
|
|||
* in a collapsible, hierarchical format.
|
||||
*/
|
||||
export const DeepAgentThinkingToolUI = makeAssistantToolUI<
|
||||
DeepAgentThinkingArgs,
|
||||
DeepAgentThinkingResult
|
||||
DeepAgentThinkingArgs,
|
||||
DeepAgentThinkingResult
|
||||
>({
|
||||
toolName: "deepagent_thinking",
|
||||
render: function DeepAgentThinkingUI({ result, status }) {
|
||||
// Loading state - tool is still running
|
||||
if (status.type === "running" || status.type === "requires-action") {
|
||||
return <ThinkingLoadingState status={result?.status} />;
|
||||
}
|
||||
toolName: "deepagent_thinking",
|
||||
render: function DeepAgentThinkingUI({ result, status }) {
|
||||
// Loading state - tool is still running
|
||||
if (status.type === "running" || status.type === "requires-action") {
|
||||
return <ThinkingLoadingState status={result?.status} />;
|
||||
}
|
||||
|
||||
// Incomplete/cancelled state
|
||||
if (status.type === "incomplete") {
|
||||
if (status.reason === "cancelled") {
|
||||
return null; // Don't show anything if cancelled
|
||||
}
|
||||
if (status.reason === "error") {
|
||||
return null; // Don't show error for thinking - it's not critical
|
||||
}
|
||||
}
|
||||
// Incomplete/cancelled state
|
||||
if (status.type === "incomplete") {
|
||||
if (status.reason === "cancelled") {
|
||||
return null; // Don't show anything if cancelled
|
||||
}
|
||||
if (status.reason === "error") {
|
||||
return null; // Don't show error for thinking - it's not critical
|
||||
}
|
||||
}
|
||||
|
||||
// No result or no steps - don't render anything
|
||||
if (!result?.steps || result.steps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// No result or no steps - don't render anything
|
||||
if (!result?.steps || result.steps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render the chain of thought
|
||||
return (
|
||||
<div className="my-3 w-full">
|
||||
<SmartChainOfThought steps={result.steps} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// Render the chain of thought
|
||||
return (
|
||||
<div className="my-3 w-full">
|
||||
<SmartChainOfThought steps={result.steps} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Inline Thinking Display Component
|
||||
*
|
||||
*
|
||||
* A simpler version that can be used inline with the message content
|
||||
* for displaying reasoning without the full tool UI infrastructure.
|
||||
*/
|
||||
export function InlineThinkingDisplay({
|
||||
steps,
|
||||
isStreaming = false,
|
||||
className,
|
||||
steps,
|
||||
isStreaming = false,
|
||||
className,
|
||||
}: {
|
||||
steps: ThinkingStep[];
|
||||
isStreaming?: boolean;
|
||||
className?: string;
|
||||
steps: ThinkingStep[];
|
||||
isStreaming?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
if (steps.length === 0 && !isStreaming) {
|
||||
return null;
|
||||
}
|
||||
if (steps.length === 0 && !isStreaming) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("my-3 w-full", className)}>
|
||||
{isStreaming && steps.length === 0 ? (
|
||||
<ThinkingLoadingState />
|
||||
) : (
|
||||
<SmartChainOfThought steps={steps} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={cn("my-3 w-full", className)}>
|
||||
{isStreaming && steps.length === 0 ? (
|
||||
<ThinkingLoadingState />
|
||||
) : (
|
||||
<SmartChainOfThought steps={steps} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { ThinkingStep, DeepAgentThinkingArgs, DeepAgentThinkingResult };
|
||||
|
||||
|
|
|
|||
|
|
@ -73,12 +73,7 @@ function ImageCancelledState({ src }: { src: string }) {
|
|||
function ParsedImage({ result }: { result: unknown }) {
|
||||
const image = parseSerializableImage(result);
|
||||
|
||||
return (
|
||||
<Image
|
||||
{...image}
|
||||
maxWidth="420px"
|
||||
/>
|
||||
);
|
||||
return <Image {...image} maxWidth="420px" />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -93,10 +88,7 @@ function ParsedImage({ result }: { result: unknown }) {
|
|||
* - Hover overlay effects
|
||||
* - Click to open full size
|
||||
*/
|
||||
export const DisplayImageToolUI = makeAssistantToolUI<
|
||||
DisplayImageArgs,
|
||||
DisplayImageResult
|
||||
>({
|
||||
export const DisplayImageToolUI = makeAssistantToolUI<DisplayImageArgs, DisplayImageResult>({
|
||||
toolName: "display_image",
|
||||
render: function DisplayImageUI({ args, result, status }) {
|
||||
const src = args.src || "Unknown";
|
||||
|
|
@ -151,4 +143,3 @@ export const DisplayImageToolUI = makeAssistantToolUI<
|
|||
});
|
||||
|
||||
export type { DisplayImageArgs, DisplayImageResult };
|
||||
|
||||
|
|
|
|||
|
|
@ -202,9 +202,7 @@ function PodcastPlayer({
|
|||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`,
|
||||
{ method: "GET", signal: controller.signal }
|
||||
),
|
||||
baseApiService.get<unknown>(
|
||||
`/api/v1/podcasts/${podcastId}`
|
||||
),
|
||||
baseApiService.get<unknown>(`/api/v1/podcasts/${podcastId}`),
|
||||
]);
|
||||
|
||||
if (!audioResponse.ok) {
|
||||
|
|
|
|||
|
|
@ -65,14 +65,14 @@ export interface ImageProps {
|
|||
*/
|
||||
export function parseSerializableImage(result: unknown): SerializableImage {
|
||||
const parsed = SerializableImageSchema.safeParse(result);
|
||||
|
||||
|
||||
if (!parsed.success) {
|
||||
console.warn("Invalid image data:", parsed.error.issues);
|
||||
// Try to extract basic info for error display
|
||||
const obj = (result && typeof result === "object" ? result : {}) as Record<string, unknown>;
|
||||
throw new Error(`Invalid image: ${parsed.error.issues.map(i => i.message).join(", ")}`);
|
||||
throw new Error(`Invalid image: ${parsed.error.issues.map((i) => i.message).join(", ")}`);
|
||||
}
|
||||
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
|
|
@ -165,7 +165,7 @@ export function ImageLoading({ title = "Loading image..." }: { title?: string })
|
|||
|
||||
/**
|
||||
* Image Component
|
||||
*
|
||||
*
|
||||
* Display images with metadata and attribution.
|
||||
* Features hover overlay with title and source attribution.
|
||||
*/
|
||||
|
|
@ -197,11 +197,7 @@ export function Image({
|
|||
|
||||
if (imageError) {
|
||||
return (
|
||||
<Card
|
||||
id={id}
|
||||
className={cn("w-full overflow-hidden", className)}
|
||||
style={{ maxWidth }}
|
||||
>
|
||||
<Card id={id} className={cn("w-full overflow-hidden", className)} style={{ maxWidth }}>
|
||||
<div className={cn("bg-muted flex items-center justify-center", aspectRatioClass)}>
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<ImageIcon className="size-8" />
|
||||
|
|
@ -266,9 +262,7 @@ export function Image({
|
|||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-white/80 text-sm line-clamp-2 mb-2">
|
||||
{description}
|
||||
</p>
|
||||
<p className="text-white/80 text-sm line-clamp-2 mb-2">{description}</p>
|
||||
)}
|
||||
|
||||
{/* Source attribution */}
|
||||
|
|
@ -295,8 +289,8 @@ export function Image({
|
|||
{/* Always visible domain badge (bottom right, shown when NOT hovered) */}
|
||||
{displayDomain && !isHovered && (
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-black/60 text-white border-0 text-xs backdrop-blur-sm"
|
||||
>
|
||||
{displayDomain}
|
||||
|
|
|
|||
|
|
@ -6,57 +6,57 @@
|
|||
* rich UI when specific tools are called by the agent.
|
||||
*/
|
||||
|
||||
export { Audio } from "./audio";
|
||||
export { GeneratePodcastToolUI } from "./generate-podcast";
|
||||
export {
|
||||
DeepAgentThinkingToolUI,
|
||||
InlineThinkingDisplay,
|
||||
type ThinkingStep,
|
||||
type DeepAgentThinkingArgs,
|
||||
type DeepAgentThinkingResult,
|
||||
Article,
|
||||
ArticleErrorBoundary,
|
||||
ArticleLoading,
|
||||
type ArticleProps,
|
||||
ArticleSkeleton,
|
||||
parseSerializableArticle,
|
||||
type SerializableArticle,
|
||||
} from "./article";
|
||||
export { Audio } from "./audio";
|
||||
export {
|
||||
type DeepAgentThinkingArgs,
|
||||
type DeepAgentThinkingResult,
|
||||
DeepAgentThinkingToolUI,
|
||||
InlineThinkingDisplay,
|
||||
type ThinkingStep,
|
||||
} from "./deepagent-thinking";
|
||||
export {
|
||||
LinkPreviewToolUI,
|
||||
MultiLinkPreviewToolUI,
|
||||
type LinkPreviewArgs,
|
||||
type LinkPreviewResult,
|
||||
type MultiLinkPreviewArgs,
|
||||
type MultiLinkPreviewResult,
|
||||
} from "./link-preview";
|
||||
type DisplayImageArgs,
|
||||
type DisplayImageResult,
|
||||
DisplayImageToolUI,
|
||||
} from "./display-image";
|
||||
export { GeneratePodcastToolUI } from "./generate-podcast";
|
||||
export {
|
||||
MediaCard,
|
||||
MediaCardErrorBoundary,
|
||||
MediaCardLoading,
|
||||
MediaCardSkeleton,
|
||||
parseSerializableMediaCard,
|
||||
type MediaCardProps,
|
||||
type SerializableMediaCard,
|
||||
} from "./media-card";
|
||||
export {
|
||||
Image,
|
||||
ImageErrorBoundary,
|
||||
ImageLoading,
|
||||
ImageSkeleton,
|
||||
parseSerializableImage,
|
||||
type ImageProps,
|
||||
type SerializableImage,
|
||||
Image,
|
||||
ImageErrorBoundary,
|
||||
ImageLoading,
|
||||
type ImageProps,
|
||||
ImageSkeleton,
|
||||
parseSerializableImage,
|
||||
type SerializableImage,
|
||||
} from "./image";
|
||||
export {
|
||||
DisplayImageToolUI,
|
||||
type DisplayImageArgs,
|
||||
type DisplayImageResult,
|
||||
} from "./display-image";
|
||||
type LinkPreviewArgs,
|
||||
type LinkPreviewResult,
|
||||
LinkPreviewToolUI,
|
||||
type MultiLinkPreviewArgs,
|
||||
type MultiLinkPreviewResult,
|
||||
MultiLinkPreviewToolUI,
|
||||
} from "./link-preview";
|
||||
export {
|
||||
Article,
|
||||
ArticleErrorBoundary,
|
||||
ArticleLoading,
|
||||
ArticleSkeleton,
|
||||
parseSerializableArticle,
|
||||
type ArticleProps,
|
||||
type SerializableArticle,
|
||||
} from "./article";
|
||||
MediaCard,
|
||||
MediaCardErrorBoundary,
|
||||
MediaCardLoading,
|
||||
type MediaCardProps,
|
||||
MediaCardSkeleton,
|
||||
parseSerializableMediaCard,
|
||||
type SerializableMediaCard,
|
||||
} from "./media-card";
|
||||
export {
|
||||
ScrapeWebpageToolUI,
|
||||
type ScrapeWebpageArgs,
|
||||
type ScrapeWebpageResult,
|
||||
type ScrapeWebpageArgs,
|
||||
type ScrapeWebpageResult,
|
||||
ScrapeWebpageToolUI,
|
||||
} from "./scrape-webpage";
|
||||
|
|
|
|||
|
|
@ -74,9 +74,7 @@ function ParsedMediaCard({ result }: { result: unknown }) {
|
|||
<MediaCard
|
||||
{...card}
|
||||
maxWidth="420px"
|
||||
responseActions={[
|
||||
{ id: "open", label: "Open", variant: "default" },
|
||||
]}
|
||||
responseActions={[{ id: "open", label: "Open", variant: "default" }]}
|
||||
onResponseAction={(id) => {
|
||||
if (id === "open" && card.href) {
|
||||
window.open(card.href, "_blank", "noopener,noreferrer");
|
||||
|
|
@ -98,10 +96,7 @@ function ParsedMediaCard({ result }: { result: unknown }) {
|
|||
* - Domain name
|
||||
* - Clickable link to open in new tab
|
||||
*/
|
||||
export const LinkPreviewToolUI = makeAssistantToolUI<
|
||||
LinkPreviewArgs,
|
||||
LinkPreviewResult
|
||||
>({
|
||||
export const LinkPreviewToolUI = makeAssistantToolUI<LinkPreviewArgs, LinkPreviewResult>({
|
||||
toolName: "link_preview",
|
||||
render: function LinkPreviewUI({ args, result, status }) {
|
||||
const url = args.url || "Unknown URL";
|
||||
|
|
@ -223,4 +218,3 @@ export const MultiLinkPreviewToolUI = makeAssistantToolUI<
|
|||
});
|
||||
|
||||
export type { LinkPreviewArgs, LinkPreviewResult, MultiLinkPreviewArgs, MultiLinkPreviewResult };
|
||||
|
||||
|
|
|
|||
|
|
@ -70,12 +70,12 @@ export interface MediaCardProps {
|
|||
*/
|
||||
export function parseSerializableMediaCard(result: unknown): SerializableMediaCard {
|
||||
const parsed = SerializableMediaCardSchema.safeParse(result);
|
||||
|
||||
|
||||
if (!parsed.success) {
|
||||
console.warn("Invalid media card data:", parsed.error.issues);
|
||||
throw new Error(`Invalid media card: ${parsed.error.issues.map(i => i.message).join(", ")}`);
|
||||
throw new Error(`Invalid media card: ${parsed.error.issues.map((i) => i.message).join(", ")}`);
|
||||
}
|
||||
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
|
|
@ -164,10 +164,7 @@ export class MediaCardErrorBoundary extends Component<
|
|||
*/
|
||||
export function MediaCardSkeleton({ maxWidth = "420px" }: { maxWidth?: string }) {
|
||||
return (
|
||||
<Card
|
||||
className="w-full overflow-hidden animate-pulse"
|
||||
style={{ maxWidth }}
|
||||
>
|
||||
<Card className="w-full overflow-hidden animate-pulse" style={{ maxWidth }}>
|
||||
<div className="aspect-[2/1] bg-muted" />
|
||||
<CardContent className="p-4">
|
||||
<div className="h-4 w-3/4 rounded bg-muted" />
|
||||
|
|
@ -180,7 +177,7 @@ export function MediaCardSkeleton({ maxWidth = "420px" }: { maxWidth?: string })
|
|||
|
||||
/**
|
||||
* MediaCard Component
|
||||
*
|
||||
*
|
||||
* A rich media card for displaying link previews, images, and other media
|
||||
* in AI chat applications. Supports thumbnails, descriptions, and actions.
|
||||
*/
|
||||
|
|
@ -353,4 +350,3 @@ export function MediaCardLoading({ title = "Loading preview..." }: { title?: str
|
|||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,9 +78,7 @@ function ParsedArticle({ result }: { result: unknown }) {
|
|||
<Article
|
||||
{...article}
|
||||
maxWidth="480px"
|
||||
responseActions={[
|
||||
{ id: "open", label: "Open Source", variant: "default" },
|
||||
]}
|
||||
responseActions={[{ id: "open", label: "Open Source", variant: "default" }]}
|
||||
onResponseAction={(id) => {
|
||||
if (id === "open" && article.href) {
|
||||
window.open(article.href, "_blank", "noopener,noreferrer");
|
||||
|
|
@ -102,10 +100,7 @@ function ParsedArticle({ result }: { result: unknown }) {
|
|||
* - Word count
|
||||
* - Link to original source
|
||||
*/
|
||||
export const ScrapeWebpageToolUI = makeAssistantToolUI<
|
||||
ScrapeWebpageArgs,
|
||||
ScrapeWebpageResult
|
||||
>({
|
||||
export const ScrapeWebpageToolUI = makeAssistantToolUI<ScrapeWebpageArgs, ScrapeWebpageResult>({
|
||||
toolName: "scrape_webpage",
|
||||
render: function ScrapeWebpageUI({ args, result, status }) {
|
||||
const url = args.url || "Unknown URL";
|
||||
|
|
@ -160,4 +155,3 @@ export const ScrapeWebpageToolUI = makeAssistantToolUI<
|
|||
});
|
||||
|
||||
export type { ScrapeWebpageArgs, ScrapeWebpageResult };
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,21 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />;
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue