mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-26 09:16:22 +02:00
Merge upstream/dev
This commit is contained in:
commit
440762fb07
92 changed files with 3227 additions and 2502 deletions
|
|
@ -12,18 +12,25 @@ import {
|
|||
ClipboardPaste,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
MessageSquare,
|
||||
RefreshCwIcon,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import {
|
||||
CitationMetadataProvider,
|
||||
useAllCitationMetadata,
|
||||
} from "@/components/assistant-ui/citation-metadata-context";
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
|
||||
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
|
||||
import type { SerializableCitation } from "@/components/tool-ui/citation";
|
||||
import {
|
||||
CreateConfluencePageToolUI,
|
||||
DeleteConfluencePageToolUI,
|
||||
|
|
@ -64,12 +71,157 @@ import {
|
|||
} from "@/components/tool-ui/notion";
|
||||
import { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "@/components/tool-ui/onedrive";
|
||||
import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute";
|
||||
import {
|
||||
openSafeNavigationHref,
|
||||
resolveSafeNavigationHref,
|
||||
} from "@/components/tool-ui/shared/media";
|
||||
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
|
||||
import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation";
|
||||
import { Drawer, DrawerContent, DrawerHandle, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { useComments } from "@/hooks/use-comments";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function extractDomain(url: string): string | undefined {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function useCitationsFromMetadata(): SerializableCitation[] {
|
||||
const allCitations = useAllCitationMetadata();
|
||||
return useMemo(() => {
|
||||
const result: SerializableCitation[] = [];
|
||||
for (const [url, meta] of allCitations) {
|
||||
const domain = extractDomain(url);
|
||||
result.push({
|
||||
id: `url-cite-${url}`,
|
||||
href: url,
|
||||
title: meta.title,
|
||||
snippet: meta.snippet,
|
||||
domain,
|
||||
favicon: domain ? `https://www.google.com/s2/favicons?domain=${domain}&sz=32` : undefined,
|
||||
type: "webpage",
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [allCitations]);
|
||||
}
|
||||
|
||||
const MobileCitationDrawer: FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const citations = useCitationsFromMetadata();
|
||||
|
||||
if (citations.length === 0) return null;
|
||||
|
||||
const maxIcons = 4;
|
||||
const visible = citations.slice(0, maxIcons);
|
||||
const remainingCount = Math.max(0, citations.length - maxIcons);
|
||||
|
||||
const handleNavigate = (citation: SerializableCitation) => {
|
||||
const href = resolveSafeNavigationHref(citation.href);
|
||||
if (href) openSafeNavigationHref(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
"isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2",
|
||||
"bg-muted/40 outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted/70",
|
||||
"focus-visible:ring-ring focus-visible:ring-2"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{visible.map((citation, index) => (
|
||||
<div
|
||||
key={citation.id}
|
||||
className={cn(
|
||||
"border-border bg-background dark:border-foreground/20 relative flex size-6 items-center justify-center rounded-full border shadow-xs",
|
||||
index > 0 && "-ml-2"
|
||||
)}
|
||||
style={{ zIndex: maxIcons - index }}
|
||||
>
|
||||
{citation.favicon ? (
|
||||
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain
|
||||
<img
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={18}
|
||||
height={18}
|
||||
className="size-4.5 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Globe className="text-muted-foreground size-3" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<div
|
||||
className="border-border bg-background dark:border-foreground/20 relative -ml-2 flex size-6 items-center justify-center rounded-full border shadow-xs"
|
||||
style={{ zIndex: 0 }}
|
||||
>
|
||||
<span className="text-muted-foreground text-[10px] font-medium tracking-tight">
|
||||
•••
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
{citations.length} source{citations.length !== 1 && "s"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerContent className="max-h-[85vh] flex flex-col">
|
||||
<DrawerHandle />
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle className="text-base font-semibold">Sources</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="overflow-y-auto flex-1 min-h-0 px-1 pb-6">
|
||||
{citations.map((citation) => (
|
||||
<button
|
||||
key={citation.id}
|
||||
type="button"
|
||||
onClick={() => handleNavigate(citation)}
|
||||
className="group flex w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
|
||||
>
|
||||
{citation.favicon ? (
|
||||
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain
|
||||
<img
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={16}
|
||||
height={16}
|
||||
className="bg-muted size-4 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Globe className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2">
|
||||
{citation.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-xs">{citation.domain}</p>
|
||||
</div>
|
||||
<ExternalLink className="text-muted-foreground size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const MessageError: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Error>
|
||||
|
|
@ -81,8 +233,10 @@ export const MessageError: FC = () => {
|
|||
};
|
||||
|
||||
const AssistantMessageInner: FC = () => {
|
||||
const isMobile = !useMediaQuery("(min-width: 768px)");
|
||||
|
||||
return (
|
||||
<>
|
||||
<CitationMetadataProvider>
|
||||
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
|
|
@ -120,6 +274,7 @@ const AssistantMessageInner: FC = () => {
|
|||
create_confluence_page: CreateConfluencePageToolUI,
|
||||
update_confluence_page: UpdateConfluencePageToolUI,
|
||||
delete_confluence_page: DeleteConfluencePageToolUI,
|
||||
web_search: () => null,
|
||||
link_preview: () => null,
|
||||
multi_link_preview: () => null,
|
||||
scrape_webpage: () => null,
|
||||
|
|
@ -131,10 +286,16 @@ const AssistantMessageInner: FC = () => {
|
|||
<MessageError />
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<div className="ml-2 mt-2">
|
||||
<MobileCitationDrawer />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
|
||||
<AssistantActionBar />
|
||||
</div>
|
||||
</>
|
||||
</CitationMetadataProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
"use client";
|
||||
|
||||
import { useAuiState } from "@assistant-ui/react";
|
||||
import { createContext, type FC, type ReactNode, useContext, useMemo } from "react";
|
||||
|
||||
export interface CitationMeta {
|
||||
title: string;
|
||||
snippet?: string;
|
||||
}
|
||||
|
||||
type CitationMetadataMap = ReadonlyMap<string, CitationMeta>;
|
||||
|
||||
const CitationMetadataContext = createContext<CitationMetadataMap>(new Map());
|
||||
|
||||
interface ToolCallResult {
|
||||
status?: string;
|
||||
citations?: Record<string, { title: string; snippet?: string }>;
|
||||
}
|
||||
|
||||
interface MessageContent {
|
||||
type: string;
|
||||
toolName?: string;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const content = useAuiState(
|
||||
({ message }) => (message as { content?: MessageContent[] })?.content
|
||||
);
|
||||
|
||||
const metadataMap = useMemo<CitationMetadataMap>(() => {
|
||||
if (!content || !Array.isArray(content)) return new Map();
|
||||
|
||||
const merged = new Map<string, CitationMeta>();
|
||||
|
||||
for (const part of content) {
|
||||
if (part.type !== "tool-call" || part.toolName !== "web_search" || !part.result) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = part.result as ToolCallResult;
|
||||
const citations = result.citations;
|
||||
if (!citations || typeof citations !== "object") continue;
|
||||
|
||||
for (const [url, meta] of Object.entries(citations)) {
|
||||
if (url.startsWith("http") && meta.title && !merged.has(url)) {
|
||||
merged.set(url, { title: meta.title, snippet: meta.snippet });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<CitationMetadataContext.Provider value={metadataMap}>
|
||||
{children}
|
||||
</CitationMetadataContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useCitationMetadata(url: string): CitationMeta | undefined {
|
||||
const map = useContext(CitationMetadataContext);
|
||||
return map.get(url);
|
||||
}
|
||||
|
||||
export function useAllCitationMetadata(): CitationMetadataMap {
|
||||
return useContext(CitationMetadataContext);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlertTriangle, Cable, Settings } from "lucide-react";
|
||||
import { AlertTriangle, Settings } from "lucide-react";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||
|
|
@ -12,17 +12,14 @@ import {
|
|||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { useConnectorsSync } from "@/hooks/use-connectors-sync";
|
||||
import { PICKER_CLOSE_EVENT, PICKER_OPEN_EVENT } from "@/hooks/use-google-picker";
|
||||
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
|
||||
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
|
||||
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
|
||||
|
|
@ -47,7 +44,7 @@ interface ConnectorIndicatorProps {
|
|||
}
|
||||
|
||||
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
|
||||
({ showTrigger = true }, ref) => {
|
||||
(_props, ref) => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
|
||||
useAtomValue(currentUserAtom);
|
||||
|
|
@ -74,8 +71,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
|
||||
// Real-time document type counts via Zero (updates instantly as docs are indexed)
|
||||
const documentTypeCounts = useZeroDocumentTypeCounts(searchSpaceId);
|
||||
const documentTypesLoading = documentTypeCounts === undefined;
|
||||
|
||||
// Read status inbox items from shared atom (populated by LayoutDataProvider)
|
||||
// instead of creating a duplicate useInbox("status") hook.
|
||||
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
|
||||
|
|
@ -178,8 +173,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
inboxItems
|
||||
);
|
||||
|
||||
const isLoading = connectorsLoading || documentTypesLoading;
|
||||
|
||||
// Get document types that have documents in the search space
|
||||
const activeDocumentTypes = documentTypeCounts
|
||||
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
|
||||
|
|
@ -205,41 +198,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
|
||||
{showTrigger && (
|
||||
<TooltipIconButton
|
||||
data-joyride="connector-icon"
|
||||
tooltip={
|
||||
hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"
|
||||
}
|
||||
side="bottom"
|
||||
className={cn(
|
||||
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
|
||||
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
|
||||
"outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
|
||||
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
|
||||
)}
|
||||
aria-label={
|
||||
hasConnectors
|
||||
? `View ${activeConnectorsCount} connectors`
|
||||
: "Add your first connector"
|
||||
}
|
||||
onClick={() => handleOpenChange(true)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<>
|
||||
<Cable className="size-4 stroke-[1.5px]" />
|
||||
{activeConnectorsCount > 0 && (
|
||||
<span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm select-none">
|
||||
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TooltipIconButton>
|
||||
)}
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
size="sm"
|
||||
variant={isConnected ? "secondary" : "default"}
|
||||
className={cn(
|
||||
"h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium",
|
||||
"relative h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium items-center justify-center",
|
||||
isConnected &&
|
||||
"bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80",
|
||||
!isConnected && "shadow-xs"
|
||||
|
|
@ -151,19 +151,18 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
onClick={isConnected ? onManage : onConnect}
|
||||
disabled={isConnecting || !isEnabled}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Spinner size="xs" />
|
||||
) : !isEnabled ? (
|
||||
"Unavailable"
|
||||
) : isConnected ? (
|
||||
"Manage"
|
||||
) : id === "youtube-crawler" ? (
|
||||
"Add"
|
||||
) : connectorType ? (
|
||||
"Connect"
|
||||
) : (
|
||||
"Add"
|
||||
)}
|
||||
<span className={isConnecting ? "opacity-0" : ""}>
|
||||
{!isEnabled
|
||||
? "Unavailable"
|
||||
: isConnected
|
||||
? "Manage"
|
||||
: id === "youtube-crawler"
|
||||
? "Add"
|
||||
: connectorType
|
||||
? "Connect"
|
||||
: "Add"}
|
||||
</span>
|
||||
{isConnecting && <Spinner size="xs" className="absolute" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -243,7 +243,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowDetails(!showDetails);
|
||||
setShowDetails(prev => !prev);
|
||||
}}
|
||||
>
|
||||
{showDetails ? (
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({ connector, onCon
|
|||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFolderTreeOpen(!isFolderTreeOpen)}
|
||||
onClick={() => setIsFolderTreeOpen(prev => !prev)}
|
||||
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
Change Selection
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
|
|||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowDetails(!showDetails);
|
||||
setShowDetails(prev => !prev);
|
||||
}}
|
||||
>
|
||||
{showDetails ? (
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ export const OneDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCh
|
|||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFolderTreeOpen(!isFolderTreeOpen)}
|
||||
onClick={() => setIsFolderTreeOpen(prev => !prev)}
|
||||
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
Change Selection
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
onClick={() => setShowApiKey(prev => !prev)}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showApiKey ? "Hide" : "Show"}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
|
||||
import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
|
||||
import { Citation } from "@/components/tool-ui/citation";
|
||||
|
||||
interface InlineCitationProps {
|
||||
chunkId: number;
|
||||
|
|
@ -55,21 +56,23 @@ interface UrlCitationProps {
|
|||
|
||||
/**
|
||||
* Inline citation for live web search results (URL-based chunk IDs).
|
||||
* Renders a clickable badge showing the source domain that opens the URL in a new tab.
|
||||
* Renders a compact chip with favicon + domain and a hover popover showing the
|
||||
* page title and snippet (extracted deterministically from web_search tool results).
|
||||
*/
|
||||
export const UrlCitation: FC<UrlCitationProps> = ({ url }) => {
|
||||
const domain = extractDomain(url);
|
||||
const meta = useCitationMetadata(url);
|
||||
|
||||
return (
|
||||
<a
|
||||
<Citation
|
||||
id={`url-cite-${url}`}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] font-bold bg-primary/80 hover:bg-primary text-primary-foreground rounded-full h-4 px-1.5 inline-flex items-center gap-0.5 align-super cursor-pointer transition-colors ml-0.5 no-underline"
|
||||
title={url}
|
||||
>
|
||||
<ExternalLink className="size-2.5 shrink-0" />
|
||||
{domain}
|
||||
</a>
|
||||
title={meta?.title || domain}
|
||||
snippet={meta?.snippet}
|
||||
domain={domain}
|
||||
favicon={`https://www.google.com/s2/favicons?domain=${domain}&sz=32`}
|
||||
variant="inline"
|
||||
type="webpage"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ function parseTextWithCitations(text: string): ReactNode[] {
|
|||
const MarkdownTextImpl = () => {
|
||||
return (
|
||||
<MarkdownTextPrimitive
|
||||
smooth={false}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
className="aui-md"
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
|
|||
steps,
|
||||
isThreadRunning = true,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
const getEffectiveStatus = useCallback(
|
||||
(step: ThinkingStep): "pending" | "in_progress" | "completed" => {
|
||||
if (step.status === "in_progress" && !isThreadRunning) {
|
||||
|
|
@ -38,12 +36,18 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
|
|||
!isThreadRunning &&
|
||||
steps.every((s) => getEffectiveStatus(s) === "completed");
|
||||
const isProcessing = isThreadRunning && !allCompleted;
|
||||
const [isOpen, setIsOpen] = useState(() => isProcessing);
|
||||
|
||||
useEffect(() => {
|
||||
if (isProcessing) {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (allCompleted) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [allCompleted]);
|
||||
}, [allCompleted, isProcessing]);
|
||||
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
|
|
@ -65,7 +69,7 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
|
|||
<div className="rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -215,7 +215,7 @@ interface ThreadListItemComponentProps {
|
|||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function ThreadListItemComponent({
|
||||
const ThreadListItemComponent = memo(function ThreadListItemComponent({
|
||||
thread,
|
||||
isActive,
|
||||
isArchived,
|
||||
|
|
@ -272,7 +272,7 @@ function ThreadListItemComponent({
|
|||
</DropdownMenu>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Format a date as relative time (e.g., "2 hours ago", "Yesterday")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
ThreadPrimitive,
|
||||
useAui,
|
||||
useAuiState,
|
||||
useThreadViewportStore,
|
||||
} from "@assistant-ui/react";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
|
|
@ -113,7 +114,8 @@ const ThreadContent: FC = () => {
|
|||
>
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-scroll px-4 pt-4"
|
||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
|
||||
style={{ scrollbarGutter: "stable" }}
|
||||
>
|
||||
<AuiIf condition={({ thread }) => thread.isEmpty}>
|
||||
<ThreadWelcome />
|
||||
|
|
@ -128,7 +130,7 @@ const ThreadContent: FC = () => {
|
|||
/>
|
||||
|
||||
<ThreadPrimitive.ViewportFooter
|
||||
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6"
|
||||
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6"
|
||||
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<ThreadScrollToBottom />
|
||||
|
|
@ -349,7 +351,15 @@ const Composer: FC = () => {
|
|||
const promptPickerRef = useRef<PromptPickerRef>(null);
|
||||
const { search_space_id, chat_id } = useParams();
|
||||
const aui = useAui();
|
||||
const threadViewportStore = useThreadViewportStore();
|
||||
const hasAutoFocusedRef = useRef(false);
|
||||
const submitCleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
submitCleanupRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
|
||||
const clipboardLoadedRef = useRef(false);
|
||||
|
|
@ -593,6 +603,63 @@ const Composer: FC = () => {
|
|||
setMentionedDocuments([]);
|
||||
setSidebarDocs([]);
|
||||
}
|
||||
if (isThreadRunning || isBlockedByOtherUser) return;
|
||||
if (showDocumentPopover) return;
|
||||
|
||||
const viewportEl = document.querySelector(".aui-thread-viewport");
|
||||
const heightBefore = viewportEl?.scrollHeight ?? 0;
|
||||
|
||||
aui.composer().send();
|
||||
editorRef.current?.clear();
|
||||
setMentionedDocuments([]);
|
||||
setSidebarDocs([]);
|
||||
|
||||
// With turnAnchor="top", ViewportSlack adds min-height to the last
|
||||
// assistant message so that scrolling-to-bottom actually positions the
|
||||
// user message at the TOP of the viewport. That slack height is
|
||||
// calculated asynchronously (ResizeObserver → style → layout).
|
||||
//
|
||||
// We poll via rAF for ~2 s, re-scrolling whenever scrollHeight changes
|
||||
// (user msg render → assistant placeholder → ViewportSlack min-height →
|
||||
// first streamed content). Backup setTimeout calls cover cases where
|
||||
// the batcher's 50 ms throttle delays the DOM update past the rAF.
|
||||
const scrollToBottom = () =>
|
||||
threadViewportStore.getState().scrollToBottom({ behavior: "instant" });
|
||||
|
||||
let lastHeight = heightBefore;
|
||||
let frames = 0;
|
||||
let cancelled = false;
|
||||
const POLL_FRAMES = 120;
|
||||
|
||||
const pollAndScroll = () => {
|
||||
if (cancelled) return;
|
||||
const el = document.querySelector(".aui-thread-viewport");
|
||||
if (el) {
|
||||
const h = el.scrollHeight;
|
||||
if (h !== lastHeight) {
|
||||
lastHeight = h;
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
if (++frames < POLL_FRAMES) {
|
||||
requestAnimationFrame(pollAndScroll);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(pollAndScroll);
|
||||
|
||||
const t1 = setTimeout(scrollToBottom, 100);
|
||||
const t2 = setTimeout(scrollToBottom, 300);
|
||||
const t3 = setTimeout(scrollToBottom, 600);
|
||||
|
||||
// Cleanup if component unmounts during the polling window. The ref is
|
||||
// checked inside pollAndScroll; timeouts are cleared in the return below.
|
||||
// Store cleanup fn so it can be called from a useEffect cleanup if needed.
|
||||
submitCleanupRef.current = () => {
|
||||
cancelled = true;
|
||||
clearTimeout(t1);
|
||||
clearTimeout(t2);
|
||||
clearTimeout(t3);
|
||||
};
|
||||
}, [
|
||||
showDocumentPopover,
|
||||
showPromptPicker,
|
||||
|
|
@ -602,6 +669,7 @@ const Composer: FC = () => {
|
|||
aui,
|
||||
setMentionedDocuments,
|
||||
setSidebarDocs,
|
||||
threadViewportStore,
|
||||
]);
|
||||
|
||||
const handleDocumentRemove = useCallback(
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export const ToolFallback: ToolCallMessagePartComponent = ({
|
|||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onClick={() => setIsExpanded(prev => !prev)}
|
||||
className="flex w-full items-center gap-3 px-5 py-4 text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none"
|
||||
>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useAtomValue } from "jotai";
|
|||
import { CheckIcon, CopyIcon, FileText, Pen } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { type FC, useState } from "react";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
|
||||
|
|
@ -51,6 +52,8 @@ export const UserMessage: FC = () => {
|
|||
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
|
||||
const metadata = useAuiState(({ message }) => message?.metadata);
|
||||
const author = metadata?.custom?.author as AuthorMetadata | undefined;
|
||||
const isSharedChat = useAtomValue(currentThreadAtom).visibility === "SEARCH_SPACE";
|
||||
const showAvatar = isSharedChat && !!author;
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
|
|
@ -81,7 +84,7 @@ export const UserMessage: FC = () => {
|
|||
<UserActionBar />
|
||||
</div>
|
||||
</div>
|
||||
{author && (
|
||||
{showAvatar && (
|
||||
<div className="shrink-0 mb-1.5">
|
||||
<UserAvatar displayName={author.displayName} avatarUrl={author.avatarUrl} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export function CommentThread({
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setIsRepliesExpanded(!isRepliesExpanded)}
|
||||
onClick={() => setIsRepliesExpanded(prev => !prev)}
|
||||
>
|
||||
{isRepliesExpanded ? (
|
||||
<ChevronDown className="mr-1 size-3" />
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { DND_TYPES } from "./FolderNode";
|
||||
|
||||
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
|
||||
|
||||
export interface DocumentNodeDoc {
|
||||
id: number;
|
||||
title: string;
|
||||
|
|
@ -78,7 +80,9 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
const statusState = doc.status?.state ?? "ready";
|
||||
const isSelectable = statusState !== "pending" && statusState !== "processing";
|
||||
const isEditable =
|
||||
doc.document_type === "NOTE" && statusState !== "pending" && statusState !== "processing";
|
||||
EDITABLE_DOCUMENT_TYPES.has(doc.document_type) &&
|
||||
statusState !== "pending" &&
|
||||
statusState !== "processing";
|
||||
|
||||
const handleCheckChange = useCallback(() => {
|
||||
if (isSelectable) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||
import { toast } from "sonner";
|
||||
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
|
|
@ -18,6 +19,8 @@ interface EditorContent {
|
|||
source_markdown: string;
|
||||
}
|
||||
|
||||
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
|
||||
|
||||
function EditorPanelSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
|
|
@ -165,12 +168,16 @@ export function EditorPanelContent({
|
|||
}
|
||||
}, [documentId, searchSpaceId]);
|
||||
|
||||
const isEditableType = editorDoc
|
||||
? EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")
|
||||
: false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-4 py-2 shrink-0 border-b">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold truncate">{displayTitle}</h2>
|
||||
{editedMarkdown !== null && (
|
||||
{isEditableType && editedMarkdown !== null && (
|
||||
<p className="text-[10px] text-muted-foreground">Unsaved changes</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -193,7 +200,7 @@ export function EditorPanelContent({
|
|||
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : isEditableType ? (
|
||||
<PlateEditor
|
||||
key={documentId}
|
||||
preset="full"
|
||||
|
|
@ -208,6 +215,10 @@ export function EditorPanelContent({
|
|||
defaultEditing={true}
|
||||
className="[&_[role=toolbar]]:!bg-sidebar"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-5 py-4">
|
||||
<MarkdownViewer content={editorDoc.source_markdown} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: any) => {
|
|||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
className="relative z-50 flex items-center justify-center p-2 -mr-2 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors touch-manipulation"
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -37,8 +37,14 @@ export function useSidebarState(defaultCollapsed = false): UseSidebarStateReturn
|
|||
}, []);
|
||||
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
}, [isCollapsed, setIsCollapsed]);
|
||||
setIsCollapsedState(prev => {
|
||||
const next = !prev;
|
||||
try {
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${next}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcut: Cmd/Ctrl + \
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,12 @@ import {
|
|||
teamDialogAtom,
|
||||
userSettingsDialogAtom,
|
||||
} from "@/atoms/settings/settings-dialog.atoms";
|
||||
import { resetTabsAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
||||
import {
|
||||
removeChatTabAtom,
|
||||
resetTabsAtom,
|
||||
syncChatTabAtom,
|
||||
type Tab,
|
||||
} from "@/atoms/tabs/tabs.atom";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { MorePagesDialog } from "@/components/settings/more-pages-dialog";
|
||||
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
|
||||
|
|
@ -103,6 +108,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
|
||||
const syncChatTab = useSetAtom(syncChatTabAtom);
|
||||
const resetTabs = useSetAtom(resetTabsAtom);
|
||||
const removeChatTab = useSetAtom(removeChatTabAtom);
|
||||
|
||||
// State for handling new chat navigation when router is out of sync
|
||||
const [pendingNewChat, setPendingNewChat] = useState(false);
|
||||
|
|
@ -325,7 +331,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
const thread = threadsData?.threads?.find((t) => t.id === chatId);
|
||||
syncChatTab({
|
||||
chatId,
|
||||
title: thread?.title || (chatId ? `Chat ${chatId}` : "New Chat"),
|
||||
// Avoid overwriting live SSE-updated tab titles with fallback values.
|
||||
title: chatId ? (thread?.title ?? undefined) : "New Chat",
|
||||
chatUrl,
|
||||
});
|
||||
}, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]);
|
||||
|
|
@ -637,15 +644,20 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
setIsDeletingChat(true);
|
||||
try {
|
||||
await deleteThread(chatToDelete.id);
|
||||
const fallbackTab = removeChatTab(chatToDelete.id);
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
if (currentChatId === chatToDelete.id) {
|
||||
resetCurrentThread();
|
||||
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
|
||||
if (isOutOfSync) {
|
||||
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
|
||||
setChatResetKey((k) => k + 1);
|
||||
if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
|
||||
router.push(fallbackTab.chatUrl);
|
||||
} else {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
|
||||
if (isOutOfSync) {
|
||||
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
|
||||
setChatResetKey((k) => k + 1);
|
||||
} else {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -664,6 +676,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
currentThreadState.id,
|
||||
params?.chat_id,
|
||||
router,
|
||||
removeChatTab,
|
||||
]);
|
||||
|
||||
// Rename handler
|
||||
|
|
@ -795,9 +808,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
confirmDeleteChat();
|
||||
}}
|
||||
disabled={isDeletingChat}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
|
||||
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90 items-center justify-center"
|
||||
>
|
||||
{isDeletingChat ? <Spinner size="sm" /> : tCommon("delete")}
|
||||
<span className={isDeletingChat ? "opacity-0" : ""}>{tCommon("delete")}</span>
|
||||
{isDeletingChat && <Spinner size="sm" className="absolute" />}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
@ -835,15 +849,13 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
<Button
|
||||
onClick={confirmRenameChat}
|
||||
disabled={isRenamingChat || !newChatTitle.trim()}
|
||||
className="gap-2"
|
||||
className="relative"
|
||||
>
|
||||
{isRenamingChat ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{tSidebar("renaming") || "Renaming"}
|
||||
</>
|
||||
) : (
|
||||
tSidebar("rename") || "Rename"
|
||||
<span className={isRenamingChat ? "opacity-0" : ""}>
|
||||
{tSidebar("rename") || "Rename"}
|
||||
</span>
|
||||
{isRenamingChat && (
|
||||
<span className="absolute h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
@ -869,15 +881,11 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
confirmDeleteSearchSpace();
|
||||
}}
|
||||
disabled={isDeletingSearchSpace}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
|
||||
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeletingSearchSpace ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{t("deleting")}
|
||||
</>
|
||||
) : (
|
||||
tCommon("delete")
|
||||
<span className={isDeletingSearchSpace ? "opacity-0" : ""}>{tCommon("delete")}</span>
|
||||
{isDeletingSearchSpace && (
|
||||
<span className="absolute h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
|
@ -903,15 +911,11 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
confirmLeaveSearchSpace();
|
||||
}}
|
||||
disabled={isLeavingSearchSpace}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
|
||||
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isLeavingSearchSpace ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{t("leaving")}
|
||||
</>
|
||||
) : (
|
||||
t("leave")
|
||||
<span className={isLeavingSearchSpace ? "opacity-0" : ""}>{t("leave")}</span>
|
||||
{isLeavingSearchSpace && (
|
||||
<span className="absolute h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { PanelRight } from "lucide-react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
||||
import { reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { activeTabAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { activeTabAtom, tabsAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface HeaderProps {
|
||||
mobileMenuTrigger?: React.ReactNode;
|
||||
|
|
@ -25,9 +25,21 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
|||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const isMobile = useIsMobile();
|
||||
const activeTab = useAtomValue(activeTabAtom);
|
||||
const tabs = useAtomValue(tabsAtom);
|
||||
const collapsed = useAtomValue(rightPanelCollapsedAtom);
|
||||
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
|
||||
const reportState = useAtomValue(reportPanelAtom);
|
||||
const editorState = useAtomValue(editorPanelAtom);
|
||||
const hitlEditState = useAtomValue(hitlEditPanelAtom);
|
||||
|
||||
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
||||
const isDocumentTab = activeTab?.type === "document";
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen = editorState.isOpen && !!editorState.documentId;
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const showExpandButton =
|
||||
!isMobile && collapsed && (documentsOpen || reportOpen || editorOpen || hitlEditOpen);
|
||||
const hasTabBar = tabs.length > 1;
|
||||
|
||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
|
||||
|
|
@ -49,15 +61,8 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
|||
|
||||
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
|
||||
|
||||
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
|
||||
const reportState = useAtomValue(reportPanelAtom);
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const hasRightPanelContent = documentsOpen || reportOpen;
|
||||
const showExpandButton = !isMobile && collapsed && hasRightPanelContent;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-main-panel/95 backdrop-blur supports-backdrop-filter:bg-main-panel/60 px-4">
|
||||
<header className="sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 bg-main-panel/95 backdrop-blur supports-backdrop-filter:bg-main-panel/60 px-4">
|
||||
{/* Left side - Mobile menu trigger + Model selector */}
|
||||
<div className="flex flex-1 items-center gap-2 min-w-0">
|
||||
{mobileMenuTrigger}
|
||||
|
|
@ -67,26 +72,12 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
|||
</div>
|
||||
|
||||
{/* Right side - Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn("ml-auto flex items-center gap-2", showExpandButton && !hasTabBar && "mr-10")}
|
||||
>
|
||||
{hasThread && (
|
||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||
)}
|
||||
{showExpandButton && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="h-8 w-8 shrink-0"
|
||||
>
|
||||
<PanelRight className="h-4 w-4" />
|
||||
<span className="sr-only">Expand panel</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Expand panel</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export function RightPanelExpandButton() {
|
|||
if (!collapsed || !hasContent) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute top-4 right-4 z-20">
|
||||
<div className="absolute top-0 right-4 z-20 flex h-12 items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@
|
|||
import { useAtomValue } from "jotai";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
||||
import { reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
||||
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
|
|
@ -13,7 +18,7 @@ import { useSidebarResize } from "../../hooks/useSidebarResize";
|
|||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||
import { Header } from "../header";
|
||||
import { IconRail } from "../icon-rail";
|
||||
import { RightPanel } from "../right-panel/RightPanel";
|
||||
import { RightPanel, RightPanelExpandButton } from "../right-panel/RightPanel";
|
||||
import {
|
||||
AllPrivateChatsSidebarContent,
|
||||
AllSharedChatsSidebarContent,
|
||||
|
|
@ -116,11 +121,26 @@ function MainContentPanel({
|
|||
children: React.ReactNode;
|
||||
}) {
|
||||
const activeTab = useAtomValue(activeTabAtom);
|
||||
const rightPanelCollapsed = useAtomValue(rightPanelCollapsedAtom);
|
||||
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
|
||||
const reportState = useAtomValue(reportPanelAtom);
|
||||
const editorState = useAtomValue(editorPanelAtom);
|
||||
const hitlEditState = useAtomValue(hitlEditPanelAtom);
|
||||
const isDocumentTab = activeTab?.type === "document";
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen = editorState.isOpen && !!editorState.documentId;
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const showRightPanelExpandButton =
|
||||
rightPanelCollapsed && (documentsOpen || reportOpen || editorOpen || hitlEditOpen);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 flex-col rounded-xl border bg-main-panel overflow-hidden min-w-0">
|
||||
<TabBar onTabSwitch={onTabSwitch} onNewChat={onNewChat} />
|
||||
<RightPanelExpandButton />
|
||||
<TabBar
|
||||
onTabSwitch={onTabSwitch}
|
||||
onNewChat={onNewChat}
|
||||
className={showRightPanelExpandButton ? "pr-14" : undefined}
|
||||
/>
|
||||
<Header />
|
||||
|
||||
{isDocumentTab && activeTab.documentId && activeTab.searchSpaceId ? (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { format } from "date-fns";
|
||||
import { useSetAtom } from "jotai";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ChevronLeft,
|
||||
|
|
@ -18,6 +19,7 @@ import { useParams, useRouter } from "next/navigation";
|
|||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -70,6 +72,7 @@ export function AllPrivateChatsSidebarContent({
|
|||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const isMobile = useIsMobile();
|
||||
const removeChatTab = useSetAtom(removeChatTabAtom);
|
||||
|
||||
const currentChatId = Array.isArray(params.chat_id)
|
||||
? Number(params.chat_id[0])
|
||||
|
|
@ -158,6 +161,7 @@ export function AllPrivateChatsSidebarContent({
|
|||
setDeletingThreadId(threadId);
|
||||
try {
|
||||
await deleteThread(threadId);
|
||||
const fallbackTab = removeChatTab(threadId);
|
||||
toast.success(t("chat_deleted") || "Chat deleted successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||
|
|
@ -166,6 +170,10 @@ export function AllPrivateChatsSidebarContent({
|
|||
if (currentChatId === threadId) {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => {
|
||||
if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
|
||||
router.push(fallbackTab.chatUrl);
|
||||
return;
|
||||
}
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
}, 250);
|
||||
}
|
||||
|
|
@ -176,7 +184,7 @@ export function AllPrivateChatsSidebarContent({
|
|||
setDeletingThreadId(null);
|
||||
}
|
||||
},
|
||||
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange]
|
||||
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange, removeChatTab]
|
||||
);
|
||||
|
||||
const handleToggleArchive = useCallback(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { format } from "date-fns";
|
||||
import { useSetAtom } from "jotai";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ChevronLeft,
|
||||
|
|
@ -18,6 +19,7 @@ import { useParams, useRouter } from "next/navigation";
|
|||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -70,6 +72,7 @@ export function AllSharedChatsSidebarContent({
|
|||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const isMobile = useIsMobile();
|
||||
const removeChatTab = useSetAtom(removeChatTabAtom);
|
||||
|
||||
const currentChatId = Array.isArray(params.chat_id)
|
||||
? Number(params.chat_id[0])
|
||||
|
|
@ -158,6 +161,7 @@ export function AllSharedChatsSidebarContent({
|
|||
setDeletingThreadId(threadId);
|
||||
try {
|
||||
await deleteThread(threadId);
|
||||
const fallbackTab = removeChatTab(threadId);
|
||||
toast.success(t("chat_deleted") || "Chat deleted successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||
|
|
@ -166,6 +170,10 @@ export function AllSharedChatsSidebarContent({
|
|||
if (currentChatId === threadId) {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => {
|
||||
if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
|
||||
router.push(fallbackTab.chatUrl);
|
||||
return;
|
||||
}
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
}, 250);
|
||||
}
|
||||
|
|
@ -176,7 +184,7 @@ export function AllSharedChatsSidebarContent({
|
|||
setDeletingThreadId(null);
|
||||
}
|
||||
},
|
||||
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange]
|
||||
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange, removeChatTab]
|
||||
);
|
||||
|
||||
const handleToggleArchive = useCallback(
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
|||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
|
||||
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
||||
import { openDocumentTabAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
|
||||
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
|
||||
import type { FolderDisplay } from "@/components/documents/FolderNode";
|
||||
|
|
@ -35,21 +35,12 @@ import {
|
|||
} from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { queries } from "@/zero/queries/index";
|
||||
|
|
@ -95,12 +86,10 @@ export function DocumentsSidebar({
|
|||
const searchSpaceId = Number(params.search_space_id);
|
||||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
|
||||
const openDocumentTab = useSetAtom(openDocumentTabAtom);
|
||||
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||
const connectorCount = connectors?.length ?? 0;
|
||||
|
||||
const isMobileLayout = useIsMobile();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search, 250);
|
||||
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
|
||||
|
|
@ -374,31 +363,6 @@ export function DocumentsSidebar({
|
|||
[]
|
||||
);
|
||||
|
||||
// Document popup viewer state (for tree view "Open" and mobile preview)
|
||||
const [viewingDoc, setViewingDoc] = useState<DocumentNodeDoc | null>(null);
|
||||
const [viewingContent, setViewingContent] = useState<string>("");
|
||||
const [viewingLoading, setViewingLoading] = useState(false);
|
||||
|
||||
const handleViewDocumentPopup = useCallback(async (doc: DocumentNodeDoc) => {
|
||||
setViewingDoc(doc);
|
||||
setViewingLoading(true);
|
||||
try {
|
||||
const fullDoc = await documentsApiService.getDocument({ id: doc.id });
|
||||
setViewingContent(fullDoc.content);
|
||||
} catch (err) {
|
||||
console.error("[DocumentsSidebar] Failed to fetch document content:", err);
|
||||
setViewingContent("Failed to load document content.");
|
||||
} finally {
|
||||
setViewingLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseViewer = useCallback(() => {
|
||||
setViewingDoc(null);
|
||||
setViewingContent("");
|
||||
setViewingLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleToggleChatMention = useCallback(
|
||||
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
|
||||
if (isMentioned) {
|
||||
|
|
@ -557,7 +521,7 @@ export function DocumentsSidebar({
|
|||
|
||||
const documentsContent = (
|
||||
<>
|
||||
<div className="shrink-0 flex h-14 items-center px-4">
|
||||
<div className="shrink-0 flex h-12 items-center px-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isMobile && (
|
||||
|
|
@ -609,7 +573,7 @@ export function DocumentsSidebar({
|
|||
</div>
|
||||
|
||||
{/* Connected tools strip */}
|
||||
<div className="shrink-0 mx-4 mt-2 mb-3 flex select-none items-center gap-2 rounded-lg border bg-muted/50 transition-colors hover:bg-muted/80">
|
||||
<div className="shrink-0 mx-4 mt-4 mb-4 flex select-none items-center gap-2 rounded-lg border bg-muted/50 transition-colors hover:bg-muted/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConnectorDialogOpen(true)}
|
||||
|
|
@ -716,24 +680,18 @@ export function DocumentsSidebar({
|
|||
onCreateFolder={handleCreateFolder}
|
||||
searchQuery={debouncedSearch.trim() || undefined}
|
||||
onPreviewDocument={(doc) => {
|
||||
if (isMobileLayout) {
|
||||
handleViewDocumentPopup(doc);
|
||||
} else {
|
||||
openDocumentTab({
|
||||
documentId: doc.id,
|
||||
searchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
}
|
||||
openEditorPanel({
|
||||
documentId: doc.id,
|
||||
searchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
}}
|
||||
onEditDocument={(doc) => {
|
||||
if (!isMobileLayout) {
|
||||
openDocumentTab({
|
||||
documentId: doc.id,
|
||||
searchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
}
|
||||
openEditorPanel({
|
||||
documentId: doc.id,
|
||||
searchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
}}
|
||||
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
|
||||
onMoveDocument={handleMoveDocument}
|
||||
|
|
@ -761,26 +719,6 @@ export function DocumentsSidebar({
|
|||
onConfirm={handleCreateFolderConfirm}
|
||||
/>
|
||||
|
||||
<Drawer open={!!viewingDoc} onOpenChange={(open) => !open && handleCloseViewer()}>
|
||||
<DrawerContent className="max-h-[85vh] flex flex-col">
|
||||
<DrawerHandle />
|
||||
<DrawerHeader className="text-left shrink-0">
|
||||
<DrawerTitle className="text-base leading-tight break-words">
|
||||
{viewingDoc?.title}
|
||||
</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-6 select-text text-xs [&_h1]:text-base! [&_h1]:mt-3! [&_h2]:text-sm! [&_h2]:mt-2! [&_h3]:text-xs! [&_h3]:mt-2!">
|
||||
{viewingLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="lg" className="text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownViewer content={viewingContent} />
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<AlertDialog
|
||||
open={bulkDeleteConfirmOpen}
|
||||
onOpenChange={(open) => !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)}
|
||||
|
|
@ -807,9 +745,10 @@ export function DocumentsSidebar({
|
|||
handleBulkDeleteSelected();
|
||||
}}
|
||||
disabled={isBulkDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isBulkDeleting ? <Spinner size="sm" /> : "Delete"}
|
||||
<span className={isBulkDeleting ? "opacity-0" : ""}>Delete</span>
|
||||
{isBulkDeleting && <Spinner size="sm" className="absolute" />}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export function Sidebar({
|
|||
>
|
||||
{/* Header - search space name or collapse button when collapsed */}
|
||||
{isCollapsed ? (
|
||||
<div className="flex h-14 shrink-0 items-center justify-center border-b">
|
||||
<div className="flex h-12 shrink-0 items-center justify-center border-b">
|
||||
<SidebarCollapseButton
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={onToggleCollapse ?? (() => {})}
|
||||
|
|
@ -113,7 +113,7 @@ export function Sidebar({
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-14 shrink-0 items-center gap-0 px-1 border-b">
|
||||
<div className="flex h-12 shrink-0 items-center gap-0 px-1 border-b">
|
||||
<SidebarHeader
|
||||
searchSpace={searchSpace}
|
||||
isCollapsed={isCollapsed}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ interface DocumentTabContentProps {
|
|||
title?: string;
|
||||
}
|
||||
|
||||
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
|
||||
|
||||
export function DocumentTabContent({ documentId, searchSpaceId, title }: DocumentTabContentProps) {
|
||||
const [doc, setDoc] = useState<DocumentContent | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
@ -171,6 +173,8 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
);
|
||||
}
|
||||
|
||||
const isEditable = EDITABLE_DOCUMENT_TYPES.has(doc.document_type ?? "");
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
|
|
@ -218,7 +222,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
<h1 className="text-base font-semibold truncate flex-1 min-w-0">
|
||||
{doc.title || title || "Untitled"}
|
||||
</h1>
|
||||
{doc.document_type === "NOTE" && (
|
||||
{isEditable && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { FileText, MessageSquare, Plus, X } from "lucide-react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
activeTabIdAtom,
|
||||
|
|
@ -58,11 +58,18 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
|
|||
if (tabs.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center shrink-0 border-b bg-main-panel", className)}>
|
||||
<div ref={scrollRef} className="flex items-center flex-1 overflow-x-auto scrollbar-none">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 items-stretch shrink-0 border-b border-border/35 bg-main-panel",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex h-full items-stretch flex-1 overflow-x-auto overflow-y-hidden scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const Icon = tab.type === "document" ? FileText : MessageSquare;
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
@ -71,15 +78,15 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
|
|||
data-tab-id={tab.id}
|
||||
onClick={() => handleTabClick(tab)}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-1.5 px-3 h-9 min-w-0 max-w-[200px] text-xs font-medium border-r transition-colors shrink-0",
|
||||
"group relative flex h-full w-[170px] items-center self-stretch px-3 min-w-0 overflow-hidden text-sm font-medium border-r border-border/35 transition-colors shrink-0",
|
||||
isActive
|
||||
? "bg-main-panel text-foreground"
|
||||
: "bg-muted/30 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
? "bg-muted/50 text-foreground"
|
||||
: "bg-transparent text-muted-foreground hover:bg-muted/25 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{isActive && <span className="absolute bottom-0 left-0 right-0 h-[2px] bg-primary" />}
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{tab.title}</span>
|
||||
<span className="block min-w-0 flex-1 truncate text-left transition-[padding-right] duration-150 group-hover:pr-5 group-focus-within:pr-5">
|
||||
{tab.title}
|
||||
</span>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: cannot nest button inside button */}
|
||||
<span
|
||||
role="button"
|
||||
|
|
@ -92,10 +99,10 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
|
|||
}
|
||||
}}
|
||||
className={cn(
|
||||
"ml-auto shrink-0 rounded-sm p-0.5 transition-colors",
|
||||
"absolute right-2 top-1/2 -translate-y-1/2 shrink-0 rounded-sm p-0.5 transition-colors",
|
||||
isActive
|
||||
? "opacity-60 hover:opacity-100 hover:bg-muted"
|
||||
: "opacity-0 group-hover:opacity-60 hover:opacity-100! hover:bg-muted"
|
||||
? "opacity-0 group-hover:opacity-70 group-focus-within:opacity-70 hover:opacity-100"
|
||||
: "opacity-0 group-hover:opacity-60 group-focus-within:opacity-60 hover:opacity-100!"
|
||||
)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
|
|
@ -103,18 +110,19 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
|
|||
</button>
|
||||
);
|
||||
})}
|
||||
{onNewChat && (
|
||||
<div className="flex h-full items-center px-1.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
|
||||
title="New Chat"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onNewChat && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className="flex items-center justify-center size-9 shrink-0 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
title="New Chat"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
|
||||
import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
|
||||
import type {
|
||||
GlobalImageGenConfig,
|
||||
GlobalNewLLMConfig,
|
||||
ImageGenerationConfig,
|
||||
NewLLMConfigPublic,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { ImageConfigDialog } from "./image-config-dialog";
|
||||
import { ModelConfigDialog } from "./model-config-dialog";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
|
|
|
|||
|
|
@ -1,558 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Check, ChevronsUpDown, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createImageGenConfigMutationAtom,
|
||||
updateImageGenConfigMutationAtom,
|
||||
} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { Spinner } from "@/components/ui/spinner";
|
||||
import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-gen-providers";
|
||||
import type {
|
||||
GlobalImageGenConfig,
|
||||
ImageGenerationConfig,
|
||||
ImageGenProvider,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ImageConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: ImageGenerationConfig | GlobalImageGenConfig | null;
|
||||
isGlobal: boolean;
|
||||
searchSpaceId: number;
|
||||
mode: "create" | "edit" | "view";
|
||||
}
|
||||
|
||||
const INITIAL_FORM = {
|
||||
name: "",
|
||||
description: "",
|
||||
provider: "",
|
||||
model_name: "",
|
||||
api_key: "",
|
||||
api_base: "",
|
||||
api_version: "",
|
||||
};
|
||||
|
||||
export function ImageConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
config,
|
||||
isGlobal,
|
||||
searchSpaceId,
|
||||
mode,
|
||||
}: ImageConfigDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [formData, setFormData] = useState(INITIAL_FORM);
|
||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
||||
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (mode === "edit" && config && !isGlobal) {
|
||||
setFormData({
|
||||
name: config.name || "",
|
||||
description: config.description || "",
|
||||
provider: config.provider || "",
|
||||
model_name: config.model_name || "",
|
||||
api_key: (config as ImageGenerationConfig).api_key || "",
|
||||
api_base: config.api_base || "",
|
||||
api_version: config.api_version || "",
|
||||
});
|
||||
} else if (mode === "create") {
|
||||
setFormData(INITIAL_FORM);
|
||||
}
|
||||
setScrollPos("top");
|
||||
}
|
||||
}, [open, mode, config, isGlobal]);
|
||||
|
||||
const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom);
|
||||
const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) onOpenChange(false);
|
||||
};
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}, []);
|
||||
|
||||
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
|
||||
|
||||
const suggestedModels = useMemo(() => {
|
||||
if (!formData.provider) return [];
|
||||
return IMAGE_GEN_MODELS.filter((m) => m.provider === formData.provider);
|
||||
}, [formData.provider]);
|
||||
|
||||
const getTitle = () => {
|
||||
if (mode === "create") return "Add Image Model";
|
||||
if (isAutoMode) return "Auto Mode (Fastest)";
|
||||
if (isGlobal) return "View Global Image Model";
|
||||
return "Edit Image Model";
|
||||
};
|
||||
|
||||
const getSubtitle = () => {
|
||||
if (mode === "create") return "Set up a new image generation provider";
|
||||
if (isAutoMode) return "Automatically routes requests across providers";
|
||||
if (isGlobal) return "Read-only global configuration";
|
||||
return "Update your image model settings";
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const result = await createConfig({
|
||||
name: formData.name,
|
||||
provider: formData.provider as ImageGenProvider,
|
||||
model_name: formData.model_name,
|
||||
api_key: formData.api_key,
|
||||
api_base: formData.api_base || undefined,
|
||||
api_version: formData.api_version || undefined,
|
||||
description: formData.description || undefined,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
if (result?.id) {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: { image_generation_config_id: result.id },
|
||||
});
|
||||
}
|
||||
toast.success("Image model created and assigned!");
|
||||
onOpenChange(false);
|
||||
} else if (!isGlobal && config) {
|
||||
await updateConfig({
|
||||
id: config.id,
|
||||
data: {
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
provider: formData.provider as ImageGenProvider,
|
||||
model_name: formData.model_name,
|
||||
api_key: formData.api_key,
|
||||
api_base: formData.api_base || undefined,
|
||||
api_version: formData.api_version || undefined,
|
||||
},
|
||||
});
|
||||
toast.success("Image model updated!");
|
||||
onOpenChange(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save image config:", error);
|
||||
toast.error("Failed to save image model");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [
|
||||
mode,
|
||||
isGlobal,
|
||||
config,
|
||||
formData,
|
||||
searchSpaceId,
|
||||
createConfig,
|
||||
updateConfig,
|
||||
updatePreferences,
|
||||
onOpenChange,
|
||||
]);
|
||||
|
||||
const handleUseGlobalConfig = useCallback(async () => {
|
||||
if (!config || !isGlobal) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: { image_generation_config_id: config.id },
|
||||
});
|
||||
toast.success(`Now using ${config.name}`);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to set image model:", error);
|
||||
toast.error("Failed to set image model");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
|
||||
|
||||
const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key;
|
||||
const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const dialogContent = (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
"relative w-full max-w-lg h-[85vh]",
|
||||
"rounded-xl bg-background shadow-2xl",
|
||||
"dark:bg-neutral-900",
|
||||
"flex flex-col overflow-hidden"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-6 pt-6 pb-4">
|
||||
<div className="space-y-1 pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||
{isAutoMode && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
{isGlobal && !isAutoMode && mode !== "create" && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Global
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||
{config && !isAutoMode && mode !== "create" && (
|
||||
<p className="text-xs font-mono text-muted-foreground/70">
|
||||
{config.model_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-4 top-4 h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto px-6 py-5"
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
{isAutoMode && (
|
||||
<Alert className="mb-5 border-violet-500/30 bg-violet-500/5">
|
||||
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
||||
Auto mode distributes image generation requests across all configured
|
||||
providers for optimal performance and rate limit protection.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isGlobal && !isAutoMode && config && (
|
||||
<>
|
||||
<Alert className="mb-5 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, create a new model.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Name
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.name}</p>
|
||||
</div>
|
||||
{config.description && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Description
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Provider
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.provider}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Model
|
||||
</div>
|
||||
<p className="text-sm font-medium font-mono">{config.model_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Name *</Label>
|
||||
<Input
|
||||
placeholder="e.g., My DALL-E 3"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Description</Label>
|
||||
<Input
|
||||
placeholder="Optional description"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData((p) => ({ ...p, description: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Provider *</Label>
|
||||
<Select
|
||||
value={formData.provider}
|
||||
onValueChange={(val) =>
|
||||
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-muted dark:border-neutral-700">
|
||||
{IMAGE_GEN_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value} description={p.example}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Model Name *</Label>
|
||||
{suggestedModels.length > 0 ? (
|
||||
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal bg-transparent"
|
||||
>
|
||||
{formData.model_name || "Select or type a model..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 bg-muted dark:border-neutral-700"
|
||||
align="start"
|
||||
>
|
||||
<Command className="bg-transparent">
|
||||
<CommandInput
|
||||
placeholder="Search or type model..."
|
||||
value={formData.model_name}
|
||||
onValueChange={(val) =>
|
||||
setFormData((p) => ({ ...p, model_name: val }))
|
||||
}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Type a custom model name
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{suggestedModels.map((m) => (
|
||||
<CommandItem
|
||||
key={m.value}
|
||||
value={m.value}
|
||||
onSelect={() => {
|
||||
setFormData((p) => ({ ...p, model_name: m.value }));
|
||||
setModelComboboxOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.model_name === m.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="font-mono text-sm">{m.value}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{m.label}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Input
|
||||
placeholder="e.g., dall-e-3"
|
||||
value={formData.model_name}
|
||||
onChange={(e) =>
|
||||
setFormData((p) => ({ ...p, model_name: e.target.value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Key *</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={formData.api_key}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Base URL</Label>
|
||||
<Input
|
||||
placeholder={selectedProvider?.apiBase || "Optional"}
|
||||
value={formData.api_base}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_base: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.provider === "AZURE_OPENAI" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Version (Azure)</Label>
|
||||
<Input
|
||||
placeholder="2024-02-15-preview"
|
||||
value={formData.api_version}
|
||||
onChange={(e) =>
|
||||
setFormData((p) => ({ ...p, api_version: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed footer */}
|
||||
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
className="text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{mode === "create" || (mode === "edit" && !isGlobal) ? (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !isFormValid}
|
||||
className="text-sm h-9 min-w-[120px]"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
{mode === "edit" ? "Saving" : "Creating"}
|
||||
</>
|
||||
) : mode === "edit" ? (
|
||||
"Save Changes"
|
||||
) : (
|
||||
"Create & Use"
|
||||
)}
|
||||
</Button>
|
||||
) : isAutoMode ? (
|
||||
<Button
|
||||
className="text-sm h-9 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Loading..." : "Use Auto Mode"}
|
||||
</Button>
|
||||
) : isGlobal && config ? (
|
||||
<Button
|
||||
className="text-sm h-9 gap-2"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Loading..." : "Use This Model"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null;
|
||||
}
|
||||
|
|
@ -1,489 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, X, Zap } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
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 { Spinner } from "@/components/ui/spinner";
|
||||
import type {
|
||||
GlobalNewLLMConfig,
|
||||
LiteLLMProvider,
|
||||
NewLLMConfigPublic,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModelConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
|
||||
isGlobal: boolean;
|
||||
searchSpaceId: number;
|
||||
mode: "create" | "edit" | "view";
|
||||
}
|
||||
|
||||
export function ModelConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
config,
|
||||
isGlobal,
|
||||
searchSpaceId,
|
||||
mode,
|
||||
}: ModelConfigDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}, []);
|
||||
|
||||
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
|
||||
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
|
||||
|
||||
const getTitle = () => {
|
||||
if (mode === "create") return "Add New Configuration";
|
||||
if (isAutoMode) return "Auto Mode (Fastest)";
|
||||
if (isGlobal) return "View Global Configuration";
|
||||
return "Edit Configuration";
|
||||
};
|
||||
|
||||
const getSubtitle = () => {
|
||||
if (mode === "create") return "Set up a new LLM provider for this search space";
|
||||
if (isAutoMode) return "Automatically routes requests across providers";
|
||||
if (isGlobal) return "Read-only global configuration";
|
||||
return "Update your configuration settings";
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (data: LLMConfigFormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const result = await createConfig({
|
||||
...data,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
|
||||
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) {
|
||||
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,
|
||||
]
|
||||
);
|
||||
|
||||
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]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const dialogContent = (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed inset-0 z-[24] bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className="fixed inset-0 z-[25] flex items-center justify-center p-4 sm:p-6"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
"relative w-full max-w-lg h-[85vh]",
|
||||
"rounded-xl bg-background shadow-2xl",
|
||||
"dark:bg-neutral-900",
|
||||
"flex flex-col overflow-hidden"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-6 pt-6 pb-4">
|
||||
<div className="space-y-1 pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||
{isAutoMode && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
{isGlobal && !isAutoMode && mode !== "create" && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Global
|
||||
</Badge>
|
||||
)}
|
||||
{!isGlobal && mode !== "create" && !isAutoMode && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Custom
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||
{config && !isAutoMode && mode !== "create" && (
|
||||
<p className="text-xs font-mono text-muted-foreground/70">
|
||||
{config.model_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-4 top-4 h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto px-6 py-5"
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
{isAutoMode && (
|
||||
<Alert className="mb-5 border-violet-500/30 bg-violet-500/5">
|
||||
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
||||
Auto mode automatically distributes requests across all available LLM
|
||||
providers to optimize performance and avoid rate limits.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isGlobal && !isAutoMode && mode !== "create" && (
|
||||
<Alert className="mb-5 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>
|
||||
)}
|
||||
|
||||
{mode === "create" ? (
|
||||
<LLMConfigForm
|
||||
searchSpaceId={searchSpaceId}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
mode="create"
|
||||
formId="model-config-form"
|
||||
hideActions
|
||||
/>
|
||||
) : isAutoMode && config ? (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
How It Works
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/50" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Key Benefits
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800/50">
|
||||
<Zap className="size-4 text-violet-600 dark:text-violet-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-violet-900 dark:text-violet-100">
|
||||
Automatic (Fastest)
|
||||
</p>
|
||||
<p className="text-xs text-violet-700 dark:text-violet-300">
|
||||
Distributes requests across all configured LLM providers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800/50">
|
||||
<Zap className="size-4 text-violet-600 dark:text-violet-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-violet-900 dark:text-violet-100">
|
||||
Rate Limit Protection
|
||||
</p>
|
||||
<p className="text-xs text-violet-700 dark:text-violet-300">
|
||||
Automatically handles rate limits with cooldowns and retries
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800/50">
|
||||
<Zap className="size-4 text-violet-600 dark:text-violet-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-violet-900 dark:text-violet-100">
|
||||
Automatic Failover
|
||||
</p>
|
||||
<p className="text-xs text-violet-700 dark:text-violet-300">
|
||||
Falls back to other providers if one becomes unavailable
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isGlobal && config ? (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Configuration Name
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.name}</p>
|
||||
</div>
|
||||
{config.description && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Description
|
||||
</div>
|
||||
<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">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Provider
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.provider}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Model
|
||||
</div>
|
||||
<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">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Citations
|
||||
</div>
|
||||
<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">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
System Instructions
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
) : config ? (
|
||||
<LLMConfigForm
|
||||
searchSpaceId={searchSpaceId}
|
||||
initialData={{
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
provider: config.provider as LiteLLMProvider,
|
||||
custom_provider: config.custom_provider,
|
||||
model_name: config.model_name,
|
||||
api_key: "api_key" in config ? (config.api_key as string) : "",
|
||||
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}
|
||||
isSubmitting={isSubmitting}
|
||||
mode="edit"
|
||||
formId="model-config-form"
|
||||
hideActions
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Fixed footer */}
|
||||
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
className="text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{mode === "create" || (!isGlobal && !isAutoMode && config) ? (
|
||||
<Button
|
||||
type="submit"
|
||||
form="model-config-form"
|
||||
disabled={isSubmitting}
|
||||
className="text-sm h-9 min-w-[120px]"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
{mode === "edit" ? "Saving" : "Creating"}
|
||||
</>
|
||||
) : mode === "edit" ? (
|
||||
"Save Changes"
|
||||
) : (
|
||||
"Create & Use"
|
||||
)}
|
||||
</Button>
|
||||
) : isAutoMode ? (
|
||||
<Button
|
||||
className="text-sm h-9 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Loading..." : "Use Auto Mode"}
|
||||
</Button>
|
||||
) : isGlobal && config ? (
|
||||
<Button
|
||||
className="text-sm h-9 gap-2"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Loading..." : "Use This Model"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null;
|
||||
}
|
||||
|
|
@ -498,7 +498,7 @@ export function ModelSelector({
|
|||
}}
|
||||
>
|
||||
<Plus className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium">Add New Configuration</span>
|
||||
<span className="text-sm font-medium">Add LLM Model</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CommandList>
|
||||
|
|
|
|||
|
|
@ -106,16 +106,16 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
|
|||
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
|
||||
style={containerStyle}
|
||||
>
|
||||
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<p className="px-3 py-2 text-xs text-destructive">Failed to load prompts</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
|
||||
) : (
|
||||
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<p className="px-3 py-2 text-xs text-destructive">Failed to load prompts</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
|
||||
) : (
|
||||
filtered.map((action, index) => (
|
||||
<button
|
||||
key={action.id}
|
||||
|
|
|
|||
|
|
@ -666,27 +666,33 @@ export function OnboardingTour() {
|
|||
}, [targetEl, isActive]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (stepIndex < TOUR_STEPS.length - 1) {
|
||||
retryCountRef.current = 0;
|
||||
setShouldAnimate(true);
|
||||
setStepIndex(stepIndex + 1);
|
||||
} else {
|
||||
// Tour completed - save to localStorage
|
||||
if (user?.id) {
|
||||
const tourKey = `surfsense-tour-${user.id}`;
|
||||
localStorage.setItem(tourKey, "true");
|
||||
retryCountRef.current = 0;
|
||||
setShouldAnimate(true);
|
||||
setStepIndex(prev => {
|
||||
if (prev < TOUR_STEPS.length - 1) {
|
||||
return prev + 1;
|
||||
} else {
|
||||
// Tour completed - save to localStorage
|
||||
if (user?.id) {
|
||||
const tourKey = `surfsense-tour-${user.id}`;
|
||||
localStorage.setItem(tourKey, "true");
|
||||
}
|
||||
setIsActive(false);
|
||||
return prev;
|
||||
}
|
||||
setIsActive(false);
|
||||
}
|
||||
}, [stepIndex, user?.id]);
|
||||
});
|
||||
}, [user?.id]);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
if (stepIndex > 0) {
|
||||
retryCountRef.current = 0;
|
||||
setShouldAnimate(true);
|
||||
setStepIndex(stepIndex - 1);
|
||||
}
|
||||
}, [stepIndex]);
|
||||
retryCountRef.current = 0;
|
||||
setShouldAnimate(true);
|
||||
setStepIndex(prev => {
|
||||
if (prev > 0) {
|
||||
return prev - 1;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
// Tour skipped - save to localStorage
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { CitationMetadataProvider } from "@/components/assistant-ui/citation-metadata-context";
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
|
|
@ -142,30 +143,33 @@ const PublicAssistantMessage: FC = () => {
|
|||
className="aui-assistant-message-root group fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||
data-role="assistant"
|
||||
>
|
||||
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: MarkdownText,
|
||||
tools: {
|
||||
by_name: {
|
||||
generate_podcast: GeneratePodcastToolUI,
|
||||
generate_report: GenerateReportToolUI,
|
||||
generate_video_presentation: GenerateVideoPresentationToolUI,
|
||||
display_image: GenerateImageToolUI,
|
||||
generate_image: GenerateImageToolUI,
|
||||
link_preview: () => null,
|
||||
multi_link_preview: () => null,
|
||||
scrape_webpage: () => null,
|
||||
<CitationMetadataProvider>
|
||||
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: MarkdownText,
|
||||
tools: {
|
||||
by_name: {
|
||||
generate_podcast: GeneratePodcastToolUI,
|
||||
generate_report: GenerateReportToolUI,
|
||||
generate_video_presentation: GenerateVideoPresentationToolUI,
|
||||
display_image: GenerateImageToolUI,
|
||||
generate_image: GenerateImageToolUI,
|
||||
web_search: () => null,
|
||||
link_preview: () => null,
|
||||
multi_link_preview: () => null,
|
||||
scrape_webpage: () => null,
|
||||
},
|
||||
Fallback: ToolFallback,
|
||||
},
|
||||
Fallback: ToolFallback,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
|
||||
<PublicAssistantActionBar />
|
||||
</div>
|
||||
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
|
||||
<PublicAssistantActionBar />
|
||||
</div>
|
||||
</CitationMetadataProvider>
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,26 +40,17 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Initialize state from fetched search space
|
||||
useEffect(() => {
|
||||
if (searchSpace) {
|
||||
setName(searchSpace.name || "");
|
||||
setDescription(searchSpace.description || "");
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [searchSpace]);
|
||||
}, [searchSpace?.name, searchSpace?.description]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
if (searchSpace) {
|
||||
const currentName = searchSpace.name || "";
|
||||
const currentDescription = searchSpace.description || "";
|
||||
const changed = currentName !== name || currentDescription !== description;
|
||||
setHasChanges(changed);
|
||||
}
|
||||
}, [searchSpace, name, description]);
|
||||
// Derive hasChanges during render
|
||||
const hasChanges = !!searchSpace && ((searchSpace.name || "") !== name || (searchSpace.description || "") !== description);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
|
|
@ -73,7 +64,6 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
},
|
||||
});
|
||||
|
||||
setHasChanges(false);
|
||||
await fetchSearchSpace();
|
||||
} catch (error: any) {
|
||||
console.error("Error saving search space details:", error);
|
||||
|
|
|
|||
|
|
@ -1,31 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Edit3,
|
||||
Info,
|
||||
Key,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createImageGenConfigMutationAtom,
|
||||
deleteImageGenConfigMutationAtom,
|
||||
updateImageGenConfigMutationAtom,
|
||||
} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
|
||||
import { AlertCircle, Edit3, Info, Plus, RefreshCw, Trash2, Wand2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
|
||||
import {
|
||||
globalImageGenConfigsAtom,
|
||||
imageGenConfigsAtom,
|
||||
} from "@/atoms/image-gen-config/image-gen-config-query.atoms";
|
||||
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -40,39 +24,9 @@ import {
|
|||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
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 { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
getImageGenModelsByProvider,
|
||||
IMAGE_GEN_PROVIDERS,
|
||||
} from "@/contracts/enums/image-gen-providers";
|
||||
import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.types";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
|
|
@ -92,23 +46,12 @@ function getInitials(name: string): string {
|
|||
|
||||
export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
// Image gen config atoms
|
||||
const {
|
||||
mutateAsync: createConfig,
|
||||
isPending: isCreating,
|
||||
error: createError,
|
||||
} = useAtomValue(createImageGenConfigMutationAtom);
|
||||
const {
|
||||
mutateAsync: updateConfig,
|
||||
isPending: isUpdating,
|
||||
error: updateError,
|
||||
} = useAtomValue(updateImageGenConfigMutationAtom);
|
||||
|
||||
const {
|
||||
mutateAsync: deleteConfig,
|
||||
isPending: isDeleting,
|
||||
error: deleteError,
|
||||
} = useAtomValue(deleteImageGenConfigMutationAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const {
|
||||
data: userConfigs,
|
||||
|
|
@ -119,7 +62,6 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
const { data: globalConfigs = [], isFetching: globalLoading } =
|
||||
useAtomValue(globalImageGenConfigsAtom);
|
||||
|
||||
// Members for user resolution
|
||||
const { data: members } = useAtomValue(membersAtom);
|
||||
const memberMap = useMemo(() => {
|
||||
const map = new Map<string, { name: string; email?: string; avatarUrl?: string }>();
|
||||
|
|
@ -135,7 +77,6 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
return map;
|
||||
}, [members]);
|
||||
|
||||
// Permissions
|
||||
const { data: access } = useAtomValue(myAccessAtom);
|
||||
const canCreate = useMemo(() => {
|
||||
if (!access) return false;
|
||||
|
|
@ -147,126 +88,35 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
if (access.is_owner) return true;
|
||||
return access.permissions?.includes("image_generations:delete") ?? false;
|
||||
}, [access]);
|
||||
// Backend uses image_generations:create for update as well
|
||||
const canUpdate = canCreate;
|
||||
const isReadOnly = !canCreate && !canDelete;
|
||||
|
||||
// Local state
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<ImageGenerationConfig | null>(null);
|
||||
const [configToDelete, setConfigToDelete] = useState<ImageGenerationConfig | null>(null);
|
||||
|
||||
const isSubmitting = isCreating || isUpdating;
|
||||
const isLoading = configsLoading || globalLoading;
|
||||
const errors = [createError, updateError, deleteError, fetchError].filter(Boolean) as Error[];
|
||||
|
||||
// Form state for create/edit dialog
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
provider: "",
|
||||
custom_provider: "",
|
||||
model_name: "",
|
||||
api_key: "",
|
||||
api_base: "",
|
||||
api_version: "",
|
||||
});
|
||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: "",
|
||||
description: "",
|
||||
provider: "",
|
||||
custom_provider: "",
|
||||
model_name: "",
|
||||
api_key: "",
|
||||
api_base: "",
|
||||
api_version: "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormSubmit = useCallback(async () => {
|
||||
if (!formData.name || !formData.provider || !formData.model_name || !formData.api_key) {
|
||||
toast.error("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (editingConfig) {
|
||||
await updateConfig({
|
||||
id: editingConfig.id,
|
||||
data: {
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
provider: formData.provider as any,
|
||||
custom_provider: formData.custom_provider || undefined,
|
||||
model_name: formData.model_name,
|
||||
api_key: formData.api_key,
|
||||
api_base: formData.api_base || undefined,
|
||||
api_version: formData.api_version || undefined,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const result = await createConfig({
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
provider: formData.provider as any,
|
||||
custom_provider: formData.custom_provider || undefined,
|
||||
model_name: formData.model_name,
|
||||
api_key: formData.api_key,
|
||||
api_base: formData.api_base || undefined,
|
||||
api_version: formData.api_version || undefined,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
// Auto-assign newly created config
|
||||
if (result?.id) {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: { image_generation_config_id: result.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
setIsDialogOpen(false);
|
||||
setEditingConfig(null);
|
||||
resetForm();
|
||||
} catch {
|
||||
// Error handled by mutation
|
||||
}
|
||||
}, [editingConfig, formData, searchSpaceId, createConfig, updateConfig, updatePreferences]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!configToDelete) return;
|
||||
try {
|
||||
await deleteConfig(configToDelete.id);
|
||||
setConfigToDelete(null);
|
||||
} catch {
|
||||
// Error handled by mutation
|
||||
}
|
||||
};
|
||||
const errors = [deleteError, fetchError].filter(Boolean) as Error[];
|
||||
|
||||
const openEditDialog = (config: ImageGenerationConfig) => {
|
||||
setEditingConfig(config);
|
||||
setFormData({
|
||||
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 || "",
|
||||
api_version: config.api_version || "",
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const openNewDialog = () => {
|
||||
setEditingConfig(null);
|
||||
resetForm();
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider);
|
||||
const suggestedModels = getImageGenModelsByProvider(formData.provider);
|
||||
const handleDelete = async () => {
|
||||
if (!configToDelete) return;
|
||||
try {
|
||||
await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
|
||||
setConfigToDelete(null);
|
||||
} catch {
|
||||
// Error handled by mutation
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
|
|
@ -336,11 +186,16 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
<Alert className="bg-muted/50 py-3">
|
||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
<span className="font-medium">
|
||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length} global
|
||||
image model(s)
|
||||
</span>{" "}
|
||||
available from your administrator.
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length}{" "}
|
||||
global image{" "}
|
||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length === 1
|
||||
? "model"
|
||||
: "models"}
|
||||
</span>{" "}
|
||||
available from your administrator. Use the model selector to view and select them.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
@ -348,31 +203,26 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
{/* Loading Skeleton */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Your Image Models Section Skeleton */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 md:h-7 w-40 md:w-48" />
|
||||
<Skeleton className="h-8 md:h-9 w-32 md:w-36 rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Cards Grid Skeleton */}
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-border/60">
|
||||
<CardContent className="p-4 flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1.5 flex-1 min-w-0">
|
||||
<Skeleton className="h-4 w-28 md:w-32" />
|
||||
<Skeleton className="h-3 w-40 md:w-48" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Provider + Model */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-5 w-24 rounded-md" />
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
|
|
@ -529,216 +379,27 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog
|
||||
{/* Create/Edit Dialog — shared component */}
|
||||
<ImageConfigDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setIsDialogOpen(false);
|
||||
setEditingConfig(null);
|
||||
resetForm();
|
||||
}
|
||||
setIsDialogOpen(open);
|
||||
if (!open) setEditingConfig(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-lg max-h-[90vh] overflow-y-auto"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingConfig ? "Edit Image Model" : "Add Image Model"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingConfig
|
||||
? "Update your image generation model"
|
||||
: "Configure a new image generation model (DALL-E 3, GPT Image 1, etc.)"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Name *</Label>
|
||||
<Input
|
||||
placeholder="e.g., My DALL-E 3"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Description</Label>
|
||||
<Input
|
||||
placeholder="Optional description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Provider */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Provider *</Label>
|
||||
<Select
|
||||
value={formData.provider}
|
||||
onValueChange={(val) =>
|
||||
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{IMAGE_GEN_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{p.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{p.example}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Model Name */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Model Name *</Label>
|
||||
{suggestedModels.length > 0 ? (
|
||||
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{formData.model_name || "Select a model"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search a model name"
|
||||
value={formData.model_name}
|
||||
onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Type a custom model name
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{suggestedModels.map((m) => (
|
||||
<CommandItem
|
||||
key={m.value}
|
||||
value={m.value}
|
||||
onSelect={() => {
|
||||
setFormData((p) => ({ ...p, model_name: m.value }));
|
||||
setModelComboboxOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.model_name === m.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="font-mono text-sm">{m.value}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{m.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Input
|
||||
placeholder="e.g., dall-e-3"
|
||||
value={formData.model_name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, model_name: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium flex items-center gap-1.5">
|
||||
<Key className="h-3.5 w-3.5" /> API Key *
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={formData.api_key}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Base (optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Base URL</Label>
|
||||
<Input
|
||||
placeholder={selectedProvider?.apiBase || "Optional"}
|
||||
value={formData.api_base}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_base: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Version (Azure) */}
|
||||
{formData.provider === "AZURE_OPENAI" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Version (Azure)</Label>
|
||||
<Input
|
||||
placeholder="2024-02-15-preview"
|
||||
value={formData.api_version}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsDialogOpen(false);
|
||||
setEditingConfig(null);
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFormSubmit}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!formData.name ||
|
||||
!formData.provider ||
|
||||
!formData.model_name ||
|
||||
!formData.api_key
|
||||
}
|
||||
>
|
||||
{isSubmitting ? <Spinner size="sm" className="mr-2" /> : null}
|
||||
{editingConfig ? "Save Changes" : "Create & Use"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
config={editingConfig}
|
||||
isGlobal={false}
|
||||
searchSpaceId={searchSpaceId}
|
||||
mode={editingConfig ? "edit" : "create"}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog
|
||||
open={!!configToDelete}
|
||||
onOpenChange={(open) => !open && setConfigToDelete(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogContent className="select-none">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
Delete Image Model
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogTitle>Delete Image Model</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-foreground">{configToDelete?.name}</span>?
|
||||
|
|
@ -749,19 +410,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Deleting
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</>
|
||||
)}
|
||||
<span className={isDeleting ? "opacity-0" : ""}>Delete</span>
|
||||
{isDeleting && <Spinner size="sm" className="absolute" />}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -112,11 +112,11 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const [assignments, setAssignments] = useState({
|
||||
const [assignments, setAssignments] = useState(() => ({
|
||||
agent_llm_id: preferences.agent_llm_id ?? "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
||||
});
|
||||
}));
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
|
@ -129,7 +129,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
};
|
||||
setAssignments(newAssignments);
|
||||
setHasChanges(false);
|
||||
}, [preferences]);
|
||||
}, [preferences?.agent_llm_id, preferences?.document_summary_llm_id, preferences?.image_generation_config_id]);
|
||||
|
||||
const handleRoleAssignment = (prefKey: string, configId: string) => {
|
||||
const newAssignments = {
|
||||
|
|
|
|||
|
|
@ -12,18 +12,14 @@ import {
|
|||
Trash2,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import {
|
||||
createNewLLMConfigMutationAtom,
|
||||
deleteNewLLMConfigMutationAtom,
|
||||
updateNewLLMConfigMutationAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import { deleteNewLLMConfigMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
newLLMConfigsAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
|
||||
import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -39,13 +35,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -69,12 +58,6 @@ function getInitials(name: string): string {
|
|||
export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
// Mutations
|
||||
const { mutateAsync: createConfig, isPending: isCreating } = useAtomValue(
|
||||
createNewLLMConfigMutationAtom
|
||||
);
|
||||
const { mutateAsync: updateConfig, isPending: isUpdating } = useAtomValue(
|
||||
updateNewLLMConfigMutationAtom
|
||||
);
|
||||
const { mutateAsync: deleteConfig, isPending: isDeleting } = useAtomValue(
|
||||
deleteNewLLMConfigMutationAtom
|
||||
);
|
||||
|
|
@ -128,33 +111,10 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
const [editingConfig, setEditingConfig] = useState<NewLLMConfig | null>(null);
|
||||
const [configToDelete, setConfigToDelete] = useState<NewLLMConfig | null>(null);
|
||||
|
||||
const isSubmitting = isCreating || isUpdating;
|
||||
|
||||
const handleFormSubmit = useCallback(
|
||||
async (formData: LLMConfigFormData) => {
|
||||
try {
|
||||
if (editingConfig) {
|
||||
const { search_space_id, ...updateData } = formData;
|
||||
await updateConfig({
|
||||
id: editingConfig.id,
|
||||
data: updateData,
|
||||
});
|
||||
} else {
|
||||
await createConfig(formData);
|
||||
}
|
||||
setIsDialogOpen(false);
|
||||
setEditingConfig(null);
|
||||
} catch {
|
||||
// Error is displayed inside the dialog by the form
|
||||
}
|
||||
},
|
||||
[editingConfig, createConfig, updateConfig]
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!configToDelete) return;
|
||||
try {
|
||||
await deleteConfig({ id: configToDelete.id });
|
||||
await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
|
||||
setConfigToDelete(null);
|
||||
} catch {
|
||||
// Error handled by mutation state
|
||||
|
|
@ -171,11 +131,6 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setIsDialogOpen(false);
|
||||
setEditingConfig(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5 md:space-y-6">
|
||||
{/* Header actions */}
|
||||
|
|
@ -196,7 +151,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
onClick={openNewDialog}
|
||||
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
||||
>
|
||||
Add Configuration
|
||||
Add LLM Model
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -243,18 +198,17 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
|
||||
{/* Global Configs Info */}
|
||||
{globalConfigs.length > 0 && (
|
||||
<div>
|
||||
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
<span className="font-medium">{globalConfigs.length} global configuration(s)</span>{" "}
|
||||
available from your administrator. These are pre-configured and ready to use.{" "}
|
||||
<span className="text-muted-foreground">
|
||||
Global configs: {globalConfigs.map((g) => g.name).join(", ")}
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
<Alert className="bg-muted/50 py-3">
|
||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{globalConfigs.length} global {globalConfigs.length === 1 ? "model" : "models"}
|
||||
</span>{" "}
|
||||
available from your administrator. Use the model selector to view and select them.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
|
|
@ -463,66 +417,26 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
)}
|
||||
|
||||
{/* Add/Edit Configuration Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogContent
|
||||
className="max-w-2xl max-h-[90vh] overflow-y-auto"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingConfig ? "Edit Configuration" : "Create New Configuration"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingConfig
|
||||
? "Update your AI model and prompt configuration"
|
||||
: "Set up a new AI model with custom prompts and citation settings"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<LLMConfigForm
|
||||
key={editingConfig ? `edit-${editingConfig.id}` : "create"}
|
||||
searchSpaceId={searchSpaceId}
|
||||
initialData={
|
||||
editingConfig
|
||||
? {
|
||||
name: editingConfig.name,
|
||||
description: editingConfig.description || "",
|
||||
provider: editingConfig.provider,
|
||||
custom_provider: editingConfig.custom_provider || "",
|
||||
model_name: editingConfig.model_name,
|
||||
api_key: editingConfig.api_key,
|
||||
api_base: editingConfig.api_base || "",
|
||||
litellm_params: editingConfig.litellm_params || {},
|
||||
system_instructions: editingConfig.system_instructions || "",
|
||||
use_default_system_instructions: editingConfig.use_default_system_instructions,
|
||||
citations_enabled: editingConfig.citations_enabled,
|
||||
}
|
||||
: {
|
||||
citations_enabled: true,
|
||||
use_default_system_instructions: true,
|
||||
}
|
||||
}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={closeDialog}
|
||||
isSubmitting={isSubmitting}
|
||||
mode={editingConfig ? "edit" : "create"}
|
||||
showAdvanced={true}
|
||||
compact={true}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ModelConfigDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDialogOpen(open);
|
||||
if (!open) setEditingConfig(null);
|
||||
}}
|
||||
config={editingConfig}
|
||||
isGlobal={false}
|
||||
searchSpaceId={searchSpaceId}
|
||||
mode={editingConfig ? "edit" : "create"}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={!!configToDelete}
|
||||
onOpenChange={(open) => !open && setConfigToDelete(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogContent className="select-none">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
Delete Configuration
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogTitle>Delete LLM Model</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-foreground">{configToDelete?.name}</span>? This
|
||||
|
|
@ -542,10 +456,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
Deleting
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</>
|
||||
"Delete"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -32,24 +32,16 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
|
||||
const [customInstructions, setCustomInstructions] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Initialize state from fetched search space
|
||||
useEffect(() => {
|
||||
if (searchSpace) {
|
||||
setCustomInstructions(searchSpace.qna_custom_instructions || "");
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [searchSpace]);
|
||||
}, [searchSpace?.qna_custom_instructions]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
if (searchSpace) {
|
||||
const currentCustom = searchSpace.qna_custom_instructions || "";
|
||||
const changed = currentCustom !== customInstructions;
|
||||
setHasChanges(changed);
|
||||
}
|
||||
}, [searchSpace, customInstructions]);
|
||||
// Derive hasChanges during render
|
||||
const hasChanges = !!searchSpace && (searchSpace.qna_custom_instructions || "") !== customInstructions;
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
|
|
@ -74,7 +66,7 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
}
|
||||
|
||||
toast.success("System instructions saved successfully");
|
||||
setHasChanges(false);
|
||||
|
||||
await fetchSearchSpace();
|
||||
} catch (error: any) {
|
||||
console.error("Error saving system instructions:", error);
|
||||
|
|
|
|||
454
surfsense_web/components/shared/image-config-dialog.tsx
Normal file
454
surfsense_web/components/shared/image-config-dialog.tsx
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createImageGenConfigMutationAtom,
|
||||
updateImageGenConfigMutationAtom,
|
||||
} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
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 { Spinner } from "@/components/ui/spinner";
|
||||
import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-gen-providers";
|
||||
import type {
|
||||
GlobalImageGenConfig,
|
||||
ImageGenerationConfig,
|
||||
ImageGenProvider,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ImageConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: ImageGenerationConfig | GlobalImageGenConfig | null;
|
||||
isGlobal: boolean;
|
||||
searchSpaceId: number;
|
||||
mode: "create" | "edit" | "view";
|
||||
}
|
||||
|
||||
const INITIAL_FORM = {
|
||||
name: "",
|
||||
description: "",
|
||||
provider: "",
|
||||
model_name: "",
|
||||
api_key: "",
|
||||
api_base: "",
|
||||
api_version: "",
|
||||
};
|
||||
|
||||
export function ImageConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
config,
|
||||
isGlobal,
|
||||
searchSpaceId,
|
||||
mode,
|
||||
}: ImageConfigDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState(INITIAL_FORM);
|
||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
||||
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (mode === "edit" && config && !isGlobal) {
|
||||
setFormData({
|
||||
name: config.name || "",
|
||||
description: config.description || "",
|
||||
provider: config.provider || "",
|
||||
model_name: config.model_name || "",
|
||||
api_key: (config as ImageGenerationConfig).api_key || "",
|
||||
api_base: config.api_base || "",
|
||||
api_version: config.api_version || "",
|
||||
});
|
||||
} else if (mode === "create") {
|
||||
setFormData(INITIAL_FORM);
|
||||
}
|
||||
setScrollPos("top");
|
||||
}
|
||||
}, [open, mode, config, isGlobal]);
|
||||
|
||||
const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom);
|
||||
const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}, []);
|
||||
|
||||
const suggestedModels = useMemo(() => {
|
||||
if (!formData.provider) return [];
|
||||
return IMAGE_GEN_MODELS.filter((m) => m.provider === formData.provider);
|
||||
}, [formData.provider]);
|
||||
|
||||
const getTitle = () => {
|
||||
if (mode === "create") return "Add Image Model";
|
||||
if (isGlobal) return "View Global Image Model";
|
||||
return "Edit Image Model";
|
||||
};
|
||||
|
||||
const getSubtitle = () => {
|
||||
if (mode === "create") return "Set up a new image generation provider";
|
||||
if (isGlobal) return "Read-only global configuration";
|
||||
return "Update your image model settings";
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const result = await createConfig({
|
||||
name: formData.name,
|
||||
provider: formData.provider as ImageGenProvider,
|
||||
model_name: formData.model_name,
|
||||
api_key: formData.api_key,
|
||||
api_base: formData.api_base || undefined,
|
||||
api_version: formData.api_version || undefined,
|
||||
description: formData.description || undefined,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
if (result?.id) {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: { image_generation_config_id: result.id },
|
||||
});
|
||||
}
|
||||
onOpenChange(false);
|
||||
} else if (!isGlobal && config) {
|
||||
await updateConfig({
|
||||
id: config.id,
|
||||
data: {
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
provider: formData.provider as ImageGenProvider,
|
||||
model_name: formData.model_name,
|
||||
api_key: formData.api_key,
|
||||
api_base: formData.api_base || undefined,
|
||||
api_version: formData.api_version || undefined,
|
||||
},
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save image config:", error);
|
||||
toast.error("Failed to save image model");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [
|
||||
mode,
|
||||
isGlobal,
|
||||
config,
|
||||
formData,
|
||||
searchSpaceId,
|
||||
createConfig,
|
||||
updateConfig,
|
||||
updatePreferences,
|
||||
onOpenChange,
|
||||
]);
|
||||
|
||||
const handleUseGlobalConfig = useCallback(async () => {
|
||||
if (!config || !isGlobal) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: { image_generation_config_id: config.id },
|
||||
});
|
||||
toast.success(`Now using ${config.name}`);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to set image model:", error);
|
||||
toast.error("Failed to set image model");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
|
||||
|
||||
const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key;
|
||||
const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-lg h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className="sr-only">{getTitle()}</DialogTitle>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-6 pt-6 pb-4 pr-14">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||
{isGlobal && mode !== "create" && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Global
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||
{config && mode !== "create" && (
|
||||
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto px-6 py-5"
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
{isGlobal && config && (
|
||||
<>
|
||||
<Alert className="mb-5 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, create a new model.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Name
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.name}</p>
|
||||
</div>
|
||||
{config.description && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Description
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Provider
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.provider}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Model
|
||||
</div>
|
||||
<p className="text-sm font-medium font-mono">{config.model_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Name *</Label>
|
||||
<Input
|
||||
placeholder="e.g., My DALL-E 3"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Description</Label>
|
||||
<Input
|
||||
placeholder="Optional description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Provider *</Label>
|
||||
<Select
|
||||
value={formData.provider}
|
||||
onValueChange={(val) =>
|
||||
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{IMAGE_GEN_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value} description={p.example}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Model Name *</Label>
|
||||
{suggestedModels.length > 0 ? (
|
||||
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal bg-transparent"
|
||||
>
|
||||
{formData.model_name || "Select or type a model..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command className="bg-transparent">
|
||||
<CommandInput
|
||||
placeholder="Search or type model..."
|
||||
value={formData.model_name}
|
||||
onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Type a custom model name
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{suggestedModels.map((m) => (
|
||||
<CommandItem
|
||||
key={m.value}
|
||||
value={m.value}
|
||||
onSelect={() => {
|
||||
setFormData((p) => ({ ...p, model_name: m.value }));
|
||||
setModelComboboxOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.model_name === m.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="font-mono text-sm">{m.value}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{m.label}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Input
|
||||
placeholder="e.g., dall-e-3"
|
||||
value={formData.model_name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, model_name: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Key *</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={formData.api_key}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Base URL</Label>
|
||||
<Input
|
||||
placeholder={selectedProvider?.apiBase || "Optional"}
|
||||
value={formData.api_base}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_base: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.provider === "AZURE_OPENAI" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Version (Azure)</Label>
|
||||
<Input
|
||||
placeholder="2024-02-15-preview"
|
||||
value={formData.api_version}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed footer */}
|
||||
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
className="text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{mode === "create" || (mode === "edit" && !isGlobal) ? (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !isFormValid}
|
||||
className="relative text-sm h-9 min-w-[120px]"
|
||||
>
|
||||
<span className={isSubmitting ? "opacity-0" : ""}>
|
||||
{mode === "edit" ? "Save Changes" : "Create & Use"}
|
||||
</span>
|
||||
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
) : isGlobal && config ? (
|
||||
<Button
|
||||
className="relative text-sm h-9"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
|
||||
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,9 +3,8 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { type Resolver, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
defaultSystemInstructionsAtom,
|
||||
|
|
@ -41,7 +40,6 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
|
||||
|
|
@ -73,28 +71,18 @@ 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;
|
||||
formId?: string;
|
||||
hideActions?: boolean;
|
||||
}
|
||||
|
||||
export function LLMConfigForm({
|
||||
initialData,
|
||||
searchSpaceId,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isSubmitting = false,
|
||||
mode = "create",
|
||||
submitLabel,
|
||||
showAdvanced = true,
|
||||
compact = false,
|
||||
formId,
|
||||
hideActions = false,
|
||||
}: LLMConfigFormProps) {
|
||||
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
|
||||
defaultSystemInstructionsAtom
|
||||
|
|
@ -105,8 +93,7 @@ export function LLMConfigForm({
|
|||
const [systemInstructionsOpen, setSystemInstructionsOpen] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
resolver: zodResolver(formSchema) as Resolver<FormValues>,
|
||||
defaultValues: {
|
||||
name: initialData?.name ?? "",
|
||||
description: initialData?.description ?? "",
|
||||
|
|
@ -233,33 +220,21 @@ export function LLMConfigForm({
|
|||
/>
|
||||
|
||||
{/* 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 className="text-xs sm:text-sm">Custom Provider Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="my-custom-provider"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{watchProvider === "CUSTOM" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="custom_provider"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Custom Provider Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-custom-provider" {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Model Name with Combobox */}
|
||||
<FormField
|
||||
|
|
@ -405,35 +380,28 @@ export function LLMConfigForm({
|
|||
</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"
|
||||
{watchProvider === "OLLAMA" && (
|
||||
<div 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")}
|
||||
>
|
||||
<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>
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Parameters */}
|
||||
|
|
@ -554,44 +522,6 @@ export function LLMConfigForm({
|
|||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{!hideActions && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-3 pt-4",
|
||||
compact ? "justify-end" : "justify-center sm:justify-end"
|
||||
)}
|
||||
>
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
className="text-xs sm:text-sm h-9 sm:h-10"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="gap-2 min-w-[140px] sm:min-w-[160px] text-xs sm:text-sm h-9 sm:h-10"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
{mode === "edit" ? "Updating..." : "Creating"}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{submitLabel ??
|
||||
(mode === "edit" ? "Update Configuration" : "Create Configuration")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
|
|
|||
333
surfsense_web/components/shared/model-config-dialog.tsx
Normal file
333
surfsense_web/components/shared/model-config-dialog.tsx
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { useCallback, useRef, 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 { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type {
|
||||
GlobalNewLLMConfig,
|
||||
LiteLLMProvider,
|
||||
NewLLMConfigPublic,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
|
||||
interface ModelConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
|
||||
isGlobal: boolean;
|
||||
searchSpaceId: number;
|
||||
mode: "create" | "edit" | "view";
|
||||
}
|
||||
|
||||
export function ModelConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
config,
|
||||
isGlobal,
|
||||
searchSpaceId,
|
||||
mode,
|
||||
}: ModelConfigDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}, []);
|
||||
|
||||
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
|
||||
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const getTitle = () => {
|
||||
if (mode === "create") return "Add New Configuration";
|
||||
if (isGlobal) return "View Global Configuration";
|
||||
return "Edit Configuration";
|
||||
};
|
||||
|
||||
const getSubtitle = () => {
|
||||
if (mode === "create") return "Set up a new LLM provider for this search space";
|
||||
if (isGlobal) return "Read-only global configuration";
|
||||
return "Update your configuration settings";
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (data: LLMConfigFormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const result = await createConfig({
|
||||
...data,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
|
||||
if (result?.id) {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: {
|
||||
agent_llm_id: result.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
} else if (!isGlobal && 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,
|
||||
},
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save configuration:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
mode,
|
||||
isGlobal,
|
||||
config,
|
||||
searchSpaceId,
|
||||
createConfig,
|
||||
updateConfig,
|
||||
updatePreferences,
|
||||
onOpenChange,
|
||||
]
|
||||
);
|
||||
|
||||
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);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-lg h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className="sr-only">{getTitle()}</DialogTitle>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-6 pt-6 pb-4 pr-14">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||
{isGlobal && mode !== "create" && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Global
|
||||
</Badge>
|
||||
)}
|
||||
{!isGlobal && mode !== "create" && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Custom
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||
{config && mode !== "create" && (
|
||||
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto px-6 py-5"
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
{isGlobal && mode !== "create" && (
|
||||
<Alert className="mb-5 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>
|
||||
)}
|
||||
|
||||
{mode === "create" ? (
|
||||
<LLMConfigForm
|
||||
searchSpaceId={searchSpaceId}
|
||||
onSubmit={handleSubmit}
|
||||
mode="create"
|
||||
formId="model-config-form"
|
||||
/>
|
||||
) : isGlobal && config ? (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Configuration Name
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.name}</p>
|
||||
</div>
|
||||
{config.description && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Description
|
||||
</div>
|
||||
<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">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Provider
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.provider}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Model
|
||||
</div>
|
||||
<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">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Citations
|
||||
</div>
|
||||
<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">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
System Instructions
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
) : config ? (
|
||||
<LLMConfigForm
|
||||
searchSpaceId={searchSpaceId}
|
||||
initialData={{
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
provider: config.provider as LiteLLMProvider,
|
||||
custom_provider: config.custom_provider,
|
||||
model_name: config.model_name,
|
||||
api_key: "api_key" in config ? (config.api_key as string) : "",
|
||||
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}
|
||||
mode="edit"
|
||||
formId="model-config-form"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Fixed footer */}
|
||||
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
className="text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{mode === "create" || (!isGlobal && config) ? (
|
||||
<Button
|
||||
type="submit"
|
||||
form="model-config-form"
|
||||
disabled={isSubmitting}
|
||||
className="relative text-sm h-9 min-w-[120px]"
|
||||
>
|
||||
<span className={isSubmitting ? "opacity-0" : ""}>
|
||||
{mode === "edit" ? "Save Changes" : "Create & Use"}
|
||||
</span>
|
||||
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
) : isGlobal && config ? (
|
||||
<Button
|
||||
className="relative text-sm h-9"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
|
||||
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -223,6 +223,7 @@ export function Audio({ id, src, title, durationMs, className }: AudioProps) {
|
|||
onClick={togglePlayPause}
|
||||
disabled={isLoading}
|
||||
className="size-7 sm:size-8"
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="size-3 sm:size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
|
|
@ -234,7 +235,7 @@ export function Audio({ id, src, title, durationMs, className }: AudioProps) {
|
|||
</Button>
|
||||
|
||||
<div className="group/volume flex items-center gap-1 sm:gap-1.5">
|
||||
<Button variant="ghost" size="icon" onClick={toggleMute} className="size-7 sm:size-8">
|
||||
<Button variant="ghost" size="icon" onClick={toggleMute} className="size-7 sm:size-8" aria-label={isMuted ? "Unmute" : "Mute"}>
|
||||
{isMuted ? (
|
||||
<VolumeXIcon className="size-3.5 sm:size-4" />
|
||||
) : (
|
||||
|
|
|
|||
8
surfsense_web/components/tool-ui/citation/_adapter.tsx
Normal file
8
surfsense_web/components/tool-ui/citation/_adapter.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"use client";
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
export { cn } from "@/lib/utils";
|
||||
395
surfsense_web/components/tool-ui/citation/citation-list.tsx
Normal file
395
surfsense_web/components/tool-ui/citation/citation-list.tsx
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { openSafeNavigationHref, resolveSafeNavigationHref } from "../shared/media";
|
||||
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
||||
import { Citation } from "./citation";
|
||||
import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
|
||||
|
||||
const TYPE_ICONS: Record<CitationType, LucideIcon> = {
|
||||
webpage: Globe,
|
||||
document: FileText,
|
||||
article: Newspaper,
|
||||
api: Database,
|
||||
code: Code2,
|
||||
other: File,
|
||||
};
|
||||
|
||||
function useHoverPopover(delay = 100) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseEnter = React.useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setOpen(true), delay);
|
||||
}, [delay]);
|
||||
|
||||
const handleMouseLeave = React.useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setOpen(false), delay);
|
||||
}, [delay]);
|
||||
|
||||
const handleFocus = React.useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleBlur = React.useCallback(
|
||||
(e: React.FocusEvent) => {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null;
|
||||
if (containerRef.current?.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
if (relatedTarget?.closest("[data-radix-popper-content-wrapper]")) {
|
||||
return;
|
||||
}
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setOpen(false), delay);
|
||||
},
|
||||
[delay]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen,
|
||||
containerRef,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CitationListProps {
|
||||
id: string;
|
||||
citations: SerializableCitation[];
|
||||
variant?: CitationVariant;
|
||||
maxVisible?: number;
|
||||
className?: string;
|
||||
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
||||
}
|
||||
|
||||
export function CitationList(props: CitationListProps) {
|
||||
const { id, citations, variant = "default", maxVisible, className, onNavigate } = props;
|
||||
|
||||
const shouldTruncate = maxVisible !== undefined && citations.length > maxVisible;
|
||||
const visibleCitations = shouldTruncate ? citations.slice(0, maxVisible) : citations;
|
||||
const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : [];
|
||||
const overflowCount = overflowCitations.length;
|
||||
|
||||
const wrapperClass =
|
||||
variant === "inline" ? "flex flex-wrap items-center gap-1.5" : "flex flex-col gap-2";
|
||||
|
||||
// Stacked variant: overlapping favicons with popover
|
||||
if (variant === "stacked") {
|
||||
return (
|
||||
<StackedCitations
|
||||
id={id}
|
||||
citations={citations}
|
||||
className={className}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "default") {
|
||||
return (
|
||||
<div
|
||||
className={cn("isolate flex flex-col gap-4", className)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation-list"
|
||||
>
|
||||
{visibleCitations.map((citation) => (
|
||||
<Citation key={citation.id} {...citation} variant="default" onNavigate={onNavigate} />
|
||||
))}
|
||||
{shouldTruncate && (
|
||||
<OverflowIndicator
|
||||
citations={overflowCitations}
|
||||
count={overflowCount}
|
||||
variant="default"
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("isolate", wrapperClass, className)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation-list"
|
||||
>
|
||||
{visibleCitations.map((citation) => (
|
||||
<Citation key={citation.id} {...citation} variant={variant} onNavigate={onNavigate} />
|
||||
))}
|
||||
{shouldTruncate && (
|
||||
<OverflowIndicator
|
||||
citations={overflowCitations}
|
||||
count={overflowCount}
|
||||
variant={variant}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverflowIndicatorProps {
|
||||
citations: SerializableCitation[];
|
||||
count: number;
|
||||
variant: CitationVariant;
|
||||
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
||||
}
|
||||
|
||||
function OverflowIndicator({ citations, count, variant, onNavigate }: OverflowIndicatorProps) {
|
||||
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
|
||||
|
||||
const handleClick = (citation: SerializableCitation) => {
|
||||
const href = resolveSafeNavigationHref(citation.href);
|
||||
if (!href) return;
|
||||
if (onNavigate) {
|
||||
onNavigate(href, citation);
|
||||
} else {
|
||||
openSafeNavigationHref(href);
|
||||
}
|
||||
};
|
||||
|
||||
const popoverContent = (
|
||||
<div className="flex max-h-72 flex-col overflow-y-auto">
|
||||
{citations.map((citation) => (
|
||||
<OverflowItem key={citation.id} citation={citation} onClick={() => handleClick(citation)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md px-2 py-1",
|
||||
"bg-muted/60 text-sm tabular-nums",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">+{count} more</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-80 p-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{popoverContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// Default variant
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-xl px-4 py-3",
|
||||
"border-border bg-card border border-dashed",
|
||||
"transition-colors duration-150",
|
||||
"hover:border-foreground/25 hover:bg-muted/50",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">+{count} more sources</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-80 p-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{popoverContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverflowItemProps {
|
||||
citation: SerializableCitation;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function OverflowItem({ citation, onClick }: OverflowItemProps) {
|
||||
const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group hover:bg-muted focus-visible:bg-muted flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors focus-visible:outline-none"
|
||||
>
|
||||
{citation.favicon ? (
|
||||
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
|
||||
<img
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={16}
|
||||
height={16}
|
||||
className="bg-muted size-4 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2">
|
||||
{citation.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-xs">{citation.domain}</p>
|
||||
</div>
|
||||
<ExternalLink className="text-muted-foreground mt-0.5 size-3.5 shrink-0 self-start opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface StackedCitationsProps {
|
||||
id: string;
|
||||
citations: SerializableCitation[];
|
||||
className?: string;
|
||||
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
||||
}
|
||||
|
||||
function StackedCitations({ id, citations, className, onNavigate }: StackedCitationsProps) {
|
||||
const { open, setOpen, containerRef, handleMouseEnter, handleMouseLeave, handleBlur } =
|
||||
useHoverPopover();
|
||||
const maxIcons = 4;
|
||||
const visibleCitations = citations.slice(0, maxIcons);
|
||||
const remainingCount = Math.max(0, citations.length - maxIcons);
|
||||
|
||||
const handleClick = (citation: SerializableCitation) => {
|
||||
const href = resolveSafeNavigationHref(citation.href);
|
||||
if (!href) return;
|
||||
if (onNavigate) {
|
||||
onNavigate(href, citation);
|
||||
} else {
|
||||
openSafeNavigationHref(href);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: blur boundary for popover focus management
|
||||
<div ref={containerRef} onBlur={handleBlur} className="inline-flex">
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation-list"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2",
|
||||
"bg-muted/40 outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted/70",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{visibleCitations.map((citation, index) => {
|
||||
const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
|
||||
return (
|
||||
<div
|
||||
key={citation.id}
|
||||
className={cn(
|
||||
"border-border bg-background dark:border-foreground/20 relative flex size-6 items-center justify-center rounded-full border shadow-xs",
|
||||
index > 0 && "-ml-2"
|
||||
)}
|
||||
style={{ zIndex: maxIcons - index }}
|
||||
>
|
||||
{citation.favicon ? (
|
||||
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
|
||||
<img
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={18}
|
||||
height={18}
|
||||
className="size-4.5 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon className="text-muted-foreground size-3" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{remainingCount > 0 && (
|
||||
<div
|
||||
className="border-border bg-background dark:border-foreground/20 relative -ml-2 flex size-6 items-center justify-center rounded-full border shadow-xs"
|
||||
style={{ zIndex: 0 }}
|
||||
>
|
||||
<span className="text-muted-foreground text-[10px] font-medium tracking-tight">
|
||||
•••
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
{citations.length} source{citations.length !== 1 && "s"}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-80 p-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onBlur={handleBlur}
|
||||
onEscapeKeyDown={() => setOpen(false)}
|
||||
>
|
||||
<div className="flex max-h-72 flex-col overflow-y-auto">
|
||||
{citations.map((citation) => (
|
||||
<OverflowItem
|
||||
key={citation.id}
|
||||
citation={citation}
|
||||
onClick={() => handleClick(citation)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
surfsense_web/components/tool-ui/citation/citation.tsx
Normal file
248
surfsense_web/components/tool-ui/citation/citation.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
|
||||
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
||||
import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
|
||||
|
||||
const FALLBACK_LOCALE = "en-US";
|
||||
|
||||
const TYPE_ICONS: Record<CitationType, LucideIcon> = {
|
||||
webpage: Globe,
|
||||
document: FileText,
|
||||
article: Newspaper,
|
||||
api: Database,
|
||||
code: Code2,
|
||||
other: File,
|
||||
};
|
||||
|
||||
function extractDomain(url: string): string | undefined {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(isoString: string, locale: string): string {
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
function useHoverPopover(delay = 100) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleMouseEnter = React.useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setOpen(true), delay);
|
||||
}, [delay]);
|
||||
|
||||
const handleMouseLeave = React.useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setOpen(false), delay);
|
||||
}, [delay]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { open, setOpen, handleMouseEnter, handleMouseLeave };
|
||||
}
|
||||
|
||||
export interface CitationProps extends SerializableCitation {
|
||||
variant?: CitationVariant;
|
||||
className?: string;
|
||||
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
||||
}
|
||||
|
||||
export function Citation(props: CitationProps) {
|
||||
const { variant = "default", className, onNavigate, ...serializable } = props;
|
||||
|
||||
const {
|
||||
id,
|
||||
href: rawHref,
|
||||
title,
|
||||
snippet,
|
||||
domain: providedDomain,
|
||||
favicon,
|
||||
author,
|
||||
publishedAt,
|
||||
type = "webpage",
|
||||
locale: providedLocale,
|
||||
} = serializable;
|
||||
|
||||
const locale = providedLocale ?? FALLBACK_LOCALE;
|
||||
const sanitizedHref = sanitizeHref(rawHref);
|
||||
const domain = providedDomain ?? extractDomain(rawHref);
|
||||
|
||||
const citationData: SerializableCitation = {
|
||||
...serializable,
|
||||
href: sanitizedHref ?? rawHref,
|
||||
domain,
|
||||
locale,
|
||||
};
|
||||
|
||||
const TypeIcon = TYPE_ICONS[type] ?? Globe;
|
||||
|
||||
const handleClick = () => {
|
||||
if (!sanitizedHref) return;
|
||||
if (onNavigate) {
|
||||
onNavigate(sanitizedHref, citationData);
|
||||
} else {
|
||||
openSafeNavigationHref(sanitizedHref);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (sanitizedHref && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
};
|
||||
|
||||
const iconElement = favicon ? (
|
||||
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={14}
|
||||
height={14}
|
||||
className="bg-muted size-3.5 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
|
||||
);
|
||||
|
||||
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
|
||||
|
||||
// Inline variant: compact chip with hover popover
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={title}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation"
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1",
|
||||
"bg-muted/60 text-sm outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{iconElement}
|
||||
<span className="text-muted-foreground">{domain}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-72 cursor-pointer p-0"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="hover:bg-muted/50 flex flex-col gap-2 p-3 transition-colors">
|
||||
<div className="flex items-start gap-2">
|
||||
{iconElement}
|
||||
<span className="text-muted-foreground text-xs">{domain}</span>
|
||||
</div>
|
||||
<p className="text-sm leading-snug font-medium">{title}</p>
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// Default variant: full card
|
||||
return (
|
||||
<article
|
||||
className={cn("relative w-full max-w-md min-w-72", className)}
|
||||
lang={locale}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation"
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: div receives role="link" conditionally when href is present */}
|
||||
<div
|
||||
className={cn(
|
||||
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
|
||||
"border-border bg-card border text-sm shadow-xs",
|
||||
"transition-colors duration-150",
|
||||
sanitizedHref && [
|
||||
"cursor-pointer",
|
||||
"hover:border-foreground/25",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
]
|
||||
)}
|
||||
onClick={sanitizedHref ? handleClick : undefined}
|
||||
role={sanitizedHref ? "link" : undefined}
|
||||
tabIndex={sanitizedHref ? 0 : undefined}
|
||||
onKeyDown={sanitizedHref ? handleKeyDown : undefined}
|
||||
>
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<div className="text-muted-foreground flex min-w-0 items-center justify-between gap-1.5 text-xs">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{iconElement}
|
||||
<span className="truncate font-medium">{domain}</span>
|
||||
{(author || publishedAt) && (
|
||||
<span className="opacity-70">
|
||||
<span className="opacity-60"> — </span>
|
||||
{author}
|
||||
{author && publishedAt && ", "}
|
||||
{publishedAt && (
|
||||
<time dateTime={publishedAt} className="tabular-nums">
|
||||
{formatDate(publishedAt, locale)}
|
||||
</time>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{sanitizedHref && (
|
||||
<ExternalLink className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-foreground text-[15px] leading-snug font-medium text-pretty">
|
||||
<span className="group-hover:decoration-foreground/30 line-clamp-2 group-hover:underline group-hover:underline-offset-2">
|
||||
{title}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground text-[13px] leading-relaxed text-pretty">
|
||||
<span className="line-clamp-3">{snippet}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
9
surfsense_web/components/tool-ui/citation/index.ts
Normal file
9
surfsense_web/components/tool-ui/citation/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export type { CitationProps } from "./citation";
|
||||
export { Citation } from "./citation";
|
||||
export type { CitationListProps } from "./citation-list";
|
||||
export { CitationList } from "./citation-list";
|
||||
export type {
|
||||
CitationType,
|
||||
CitationVariant,
|
||||
SerializableCitation,
|
||||
} from "./schema";
|
||||
34
surfsense_web/components/tool-ui/citation/schema.ts
Normal file
34
surfsense_web/components/tool-ui/citation/schema.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { z } from "zod";
|
||||
import { ToolUIIdSchema, ToolUIReceiptSchema, ToolUIRoleSchema } from "../shared/schema";
|
||||
|
||||
export const CitationTypeSchema = z.enum([
|
||||
"webpage",
|
||||
"document",
|
||||
"article",
|
||||
"api",
|
||||
"code",
|
||||
"other",
|
||||
]);
|
||||
|
||||
export type CitationType = z.infer<typeof CitationTypeSchema>;
|
||||
|
||||
export const CitationVariantSchema = z.enum(["default", "inline", "stacked"]);
|
||||
|
||||
export type CitationVariant = z.infer<typeof CitationVariantSchema>;
|
||||
|
||||
export const SerializableCitationSchema = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
href: z.string().url(),
|
||||
title: z.string(),
|
||||
snippet: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
favicon: z.string().url().optional(),
|
||||
author: z.string().optional(),
|
||||
publishedAt: z.string().datetime().optional(),
|
||||
type: CitationTypeSchema.optional(),
|
||||
locale: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SerializableCitation = z.infer<typeof SerializableCitationSchema>;
|
||||
|
|
@ -149,17 +149,15 @@ function ApprovalCard({
|
|||
const context = interruptData.context;
|
||||
const page = context?.page;
|
||||
|
||||
const initialEditState = {
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
const [editedArgs, setEditedArgs] = useState(() => ({
|
||||
title: actionArgs.new_title
|
||||
? String(actionArgs.new_title)
|
||||
: (page?.page_title ?? args.new_title ?? ""),
|
||||
content: actionArgs.new_content
|
||||
? String(actionArgs.new_content)
|
||||
: (page?.body ?? args.new_content ?? ""),
|
||||
};
|
||||
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
const [editedArgs, setEditedArgs] = useState(initialEditState);
|
||||
}));
|
||||
const [hasPanelEdits, setHasPanelEdits] = useState(false);
|
||||
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
|
||||
|
||||
|
|
|
|||
|
|
@ -173,7 +173,8 @@ function ApprovalCard({
|
|||
const issue = context?.issue;
|
||||
const priorities = context?.priorities ?? [];
|
||||
|
||||
const initialEditState = {
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
const [editedArgs, setEditedArgs] = useState(() => ({
|
||||
summary: actionArgs.new_summary
|
||||
? String(actionArgs.new_summary)
|
||||
: (issue?.issue_title ?? args.new_summary ?? ""),
|
||||
|
|
@ -183,10 +184,7 @@ function ApprovalCard({
|
|||
priority: actionArgs.new_priority
|
||||
? String(actionArgs.new_priority)
|
||||
: (issue?.priority ?? args.new_priority ?? "__none__"),
|
||||
};
|
||||
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
const [editedArgs, setEditedArgs] = useState(initialEditState);
|
||||
}));
|
||||
const [hasPanelEdits, setHasPanelEdits] = useState(false);
|
||||
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
|
||||
|
||||
|
|
|
|||
|
|
@ -182,7 +182,8 @@ function ApprovalCard({
|
|||
const priorities = context?.priorities ?? [];
|
||||
const issue = context?.issue;
|
||||
|
||||
const initialEditState = {
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
const [editedArgs, setEditedArgs] = useState(() => ({
|
||||
title: actionArgs.new_title
|
||||
? String(actionArgs.new_title)
|
||||
: (issue?.title ?? args.new_title ?? ""),
|
||||
|
|
@ -202,10 +203,7 @@ function ApprovalCard({
|
|||
labelIds: Array.isArray(actionArgs.new_label_ids)
|
||||
? (actionArgs.new_label_ids as string[])
|
||||
: (issue?.current_labels?.map((l) => l.id) ?? []),
|
||||
};
|
||||
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
const [editedArgs, setEditedArgs] = useState(initialEditState);
|
||||
}));
|
||||
const [hasPanelEdits, setHasPanelEdits] = useState(false);
|
||||
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
|
||||
|
||||
|
|
|
|||
5
surfsense_web/components/tool-ui/shared/media/index.ts
Normal file
5
surfsense_web/components/tool-ui/shared/media/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export {
|
||||
openSafeNavigationHref,
|
||||
resolveSafeNavigationHref,
|
||||
} from "./safe-navigation";
|
||||
export { sanitizeHref } from "./sanitize-href";
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { sanitizeHref } from "./sanitize-href";
|
||||
|
||||
export function resolveSafeNavigationHref(
|
||||
...candidates: Array<string | null | undefined>
|
||||
): string | undefined {
|
||||
for (const candidate of candidates) {
|
||||
const safeHref = sanitizeHref(candidate ?? undefined);
|
||||
if (safeHref) {
|
||||
return safeHref;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function openSafeNavigationHref(href: string | undefined): boolean {
|
||||
if (!href || typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
return true;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
export function sanitizeHref(href?: string): string | undefined {
|
||||
if (!href) return undefined;
|
||||
const candidate = href.trim();
|
||||
if (!candidate) return undefined;
|
||||
|
||||
if (
|
||||
candidate.startsWith("/") ||
|
||||
candidate.startsWith("./") ||
|
||||
candidate.startsWith("../") ||
|
||||
candidate.startsWith("?") ||
|
||||
candidate.startsWith("#")
|
||||
) {
|
||||
if (candidate.startsWith("//")) return undefined;
|
||||
// eslint-disable-next-line no-control-regex -- intentionally matching control characters
|
||||
if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(candidate);
|
||||
if (url.protocol === "http:" || url.protocol === "https:") {
|
||||
return url.toString();
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
149
surfsense_web/components/tool-ui/shared/schema.ts
Normal file
149
surfsense_web/components/tool-ui/shared/schema.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Tool UI conventions:
|
||||
* - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
|
||||
* - Schema: `SerializableXSchema`
|
||||
* - Parser: `parseSerializableX(input: unknown)` (throws on invalid)
|
||||
* - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)
|
||||
* - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions
|
||||
* - Root attrs: `data-tool-ui-id` + `data-slot`
|
||||
*/
|
||||
|
||||
/**
|
||||
* Schema for tool UI identity.
|
||||
*
|
||||
* Every tool UI should have a unique identifier that:
|
||||
* - Is stable across re-renders
|
||||
* - Is meaningful (not auto-generated)
|
||||
* - Is unique within the conversation
|
||||
*
|
||||
* Format recommendation: `{component-type}-{semantic-identifier}`
|
||||
* Examples: "data-table-expenses-q3", "option-list-deploy-target"
|
||||
*/
|
||||
export const ToolUIIdSchema = z.string().min(1);
|
||||
|
||||
export type ToolUIId = z.infer<typeof ToolUIIdSchema>;
|
||||
|
||||
/**
|
||||
* Primary role of a Tool UI surface in a chat context.
|
||||
*/
|
||||
export const ToolUIRoleSchema = z.enum([
|
||||
"information",
|
||||
"decision",
|
||||
"control",
|
||||
"state",
|
||||
"composite",
|
||||
]);
|
||||
|
||||
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
|
||||
|
||||
export const ToolUIReceiptOutcomeSchema = z.enum(["success", "partial", "failed", "cancelled"]);
|
||||
|
||||
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
|
||||
|
||||
/**
|
||||
* Optional receipt metadata: a durable summary of an outcome.
|
||||
*/
|
||||
export const ToolUIReceiptSchema = z.object({
|
||||
outcome: ToolUIReceiptOutcomeSchema,
|
||||
summary: z.string().min(1),
|
||||
identifiers: z.record(z.string(), z.string()).optional(),
|
||||
at: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;
|
||||
|
||||
/**
|
||||
* Base schema for Tool UI payloads (id + optional role/receipt).
|
||||
*/
|
||||
export const ToolUISurfaceSchema = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
});
|
||||
|
||||
export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;
|
||||
|
||||
export const ActionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
/**
|
||||
* Canonical narration the assistant can use after this action is taken.
|
||||
*
|
||||
* Example: "I exported the table as CSV." / "I opened the link in a new tab."
|
||||
*/
|
||||
sentence: z.string().optional(),
|
||||
confirmLabel: z.string().optional(),
|
||||
variant: z.enum(["default", "destructive", "secondary", "ghost", "outline"]).optional(),
|
||||
icon: z.custom<ReactNode>().optional(),
|
||||
loading: z.boolean().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
shortcut: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>;
|
||||
export type LocalAction = Action;
|
||||
export type DecisionAction = Action;
|
||||
|
||||
export const DecisionResultSchema = z.object({
|
||||
kind: z.literal("decision"),
|
||||
version: z.literal(1),
|
||||
decisionId: z.string().min(1),
|
||||
actionId: z.string().min(1),
|
||||
actionLabel: z.string().min(1),
|
||||
at: z.string().datetime(),
|
||||
payload: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type DecisionResult<TPayload extends Record<string, unknown> = Record<string, unknown>> =
|
||||
Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
|
||||
payload?: TPayload;
|
||||
};
|
||||
|
||||
export function createDecisionResult<
|
||||
TPayload extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(args: {
|
||||
decisionId: string;
|
||||
action: { id: string; label: string };
|
||||
payload?: TPayload;
|
||||
}): DecisionResult<TPayload> {
|
||||
return {
|
||||
kind: "decision",
|
||||
version: 1,
|
||||
decisionId: args.decisionId,
|
||||
actionId: args.action.id,
|
||||
actionLabel: args.action.label,
|
||||
at: new Date().toISOString(),
|
||||
payload: args.payload,
|
||||
};
|
||||
}
|
||||
|
||||
export const ActionButtonsPropsSchema = z.object({
|
||||
actions: z.array(ActionSchema).min(1),
|
||||
align: z.enum(["left", "center", "right"]).optional(),
|
||||
confirmTimeout: z.number().positive().optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
export const SerializableActionSchema = ActionSchema.omit({ icon: true });
|
||||
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
|
||||
actions: z.array(SerializableActionSchema),
|
||||
}).omit({ className: true });
|
||||
|
||||
export interface ActionsConfig {
|
||||
items: Action[];
|
||||
align?: "left" | "center" | "right";
|
||||
confirmTimeout?: number;
|
||||
}
|
||||
|
||||
export const SerializableActionsConfigSchema = z.object({
|
||||
items: z.array(SerializableActionSchema).min(1),
|
||||
align: z.enum(["left", "center", "right"]).optional(),
|
||||
confirmTimeout: z.number().positive().optional(),
|
||||
});
|
||||
|
||||
export type SerializableActionsConfig = z.infer<typeof SerializableActionsConfigSchema>;
|
||||
|
||||
export type SerializableAction = z.infer<typeof SerializableActionSchema>;
|
||||
|
|
@ -93,7 +93,7 @@ DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
|||
function DrawerHandle({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("mx-auto mt-4 h-1.5 w-12 rounded-full bg-muted-foreground/40", className)}
|
||||
className={cn("mx-auto mt-4 h-2 w-12 rounded-full bg-muted-foreground/40", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue