mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-23 19:05:16 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/ui-revamp
This commit is contained in:
commit
4f3914b058
302 changed files with 22318 additions and 6067 deletions
66
surfsense_web/app/(home)/blog/[slug]/loading.tsx
Normal file
66
surfsense_web/app/(home)/blog/[slug]/loading.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function BlogPostLoading() {
|
||||
return (
|
||||
<div className="min-h-screen relative pt-20">
|
||||
<div className="max-w-3xl mx-auto px-6 lg:px-10 pt-10 pb-20">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="h-4 w-3" />
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="h-4 w-3" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="space-y-3 mb-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-4/5" />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<Skeleton className="h-5 w-full mb-2" />
|
||||
<Skeleton className="h-5 w-3/4 mb-8" />
|
||||
|
||||
{/* Author + date */}
|
||||
<div className="flex items-center gap-3 mb-10">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cover image */}
|
||||
<Skeleton className="w-full aspect-video rounded-xl mb-10" />
|
||||
|
||||
{/* Article body paragraphs */}
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2 mb-6">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Sub-heading */}
|
||||
<Skeleton className="h-7 w-56 mt-8 mb-4" />
|
||||
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2 mb-6">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-11/12" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import { format } from "date-fns";
|
||||
import FuzzySearch from "fuzzy-search";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Container } from "@/components/container";
|
||||
import type { BlogEntry } from "./page";
|
||||
|
||||
|
|
@ -127,17 +127,13 @@ function MagazineSearchGrid({
|
|||
[allBlogs]
|
||||
);
|
||||
|
||||
const [results, setResults] = useState(allBlogs);
|
||||
useEffect(() => {
|
||||
setResults(searcher.search(search));
|
||||
}, [search, searcher]);
|
||||
|
||||
const gridItems = useMemo(() => {
|
||||
const results = search.trim() ? searcher.search(search) : allBlogs;
|
||||
if (search.trim()) {
|
||||
return results;
|
||||
}
|
||||
return results.filter((b) => b.slug !== featuredSlug);
|
||||
}, [results, search, featuredSlug]);
|
||||
}, [search, searcher, allBlogs, featuredSlug]);
|
||||
|
||||
return (
|
||||
<section aria-labelledby="archive-heading">
|
||||
|
|
|
|||
50
surfsense_web/app/(home)/blog/loading.tsx
Normal file
50
surfsense_web/app/(home)/blog/loading.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function BlogIndexLoading() {
|
||||
return (
|
||||
<div className="relative overflow-hidden bg-neutral-50 px-4 pt-20 md:px-8 dark:bg-neutral-950">
|
||||
<div className="mx-auto max-w-6xl pt-12 pb-24 md:pt-20">
|
||||
{/* Header */}
|
||||
<div className="mb-10 md:mb-14">
|
||||
<Skeleton className="h-10 w-24 rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Featured post skeleton */}
|
||||
<div className="mb-14 overflow-hidden rounded-3xl border border-neutral-200/80 dark:border-neutral-800">
|
||||
<Skeleton className="aspect-[2.4/1] min-h-[220px] w-full rounded-none" />
|
||||
<div className="p-6 md:p-8 space-y-3">
|
||||
<Skeleton className="h-5 w-24 rounded-full" />
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<Skeleton className="h-4 w-full max-w-lg" />
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search bar skeleton */}
|
||||
<div className="mb-10">
|
||||
<Skeleton className="h-11 w-full max-w-md rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Grid of article cards */}
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-3">
|
||||
<Skeleton className="aspect-video w-full rounded-2xl" />
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
surfsense_web/app/(home)/changelog/loading.tsx
Normal file
63
surfsense_web/app/(home)/changelog/loading.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function ChangelogLoading() {
|
||||
return (
|
||||
<div className="min-h-screen relative pt-20">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border/50">
|
||||
<div className="max-w-5xl mx-auto relative">
|
||||
<div className="p-6 flex items-center justify-between">
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="h-4 w-3" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-48 mb-2" />
|
||||
<Skeleton className="h-4 w-80" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="max-w-5xl mx-auto px-6 lg:px-10 pt-10 pb-20">
|
||||
<div className="relative">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="relative flex flex-col md:flex-row gap-y-6 mb-10">
|
||||
{/* Left: date + version */}
|
||||
<div className="md:w-48 flex-shrink-0">
|
||||
<Skeleton className="h-4 w-24 mb-3" />
|
||||
<Skeleton className="h-12 w-12 rounded-xl" />
|
||||
</div>
|
||||
|
||||
{/* Right: content */}
|
||||
<div className="flex-1 md:pl-8 relative pb-10">
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<Skeleton className="h-7 w-2/3" />
|
||||
{/* Tags */}
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
{/* Body paragraphs */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
surfsense_web/app/(home)/free/[model_slug]/loading.tsx
Normal file
65
surfsense_web/app/(home)/free/[model_slug]/loading.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function FreeModelLoading() {
|
||||
return (
|
||||
<>
|
||||
{/* Chat area skeleton - fills viewport */}
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Chat header */}
|
||||
<div className="flex items-center gap-3 border-b px-4 py-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</div>
|
||||
|
||||
{/* Chat messages area */}
|
||||
<div className="flex-1 flex flex-col justify-end gap-4 px-4 py-6">
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-10 w-56 rounded-2xl" />
|
||||
</div>
|
||||
<div className="space-y-2 max-w-lg">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="border-t px-4 py-3">
|
||||
<Skeleton className="h-12 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SEO section skeleton */}
|
||||
<div className="border-t bg-background">
|
||||
<div className="container mx-auto px-4 py-10 max-w-3xl">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="h-4 w-3" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-3" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-7 w-3/4 mb-2" />
|
||||
<Skeleton className="h-4 w-full mb-1" />
|
||||
<Skeleton className="h-4 w-2/3 mb-8" />
|
||||
|
||||
<div className="my-8 h-px bg-border" />
|
||||
|
||||
{/* FAQ skeleton */}
|
||||
<Skeleton className="h-6 w-64 mb-4" />
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border bg-card p-4 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
60
surfsense_web/app/(home)/free/loading.tsx
Normal file
60
surfsense_web/app/(home)/free/loading.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function FreeChatLoading() {
|
||||
return (
|
||||
<div className="min-h-screen pt-20">
|
||||
<article className="container mx-auto px-4 pb-20">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="h-4 w-3" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
|
||||
{/* Hero section */}
|
||||
<section className="mt-8 text-center max-w-3xl mx-auto space-y-4">
|
||||
<Skeleton className="h-12 w-3/4 mx-auto" />
|
||||
<Skeleton className="h-12 w-2/3 mx-auto" />
|
||||
<Skeleton className="h-5 w-full max-w-lg mx-auto" />
|
||||
<Skeleton className="h-5 w-4/5 max-w-lg mx-auto" />
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 mt-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-28 rounded-full" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="my-12 max-w-4xl mx-auto h-px bg-border" />
|
||||
|
||||
{/* Model table */}
|
||||
<section className="max-w-4xl mx-auto">
|
||||
<Skeleton className="h-7 w-64 mb-2" />
|
||||
<Skeleton className="h-4 w-80 mb-6" />
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
{/* Table header */}
|
||||
<div className="flex gap-4 px-4 py-3 bg-muted/50 border-b">
|
||||
<Skeleton className="h-4 w-[45%]" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
|
||||
{/* Table rows */}
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 px-4 py-3 border-b last:border-0">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-6 w-14 rounded-full" />
|
||||
<Skeleton className="h-8 w-20 rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
|
|||
import type React from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
|
||||
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import {
|
||||
|
|
@ -33,6 +34,7 @@ export function DashboardClientLayout({
|
|||
const pathname = usePathname();
|
||||
const { search_space_id } = useParams();
|
||||
const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom);
|
||||
const setPendingUserImageUrls = useSetAtom(pendingUserImageDataUrlsAtom);
|
||||
|
||||
const {
|
||||
data: preferences = {},
|
||||
|
|
@ -142,6 +144,14 @@ export function DashboardClientLayout({
|
|||
|
||||
const electronAPI = useElectronAPI();
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronAPI?.onChatScreenCapture) return;
|
||||
return electronAPI.onChatScreenCapture((dataUrl: string) => {
|
||||
if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:image/")) return;
|
||||
setPendingUserImageUrls((prev) => [...prev, dataUrl]);
|
||||
});
|
||||
}, [electronAPI, setPendingUserImageUrls]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeSeacrhSpaceId =
|
||||
typeof search_space_id === "string"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
mentionedDocumentsAtom,
|
||||
messageDocumentsMapAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
|
||||
import {
|
||||
clearPlanOwnerRegistry,
|
||||
// extractWriteTodosFromContent,
|
||||
|
|
@ -44,8 +45,8 @@ import {
|
|||
} from "@/components/assistant-ui/token-usage-context";
|
||||
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
|
||||
import { useMessagesSync } from "@/hooks/use-messages-sync";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { getAgentFilesystemSelection } from "@/lib/agent-filesystem";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { convertToThreadMessage } from "@/lib/chat/message-utils";
|
||||
import {
|
||||
|
|
@ -75,6 +76,10 @@ import {
|
|||
type ThreadListResponse,
|
||||
type ThreadRecord,
|
||||
} from "@/lib/chat/thread-persistence";
|
||||
import {
|
||||
extractUserTurnForNewChatApi,
|
||||
type NewChatUserImagePayload,
|
||||
} from "@/lib/chat/user-turn-api-parts";
|
||||
import { NotFoundError } from "@/lib/error";
|
||||
import {
|
||||
trackChatCreated,
|
||||
|
|
@ -228,6 +233,8 @@ export default function NewChatPage() {
|
|||
const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom);
|
||||
const removeChatTab = useSetAtom(removeChatTabAtom);
|
||||
const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom);
|
||||
const pendingUserImageUrls = useAtomValue(pendingUserImageDataUrlsAtom);
|
||||
const setPendingUserImageUrls = useSetAtom(pendingUserImageDataUrlsAtom);
|
||||
|
||||
// Get current user for author info in shared chats
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
|
|
@ -489,18 +496,12 @@ export default function NewChatPage() {
|
|||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
// Extract user query text from content parts
|
||||
let userQuery = "";
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
userQuery += part.text;
|
||||
}
|
||||
}
|
||||
const urlsSnapshot = [...pendingUserImageUrls];
|
||||
const { userQuery, userImages } = extractUserTurnForNewChatApi(message, urlsSnapshot);
|
||||
|
||||
if (!userQuery.trim()) return;
|
||||
if (!userQuery.trim() && userImages.length === 0) return;
|
||||
|
||||
// Check if podcast is already generating
|
||||
if (isPodcastGenerating() && looksLikePodcastRequest(userQuery)) {
|
||||
if (userQuery.trim() && isPodcastGenerating() && looksLikePodcastRequest(userQuery)) {
|
||||
toast.warning("A podcast is already being generated.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -540,6 +541,10 @@ export default function NewChatPage() {
|
|||
}
|
||||
}
|
||||
|
||||
if (urlsSnapshot.length > 0) {
|
||||
setPendingUserImageUrls((prev) => prev.filter((u) => !urlsSnapshot.includes(u)));
|
||||
}
|
||||
|
||||
// Add user message to state
|
||||
const userMsgId = `msg-user-${Date.now()}`;
|
||||
|
||||
|
|
@ -555,10 +560,27 @@ export default function NewChatPage() {
|
|||
}
|
||||
: undefined;
|
||||
|
||||
const existingImageUrls = new Set(
|
||||
message.content
|
||||
.filter(
|
||||
(p): p is { type: "image"; image: string } =>
|
||||
typeof p === "object" &&
|
||||
p !== null &&
|
||||
"type" in p &&
|
||||
p.type === "image" &&
|
||||
"image" in p
|
||||
)
|
||||
.map((p) => p.image)
|
||||
);
|
||||
const extraImageParts = urlsSnapshot
|
||||
.filter((u) => !existingImageUrls.has(u))
|
||||
.map((image) => ({ type: "image" as const, image }));
|
||||
const userDisplayContent = [...message.content, ...extraImageParts];
|
||||
|
||||
const userMessage: ThreadMessageLike = {
|
||||
id: userMsgId,
|
||||
role: "user",
|
||||
content: message.content,
|
||||
content: userDisplayContent,
|
||||
createdAt: new Date(),
|
||||
metadata: authorMetadata,
|
||||
};
|
||||
|
|
@ -566,7 +588,7 @@ export default function NewChatPage() {
|
|||
|
||||
// Track message sent
|
||||
trackChatMessageSent(searchSpaceId, currentThreadId, {
|
||||
hasAttachments: false,
|
||||
hasAttachments: userImages.length > 0,
|
||||
hasMentionedDocuments:
|
||||
mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
|
||||
mentionedDocumentIds.document_ids.length > 0,
|
||||
|
|
@ -590,7 +612,7 @@ export default function NewChatPage() {
|
|||
}));
|
||||
}
|
||||
|
||||
const persistContent: unknown[] = [...message.content];
|
||||
const persistContent: unknown[] = [...userDisplayContent];
|
||||
|
||||
if (allMentionedDocs.length > 0) {
|
||||
persistContent.push({
|
||||
|
|
@ -655,8 +677,7 @@ export default function NewChatPage() {
|
|||
const selection = await getAgentFilesystemSelection(searchSpaceId);
|
||||
if (
|
||||
selection.filesystem_mode === "desktop_local_folder" &&
|
||||
(!selection.local_filesystem_mounts ||
|
||||
selection.local_filesystem_mounts.length === 0)
|
||||
(!selection.local_filesystem_mounts || selection.local_filesystem_mounts.length === 0)
|
||||
) {
|
||||
toast.error("Select a local folder before using Local Folder mode.");
|
||||
return;
|
||||
|
|
@ -704,6 +725,7 @@ export default function NewChatPage() {
|
|||
? mentionedDocumentIds.surfsense_doc_ids
|
||||
: undefined,
|
||||
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
|
||||
...(userImages.length > 0 ? { user_images: userImages } : {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
|
@ -835,14 +857,7 @@ export default function NewChatPage() {
|
|||
});
|
||||
} else {
|
||||
const tcId = `interrupt-${action.name}`;
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
toolsWithUI,
|
||||
tcId,
|
||||
action.name,
|
||||
action.args,
|
||||
true
|
||||
);
|
||||
addToolCall(contentPartsState, toolsWithUI, tcId, action.name, action.args, true);
|
||||
updateToolCall(contentPartsState, tcId, {
|
||||
result: { __interrupt__: true, ...interruptData },
|
||||
});
|
||||
|
|
@ -980,6 +995,9 @@ export default function NewChatPage() {
|
|||
disabledTools,
|
||||
updateChatTabTitle,
|
||||
tokenUsageStore,
|
||||
pendingUserImageUrls,
|
||||
setPendingUserImageUrls,
|
||||
toolsWithUI,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -1180,14 +1198,7 @@ export default function NewChatPage() {
|
|||
});
|
||||
} else {
|
||||
const tcId = `interrupt-${action.name}`;
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
toolsWithUI,
|
||||
tcId,
|
||||
action.name,
|
||||
action.args,
|
||||
true
|
||||
);
|
||||
addToolCall(contentPartsState, toolsWithUI, tcId, action.name, action.args, true);
|
||||
updateToolCall(contentPartsState, tcId, {
|
||||
result: {
|
||||
__interrupt__: true,
|
||||
|
|
@ -1252,7 +1263,7 @@ export default function NewChatPage() {
|
|||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[pendingInterrupt, messages, searchSpaceId, tokenUsageStore]
|
||||
[pendingInterrupt, messages, searchSpaceId, tokenUsageStore, toolsWithUI]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -1320,15 +1331,24 @@ export default function NewChatPage() {
|
|||
* Handle regeneration (edit or reload) by calling the regenerate endpoint
|
||||
* and streaming the response. This rewinds the LangGraph checkpointer state.
|
||||
*
|
||||
* @param newUserQuery - The new user query (for edit). Pass null/undefined for reload.
|
||||
* @param newUserQuery - `null` = reload with same turn from the server. A string = edit
|
||||
* (including an empty string when the edited turn is images-only); pass `editExtras` for images/content.
|
||||
*/
|
||||
const handleRegenerate = useCallback(
|
||||
async (newUserQuery?: string | null) => {
|
||||
async (
|
||||
newUserQuery: string | null,
|
||||
editExtras?: {
|
||||
userMessageContent: ThreadMessageLike["content"];
|
||||
userImages: NewChatUserImagePayload[];
|
||||
}
|
||||
) => {
|
||||
if (!threadId) {
|
||||
toast.error("Cannot regenerate: no active chat thread");
|
||||
return;
|
||||
}
|
||||
|
||||
const isEdit = newUserQuery !== null;
|
||||
|
||||
// Abort any previous streaming request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
|
|
@ -1342,11 +1362,11 @@ export default function NewChatPage() {
|
|||
}
|
||||
|
||||
// Extract the original user query BEFORE removing messages (for reload mode)
|
||||
let userQueryToDisplay = newUserQuery;
|
||||
let userQueryToDisplay: string | undefined;
|
||||
let originalUserMessageContent: ThreadMessageLike["content"] | null = null;
|
||||
let originalUserMessageMetadata: ThreadMessageLike["metadata"] | undefined;
|
||||
|
||||
if (!newUserQuery) {
|
||||
if (!isEdit) {
|
||||
// Reload mode - find and preserve the last user message content
|
||||
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
|
||||
if (lastUserMessage) {
|
||||
|
|
@ -1360,6 +1380,8 @@ export default function NewChatPage() {
|
|||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
userQueryToDisplay = newUserQuery;
|
||||
}
|
||||
|
||||
// Remove the last two messages (user + assistant) from the UI immediately
|
||||
|
|
@ -1395,11 +1417,11 @@ export default function NewChatPage() {
|
|||
const userMessage: ThreadMessageLike = {
|
||||
id: userMsgId,
|
||||
role: "user",
|
||||
content: newUserQuery
|
||||
? [{ type: "text", text: newUserQuery }]
|
||||
content: isEdit
|
||||
? (editExtras?.userMessageContent ?? [{ type: "text", text: newUserQuery ?? "" }])
|
||||
: originalUserMessageContent || [{ type: "text", text: userQueryToDisplay || "" }],
|
||||
createdAt: new Date(),
|
||||
metadata: newUserQuery ? undefined : originalUserMessageMetadata,
|
||||
metadata: isEdit ? undefined : originalUserMessageMetadata,
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
|
|
@ -1416,20 +1438,24 @@ export default function NewChatPage() {
|
|||
|
||||
try {
|
||||
const selection = await getAgentFilesystemSelection(searchSpaceId);
|
||||
const requestBody: Record<string, unknown> = {
|
||||
search_space_id: searchSpaceId,
|
||||
user_query: newUserQuery,
|
||||
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
|
||||
filesystem_mode: selection.filesystem_mode,
|
||||
client_platform: selection.client_platform,
|
||||
local_filesystem_mounts: selection.local_filesystem_mounts,
|
||||
};
|
||||
if (isEdit) {
|
||||
requestBody.user_images = editExtras?.userImages ?? [];
|
||||
}
|
||||
const response = await fetch(getRegenerateUrl(threadId), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
search_space_id: searchSpaceId,
|
||||
user_query: newUserQuery || null,
|
||||
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
|
||||
filesystem_mode: selection.filesystem_mode,
|
||||
client_platform: selection.client_platform,
|
||||
local_filesystem_mounts: selection.local_filesystem_mounts,
|
||||
}),
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
|
|
@ -1519,8 +1545,8 @@ export default function NewChatPage() {
|
|||
if (contentParts.length > 0) {
|
||||
try {
|
||||
// Persist user message (for both edit and reload modes, since backend deleted it)
|
||||
const userContentToPersist = newUserQuery
|
||||
? [{ type: "text", text: newUserQuery }]
|
||||
const userContentToPersist = isEdit
|
||||
? (editExtras?.userMessageContent ?? [{ type: "text", text: newUserQuery ?? "" }])
|
||||
: originalUserMessageContent || [{ type: "text", text: userQueryToDisplay || "" }];
|
||||
|
||||
const savedUserMessage = await appendMessage(threadId, {
|
||||
|
|
@ -1579,27 +1605,21 @@ export default function NewChatPage() {
|
|||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[threadId, searchSpaceId, messages, disabledTools, tokenUsageStore]
|
||||
[threadId, searchSpaceId, messages, disabledTools, tokenUsageStore, toolsWithUI]
|
||||
);
|
||||
|
||||
// Handle editing a message - truncates history and regenerates with new query
|
||||
const onEdit = useCallback(
|
||||
async (message: AppendMessage) => {
|
||||
// Extract the new user query from the message content
|
||||
let newUserQuery = "";
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
newUserQuery += part.text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!newUserQuery.trim()) {
|
||||
const { userQuery, userImages } = extractUserTurnForNewChatApi(message, []);
|
||||
const queryForApi = userQuery.trim();
|
||||
if (!queryForApi && userImages.length === 0) {
|
||||
toast.error("Cannot edit with empty message");
|
||||
return;
|
||||
}
|
||||
|
||||
// Call regenerate with the new query
|
||||
await handleRegenerate(newUserQuery.trim());
|
||||
const userMessageContent = message.content as unknown as ThreadMessageLike["content"];
|
||||
await handleRegenerate(queryForApi, { userMessageContent, userImages });
|
||||
},
|
||||
[handleRegenerate]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,451 @@
|
|||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertTriangle, Check, Plus, ShieldCheck, Trash2, X } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
type AgentPermissionAction,
|
||||
type AgentPermissionRule,
|
||||
type AgentPermissionRuleCreate,
|
||||
agentPermissionsApiService,
|
||||
} from "@/lib/apis/agent-permissions-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ACTION_DESCRIPTIONS: Record<AgentPermissionAction, string> = {
|
||||
allow: "Always run without prompting",
|
||||
deny: "Block silently",
|
||||
ask: "Pause and ask for approval",
|
||||
};
|
||||
|
||||
const ACTION_BADGE: Record<AgentPermissionAction, { label: string; className: string }> = {
|
||||
allow: { label: "Allow", className: "bg-emerald-500/10 text-emerald-600 border-emerald-500/30" },
|
||||
deny: { label: "Deny", className: "bg-destructive/10 text-destructive border-destructive/30" },
|
||||
ask: { label: "Ask", className: "bg-amber-500/10 text-amber-600 border-amber-500/30" },
|
||||
};
|
||||
|
||||
const EMPTY_FORM: AgentPermissionRuleCreate = {
|
||||
permission: "",
|
||||
pattern: "*",
|
||||
action: "ask",
|
||||
user_id: null,
|
||||
thread_id: null,
|
||||
};
|
||||
|
||||
function permissionRulesQueryKey(searchSpaceId: number) {
|
||||
return ["agent-permission-rules", searchSpaceId] as const;
|
||||
}
|
||||
|
||||
function ScopeBadge({ rule }: { rule: AgentPermissionRule }) {
|
||||
if (rule.thread_id !== null) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Thread #{rule.thread_id}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (rule.user_id !== null) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
User-specific
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Search space
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentPermissionsContent() {
|
||||
const searchSpaceIdRaw = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const searchSpaceId = searchSpaceIdRaw ? Number(searchSpaceIdRaw) : null;
|
||||
|
||||
const { data: flags } = useAtomValue(agentFlagsAtom);
|
||||
const featureEnabled = !!flags?.enable_permission && !flags?.disable_new_agent_stack;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: rules,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: searchSpaceId
|
||||
? permissionRulesQueryKey(searchSpaceId)
|
||||
: ["agent-permission-rules", "none"],
|
||||
queryFn: () => agentPermissionsApiService.list(searchSpaceId as number),
|
||||
enabled: !!searchSpaceId && featureEnabled,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: AgentPermissionRuleCreate) =>
|
||||
agentPermissionsApiService.create(searchSpaceId as number, payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Rule created.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionRulesQueryKey(searchSpaceId as number),
|
||||
});
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to create rule.");
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (params: { ruleId: number; action: AgentPermissionAction; pattern?: string }) =>
|
||||
agentPermissionsApiService.update(searchSpaceId as number, params.ruleId, {
|
||||
action: params.action,
|
||||
pattern: params.pattern,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionRulesQueryKey(searchSpaceId as number),
|
||||
});
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to update rule.");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (ruleId: number) =>
|
||||
agentPermissionsApiService.remove(searchSpaceId as number, ruleId),
|
||||
onSuccess: () => {
|
||||
toast.success("Rule deleted.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionRulesQueryKey(searchSpaceId as number),
|
||||
});
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to delete rule.");
|
||||
},
|
||||
});
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formData, setFormData] = useState<AgentPermissionRuleCreate>(EMPTY_FORM);
|
||||
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
||||
|
||||
const sortedRules = useMemo(() => rules ?? [], [rules]);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!formData.permission.trim()) {
|
||||
toast.error("Permission is required.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
...formData,
|
||||
permission: formData.permission.trim(),
|
||||
pattern: formData.pattern.trim() || "*",
|
||||
});
|
||||
setShowForm(false);
|
||||
setFormData(EMPTY_FORM);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError && err.message) {
|
||||
// already toasted by onError
|
||||
}
|
||||
}
|
||||
}, [createMutation, formData]);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (deleteTarget === null) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteTarget);
|
||||
} finally {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}, [deleteMutation, deleteTarget]);
|
||||
|
||||
if (!featureEnabled) {
|
||||
return (
|
||||
<Alert className="border-dashed">
|
||||
<ShieldCheck className="size-4" />
|
||||
<AlertTitle>Permission middleware is disabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
Flip{" "}
|
||||
<code className="rounded bg-muted px-1 text-[10px]">SURFSENSE_ENABLE_PERMISSION</code> on
|
||||
the backend to manage allow/deny/ask rules from this panel.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!searchSpaceId) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">Open a search space to manage agent rules.</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
|
||||
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
|
||||
<p className="mt-2 text-sm text-destructive">Failed to load rules</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error instanceof Error ? error.message : "Unknown error."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-6 overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tell the agent which tools to allow, deny, or ask before running. Rules use wildcard
|
||||
patterns and are evaluated at the most specific scope first.
|
||||
</p>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(true);
|
||||
setFormData(EMPTY_FORM);
|
||||
}}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
New rule
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="rounded-lg border border-border/60 bg-card p-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold tracking-tight">New permission rule</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="permission-name">Permission</Label>
|
||||
<Input
|
||||
id="permission-name"
|
||||
value={formData.permission}
|
||||
placeholder="e.g. tool:create_linear_issue or tool:*"
|
||||
onChange={(e) => setFormData((p) => ({ ...p, permission: e.target.value }))}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Match a tool capability. Use <code className="font-mono">*</code> for wildcards.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pattern">Argument pattern</Label>
|
||||
<Input
|
||||
id="pattern"
|
||||
value={formData.pattern}
|
||||
placeholder="*"
|
||||
onChange={(e) => setFormData((p) => ({ ...p, pattern: e.target.value }))}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Wildcard against the canonical argument (e.g. <code>prod-*</code>).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Action</Label>
|
||||
<Select
|
||||
value={formData.action}
|
||||
onValueChange={(value) =>
|
||||
setFormData((p) => ({ ...p, action: value as AgentPermissionAction }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">Allow — run without asking</SelectItem>
|
||||
<SelectItem value="ask">Ask — pause for approval</SelectItem>
|
||||
<SelectItem value="deny">Deny — block silently</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{ACTION_DESCRIPTIONS[formData.action]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setFormData(EMPTY_FORM);
|
||||
}}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={createMutation.isPending || !formData.permission.trim()}
|
||||
className="relative"
|
||||
>
|
||||
<span className={createMutation.isPending ? "opacity-0" : ""}>Create</span>
|
||||
{createMutation.isPending && <Spinner className="absolute size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedRules.length === 0 && !showForm && (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
|
||||
<ShieldCheck className="mx-auto size-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No rules yet</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Without rules the agent uses the deployment default for every tool.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedRules.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{sortedRules.map((rule) => {
|
||||
const badge = ACTION_BADGE[rule.action];
|
||||
const isUpdating =
|
||||
updateMutation.isPending && updateMutation.variables?.ruleId === rule.id;
|
||||
const isDeleting = deleteMutation.isPending && deleteMutation.variables === rule.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="group flex flex-col gap-3 rounded-lg border border-border/60 bg-card p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<code className="truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
{rule.permission}
|
||||
</code>
|
||||
{rule.pattern !== "*" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
→ <code className="font-mono">{rule.pattern}</code>
|
||||
</span>
|
||||
)}
|
||||
<ScopeBadge rule={rule} />
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Created {formatRelativeDate(rule.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Select
|
||||
value={rule.action}
|
||||
onValueChange={(value) =>
|
||||
updateMutation.mutate({
|
||||
ruleId: rule.id,
|
||||
action: value as AgentPermissionAction,
|
||||
})
|
||||
}
|
||||
disabled={isUpdating || isDeleting}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("h-8 gap-1 border px-2 text-[11px]", badge.className)}
|
||||
>
|
||||
<SelectValue>
|
||||
<span className="flex items-center gap-1">
|
||||
{rule.action === "allow" && <Check className="size-3" />}
|
||||
{rule.action === "deny" && <X className="size-3" />}
|
||||
{badge.label}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">Allow</SelectItem>
|
||||
<SelectItem value="ask">Ask</SelectItem>
|
||||
<SelectItem value="deny">Deny</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(rule.id)}
|
||||
disabled={isUpdating || isDeleting}
|
||||
aria-label="Delete rule"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this rule?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The agent will fall back to deployment defaults for matching tool calls.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleConfirmDelete();
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? "Deleting…" : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { CircleCheck, CircleSlash, Cog, RotateCcw } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { AgentFeatureFlags } from "@/lib/apis/agent-flags-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FlagKey = keyof AgentFeatureFlags;
|
||||
|
||||
interface FlagDef {
|
||||
key: FlagKey;
|
||||
label: string;
|
||||
description: string;
|
||||
envVar: string;
|
||||
}
|
||||
|
||||
interface FlagGroup {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
flags: FlagDef[];
|
||||
}
|
||||
|
||||
const FLAG_GROUPS: FlagGroup[] = [
|
||||
{
|
||||
id: "tier1",
|
||||
title: "Tier 1 — Agent quality",
|
||||
subtitle: "Context editing, retries, fallbacks, doom-loop, tool-call repair.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_context_editing",
|
||||
label: "Context editing",
|
||||
description: "Trim tool outputs and spill old text into backend storage.",
|
||||
envVar: "SURFSENSE_ENABLE_CONTEXT_EDITING",
|
||||
},
|
||||
{
|
||||
key: "enable_compaction_v2",
|
||||
label: "Compaction v2",
|
||||
description: "SurfSense-aware compaction replacing safe summarization.",
|
||||
envVar: "SURFSENSE_ENABLE_COMPACTION_V2",
|
||||
},
|
||||
{
|
||||
key: "enable_retry_after",
|
||||
label: "Retry-After",
|
||||
description: "Honour rate-limit retry-after headers automatically.",
|
||||
envVar: "SURFSENSE_ENABLE_RETRY_AFTER",
|
||||
},
|
||||
{
|
||||
key: "enable_model_fallback",
|
||||
label: "Model fallback",
|
||||
description: "Fail over to a backup model on persistent errors.",
|
||||
envVar: "SURFSENSE_ENABLE_MODEL_FALLBACK",
|
||||
},
|
||||
{
|
||||
key: "enable_model_call_limit",
|
||||
label: "Model call limit",
|
||||
description: "Cap total model calls per turn to prevent budget run-aways.",
|
||||
envVar: "SURFSENSE_ENABLE_MODEL_CALL_LIMIT",
|
||||
},
|
||||
{
|
||||
key: "enable_tool_call_limit",
|
||||
label: "Tool call limit",
|
||||
description: "Cap total tool calls per turn.",
|
||||
envVar: "SURFSENSE_ENABLE_TOOL_CALL_LIMIT",
|
||||
},
|
||||
{
|
||||
key: "enable_tool_call_repair",
|
||||
label: "Tool-call name repair",
|
||||
description: "Recover from lower-cased / fuzzy tool names emitted by smaller models.",
|
||||
envVar: "SURFSENSE_ENABLE_TOOL_CALL_REPAIR",
|
||||
},
|
||||
{
|
||||
key: "enable_doom_loop",
|
||||
label: "Doom-loop detection",
|
||||
description: "Detect repeated identical tool calls and ask the user to confirm.",
|
||||
envVar: "SURFSENSE_ENABLE_DOOM_LOOP",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tier2",
|
||||
title: "Tier 2 — Safety",
|
||||
subtitle: "Permission rules, busy-mutex, smarter tool selection.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_permission",
|
||||
label: "Permission middleware",
|
||||
description: "Apply allow/deny/ask rules from the Agent Permissions tab.",
|
||||
envVar: "SURFSENSE_ENABLE_PERMISSION",
|
||||
},
|
||||
{
|
||||
key: "enable_busy_mutex",
|
||||
label: "Busy mutex",
|
||||
description: "Prevent two concurrent runs from corrupting the same thread.",
|
||||
envVar: "SURFSENSE_ENABLE_BUSY_MUTEX",
|
||||
},
|
||||
{
|
||||
key: "enable_llm_tool_selector",
|
||||
label: "LLM tool selector",
|
||||
description: "Use a smaller model to pre-filter the tool list per turn.",
|
||||
envVar: "SURFSENSE_ENABLE_LLM_TOOL_SELECTOR",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tier4",
|
||||
title: "Tier 4 — Skills + subagents",
|
||||
subtitle: "Built-in skills, specialized subagents, KB planner runnable.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_skills",
|
||||
label: "Skills",
|
||||
description: "Load on-demand skill packs (kb-research, report-writing, …).",
|
||||
envVar: "SURFSENSE_ENABLE_SKILLS",
|
||||
},
|
||||
{
|
||||
key: "enable_specialized_subagents",
|
||||
label: "Specialized subagents",
|
||||
description: "Spin up explore / report_writer / connector_negotiator subagents.",
|
||||
envVar: "SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS",
|
||||
},
|
||||
{
|
||||
key: "enable_kb_planner_runnable",
|
||||
label: "KB planner runnable",
|
||||
description: "Compile a private planner sub-agent for KB search.",
|
||||
envVar: "SURFSENSE_ENABLE_KB_PLANNER_RUNNABLE",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tier5",
|
||||
title: "Tier 5 — Audit + revert",
|
||||
subtitle: "Action log + revert route used by the Agent Actions sheet.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_action_log",
|
||||
label: "Action log",
|
||||
description: "Persist every tool call to agent_action_log.",
|
||||
envVar: "SURFSENSE_ENABLE_ACTION_LOG",
|
||||
},
|
||||
{
|
||||
key: "enable_revert_route",
|
||||
label: "Revert route",
|
||||
description: "Allow reverting reversible actions from the action log.",
|
||||
envVar: "SURFSENSE_ENABLE_REVERT_ROUTE",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tier6",
|
||||
title: "Tier 6 — Plugins",
|
||||
subtitle: "Optional middleware loaded from entry points.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_plugin_loader",
|
||||
label: "Plugin loader",
|
||||
description: "Load surfsense.plugins entry-point middleware.",
|
||||
envVar: "SURFSENSE_ENABLE_PLUGIN_LOADER",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "obs",
|
||||
title: "Observability",
|
||||
subtitle: "Telemetry pipelines (orthogonal to feature gating).",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_otel",
|
||||
label: "OpenTelemetry",
|
||||
description: "Emit OTel spans (also requires OTEL_EXPORTER_OTLP_ENDPOINT).",
|
||||
envVar: "SURFSENSE_ENABLE_OTEL",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function FlagRow({ def, value }: { def: FlagDef; value: boolean }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{def.label}</span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{def.envVar}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{def.description}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={value ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"shrink-0 gap-1",
|
||||
value
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{value ? <CircleCheck className="size-3" /> : <CircleSlash className="size-3" />}
|
||||
{value ? "On" : "Off"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentStatusContent() {
|
||||
const { data: flags, isLoading, isError, error, refetch } = useAtomValue(agentFlagsAtom);
|
||||
|
||||
const enabledCount = useMemo(() => {
|
||||
if (!flags) return 0;
|
||||
return Object.entries(flags).filter(([k, v]) => k !== "disable_new_agent_stack" && v === true)
|
||||
.length;
|
||||
}, [flags]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-12 w-full rounded-md" />
|
||||
<Skeleton className="h-32 w-full rounded-md" />
|
||||
<Skeleton className="h-32 w-full rounded-md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !flags) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load agent status</AlertTitle>
|
||||
<AlertDescription className="flex items-center gap-2">
|
||||
{error instanceof Error ? error.message : "Unknown error."}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="ml-auto inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs hover:bg-background"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
Retry
|
||||
</button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const masterOff = flags.disable_new_agent_stack;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{masterOff ? (
|
||||
<Alert variant="destructive">
|
||||
<Cog className="size-4" />
|
||||
<AlertTitle>Master kill-switch is on</AlertTitle>
|
||||
<AlertDescription>
|
||||
<code className="rounded bg-muted px-1 text-[10px]">
|
||||
SURFSENSE_DISABLE_NEW_AGENT_STACK=true
|
||||
</code>
|
||||
forces every new middleware off, regardless of the individual flags below. Restart the
|
||||
backend after changing it.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert>
|
||||
<Cog className="size-4" />
|
||||
<AlertTitle className="flex items-center gap-2">
|
||||
Agent stack
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{enabledCount} on
|
||||
</Badge>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Read-only mirror of the backend's <code>AgentFeatureFlags</code>. Flip an env var and
|
||||
restart the backend to change a value.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{FLAG_GROUPS.map((group, groupIdx) => {
|
||||
const allOff = group.flags.every((f) => !flags[f.key]);
|
||||
return (
|
||||
<div key={group.id}>
|
||||
{groupIdx > 0 && <Separator className="my-4" />}
|
||||
<div className="rounded-lg border border-border/60 bg-card">
|
||||
<div className="flex items-start justify-between gap-3 border-b px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{group.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{group.subtitle}</p>
|
||||
</div>
|
||||
{allOff && (
|
||||
<Badge variant="outline" className="text-[10px] text-muted-foreground">
|
||||
all off
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="divide-y divide-border/50 px-4">
|
||||
{group.flags.map((def) => (
|
||||
<FlagRow key={def.key} def={def} value={flags[def.key]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,7 +20,6 @@ import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
|||
export function DesktopContent() {
|
||||
const api = useElectronAPI();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
|
||||
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
|
||||
|
|
@ -41,14 +40,12 @@ export function DesktopContent() {
|
|||
setAutoLaunchSupported(hasAutoLaunchApi);
|
||||
|
||||
Promise.all([
|
||||
api.getAutocompleteEnabled(),
|
||||
api.getActiveSearchSpace?.() ?? Promise.resolve(null),
|
||||
searchSpacesApiService.getSearchSpaces(),
|
||||
hasAutoLaunchApi ? api.getAutoLaunch() : Promise.resolve(null),
|
||||
])
|
||||
.then(([autoEnabled, spaceId, spaces, autoLaunch]) => {
|
||||
.then(([spaceId, spaces, autoLaunch]) => {
|
||||
if (!mounted) return;
|
||||
setEnabled(autoEnabled);
|
||||
setActiveSpaceId(spaceId);
|
||||
if (spaces) setSearchSpaces(spaces);
|
||||
if (autoLaunch) {
|
||||
|
|
@ -86,11 +83,6 @@ export function DesktopContent() {
|
|||
);
|
||||
}
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
setEnabled(checked);
|
||||
await api.setAutocompleteEnabled(checked);
|
||||
};
|
||||
|
||||
const handleAutoLaunchToggle = async (checked: boolean) => {
|
||||
if (!autoLaunchSupported || !api.setAutoLaunch) {
|
||||
toast.error("Please update the desktop app to configure launch on startup");
|
||||
|
|
@ -133,13 +125,12 @@ export function DesktopContent() {
|
|||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Default Search Space */}
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<CardTitle className="text-base md:text-lg">Default Search Space</CardTitle>
|
||||
<CardDescription className="text-xs md:text-sm">
|
||||
Choose which search space General Assist, Quick Assist, and Extreme Assist operate
|
||||
against.
|
||||
Choose which search space General Assist, Screenshot Assist, and Quick Assist use by
|
||||
default.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
|
|
@ -164,7 +155,6 @@ export function DesktopContent() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Launch on Startup */}
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<CardTitle className="text-base md:text-lg flex items-center gap-2">
|
||||
|
|
@ -215,29 +205,6 @@ export function DesktopContent() {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Extreme Assist Toggle */}
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<CardTitle className="text-base md:text-lg">Extreme Assist</CardTitle>
|
||||
<CardDescription className="text-xs md:text-sm">
|
||||
Get inline writing suggestions powered by your knowledge base as you type in any app.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="autocomplete-toggle" className="text-sm font-medium cursor-pointer">
|
||||
Enable Extreme Assist
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show suggestions while typing in other applications.
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="autocomplete-toggle" checked={enabled} onCheckedChange={handleToggle} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { BrainCog, Rocket, RotateCcw, Zap } from "lucide-react";
|
||||
import { Crop, Rocket, RotateCcw, Zap } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DEFAULT_SHORTCUTS, keyEventToAccelerator } from "@/components/desktop/shortcut-recorder";
|
||||
|
|
@ -9,13 +9,13 @@ import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
|||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
|
||||
type ShortcutKey = "generalAssist" | "quickAsk" | "autocomplete";
|
||||
type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist";
|
||||
type ShortcutMap = typeof DEFAULT_SHORTCUTS;
|
||||
|
||||
const HOTKEY_ROWS: Array<{ key: ShortcutKey; label: string; icon: React.ElementType }> = [
|
||||
{ key: "generalAssist", label: "General Assist", icon: Rocket },
|
||||
{ key: "screenshotAssist", label: "Screenshot Assist", icon: Crop },
|
||||
{ key: "quickAsk", label: "Quick Assist", icon: Zap },
|
||||
{ key: "autocomplete", label: "Extreme Assist", icon: BrainCog },
|
||||
];
|
||||
|
||||
function acceleratorToKeys(accel: string, isMac: boolean): string[] {
|
||||
|
|
@ -111,9 +111,7 @@ function HotkeyRow({
|
|||
}
|
||||
>
|
||||
{recording ? (
|
||||
<span className="px-2 text-[9px] text-primary whitespace-nowrap">
|
||||
Press hotkeys...
|
||||
</span>
|
||||
<span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys...</span>
|
||||
) : (
|
||||
<ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" />
|
||||
)}
|
||||
|
|
@ -155,15 +153,14 @@ export function DesktopShortcutsContent() {
|
|||
if (!api) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">Hotkeys are only available in the SurfSense desktop app.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Hotkeys are only available in the SurfSense desktop app.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const updateShortcut = (
|
||||
key: "generalAssist" | "quickAsk" | "autocomplete",
|
||||
accelerator: string
|
||||
) => {
|
||||
const updateShortcut = (key: ShortcutKey, accelerator: string) => {
|
||||
setShortcuts((prev) => {
|
||||
const updated = { ...prev, [key]: accelerator };
|
||||
api.setShortcuts?.({ [key]: accelerator }).catch(() => {
|
||||
|
|
@ -178,28 +175,26 @@ export function DesktopShortcutsContent() {
|
|||
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
|
||||
};
|
||||
|
||||
return (
|
||||
shortcutsLoaded ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
{HOTKEY_ROWS.map((row) => (
|
||||
<HotkeyRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
value={shortcuts[row.key]}
|
||||
defaultValue={DEFAULT_SHORTCUTS[row.key]}
|
||||
icon={row.icon}
|
||||
isMac={isMac}
|
||||
onChange={(accel) => updateShortcut(row.key, accel)}
|
||||
onReset={() => resetShortcut(row.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
return shortcutsLoaded ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
{HOTKEY_ROWS.map((row) => (
|
||||
<HotkeyRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
value={shortcuts[row.key]}
|
||||
defaultValue={DEFAULT_SHORTCUTS[row.key]}
|
||||
icon={row.icon}
|
||||
isMac={isMac}
|
||||
onChange={(accel) => updateShortcut(row.key, accel)}
|
||||
onReset={() => resetShortcut(row.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { BrainCog, Eye, EyeOff, Rocket, RotateCcw, Zap } from "lucide-react";
|
||||
import { Crop, Eye, EyeOff, Rocket, RotateCcw, Zap } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
|
@ -21,28 +21,33 @@ import { setBearerToken } from "@/lib/auth-utils";
|
|||
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
|
||||
|
||||
const isGoogleAuth = AUTH_TYPE === "GOOGLE";
|
||||
type ShortcutKey = "generalAssist" | "quickAsk" | "autocomplete";
|
||||
type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist";
|
||||
type ShortcutMap = typeof DEFAULT_SHORTCUTS;
|
||||
|
||||
const HOTKEY_ROWS: Array<{ key: ShortcutKey; label: string; description: string; icon: React.ElementType }> = [
|
||||
const HOTKEY_ROWS: Array<{
|
||||
key: ShortcutKey;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
}> = [
|
||||
{
|
||||
key: "generalAssist",
|
||||
label: "General Assist",
|
||||
description: "Launch SurfSense instantly from any application",
|
||||
icon: Rocket,
|
||||
},
|
||||
{
|
||||
key: "screenshotAssist",
|
||||
label: "Screenshot Assist",
|
||||
description: "Draw a region on screen to attach that capture to chat",
|
||||
icon: Crop,
|
||||
},
|
||||
{
|
||||
key: "quickAsk",
|
||||
label: "Quick Assist",
|
||||
description: "Select text anywhere, then ask AI to explain, rewrite, or act on it",
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
key: "autocomplete",
|
||||
label: "Extreme Assist",
|
||||
description: "AI drafts text using your screen context and knowledge base",
|
||||
icon: BrainCog,
|
||||
},
|
||||
];
|
||||
|
||||
function acceleratorToKeys(accel: string, isMac: boolean): string[] {
|
||||
|
|
@ -182,7 +187,7 @@ export default function DesktopLoginPage() {
|
|||
}, [api]);
|
||||
|
||||
const updateShortcut = useCallback(
|
||||
(key: "generalAssist" | "quickAsk" | "autocomplete", accelerator: string) => {
|
||||
(key: ShortcutKey, accelerator: string) => {
|
||||
setShortcuts((prev) => {
|
||||
const updated = { ...prev, [key]: accelerator };
|
||||
api?.setShortcuts?.({ [key]: accelerator }).catch(() => {
|
||||
|
|
@ -196,7 +201,7 @@ export default function DesktopLoginPage() {
|
|||
);
|
||||
|
||||
const resetShortcut = useCallback(
|
||||
(key: "generalAssist" | "quickAsk" | "autocomplete") => {
|
||||
(key: ShortcutKey) => {
|
||||
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
|
||||
},
|
||||
[updateShortcut]
|
||||
|
|
@ -369,7 +374,9 @@ export default function DesktopLoginPage() {
|
|||
|
||||
<Button type="submit" disabled={isLoggingIn} className="relative h-9 mt-1">
|
||||
<span className={isLoggingIn ? "opacity-0" : ""}>Sign in</span>
|
||||
{isLoggingIn && <Spinner size="sm" className="absolute text-primary-foreground" />}
|
||||
{isLoggingIn && (
|
||||
<Spinner size="sm" className="absolute text-primary-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -19,14 +19,15 @@ const STEPS = [
|
|||
id: "screen-recording",
|
||||
title: "Screen Recording",
|
||||
description:
|
||||
"Lets SurfSense capture your screen to understand context and provide smart writing suggestions.",
|
||||
"Lets SurfSense capture a region of your screen, full display, or browser (where supported) to attach to chat in Screenshot Assist, or to capture the full display from the composer.",
|
||||
action: "requestScreenRecording",
|
||||
field: "screenRecording" as const,
|
||||
},
|
||||
{
|
||||
id: "accessibility",
|
||||
title: "Accessibility",
|
||||
description: "Lets SurfSense insert suggestions seamlessly, right where you\u2019re typing.",
|
||||
description:
|
||||
"Lets SurfSense bring the app to the foreground and work with the active application (for example Quick Assist) when you use desktop shortcuts.",
|
||||
action: "requestAccessibility",
|
||||
field: "accessibility" as const,
|
||||
},
|
||||
|
|
@ -131,7 +132,8 @@ export default function DesktopPermissionsPage() {
|
|||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">System Permissions</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
SurfSense needs two macOS permissions to provide context-aware writing suggestions.
|
||||
SurfSense needs two macOS permissions for Screenshot Assist and for desktop features that
|
||||
require focusing the app or the active application.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
import "./suggestion.css";
|
||||
|
||||
export const metadata = {
|
||||
title: "SurfSense Suggestion",
|
||||
};
|
||||
|
||||
export default function SuggestionLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="suggestion-body">{children}</div>;
|
||||
}
|
||||
|
|
@ -1,384 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { ensureTokensFromElectron, getBearerToken } from "@/lib/auth-utils";
|
||||
|
||||
type SSEEvent =
|
||||
| { type: "text-delta"; id: string; delta: string }
|
||||
| { type: "text-start"; id: string }
|
||||
| { type: "text-end"; id: string }
|
||||
| { type: "start"; messageId: string }
|
||||
| { type: "finish" }
|
||||
| { type: "error"; errorText: string }
|
||||
| {
|
||||
type: "data-thinking-step";
|
||||
data: { id: string; title: string; status: string; items: string[] };
|
||||
}
|
||||
| {
|
||||
type: "data-suggestions";
|
||||
data: { options: string[] };
|
||||
};
|
||||
|
||||
interface AgentStep {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
type FriendlyError = { message: string; isSetup?: boolean };
|
||||
|
||||
function friendlyError(raw: string | number): FriendlyError {
|
||||
if (typeof raw === "number") {
|
||||
if (raw === 401) return { message: "Please sign in to use suggestions." };
|
||||
if (raw === 403) return { message: "You don\u2019t have permission for this." };
|
||||
if (raw === 404) return { message: "Suggestion service not found. Is the backend running?" };
|
||||
if (raw >= 500) return { message: "Something went wrong on the server. Try again." };
|
||||
return { message: "Something went wrong. Try again." };
|
||||
}
|
||||
const lower = raw.toLowerCase();
|
||||
if (lower.includes("not authenticated") || lower.includes("unauthorized"))
|
||||
return { message: "Please sign in to use suggestions." };
|
||||
if (lower.includes("no vision llm configured") || lower.includes("no llm configured"))
|
||||
return {
|
||||
message: "Configure a vision-capable model (e.g. GPT-4o, Gemini) to enable autocomplete.",
|
||||
isSetup: true,
|
||||
};
|
||||
if (lower.includes("does not support vision"))
|
||||
return {
|
||||
message: "The selected model doesn\u2019t support vision. Choose a vision-capable model.",
|
||||
isSetup: true,
|
||||
};
|
||||
if (lower.includes("fetch") || lower.includes("network") || lower.includes("econnrefused"))
|
||||
return { message: "Can\u2019t reach the server. Check your connection." };
|
||||
return { message: "Something went wrong. Try again." };
|
||||
}
|
||||
|
||||
const AUTO_DISMISS_MS = 3000;
|
||||
|
||||
function StepIcon({ status }: { status: string }) {
|
||||
if (status === "complete") {
|
||||
return (
|
||||
<svg
|
||||
className="step-icon step-icon-done"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-label="Step complete"
|
||||
>
|
||||
<circle cx="8" cy="8" r="7" stroke="#4ade80" strokeWidth="1.5" />
|
||||
<path
|
||||
d="M5 8.5l2 2 4-4.5"
|
||||
stroke="#4ade80"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return <span className="step-spinner" />;
|
||||
}
|
||||
|
||||
export default function SuggestionPage() {
|
||||
const api = useElectronAPI();
|
||||
const [options, setOptions] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<FriendlyError | null>(null);
|
||||
const [steps, setSteps] = useState<AgentStep[]>([]);
|
||||
const [expandedOption, setExpandedOption] = useState<number | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const isDesktop = !!api?.onAutocompleteContext;
|
||||
|
||||
useEffect(() => {
|
||||
if (!api?.onAutocompleteContext) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error || error.isSetup) return;
|
||||
const timer = setTimeout(() => {
|
||||
api?.dismissSuggestion?.();
|
||||
}, AUTO_DISMISS_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [error, api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || error || options.length > 0) return;
|
||||
const timer = setTimeout(() => {
|
||||
api?.dismissSuggestion?.();
|
||||
}, AUTO_DISMISS_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isLoading, error, options, api]);
|
||||
|
||||
const fetchSuggestion = useCallback(
|
||||
async (screenshot: string, searchSpaceId: string, appName?: string, windowTitle?: string) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setIsLoading(true);
|
||||
setOptions([]);
|
||||
setError(null);
|
||||
setSteps([]);
|
||||
setExpandedOption(null);
|
||||
|
||||
let token = getBearerToken();
|
||||
if (!token) {
|
||||
await ensureTokensFromElectron();
|
||||
token = getBearerToken();
|
||||
}
|
||||
if (!token) {
|
||||
setError(friendlyError("not authenticated"));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/autocomplete/vision/stream`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
screenshot,
|
||||
search_space_id: parseInt(searchSpaceId, 10),
|
||||
app_name: appName || "",
|
||||
window_title: windowTitle || "",
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setError(friendlyError(response.status));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
setError(friendlyError("network error"));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const events = buffer.split(/\r?\n\r?\n/);
|
||||
buffer = events.pop() || "";
|
||||
|
||||
for (const event of events) {
|
||||
const lines = event.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const data = line.slice(6).trim();
|
||||
if (!data || data === "[DONE]") continue;
|
||||
|
||||
try {
|
||||
const parsed: SSEEvent = JSON.parse(data);
|
||||
if (parsed.type === "data-suggestions") {
|
||||
setOptions(parsed.data.options);
|
||||
} else if (parsed.type === "error") {
|
||||
setError(friendlyError(parsed.errorText));
|
||||
} else if (parsed.type === "data-thinking-step") {
|
||||
const { id, title, status, items } = parsed.data;
|
||||
setSteps((prev) => {
|
||||
const existing = prev.findIndex((s) => s.id === id);
|
||||
if (existing >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[existing] = { id, title, status, items };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, { id, title, status, items }];
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError(friendlyError("network error"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api?.onAutocompleteContext) return;
|
||||
|
||||
const cleanup = api.onAutocompleteContext((data) => {
|
||||
const searchSpaceId = data.searchSpaceId || "1";
|
||||
if (data.screenshot) {
|
||||
fetchSuggestion(data.screenshot, searchSpaceId, data.appName, data.windowTitle);
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [fetchSuggestion, api]);
|
||||
|
||||
if (!isDesktop) {
|
||||
return (
|
||||
<div className="suggestion-tooltip">
|
||||
<span className="suggestion-error-text">
|
||||
This page is only available in the SurfSense desktop app.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error.isSetup) {
|
||||
return (
|
||||
<div className="suggestion-tooltip suggestion-setup">
|
||||
<div className="setup-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" width="28" height="28" aria-hidden="true">
|
||||
<path
|
||||
d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="3"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="setup-content">
|
||||
<span className="setup-title">Vision Model Required</span>
|
||||
<span className="setup-message">{error.message}</span>
|
||||
<span className="setup-hint">Settings → Vision Models</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="setup-dismiss"
|
||||
onClick={() => api?.dismissSuggestion?.()}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="suggestion-tooltip suggestion-error">
|
||||
<span className="suggestion-error-text">{error.message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showLoading = isLoading && options.length === 0;
|
||||
|
||||
if (showLoading) {
|
||||
return (
|
||||
<div className="suggestion-tooltip">
|
||||
<div className="agent-activity">
|
||||
{steps.length === 0 && (
|
||||
<div className="activity-initial">
|
||||
<span className="step-spinner" />
|
||||
<span className="activity-label">Preparing…</span>
|
||||
</div>
|
||||
)}
|
||||
{steps.length > 0 && (
|
||||
<div className="activity-steps">
|
||||
{steps.map((step) => (
|
||||
<div key={step.id} className="activity-step">
|
||||
<StepIcon status={step.status} />
|
||||
<span className="step-label">
|
||||
{step.title}
|
||||
{step.items.length > 0 && (
|
||||
<span className="step-detail"> · {step.items[0]}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSelect = (text: string) => {
|
||||
api?.acceptSuggestion?.(text);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
api?.dismissSuggestion?.();
|
||||
};
|
||||
|
||||
const TRUNCATE_LENGTH = 120;
|
||||
|
||||
if (options.length === 0) {
|
||||
return (
|
||||
<div className="suggestion-tooltip suggestion-error">
|
||||
<span className="suggestion-error-text">No suggestions available.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="suggestion-tooltip">
|
||||
<div className="suggestion-options">
|
||||
{options.map((option, index) => {
|
||||
const isExpanded = expandedOption === index;
|
||||
const needsTruncation = option.length > TRUNCATE_LENGTH;
|
||||
const displayText =
|
||||
needsTruncation && !isExpanded ? option.slice(0, TRUNCATE_LENGTH) + "…" : option;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
className="suggestion-option"
|
||||
onClick={() => handleSelect(option)}
|
||||
>
|
||||
<span className="option-number">{index + 1}</span>
|
||||
<span className="option-text">{displayText}</span>
|
||||
{needsTruncation && (
|
||||
<button
|
||||
type="button"
|
||||
className="option-expand"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedOption(isExpanded ? null : index);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? "less" : "more"}
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="suggestion-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="suggestion-btn suggestion-btn-dismiss"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,352 +0,0 @@
|
|||
html:has(.suggestion-body),
|
||||
body:has(.suggestion-body) {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
overflow: hidden !important;
|
||||
height: auto !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.suggestion-body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.suggestion-tooltip {
|
||||
box-sizing: border-box;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin: 4px;
|
||||
max-width: 400px;
|
||||
/* MAX_HEIGHT in suggestion-window.ts is 400px. Subtract 8px for margin
|
||||
(4px * 2) so the tooltip + margin fits within the Electron window.
|
||||
box-sizing: border-box ensures padding + border are included. */
|
||||
max-height: 392px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.suggestion-text {
|
||||
color: #d4d4d4;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
margin: 0 0 6px 0;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.suggestion-text::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.suggestion-text::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.suggestion-text::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.suggestion-text::-webkit-scrollbar-thumb:hover {
|
||||
background: #777;
|
||||
}
|
||||
|
||||
.suggestion-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
padding-top: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.suggestion-btn {
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #3c3c3c;
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
line-height: 16px;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.suggestion-btn-accept {
|
||||
background: #2563eb;
|
||||
border-color: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.suggestion-btn-accept:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.suggestion-btn-dismiss {
|
||||
background: #2a2a2a;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.suggestion-btn-dismiss:hover {
|
||||
background: #333;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.suggestion-error {
|
||||
border-color: #5c2626;
|
||||
}
|
||||
|
||||
.suggestion-error-text {
|
||||
color: #f48771;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* --- Setup prompt (vision model not configured) --- */
|
||||
|
||||
.suggestion-setup {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
border-color: #3b2d6b;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.setup-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.setup-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setup-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.setup-message {
|
||||
font-size: 11.5px;
|
||||
color: #a1a1aa;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.setup-hint {
|
||||
font-size: 10.5px;
|
||||
color: #7c6dac;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.setup-dismiss {
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b6b7b;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
|
||||
.setup-dismiss:hover {
|
||||
color: #c4b5fd;
|
||||
background: rgba(124, 109, 172, 0.15);
|
||||
}
|
||||
|
||||
/* --- Agent activity indicator --- */
|
||||
|
||||
.agent-activity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
max-height: 340px;
|
||||
}
|
||||
|
||||
.agent-activity::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-initial {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.activity-label {
|
||||
color: #a1a1aa;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.activity-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.activity-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
color: #d4d4d4;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.step-detail {
|
||||
color: #71717a;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Spinner (in_progress) */
|
||||
.step-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
border: 1.5px solid #3f3f46;
|
||||
border-top-color: #a78bfa;
|
||||
border-radius: 50%;
|
||||
animation: step-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
/* Checkmark icon (complete) */
|
||||
.step-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes step-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Suggestion option cards --- */
|
||||
|
||||
.suggestion-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.suggestion-options::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.suggestion-options::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.suggestion-options::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.suggestion-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #333;
|
||||
background: #262626;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.suggestion-option:hover {
|
||||
background: #2a2d3a;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.option-number {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #3f3f46;
|
||||
color: #d4d4d4;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.suggestion-option:hover .option-number {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.option-text {
|
||||
color: #d4d4d4;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.option-expand {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #71717a;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
font-family: inherit;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.option-expand:hover {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
55
surfsense_web/app/docs/[[...slug]]/loading.tsx
Normal file
55
surfsense_web/app/docs/[[...slug]]/loading.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function DocsLoading() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-6 max-w-4xl mx-auto w-full">
|
||||
{/* Title */}
|
||||
<Skeleton className="h-9 w-64" />
|
||||
|
||||
{/* Description */}
|
||||
<Skeleton className="h-5 w-full max-w-md" />
|
||||
|
||||
<div className="mt-4 space-y-8">
|
||||
{/* Paragraph block 1 */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
|
||||
{/* Sub-heading */}
|
||||
<Skeleton className="h-7 w-48" />
|
||||
|
||||
{/* Paragraph block 2 */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
|
||||
{/* Code block placeholder */}
|
||||
<Skeleton className="h-28 w-full rounded-lg" />
|
||||
|
||||
{/* Sub-heading */}
|
||||
<Skeleton className="h-7 w-56" />
|
||||
|
||||
{/* List items */}
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<Skeleton className="mt-1 h-3 w-3 shrink-0 rounded-full" />
|
||||
<Skeleton className="h-4 w-full max-w-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Paragraph block 3 */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
surfsense_web/atoms/agent/action-log-sheet.atom.ts
Normal file
19
surfsense_web/atoms/agent/action-log-sheet.atom.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
interface ActionLogSheetState {
|
||||
open: boolean;
|
||||
threadId: number | null;
|
||||
}
|
||||
|
||||
export const actionLogSheetAtom = atom<ActionLogSheetState>({
|
||||
open: false,
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
export const openActionLogSheetAtom = atom(null, (_get, set, threadId: number) => {
|
||||
set(actionLogSheetAtom, { open: true, threadId });
|
||||
});
|
||||
|
||||
export const closeActionLogSheetAtom = atom(null, (_get, set) => {
|
||||
set(actionLogSheetAtom, { open: false, threadId: null });
|
||||
});
|
||||
17
surfsense_web/atoms/agent/agent-flags-query.atom.ts
Normal file
17
surfsense_web/atoms/agent/agent-flags-query.atom.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import { agentFlagsApiService } from "@/lib/apis/agent-flags-api.service";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
|
||||
export const AGENT_FLAGS_QUERY_KEY = ["agent", "flags"] as const;
|
||||
|
||||
/**
|
||||
* Reads the backend agent feature flags. Cached for the lifetime of the
|
||||
* page (flags only change on backend restart) so we can drive UI gating
|
||||
* without re-hitting the API.
|
||||
*/
|
||||
export const agentFlagsAtom = atomWithQuery(() => ({
|
||||
queryKey: AGENT_FLAGS_QUERY_KEY,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
enabled: !!getBearerToken(),
|
||||
queryFn: () => agentFlagsApiService.get(),
|
||||
}));
|
||||
|
|
@ -9,29 +9,6 @@ import type { Document } from "@/contracts/types/document.types";
|
|||
*/
|
||||
export const mentionedDocumentsAtom = atom<Pick<Document, "id" | "title" | "document_type">[]>([]);
|
||||
|
||||
/**
|
||||
* Back-compat alias for sidebar checkbox selection.
|
||||
* This now points to mentionedDocumentsAtom so the app has a single source
|
||||
* of truth for mentioned/selected documents.
|
||||
*/
|
||||
export const sidebarSelectedDocumentsAtom = atom<
|
||||
Pick<Document, "id" | "title" | "document_type">[],
|
||||
[
|
||||
| Pick<Document, "id" | "title" | "document_type">[]
|
||||
| ((
|
||||
prev: Pick<Document, "id" | "title" | "document_type">[]
|
||||
) => Pick<Document, "id" | "title" | "document_type">[]),
|
||||
],
|
||||
void
|
||||
>(
|
||||
(get) => get(mentionedDocumentsAtom),
|
||||
(get, set, update) => {
|
||||
const prev = get(mentionedDocumentsAtom);
|
||||
const next = typeof update === "function" ? update(prev) : update;
|
||||
set(mentionedDocumentsAtom, next);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Derived read-only atom that maps deduplicated mentioned docs
|
||||
* into backend payload fields.
|
||||
|
|
|
|||
3
surfsense_web/atoms/chat/pending-user-images.atom.ts
Normal file
3
surfsense_web/atoms/chat/pending-user-images.atom.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
export const pendingUserImageDataUrlsAtom = atom<string[]>([]);
|
||||
40
surfsense_web/atoms/citation/citation-panel.atom.ts
Normal file
40
surfsense_web/atoms/citation/citation-panel.atom.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { atom } from "jotai";
|
||||
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
|
||||
|
||||
interface CitationPanelState {
|
||||
isOpen: boolean;
|
||||
chunkId: number | null;
|
||||
}
|
||||
|
||||
const initialState: CitationPanelState = {
|
||||
isOpen: false,
|
||||
chunkId: null,
|
||||
};
|
||||
|
||||
export const citationPanelAtom = atom<CitationPanelState>(initialState);
|
||||
|
||||
export const citationPanelOpenAtom = atom((get) => get(citationPanelAtom).isOpen);
|
||||
|
||||
const preCitationCollapsedAtom = atom<boolean | null>(null);
|
||||
|
||||
export const openCitationPanelAtom = atom(null, (get, set, payload: { chunkId: number }) => {
|
||||
if (!get(citationPanelAtom).isOpen) {
|
||||
set(preCitationCollapsedAtom, get(rightPanelCollapsedAtom));
|
||||
}
|
||||
set(citationPanelAtom, {
|
||||
isOpen: true,
|
||||
chunkId: payload.chunkId,
|
||||
});
|
||||
set(rightPanelTabAtom, "citation");
|
||||
set(rightPanelCollapsedAtom, false);
|
||||
});
|
||||
|
||||
export const closeCitationPanelAtom = atom(null, (get, set) => {
|
||||
set(citationPanelAtom, initialState);
|
||||
set(rightPanelTabAtom, "sources");
|
||||
const prev = get(preCitationCollapsedAtom);
|
||||
if (prev !== null) {
|
||||
set(rightPanelCollapsedAtom, prev);
|
||||
set(preCitationCollapsedAtom, null);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
export type RightPanelTab = "sources" | "report" | "editor" | "hitl-edit";
|
||||
export type RightPanelTab = "sources" | "report" | "editor" | "hitl-edit" | "citation";
|
||||
|
||||
export const rightPanelTabAtom = atom<RightPanelTab>("sources");
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Activity } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { openActionLogSheetAtom } from "@/atoms/agent/action-log-sheet.atom";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface ActionLogButtonProps {
|
||||
threadId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header button that opens the agent action log sheet for the current
|
||||
* thread. Renders nothing when:
|
||||
* - the action log feature flag is off (graceful no-op for older
|
||||
* deployments), OR
|
||||
* - there is no active thread (lazy-created chats haven't started).
|
||||
*/
|
||||
export function ActionLogButton({ threadId }: ActionLogButtonProps) {
|
||||
const { data: flags } = useAtomValue(agentFlagsAtom);
|
||||
const open = useSetAtom(openActionLogSheetAtom);
|
||||
|
||||
const enabled = !!flags?.enable_action_log && !flags?.disable_new_agent_stack;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (threadId !== null) open(threadId);
|
||||
}, [open, threadId]);
|
||||
|
||||
if (!enabled || threadId === null) return null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-8 p-0"
|
||||
aria-label="Open agent action log"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Activity className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Agent actions</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
215
surfsense_web/components/agent-action-log/action-log-item.tsx
Normal file
215
surfsense_web/components/agent-action-log/action-log-item.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight, RotateCcw, ShieldOff, Undo2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getToolIcon } from "@/contracts/enums/toolIcons";
|
||||
import { type AgentAction, agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatToolName(name: string): string {
|
||||
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
interface ActionLogItemProps {
|
||||
action: AgentAction;
|
||||
threadId: number;
|
||||
onRevertSuccess: () => void;
|
||||
}
|
||||
|
||||
export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isReverting, setIsReverting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const isAlreadyReverted = action.reverted_by_action_id !== null;
|
||||
const isRevertAction = action.is_revert_action;
|
||||
const hasError = action.error !== null && action.error !== undefined;
|
||||
|
||||
const Icon = getToolIcon(action.tool_name);
|
||||
const displayName = formatToolName(action.tool_name);
|
||||
|
||||
const argsPreview = action.args ? JSON.stringify(action.args, null, 2) : null;
|
||||
const truncatedArgs =
|
||||
argsPreview && argsPreview.length > 600 ? `${argsPreview.slice(0, 600)}…` : argsPreview;
|
||||
|
||||
const canRevert = action.reversible && !isAlreadyReverted && !isRevertAction && !hasError;
|
||||
|
||||
const handleRevert = async () => {
|
||||
setIsReverting(true);
|
||||
try {
|
||||
const response = await agentActionsApiService.revert(threadId, action.id);
|
||||
toast.success(response.message || "Action reverted successfully.");
|
||||
onRevertSuccess();
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof AppError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Failed to revert action.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsReverting(false);
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-card transition-colors",
|
||||
isAlreadyReverted && "opacity-70"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded((v) => !v)}
|
||||
className="flex w-full items-start gap-3 p-3 text-left hover:bg-muted/40"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
{isRevertAction ? (
|
||||
<Undo2 className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium">{displayName}</span>
|
||||
{isRevertAction && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Revert
|
||||
</Badge>
|
||||
)}
|
||||
{hasError && (
|
||||
<Badge variant="destructive" className="text-[10px]">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
{!isRevertAction && action.reversible && !isAlreadyReverted && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Reversible
|
||||
</Badge>
|
||||
)}
|
||||
{isAlreadyReverted && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Reverted
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{formatRelativeDate(action.created_at)}</p>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="flex flex-col gap-3 border-t bg-muted/20 p-3">
|
||||
{truncatedArgs && (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Arguments
|
||||
</p>
|
||||
<pre className="max-h-48 overflow-auto rounded-md bg-background p-2 text-[11px] text-foreground/80">
|
||||
{truncatedArgs}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{action.error && (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Error
|
||||
</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-md bg-destructive/10 p-2 text-[11px] text-destructive">
|
||||
{JSON.stringify(action.error, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{action.reverse_descriptor && (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Reverse plan
|
||||
</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-md bg-background p-2 text-[11px] text-foreground/80">
|
||||
{JSON.stringify(action.reverse_descriptor, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Action ID: <span className="font-mono">{action.id}</span>
|
||||
</p>
|
||||
{canRevert ? (
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="gap-1.5">
|
||||
<RotateCcw className="size-3.5" />
|
||||
Revert
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revert this action?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will undo <span className="font-medium">{displayName}</span> and append a
|
||||
new audit entry. The agent's chat history is preserved — only the tool's
|
||||
effects on your knowledge base or connectors will be reversed where possible.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isReverting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRevert();
|
||||
}}
|
||||
disabled={isReverting}
|
||||
>
|
||||
{isReverting ? "Reverting…" : "Revert"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<ShieldOff className="size-3.5" />
|
||||
{isAlreadyReverted
|
||||
? "Already reverted"
|
||||
: isRevertAction
|
||||
? "Revert entry"
|
||||
: hasError
|
||||
? "Cannot revert errored action"
|
||||
: "Not reversible"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
surfsense_web/components/agent-action-log/action-log-sheet.tsx
Normal file
185
surfsense_web/components/agent-action-log/action-log-sheet.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { Activity, RefreshCcw } from "lucide-react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { actionLogSheetAtom } from "@/atoms/agent/action-log-sheet.atom";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
|
||||
import { ActionLogItem } from "./action-log-item";
|
||||
|
||||
const ACTION_LOG_PAGE_SIZE = 50;
|
||||
|
||||
function actionLogQueryKey(threadId: number) {
|
||||
return ["agent-actions", threadId] as const;
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Activity className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">No actions logged yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Once the agent calls a tool in this thread, it will show up here. From the log you can
|
||||
inspect arguments and revert reversible actions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisabledState() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Activity className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">Action log is disabled</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This deployment hasn't enabled the agent action log. An admin can flip
|
||||
<code className="ml-1 rounded bg-muted px-1 text-[10px]">
|
||||
SURFSENSE_ENABLE_ACTION_LOG
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SKELETON_KEYS = ["s1", "s2", "s3", "s4"] as const;
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
{SKELETON_KEYS.map((key) => (
|
||||
<Skeleton key={key} className="h-16 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionLogSheet() {
|
||||
const [state, setState] = useAtom(actionLogSheetAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: flags } = useAtomValue(agentFlagsAtom);
|
||||
const actionLogEnabled = !!flags?.enable_action_log && !flags?.disable_new_agent_stack;
|
||||
const revertEnabled = !!flags?.enable_revert_route && !flags?.disable_new_agent_stack;
|
||||
|
||||
const threadId = state.threadId;
|
||||
|
||||
const { data, isLoading, isFetching, isError, error, refetch } = useQuery({
|
||||
queryKey: threadId !== null ? actionLogQueryKey(threadId) : ["agent-actions", "none"],
|
||||
queryFn: () =>
|
||||
agentActionsApiService.listForThread(threadId as number, {
|
||||
page: 0,
|
||||
pageSize: ACTION_LOG_PAGE_SIZE,
|
||||
}),
|
||||
enabled: state.open && threadId !== null && actionLogEnabled,
|
||||
staleTime: 15 * 1000,
|
||||
});
|
||||
|
||||
const handleRevertSuccess = useCallback(() => {
|
||||
if (threadId !== null) {
|
||||
queryClient.invalidateQueries({ queryKey: actionLogQueryKey(threadId) });
|
||||
}
|
||||
}, [queryClient, threadId]);
|
||||
|
||||
const items = useMemo(() => data?.items ?? [], [data]);
|
||||
|
||||
return (
|
||||
<Sheet open={state.open} onOpenChange={(open) => setState((s) => ({ ...s, open }))}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex h-full w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-md"
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="size-4 text-muted-foreground" />
|
||||
<SheetTitle className="text-base font-semibold">Agent actions</SheetTitle>
|
||||
{data?.total !== undefined && data.total > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{data.total}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching || !actionLogEnabled}
|
||||
className="size-8 p-0"
|
||||
aria-label="Refresh action log"
|
||||
>
|
||||
<RefreshCcw className={isFetching ? "size-3.5 animate-spin" : "size-3.5"} />
|
||||
</Button>
|
||||
</div>
|
||||
<SheetDescription className="text-xs text-muted-foreground">
|
||||
Audit trail of every tool call the agent made in this thread.
|
||||
{revertEnabled
|
||||
? " Reversible actions can be undone in place."
|
||||
: " Reverts are read-only on this deployment."}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto scrollbar-thin">
|
||||
{!actionLogEnabled ? (
|
||||
<DisabledState />
|
||||
) : threadId === null ? (
|
||||
<EmptyState />
|
||||
) : isLoading ? (
|
||||
<LoadingState />
|
||||
) : isError ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
|
||||
<p className="text-sm font-medium text-destructive">Failed to load actions</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error instanceof Error ? error.message : "Unknown error"}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
{items.map((action) => (
|
||||
<ActionLogItem
|
||||
key={action.id}
|
||||
action={action}
|
||||
threadId={threadId}
|
||||
onRevertSuccess={handleRevertSuccess}
|
||||
/>
|
||||
))}
|
||||
{data?.has_more && (
|
||||
<p className="py-2 text-center text-[11px] text-muted-foreground">
|
||||
Showing {items.length} of {data.total}. Older actions are paginated.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -123,9 +123,9 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
handleSkipIndexing,
|
||||
handleStartEdit,
|
||||
handleSaveConnector,
|
||||
handleDisconnectConnector,
|
||||
handleDisconnectFromList,
|
||||
handleBackFromEdit,
|
||||
handleDisconnectConnector,
|
||||
handleDisconnectFromList,
|
||||
handleBackFromEdit,
|
||||
handleBackFromConnect,
|
||||
handleBackFromYouTube,
|
||||
handleViewAccountsList,
|
||||
|
|
@ -226,27 +226,31 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
{isYouTubeView && searchSpaceId ? (
|
||||
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
|
||||
) : viewingMCPList ? (
|
||||
<ConnectorAccountsListView
|
||||
connectorType="MCP_CONNECTOR"
|
||||
connectorTitle="MCP Connectors"
|
||||
connectors={(allConnectors || []) as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromMCPList}
|
||||
onManage={handleStartEdit}
|
||||
onDisconnect={(connector) => handleDisconnectFromList(connector, () => refreshConnectors())}
|
||||
onAddAccount={handleAddNewMCPFromList}
|
||||
addButtonText="Add New MCP Server"
|
||||
/>
|
||||
<ConnectorAccountsListView
|
||||
connectorType="MCP_CONNECTOR"
|
||||
connectorTitle="MCP Connectors"
|
||||
connectors={(allConnectors || []) as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromMCPList}
|
||||
onManage={handleStartEdit}
|
||||
onDisconnect={(connector) =>
|
||||
handleDisconnectFromList(connector, () => refreshConnectors())
|
||||
}
|
||||
onAddAccount={handleAddNewMCPFromList}
|
||||
addButtonText="Add New MCP Server"
|
||||
/>
|
||||
) : viewingAccountsType ? (
|
||||
<ConnectorAccountsListView
|
||||
connectorType={viewingAccountsType.connectorType}
|
||||
connectorTitle={viewingAccountsType.connectorTitle}
|
||||
connectors={(connectors || []) as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromAccountsList}
|
||||
onManage={handleStartEdit}
|
||||
onDisconnect={(connector) => handleDisconnectFromList(connector, () => refreshConnectors())}
|
||||
onAddAccount={() => {
|
||||
<ConnectorAccountsListView
|
||||
connectorType={viewingAccountsType.connectorType}
|
||||
connectorTitle={viewingAccountsType.connectorTitle}
|
||||
connectors={(connectors || []) as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromAccountsList}
|
||||
onManage={handleStartEdit}
|
||||
onDisconnect={(connector) =>
|
||||
handleDisconnectFromList(connector, () => refreshConnectors())
|
||||
}
|
||||
onAddAccount={() => {
|
||||
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
|
||||
const oauthConnector =
|
||||
OAUTH_CONNECTORS.find(
|
||||
|
|
|
|||
|
|
@ -213,13 +213,13 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
className="w-full h-8 text-[13px] px-3 rounded-lg font-medium 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"
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Testing Connection...
|
||||
</>
|
||||
) : (
|
||||
"Test Connection"
|
||||
)}
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Testing Connection...
|
||||
</>
|
||||
) : (
|
||||
"Test Connection"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -218,13 +218,13 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
|
|||
className="w-full h-8 text-[13px] px-3 rounded-lg font-medium 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"
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Testing Connection...
|
||||
</>
|
||||
) : (
|
||||
"Test Connection"
|
||||
)}
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Testing Connection...
|
||||
</>
|
||||
) : (
|
||||
"Test Connection"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ export const TeamsConfig: FC<TeamsConfigProps> = () => {
|
|||
<div className="text-xs sm:text-sm">
|
||||
<p className="font-medium text-xs sm:text-sm">Microsoft Teams Access</p>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
|
||||
Your agent can search and read messages from Teams channels you have access to,
|
||||
and send messages on your behalf. Make sure you're a member of the teams
|
||||
you want to interact with.
|
||||
Your agent can search and read messages from Teams channels you have access to, and send
|
||||
messages on your behalf. Make sure you're a member of the teams you want to interact
|
||||
with.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { DateRangeSelector } from "../../components/date-range-selector";
|
|||
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
||||
import { SummaryConfig } from "../../components/summary-config";
|
||||
import { VisionLLMConfig } from "../../components/vision-llm-config";
|
||||
import { LIVE_CONNECTOR_TYPES, getReauthEndpoint } from "../../constants/connector-constants";
|
||||
import { getReauthEndpoint, LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants";
|
||||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||
import { MCPServiceConfig } from "../components/mcp-service-config";
|
||||
import { getConnectorConfigComponent } from "../index";
|
||||
|
|
@ -314,8 +314,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
|
||||
{connector.is_indexable &&
|
||||
(() => {
|
||||
const isGoogleDrive =
|
||||
connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
|
||||
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
|
||||
const isComposioGoogleDrive =
|
||||
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
|
||||
const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive;
|
||||
|
|
@ -327,8 +326,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
(connector.config?.selected_files as
|
||||
| Array<{ id: string; name: string }>
|
||||
| undefined) || [];
|
||||
const hasItemsSelected =
|
||||
selectedFolders.length > 0 || selectedFiles.length > 0;
|
||||
const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0;
|
||||
const isDisabled = requiresFolderSelection && !hasItemsSelected;
|
||||
|
||||
return (
|
||||
|
|
@ -380,8 +378,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
|
||||
{/* Fixed Footer - Action buttons */}
|
||||
<div className="flex-shrink-0 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 sm:gap-0 px-6 sm:px-12 py-6 sm:py-6 bg-muted border-t border-border">
|
||||
{showDisconnectConfirm ? (
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 sm:flex-initial">
|
||||
{showDisconnectConfirm ? (
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 sm:flex-initial">
|
||||
<span className="text-xs sm:text-sm text-muted-foreground sm:whitespace-nowrap">
|
||||
{isLive
|
||||
? "Your agent will lose access to this service."
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import { DateRangeSelector } from "../../components/date-range-selector";
|
|||
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
||||
import { SummaryConfig } from "../../components/summary-config";
|
||||
import { VisionLLMConfig } from "../../components/vision-llm-config";
|
||||
import { LIVE_CONNECTOR_TYPES, type IndexingConfigState } from "../../constants/connector-constants";
|
||||
import {
|
||||
type IndexingConfigState,
|
||||
LIVE_CONNECTOR_TYPES,
|
||||
} from "../../constants/connector-constants";
|
||||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||
import { getConnectorConfigComponent } from "../index";
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { COMPOSIO_CONNECTORS, LIVE_CONNECTOR_TYPES, OAUTH_CONNECTORS } from "../constants/connector-constants";
|
||||
import {
|
||||
COMPOSIO_CONNECTORS,
|
||||
LIVE_CONNECTOR_TYPES,
|
||||
OAUTH_CONNECTORS,
|
||||
} from "../constants/connector-constants";
|
||||
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
||||
import { getConnectorDisplayName } from "./all-connectors-tab";
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
|||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LIVE_CONNECTOR_TYPES, getReauthEndpoint } from "../constants/connector-constants";
|
||||
import { getReauthEndpoint, LIVE_CONNECTOR_TYPES } from "../constants/connector-constants";
|
||||
import { useConnectorStatus } from "../hooks/use-connector-status";
|
||||
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
|
||||
|
||||
|
|
@ -182,11 +182,14 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{typeConnectors.map((connector) => {
|
||||
const isIndexing = indexingConnectorIds.has(connector.id);
|
||||
const connectorReauthEndpoint = getReauthEndpoint(connector);
|
||||
const isAuthExpired = !!connectorReauthEndpoint && connector.config?.auth_expired === true;
|
||||
const isLive = LIVE_CONNECTOR_TYPES.has(connector.connector_type) || Boolean(connector.config?.server_config);
|
||||
{typeConnectors.map((connector) => {
|
||||
const isIndexing = indexingConnectorIds.has(connector.id);
|
||||
const connectorReauthEndpoint = getReauthEndpoint(connector);
|
||||
const isAuthExpired =
|
||||
!!connectorReauthEndpoint && connector.config?.auth_expired === true;
|
||||
const isLive =
|
||||
LIVE_CONNECTOR_TYPES.has(connector.connector_type) ||
|
||||
Boolean(connector.config?.server_config);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -225,73 +228,73 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{isAuthExpired ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-amber-600 hover:bg-amber-700 text-white border-0 shadow-xs shrink-0"
|
||||
onClick={() => handleReauth(connector)}
|
||||
disabled={reauthingId === connector.id}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("size-3.5", reauthingId === connector.id && "animate-spin")}
|
||||
/>
|
||||
Re-authenticate
|
||||
</Button>
|
||||
) : isLive && onDisconnect ? (
|
||||
confirmDisconnectId === connector.id ? (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{isAuthExpired ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-amber-600 hover:bg-amber-700 text-white border-0 shadow-xs shrink-0"
|
||||
onClick={() => handleReauth(connector)}
|
||||
disabled={reauthingId === connector.id}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("size-3.5", reauthingId === connector.id && "animate-spin")}
|
||||
/>
|
||||
Re-authenticate
|
||||
</Button>
|
||||
) : isLive && onDisconnect ? (
|
||||
confirmDisconnectId === connector.id ? (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium shadow-xs"
|
||||
onClick={async () => {
|
||||
setDisconnectingId(connector.id);
|
||||
setConfirmDisconnectId(null);
|
||||
try {
|
||||
await onDisconnect(connector);
|
||||
} finally {
|
||||
setDisconnectingId(null);
|
||||
}
|
||||
}}
|
||||
disabled={disconnectingId === connector.id}
|
||||
>
|
||||
{disconnectingId === connector.id ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
"Confirm"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-2 rounded-lg"
|
||||
onClick={() => setConfirmDisconnectId(null)}
|
||||
disabled={disconnectingId === connector.id}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium shadow-xs"
|
||||
onClick={async () => {
|
||||
setDisconnectingId(connector.id);
|
||||
setConfirmDisconnectId(null);
|
||||
try {
|
||||
await onDisconnect(connector);
|
||||
} finally {
|
||||
setDisconnectingId(null);
|
||||
}
|
||||
}}
|
||||
disabled={disconnectingId === connector.id}
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-red-50 hover:text-red-700 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-red-950 dark:hover:text-red-400 shrink-0"
|
||||
onClick={() => setConfirmDisconnectId(connector.id)}
|
||||
>
|
||||
{disconnectingId === connector.id ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
"Confirm"
|
||||
)}
|
||||
<Trash2 className="size-3.5" />
|
||||
Disconnect
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-2 rounded-lg"
|
||||
onClick={() => setConfirmDisconnectId(null)}
|
||||
disabled={disconnectingId === connector.id}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-red-50 hover:text-red-700 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-red-950 dark:hover:text-red-400 shrink-0"
|
||||
onClick={() => setConfirmDisconnectId(connector.id)}
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium 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 shrink-0"
|
||||
onClick={() => onManage(connector)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Disconnect
|
||||
Manage
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium 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 shrink-0"
|
||||
onClick={() => onManage(connector)}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { FileText } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { ExternalLink, FileText } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
|
||||
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
|
||||
import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Citation } from "@/components/tool-ui/citation";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
interface InlineCitationProps {
|
||||
chunkId: number;
|
||||
isDocsChunk?: boolean;
|
||||
}
|
||||
|
||||
const POPOVER_HOVER_CLOSE_DELAY_MS = 150;
|
||||
|
||||
/**
|
||||
* Inline citation for knowledge-base chunks (numeric chunk IDs).
|
||||
* Renders a clickable badge showing the actual chunk ID that opens the SourceDetailPanel.
|
||||
* Negative chunk IDs indicate anonymous/synthetic uploads and render as a static badge.
|
||||
* Inline citation badge for knowledge-base chunks (numeric chunk IDs) and
|
||||
* Surfsense documentation chunks (`isDocsChunk`). Negative chunk IDs render as
|
||||
* a static "doc" pill (anonymous/synthetic uploads).
|
||||
*
|
||||
* Numeric KB chunks: clicking opens the citation panel in the right
|
||||
* sidebar (alongside the chat — does not replace it). The panel shows
|
||||
* the cited chunk surrounded by adjacent chunks (via the API's
|
||||
* `chunk_window`), with the cited one highlighted and an option to
|
||||
* expand the window or jump into the full document via the editor panel.
|
||||
*
|
||||
* Surfsense docs chunks: rendered as a hover-controlled shadcn Popover that
|
||||
* lazily fetches and previews the cited chunk inline, since those docs aren't
|
||||
* indexed into the user's search space and have no tab to open.
|
||||
*/
|
||||
export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk = false }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
if (chunkId < 0) {
|
||||
return (
|
||||
<Tooltip>
|
||||
|
|
@ -38,26 +55,131 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
|
|||
);
|
||||
}
|
||||
|
||||
if (isDocsChunk) {
|
||||
return <SurfsenseDocCitation chunkId={chunkId} />;
|
||||
}
|
||||
|
||||
return <NumericChunkCitation chunkId={chunkId} />;
|
||||
};
|
||||
|
||||
const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
|
||||
const openCitationPanel = useSetAtom(openCitationPanelAtom);
|
||||
|
||||
return (
|
||||
<SourceDetailPanel
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
chunkId={chunkId}
|
||||
sourceType={isDocsChunk ? "SURFSENSE_DOCS" : ""}
|
||||
title={isDocsChunk ? "Surfsense Documentation" : "Source"}
|
||||
description=""
|
||||
url=""
|
||||
isDocsChunk={isDocsChunk}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCitationPanel({ chunkId })}
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||
title={`View source chunk #${chunkId}`}
|
||||
aria-label={`View cited chunk ${chunkId}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||
title={`View source chunk #${chunkId}`}
|
||||
{chunkId}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = useCallback(() => {
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
cancelClose();
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
setOpen(false);
|
||||
closeTimerRef.current = null;
|
||||
}, POPOVER_HOVER_CLOSE_DELAY_MS);
|
||||
}, [cancelClose]);
|
||||
|
||||
useEffect(() => () => cancelClose(), [cancelClose]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`),
|
||||
queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId),
|
||||
enabled: open,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const citedChunk = data?.chunks.find((c) => c.id === chunkId) ?? data?.chunks[0];
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={scheduleClose}
|
||||
onFocus={() => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
}}
|
||||
onBlur={scheduleClose}
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center gap-0.5 rounded-md bg-primary/10 px-1.5 text-[11px] font-medium text-primary align-baseline shadow-sm transition-colors hover:bg-primary/15 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||
aria-label={`Show Surfsense documentation chunk ${chunkId}`}
|
||||
title="Surfsense documentation"
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
doc
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-96 max-w-[calc(100vw-2rem)] p-0"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
onMouseEnter={cancelClose}
|
||||
onMouseLeave={scheduleClose}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{chunkId}
|
||||
</button>
|
||||
</SourceDetailPanel>
|
||||
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{data?.title ?? "Surfsense documentation"}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">Chunk #{chunkId}</p>
|
||||
</div>
|
||||
{data?.source && (
|
||||
<a
|
||||
href={data.source}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-primary hover:bg-primary/10"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
Open
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-72 overflow-auto px-3 py-2 text-sm">
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 py-4 text-muted-foreground">
|
||||
<Spinner size="xs" />
|
||||
<span className="text-xs">Loading…</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="py-4 text-xs text-destructive">
|
||||
{error instanceof Error ? error.message : "Failed to load chunk"}
|
||||
</p>
|
||||
)}
|
||||
{!isLoading && !error && citedChunk?.content && (
|
||||
<MarkdownViewer content={citedChunk.content} maxLength={1500} />
|
||||
)}
|
||||
{!isLoading && !error && !citedChunk?.content && (
|
||||
<p className="py-4 text-xs text-muted-foreground">No content available.</p>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function renderElementToHTML(element: ReactElement): string {
|
||||
|
|
@ -57,7 +58,6 @@ interface InlineMentionEditorProps {
|
|||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
initialDocuments?: MentionedDocument[];
|
||||
initialText?: string;
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +109,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
onKeyDown,
|
||||
disabled = false,
|
||||
className,
|
||||
initialDocuments = [],
|
||||
initialText,
|
||||
},
|
||||
ref
|
||||
|
|
@ -117,17 +116,24 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [mentionedDocs, setMentionedDocs] = useState<Map<string, MentionedDocument>>(
|
||||
() => new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))
|
||||
() => new Map()
|
||||
);
|
||||
const isComposingRef = useRef(false);
|
||||
const lastSelectionRangeRef = useRef<Range | null>(null);
|
||||
const isRangeInsideEditor = useCallback((range: Range | null): range is Range => {
|
||||
if (!range || !editorRef.current) return false;
|
||||
return (
|
||||
editorRef.current.contains(range.startContainer) &&
|
||||
editorRef.current.contains(range.endContainer)
|
||||
);
|
||||
}, []);
|
||||
const isSelectionInsideEditor = useCallback(
|
||||
(selection: Selection | null): selection is Selection => {
|
||||
if (!selection || selection.rangeCount === 0 || !editorRef.current) return false;
|
||||
const range = selection.getRangeAt(0);
|
||||
return editorRef.current.contains(range.startContainer);
|
||||
return isRangeInsideEditor(range);
|
||||
},
|
||||
[]
|
||||
[isRangeInsideEditor]
|
||||
);
|
||||
|
||||
const rememberSelection = useCallback(() => {
|
||||
|
|
@ -139,11 +145,11 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
const restoreRememberedSelection = useCallback((): Selection | null => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return null;
|
||||
if (!lastSelectionRangeRef.current) return selection;
|
||||
if (!isRangeInsideEditor(lastSelectionRangeRef.current)) return null;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(lastSelectionRangeRef.current.cloneRange());
|
||||
return selection;
|
||||
}, []);
|
||||
}, [isRangeInsideEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSelectionChange = () => {
|
||||
|
|
@ -154,23 +160,13 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
return () => document.removeEventListener("selectionchange", handleSelectionChange);
|
||||
}, [rememberSelection]);
|
||||
|
||||
|
||||
// Sync initial documents
|
||||
useEffect(() => {
|
||||
if (initialDocuments.length > 0) {
|
||||
setMentionedDocs(
|
||||
new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))
|
||||
);
|
||||
}
|
||||
}, [initialDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialText || !editorRef.current) return;
|
||||
editorRef.current.innerText = initialText;
|
||||
editorRef.current.appendChild(document.createElement("br"));
|
||||
editorRef.current.appendChild(document.createElement("br"));
|
||||
setIsEmpty(false);
|
||||
onChange?.(initialText, initialDocuments);
|
||||
onChange?.(initialText, []);
|
||||
editorRef.current.focus();
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
|
|
@ -182,7 +178,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
range.insertNode(anchor);
|
||||
anchor.scrollIntoView({ block: "end" });
|
||||
anchor.remove();
|
||||
}, [initialText, initialDocuments, onChange]);
|
||||
}, [initialText, onChange]);
|
||||
|
||||
// Focus at the end of the editor
|
||||
const focusAtEnd = useCallback(() => {
|
||||
|
|
@ -284,7 +280,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
chip.remove();
|
||||
const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
|
||||
const docKey = getMentionDocKey(doc);
|
||||
setMentionedDocs((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(docKey);
|
||||
|
|
@ -358,7 +354,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
};
|
||||
|
||||
// Add to mentioned docs map using unique key
|
||||
const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
|
||||
const docKey = getMentionDocKey(doc);
|
||||
setMentionedDocs((prev) => new Map(prev).set(docKey, mentionDoc));
|
||||
const nextDocs = new Map(mentionedDocs);
|
||||
nextDocs.set(docKey, mentionDoc);
|
||||
|
|
@ -367,12 +363,33 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
const selection = window.getSelection();
|
||||
const hasActiveSelection = isSelectionInsideEditor(selection);
|
||||
const resolvedSelection = hasActiveSelection ? selection : restoreRememberedSelection();
|
||||
if (!resolvedSelection || resolvedSelection.rangeCount === 0) {
|
||||
// No selection, just append
|
||||
if (
|
||||
!resolvedSelection ||
|
||||
resolvedSelection.rangeCount === 0 ||
|
||||
!isSelectionInsideEditor(resolvedSelection)
|
||||
) {
|
||||
// No valid in-editor selection: deterministically insert at end.
|
||||
editorRef.current.focus();
|
||||
const endSelection = window.getSelection();
|
||||
if (!endSelection) return;
|
||||
const endRange = document.createRange();
|
||||
endRange.selectNodeContents(editorRef.current);
|
||||
endRange.collapse(false);
|
||||
endSelection.removeAllRanges();
|
||||
endSelection.addRange(endRange);
|
||||
|
||||
const chip = createChipElement(mentionDoc);
|
||||
editorRef.current.appendChild(chip);
|
||||
editorRef.current.appendChild(document.createTextNode(" "));
|
||||
focusAtEnd();
|
||||
endRange.insertNode(chip);
|
||||
endRange.setStartAfter(chip);
|
||||
endRange.collapse(true);
|
||||
const space = document.createTextNode(" ");
|
||||
endRange.insertNode(space);
|
||||
endRange.setStartAfter(space);
|
||||
endRange.collapse(true);
|
||||
endSelection.removeAllRanges();
|
||||
endSelection.addRange(endRange);
|
||||
|
||||
syncEditorState(nextDocs);
|
||||
rememberSelection();
|
||||
return;
|
||||
}
|
||||
|
|
@ -456,7 +473,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
},
|
||||
[
|
||||
createChipElement,
|
||||
focusAtEnd,
|
||||
isSelectionInsideEditor,
|
||||
mentionedDocs,
|
||||
rememberSelection,
|
||||
|
|
@ -531,7 +547,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
const removeDocumentChip = useCallback(
|
||||
(docId: number, docType?: string) => {
|
||||
if (!editorRef.current) return;
|
||||
const chipKey = `${docType ?? "UNKNOWN"}:${docId}`;
|
||||
const chipKey = getMentionDocKey({ id: docId, document_type: docType });
|
||||
const chips = editorRef.current.querySelectorAll<HTMLSpanElement>(
|
||||
`span[${CHIP_DATA_ATTR}="true"]`
|
||||
);
|
||||
|
|
@ -696,7 +712,10 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
const chipDocType = getChipDocType(prevSibling);
|
||||
if (chipId !== null) {
|
||||
prevSibling.remove();
|
||||
const chipKey = `${chipDocType}:${chipId}`;
|
||||
const chipKey = getMentionDocKey({
|
||||
id: chipId,
|
||||
document_type: chipDocType,
|
||||
});
|
||||
setMentionedDocs((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(chipKey);
|
||||
|
|
@ -734,7 +753,10 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
const chipDocType = getChipDocType(prevChild);
|
||||
if (chipId !== null) {
|
||||
prevChild.remove();
|
||||
const chipKey = `${chipDocType}:${chipId}`;
|
||||
const chipKey = getMentionDocKey({
|
||||
id: chipId,
|
||||
document_type: chipDocType,
|
||||
});
|
||||
setMentionedDocs((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(chipKey);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
|||
import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
|
|
@ -30,6 +29,7 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function MarkdownCodeBlockSkeleton() {
|
||||
|
|
@ -85,10 +85,13 @@ function preprocessMarkdown(content: string): string {
|
|||
}
|
||||
);
|
||||
|
||||
// All math forms are normalised to $$...$$ so we can disable single-dollar
|
||||
// inline math in remark-math (otherwise currency like "$3,120.00 and $0.00"
|
||||
// gets parsed as a LaTeX expression).
|
||||
// 1. Block math: \[...\] → $$...$$
|
||||
content = content.replace(/\\\[([\s\S]*?)\\\]/g, (_, inner) => `$$${inner}$$`);
|
||||
// 2. Inline math: \(...\) → $...$
|
||||
content = content.replace(/\\\(([\s\S]*?)\\\)/g, (_, inner) => `$${inner}$`);
|
||||
// 2. Inline math: \(...\) → $$...$$
|
||||
content = content.replace(/\\\(([\s\S]*?)\\\)/g, (_, inner) => `$$${inner}$$`);
|
||||
// 3. Block: \begin{equation}...\end{equation} → $$...$$
|
||||
content = content.replace(
|
||||
/\\begin\{equation\}([\s\S]*?)\\end\{equation\}/g,
|
||||
|
|
@ -99,8 +102,11 @@ function preprocessMarkdown(content: string): string {
|
|||
/\\begin\{displaymath\}([\s\S]*?)\\end\{displaymath\}/g,
|
||||
(_, inner) => `$$${inner}$$`
|
||||
);
|
||||
// 5. Inline: \begin{math}...\end{math} → $...$
|
||||
content = content.replace(/\\begin\{math\}([\s\S]*?)\\end\{math\}/g, (_, inner) => `$${inner}$`);
|
||||
// 5. Inline: \begin{math}...\end{math} → $$...$$
|
||||
content = content.replace(
|
||||
/\\begin\{math\}([\s\S]*?)\\end\{math\}/g,
|
||||
(_, inner) => `$$${inner}$$`
|
||||
);
|
||||
// 6. Strip backtick wrapping around math: `$$...$$` → $$...$$ and `$...$` → $...$
|
||||
content = content.replace(/`(\${1,2})((?:(?!\1).)+)\1`/g, "$1$2$1");
|
||||
|
||||
|
|
@ -180,7 +186,7 @@ const MarkdownTextImpl = () => {
|
|||
return (
|
||||
<MarkdownTextPrimitive
|
||||
smooth={false}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
className="aui-md"
|
||||
components={defaultComponents}
|
||||
|
|
@ -493,10 +499,7 @@ const defaultComponents = memoizeMarkdownComponents({
|
|||
const mounts = (await electronAPI.getAgentFilesystemMounts(
|
||||
resolvedSearchSpaceId
|
||||
)) as AgentFilesystemMount[];
|
||||
resolvedLocalPath = normalizeLocalVirtualPathForEditor(
|
||||
inlineValue,
|
||||
mounts
|
||||
);
|
||||
resolvedLocalPath = normalizeLocalVirtualPathForEditor(inlineValue, mounts);
|
||||
} catch {
|
||||
// Fall back to the raw inline path if mount lookup fails.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
AlertCircle,
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
Camera,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clipboard,
|
||||
|
|
@ -39,6 +40,7 @@ import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
|||
import {
|
||||
mentionedDocumentsAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
|
||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
|
|
@ -87,6 +89,8 @@ import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
|||
import { useCommentsSync } from "@/hooks/use-comments-sync";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture";
|
||||
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -293,6 +297,32 @@ const ConnectToolsBanner: FC<{ isThreadEmpty: boolean }> = ({ isThreadEmpty }) =
|
|||
);
|
||||
};
|
||||
|
||||
const PendingScreenImageStrip: FC = () => {
|
||||
const [urls, setUrls] = useAtom(pendingUserImageDataUrlsAtom);
|
||||
if (urls.length === 0) return null;
|
||||
return (
|
||||
<div className="mx-3 mt-2 flex flex-wrap gap-2">
|
||||
{urls.map((url, index) => (
|
||||
<div
|
||||
key={url}
|
||||
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-md border border-border/50 bg-muted"
|
||||
>
|
||||
{/* biome-ignore lint/performance/noImgElement: data URL thumbnails from capture */}
|
||||
<img src={url} alt="" className="size-full object-cover" draggable={false} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUrls((prev) => prev.filter((_, i) => i !== index))}
|
||||
className="absolute right-0.5 top-0.5 flex size-5 items-center justify-center rounded-full bg-background/90 text-muted-foreground shadow-sm transition-opacity hover:text-foreground sm:opacity-0 sm:group-hover:opacity-100"
|
||||
aria-label="Remove screenshot"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDismiss }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isLong = text.length > 120;
|
||||
|
|
@ -338,6 +368,9 @@ const Composer: FC = () => {
|
|||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
const [actionQuery, setActionQuery] = useState("");
|
||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||
const prevMentionedDocsRef = useRef<
|
||||
Map<string, Pick<Document, "id" | "title" | "document_type">>
|
||||
>(new Map());
|
||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
const promptPickerRef = useRef<PromptPickerRef>(null);
|
||||
const viewportRef = useRef<Element | null>(null);
|
||||
|
|
@ -624,60 +657,64 @@ const Composer: FC = () => {
|
|||
|
||||
const handleDocumentRemove = useCallback(
|
||||
(docId: number, docType?: string) => {
|
||||
setMentionedDocuments((prev) =>
|
||||
prev.filter((doc) => !(doc.id === docId && doc.document_type === docType))
|
||||
);
|
||||
setMentionedDocuments((prev) => {
|
||||
if (!docType) {
|
||||
// Defensive fallback: keep UI in sync even when chip type is unavailable.
|
||||
return prev.filter((doc) => doc.id !== docId);
|
||||
}
|
||||
const removedKey = getMentionDocKey({ id: docId, document_type: docType });
|
||||
return prev.filter((doc) => getMentionDocKey(doc) !== removedKey);
|
||||
});
|
||||
},
|
||||
[setMentionedDocuments]
|
||||
);
|
||||
|
||||
const handleDocumentsMention = useCallback(
|
||||
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
|
||||
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
|
||||
const newDocs = documents.filter(
|
||||
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
|
||||
);
|
||||
const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
|
||||
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
|
||||
|
||||
for (const doc of newDocs) {
|
||||
for (const doc of documents) {
|
||||
const key = getMentionDocKey(doc);
|
||||
if (editorDocKeys.has(key)) continue;
|
||||
editorRef.current?.insertDocumentChip(doc);
|
||||
}
|
||||
|
||||
setMentionedDocuments((prev) => {
|
||||
const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
|
||||
const uniqueNewDocs = documents.filter(
|
||||
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
|
||||
);
|
||||
const existingKeySet = new Set(prev.map((d) => getMentionDocKey(d)));
|
||||
const uniqueNewDocs = documents.filter((doc) => !existingKeySet.has(getMentionDocKey(doc)));
|
||||
return [...prev, ...uniqueNewDocs];
|
||||
});
|
||||
|
||||
setMentionQuery("");
|
||||
},
|
||||
[mentionedDocuments, setMentionedDocuments]
|
||||
[setMentionedDocuments]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
const nextDocsMap = new Map(mentionedDocuments.map((doc) => [getMentionDocKey(doc), doc]));
|
||||
const prevDocsMap = prevMentionedDocsRef.current;
|
||||
|
||||
const toKey = (doc: { id: number; document_type?: string }) =>
|
||||
`${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
|
||||
|
||||
const atomDocs = mentionedDocuments;
|
||||
const editorDocs = editor.getMentionedDocuments();
|
||||
const atomKeys = new Set(atomDocs.map(toKey));
|
||||
const editorKeys = new Set(editorDocs.map(toKey));
|
||||
|
||||
for (const doc of atomDocs) {
|
||||
if (!editorKeys.has(toKey(doc))) {
|
||||
editor.insertDocumentChip(doc, { removeTriggerText: false });
|
||||
}
|
||||
if (!editor) {
|
||||
prevMentionedDocsRef.current = nextDocsMap;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const doc of editorDocs) {
|
||||
if (!atomKeys.has(toKey(doc))) {
|
||||
const editorKeys = new Set(editor.getMentionedDocuments().map(getMentionDocKey));
|
||||
|
||||
for (const [key, doc] of nextDocsMap) {
|
||||
if (prevDocsMap.has(key) || editorKeys.has(key)) continue;
|
||||
editor.insertDocumentChip(doc, { removeTriggerText: false });
|
||||
}
|
||||
|
||||
for (const [key, doc] of prevDocsMap) {
|
||||
if (!nextDocsMap.has(key)) {
|
||||
editor.removeDocumentChip(doc.id, doc.document_type);
|
||||
}
|
||||
}
|
||||
|
||||
prevMentionedDocsRef.current = nextDocsMap;
|
||||
}, [mentionedDocuments]);
|
||||
|
||||
return (
|
||||
|
|
@ -722,6 +759,7 @@ const Composer: FC = () => {
|
|||
</div>
|
||||
)}
|
||||
<div className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow">
|
||||
<PendingScreenImageStrip />
|
||||
{clipboardInitialText && (
|
||||
<ClipboardChip
|
||||
text={clipboardInitialText}
|
||||
|
|
@ -779,11 +817,23 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
},
|
||||
[]
|
||||
);
|
||||
const pendingScreenImages = useAtomValue(pendingUserImageDataUrlsAtom);
|
||||
const setPendingScreenImages = useSetAtom(pendingUserImageDataUrlsAtom);
|
||||
const electronAPI = useElectronAPI();
|
||||
|
||||
const isComposerTextEmpty = useAuiState(({ composer }) => {
|
||||
const text = composer.text?.trim() || "";
|
||||
return text.length === 0;
|
||||
});
|
||||
const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0;
|
||||
const isComposerEmpty =
|
||||
isComposerTextEmpty && mentionedDocuments.length === 0 && pendingScreenImages.length === 0;
|
||||
|
||||
const handleScreenCapture = useCallback(async () => {
|
||||
const url = electronAPI?.captureFullScreen
|
||||
? await electronAPI.captureFullScreen()
|
||||
: await captureDisplayToPngDataUrl();
|
||||
if (url) setPendingScreenImages((prev) => [...prev, url]);
|
||||
}, [electronAPI, setPendingScreenImages]);
|
||||
|
||||
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
|
||||
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
|
||||
|
|
@ -1210,6 +1260,17 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipIconButton
|
||||
tooltip="Capture screen"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-full"
|
||||
aria-label="Capture screen"
|
||||
onClick={() => void handleScreenCapture()}
|
||||
>
|
||||
<Camera className="size-4" />
|
||||
</TooltipIconButton>
|
||||
<AuiIf condition={({ thread }) => !thread.isRunning}>
|
||||
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
||||
<TooltipIconButton
|
||||
|
|
@ -1219,7 +1280,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
: !hasModelConfigured
|
||||
? "Please select a model from the header to start chatting"
|
||||
: isComposerEmpty
|
||||
? "Enter a message to send"
|
||||
? "Enter a message or add a screenshot to send"
|
||||
: "Send message"
|
||||
}
|
||||
side="bottom"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
DoomLoopApprovalToolUI,
|
||||
isDoomLoopInterrupt,
|
||||
} from "@/components/tool-ui/doom-loop-approval";
|
||||
import { GenericHitlApprovalToolUI } from "@/components/tool-ui/generic-hitl-approval";
|
||||
import { getToolIcon } from "@/contracts/enums/toolIcons";
|
||||
import { isInterruptResult } from "@/lib/hitl";
|
||||
|
|
@ -150,6 +154,9 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
|
|||
|
||||
export const ToolFallback: ToolCallMessagePartComponent = (props) => {
|
||||
if (isInterruptResult(props.result)) {
|
||||
if (isDoomLoopInterrupt(props.result)) {
|
||||
return <DoomLoopApprovalToolUI {...props} />;
|
||||
}
|
||||
return <GenericHitlApprovalToolUI {...props} />;
|
||||
}
|
||||
return <DefaultToolFallbackInner {...props} />;
|
||||
|
|
|
|||
230
surfsense_web/components/citation-panel/citation-panel.tsx
Normal file
230
surfsense_web/components/citation-panel/citation-panel.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { ChevronDown, ChevronUp, ExternalLink, XIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
|
||||
const DEFAULT_CHUNK_WINDOW = 5;
|
||||
const EXPANDED_CHUNK_WINDOW = 50;
|
||||
|
||||
interface CitationPanelContentProps {
|
||||
chunkId: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-panel citation viewer. Shows the cited chunk surrounded by
|
||||
* adjacent chunks (±N chunks via the API's `chunk_window` parameter),
|
||||
* with the cited one visually highlighted and auto-scrolled into view.
|
||||
* The window can be expanded to a wider range, or the user can jump to
|
||||
* the full document via the editor panel.
|
||||
*/
|
||||
export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, onClose }) => {
|
||||
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded(false);
|
||||
}, []);
|
||||
|
||||
const chunkWindow = expanded ? EXPANDED_CHUNK_WINDOW : DEFAULT_CHUNK_WINDOW;
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["citation-panel", chunkId, chunkWindow] as const,
|
||||
queryFn: () =>
|
||||
documentsApiService.getDocumentByChunk({
|
||||
chunk_id: chunkId,
|
||||
chunk_window: chunkWindow,
|
||||
}),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const cited = useMemo(() => data?.chunks.find((c) => c.id === chunkId) ?? null, [data, chunkId]);
|
||||
|
||||
const totalChunks = data?.total_chunks ?? data?.chunks.length ?? 0;
|
||||
const startIndex = data?.chunk_start_index ?? 0;
|
||||
const citedIndexInWindow = data
|
||||
? Math.max(
|
||||
0,
|
||||
data.chunks.findIndex((c) => c.id === chunkId)
|
||||
)
|
||||
: 0;
|
||||
const shownAbove = citedIndexInWindow;
|
||||
const shownBelow = data ? Math.max(0, data.chunks.length - 1 - citedIndexInWindow) : 0;
|
||||
const hasMoreAbove = startIndex > 0;
|
||||
const hasMoreBelow = data ? startIndex + data.chunks.length < totalChunks : false;
|
||||
|
||||
// Scroll the cited chunk into view inside the panel's scroll container
|
||||
// (not the page). We anchor the scroll to the panel's scroll element
|
||||
// so opening the citation doesn't yank the chat scroll on the left.
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const citedRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!cited) return;
|
||||
const id = requestAnimationFrame(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
const target = citedRef.current;
|
||||
if (!container || !target) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const offset = targetRect.top - containerRect.top + container.scrollTop;
|
||||
container.scrollTo({
|
||||
top: Math.max(0, offset - 16),
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [cited]);
|
||||
|
||||
const handleOpenFullDocument = () => {
|
||||
if (!data) return;
|
||||
openEditorPanel({
|
||||
documentId: data.id,
|
||||
searchSpaceId: data.search_space_id,
|
||||
title: data.title,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0 border-b">
|
||||
<div className="flex h-14 items-center justify-between px-4">
|
||||
<h2 className="text-lg font-medium text-muted-foreground select-none">Citation</h2>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{onClose && (
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close citation panel</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-10 items-center justify-between gap-2 border-t px-4">
|
||||
<div className="min-w-0 flex flex-1 items-center gap-2">
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{data?.title ?? (isLoading ? "Loading…" : `Chunk #${chunkId}`)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 text-[11px] text-muted-foreground">
|
||||
<span>Chunk #{chunkId}</span>
|
||||
{totalChunks > 0 && <span>· {totalChunks} chunks</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 py-8 text-muted-foreground">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm">Loading citation…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="py-8 text-sm text-destructive">
|
||||
{error instanceof Error ? error.message : "Failed to load citation"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && data && (
|
||||
<>
|
||||
{hasMoreAbove && (
|
||||
<p className="mb-3 text-center text-[11px] text-muted-foreground">
|
||||
… {startIndex} earlier chunk{startIndex === 1 ? "" : "s"} not shown
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{data.chunks.map((chunk) => {
|
||||
const isCited = chunk.id === chunkId;
|
||||
return (
|
||||
<div
|
||||
key={chunk.id}
|
||||
ref={isCited ? citedRef : null}
|
||||
data-cited={isCited || undefined}
|
||||
className={
|
||||
isCited
|
||||
? "rounded-md border-2 border-primary bg-primary/5 px-4 py-3 shadow-sm"
|
||||
: "rounded-md border border-border/40 bg-muted/20 px-4 py-3 opacity-70 transition-opacity hover:opacity-100"
|
||||
}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span
|
||||
className={
|
||||
isCited
|
||||
? "text-[11px] font-semibold text-primary"
|
||||
: "text-[11px] font-medium text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{isCited ? "Cited chunk" : `Chunk #${chunk.id}`}
|
||||
</span>
|
||||
{isCited && (
|
||||
<span className="text-[11px] text-muted-foreground">#{chunk.id}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<MarkdownViewer content={chunk.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasMoreBelow && (
|
||||
<p className="mt-3 text-center text-[11px] text-muted-foreground">
|
||||
… {totalChunks - (startIndex + data.chunks.length)} later chunk
|
||||
{totalChunks - (startIndex + data.chunks.length) === 1 ? "" : "s"} not shown
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLoading && !error && data && (
|
||||
<div className="shrink-0 flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3">
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Showing {shownAbove} above · cited · {shownBelow} below
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(hasMoreAbove || hasMoreBelow) && !expanded && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<ChevronDown className="mr-1 size-3.5" />
|
||||
More context
|
||||
</Button>
|
||||
)}
|
||||
{expanded && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
<ChevronUp className="mr-1 size-3.5" />
|
||||
Less
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={handleOpenFullDocument}
|
||||
>
|
||||
<ExternalLink className="mr-1 size-3.5" />
|
||||
Open full document
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -38,7 +38,7 @@ export function acceleratorToDisplay(accel: string): string[] {
|
|||
export const DEFAULT_SHORTCUTS = {
|
||||
generalAssist: "CommandOrControl+Shift+S",
|
||||
quickAsk: "CommandOrControl+Alt+S",
|
||||
autocomplete: "CommandOrControl+Shift+Space",
|
||||
screenshotAssist: "CommandOrControl+Shift+Space",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { DndProvider } from "react-dnd";
|
|||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { renamingFolderIdAtom } from "@/atoms/documents/folder.atoms";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { DocumentNode, type DocumentNodeDoc } from "./DocumentNode";
|
||||
import { type FolderDisplay, FolderNode } from "./FolderNode";
|
||||
|
||||
|
|
@ -17,7 +18,7 @@ interface FolderTreeViewProps {
|
|||
documents: DocumentNodeDoc[];
|
||||
expandedIds: Set<number>;
|
||||
onToggleExpand: (folderId: number) => void;
|
||||
mentionedDocIds: Set<number>;
|
||||
mentionedDocKeys: Set<string>;
|
||||
onToggleChatMention: (
|
||||
doc: { id: number; title: string; document_type: string },
|
||||
isMentioned: boolean
|
||||
|
|
@ -62,7 +63,7 @@ export function FolderTreeView({
|
|||
documents,
|
||||
expandedIds,
|
||||
onToggleExpand,
|
||||
mentionedDocIds,
|
||||
mentionedDocKeys,
|
||||
onToggleChatMention,
|
||||
onToggleFolderSelect,
|
||||
onRenameFolder,
|
||||
|
|
@ -181,7 +182,7 @@ export function FolderTreeView({
|
|||
|
||||
function compute(folderId: number): { selected: number; total: number } {
|
||||
const directDocs = (docsByFolder[folderId] ?? []).filter(isSelectable);
|
||||
let selected = directDocs.filter((d) => mentionedDocIds.has(d.id)).length;
|
||||
let selected = directDocs.filter((d) => mentionedDocKeys.has(getMentionDocKey(d))).length;
|
||||
let total = directDocs.length;
|
||||
|
||||
for (const child of foldersByParent[folderId] ?? []) {
|
||||
|
|
@ -202,7 +203,7 @@ export function FolderTreeView({
|
|||
if (states[f.id] === undefined) compute(f.id);
|
||||
}
|
||||
return states;
|
||||
}, [folders, docsByFolder, foldersByParent, mentionedDocIds]);
|
||||
}, [folders, docsByFolder, foldersByParent, mentionedDocKeys]);
|
||||
|
||||
const folderMap = useMemo(() => {
|
||||
const map: Record<number, FolderDisplay> = {};
|
||||
|
|
@ -276,7 +277,7 @@ export function FolderTreeView({
|
|||
key={`doc-${d.id}`}
|
||||
doc={d}
|
||||
depth={depth}
|
||||
isMentioned={mentionedDocIds.has(d.id)}
|
||||
isMentioned={mentionedDocKeys.has(getMentionDocKey(d))}
|
||||
onToggleChatMention={onToggleChatMention}
|
||||
onPreview={onPreviewDocument}
|
||||
onEdit={onEditDocument}
|
||||
|
|
@ -356,7 +357,7 @@ export function FolderTreeView({
|
|||
key={`doc-${d.id}`}
|
||||
doc={d}
|
||||
depth={depth}
|
||||
isMentioned={mentionedDocIds.has(d.id)}
|
||||
isMentioned={mentionedDocKeys.has(getMentionDocKey(d))}
|
||||
onToggleChatMention={onToggleChatMention}
|
||||
onPreview={onPreviewDocument}
|
||||
onEdit={onEditDocument}
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ export function EditorPanelContent({
|
|||
const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
|
||||
const isLocalFileMode = kind === "local_file";
|
||||
const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown";
|
||||
|
||||
const resolveLocalVirtualPath = useCallback(
|
||||
async (candidatePath: string): Promise<string> => {
|
||||
if (!electronAPI?.getAgentFilesystemMounts) {
|
||||
|
|
@ -248,7 +249,15 @@ export function EditorPanelContent({
|
|||
|
||||
doFetch().catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [documentId, electronAPI, isLocalFileMode, localFilePath, resolveLocalVirtualPath, searchSpaceId, title]);
|
||||
}, [
|
||||
documentId,
|
||||
electronAPI,
|
||||
isLocalFileMode,
|
||||
localFilePath,
|
||||
resolveLocalVirtualPath,
|
||||
searchSpaceId,
|
||||
title,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -282,75 +291,92 @@ export function EditorPanelContent({
|
|||
}
|
||||
}, [editorDoc?.source_markdown]);
|
||||
|
||||
const handleSave = useCallback(async (_options?: { silent?: boolean }) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isLocalFileMode) {
|
||||
if (!localFilePath) {
|
||||
throw new Error("Missing local file path");
|
||||
const handleSave = useCallback(
|
||||
async (options?: { silent?: boolean }) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isLocalFileMode) {
|
||||
if (!localFilePath) {
|
||||
throw new Error("Missing local file path");
|
||||
}
|
||||
if (!electronAPI?.writeAgentLocalFileText) {
|
||||
throw new Error("Local file editor is available only in desktop mode.");
|
||||
}
|
||||
const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath);
|
||||
const contentToSave = markdownRef.current;
|
||||
const writeResult = await electronAPI.writeAgentLocalFileText(
|
||||
resolvedLocalPath,
|
||||
contentToSave,
|
||||
searchSpaceId
|
||||
);
|
||||
if (!writeResult.ok) {
|
||||
throw new Error(writeResult.error || "Failed to save local file");
|
||||
}
|
||||
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: contentToSave } : prev));
|
||||
setEditedMarkdown(markdownRef.current === contentToSave ? null : markdownRef.current);
|
||||
return true;
|
||||
}
|
||||
if (!electronAPI?.writeAgentLocalFileText) {
|
||||
throw new Error("Local file editor is available only in desktop mode.");
|
||||
if (!searchSpaceId || !documentId) {
|
||||
throw new Error("Missing document context");
|
||||
}
|
||||
const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath);
|
||||
const contentToSave = markdownRef.current;
|
||||
const writeResult = await electronAPI.writeAgentLocalFileText(
|
||||
resolvedLocalPath,
|
||||
contentToSave,
|
||||
searchSpaceId
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
toast.error("Please login to save");
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source_markdown: markdownRef.current }),
|
||||
}
|
||||
);
|
||||
if (!writeResult.ok) {
|
||||
throw new Error(writeResult.error || "Failed to save local file");
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to save document" }));
|
||||
throw new Error(errorData.detail || "Failed to save document");
|
||||
}
|
||||
|
||||
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev));
|
||||
setEditedMarkdown(null);
|
||||
if (!options?.silent) {
|
||||
toast.success("Document saved! Reindexing in background...");
|
||||
}
|
||||
setEditorDoc((prev) =>
|
||||
prev ? { ...prev, source_markdown: contentToSave } : prev
|
||||
);
|
||||
setEditedMarkdown(markdownRef.current === contentToSave ? null : markdownRef.current);
|
||||
return true;
|
||||
}
|
||||
if (!searchSpaceId || !documentId) {
|
||||
throw new Error("Missing document context");
|
||||
}
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
toast.error("Please login to save");
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source_markdown: markdownRef.current }),
|
||||
} catch (err) {
|
||||
console.error("Error saving document:", err);
|
||||
if (!options?.silent) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save document");
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to save document" }));
|
||||
throw new Error(errorData.detail || "Failed to save document");
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev));
|
||||
setEditedMarkdown(null);
|
||||
toast.success("Document saved! Reindexing in background...");
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Error saving document:", err);
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save document");
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [documentId, electronAPI, isLocalFileMode, localFilePath, resolveLocalVirtualPath, searchSpaceId]);
|
||||
},
|
||||
[
|
||||
documentId,
|
||||
electronAPI,
|
||||
isLocalFileMode,
|
||||
localFilePath,
|
||||
resolveLocalVirtualPath,
|
||||
searchSpaceId,
|
||||
]
|
||||
);
|
||||
|
||||
const isEditableType = editorDoc
|
||||
? (editorRenderMode === "source_code" ||
|
||||
EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) &&
|
||||
!isLargeDocument
|
||||
: false;
|
||||
// Render through PlateEditor for editable doc types (FILE/NOTE).
|
||||
// Everything else (large docs, non-editable types) falls back to the
|
||||
// lightweight `MarkdownViewer` — Plate is heavy on multi-MB docs and
|
||||
// non-editable types don't benefit from its editing UX.
|
||||
const renderInPlateEditor = isEditableType;
|
||||
const hasUnsavedChanges = editedMarkdown !== null;
|
||||
const showDesktopHeader = !!onClose;
|
||||
const showEditingActions = isEditableType && isEditing;
|
||||
|
|
@ -365,6 +391,60 @@ export function EditorPanelContent({
|
|||
setIsEditing(false);
|
||||
}, [editorDoc?.source_markdown]);
|
||||
|
||||
const handleDownloadMarkdown = useCallback(async () => {
|
||||
if (!searchSpaceId || !documentId) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
if (!response.ok) throw new Error("Download failed");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
const disposition = response.headers.get("content-disposition");
|
||||
const match = disposition?.match(/filename="(.+)"/);
|
||||
a.download = match?.[1] ?? `${editorDoc?.title || "document"}.md`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Download started");
|
||||
} catch {
|
||||
toast.error("Failed to download document");
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}, [documentId, editorDoc?.title, searchSpaceId]);
|
||||
|
||||
const largeDocAlert = isLargeDocument && !isLocalFileMode && editorDoc && (
|
||||
<Alert className="mb-4">
|
||||
<FileText className="size-4" />
|
||||
<AlertDescription className="flex items-center justify-between gap-4">
|
||||
<span>
|
||||
This document is too large for the editor (
|
||||
{Math.round((editorDoc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "}
|
||||
{editorDoc.chunk_count ?? 0} chunks). Showing a preview below.
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative shrink-0"
|
||||
disabled={downloading}
|
||||
onClick={handleDownloadMarkdown}
|
||||
>
|
||||
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
|
||||
<Download className="size-3.5" />
|
||||
Download .md
|
||||
</span>
|
||||
{downloading && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDesktopHeader ? (
|
||||
|
|
@ -549,63 +629,6 @@ export function EditorPanelContent({
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isLargeDocument && !isLocalFileMode ? (
|
||||
<div className="h-full overflow-y-auto px-5 py-4">
|
||||
<Alert className="mb-4">
|
||||
<FileText className="size-4" />
|
||||
<AlertDescription className="flex items-center justify-between gap-4">
|
||||
<span>
|
||||
This document is too large for the editor (
|
||||
{Math.round((editorDoc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "}
|
||||
{editorDoc.chunk_count ?? 0} chunks). Showing a preview below.
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative shrink-0"
|
||||
disabled={downloading}
|
||||
onClick={async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
if (!searchSpaceId || !documentId) {
|
||||
throw new Error("Missing document context");
|
||||
}
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
if (!response.ok) throw new Error("Download failed");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
const disposition = response.headers.get("content-disposition");
|
||||
const match = disposition?.match(/filename="(.+)"/);
|
||||
a.download = match?.[1] ?? `${editorDoc.title || "document"}.md`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Download started");
|
||||
} catch {
|
||||
toast.error("Failed to download document");
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
Download .md
|
||||
</span>
|
||||
{downloading && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<MarkdownViewer content={editorDoc.source_markdown} />
|
||||
</div>
|
||||
) : editorRenderMode === "source_code" ? (
|
||||
<div className="h-full overflow-hidden">
|
||||
<SourceCodeEditor
|
||||
|
|
@ -624,20 +647,32 @@ export function EditorPanelContent({
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
) : isEditableType ? (
|
||||
<PlateEditor
|
||||
key={`${isLocalFileMode ? localFilePath ?? "local-file" : documentId}-${isEditing ? "editing" : "viewing"}`}
|
||||
preset="full"
|
||||
markdown={editorDoc.source_markdown}
|
||||
onMarkdownChange={handleMarkdownChange}
|
||||
readOnly={!isEditing}
|
||||
placeholder="Start writing..."
|
||||
editorVariant="default"
|
||||
allowModeToggle={false}
|
||||
reserveToolbarSpace
|
||||
defaultEditing={isEditing}
|
||||
className="[&_[role=toolbar]]:!bg-sidebar"
|
||||
/>
|
||||
) : isLargeDocument && !isLocalFileMode ? (
|
||||
// Large doc — fast Streamdown preview + download CTA.
|
||||
// Plate is heavy on multi-MB docs.
|
||||
<div className="h-full overflow-y-auto px-5 py-4">
|
||||
{largeDocAlert}
|
||||
<MarkdownViewer content={editorDoc.source_markdown} />
|
||||
</div>
|
||||
) : renderInPlateEditor ? (
|
||||
// Editable doc (FILE/NOTE) — Plate editing UX.
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<PlateEditor
|
||||
key={`${isLocalFileMode ? (localFilePath ?? "local-file") : documentId}-${isEditing ? "editing" : "viewing"}`}
|
||||
preset="full"
|
||||
markdown={editorDoc.source_markdown}
|
||||
onMarkdownChange={handleMarkdownChange}
|
||||
readOnly={!isEditing}
|
||||
placeholder="Start writing..."
|
||||
editorVariant="default"
|
||||
allowModeToggle={false}
|
||||
reserveToolbarSpace
|
||||
defaultEditing={isEditing}
|
||||
className="**:[[role=toolbar]]:bg-sidebar!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-5 py-4">
|
||||
<MarkdownViewer content={editorDoc.source_markdown} />
|
||||
|
|
@ -746,7 +781,8 @@ export function MobileEditorPanel() {
|
|||
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||
: !!panelState.localFilePath;
|
||||
|
||||
if (isDesktop || !panelState.isOpen || !hasTarget || panelState.kind === "local_file") return null;
|
||||
if (isDesktop || !panelState.isOpen || !hasTarget || panelState.kind === "local_file")
|
||||
return null;
|
||||
|
||||
return <MobileEditorDrawer />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import { type EditorPreset, presetMap } from "@/components/editor/presets";
|
|||
import { escapeMdxExpressions } from "@/components/editor/utils/escape-mdx";
|
||||
import { Editor, EditorContainer } from "@/components/ui/editor";
|
||||
|
||||
/** Live editor instance returned by `usePlateEditor`. */
|
||||
export type PlateEditorInstance = ReturnType<typeof usePlateEditor>;
|
||||
|
||||
export interface PlateEditorProps {
|
||||
/** Markdown string to load as initial content */
|
||||
markdown?: string;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { createPlatePlugin } from "platejs/react";
|
||||
import { useEditorReadOnly } from "platejs/react";
|
||||
import { createPlatePlugin, useEditorReadOnly } from "platejs/react";
|
||||
|
||||
import { useEditorSave } from "@/components/editor/editor-save-context";
|
||||
import { FixedToolbar } from "@/components/ui/fixed-toolbar";
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
|
||||
|
|
|
|||
|
|
@ -63,10 +63,10 @@ const TAB_ITEMS = [
|
|||
featured: true,
|
||||
},
|
||||
{
|
||||
title: "Extreme Assist",
|
||||
title: "Screenshot Assist",
|
||||
description:
|
||||
"Get inline writing suggestions powered by your knowledge base as you type in any app.",
|
||||
src: "/homepage/hero_tutorial/extreme_assist.mp4",
|
||||
"Use a global shortcut to select a region on your screen and attach it to your chat message.",
|
||||
src: "/homepage/hero_tutorial/screenshot_assist.mp4",
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
|
||||
import { TeamDialog } from "@/components/settings/team-dialog";
|
||||
import { ActionLogSheet } from "@/components/agent-action-log/action-log-sheet";
|
||||
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -909,6 +910,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
<SearchSpaceSettingsDialog searchSpaceId={Number(searchSpaceId)} />
|
||||
<UserSettingsDialog />
|
||||
<TeamDialog searchSpaceId={Number(searchSpaceId)} />
|
||||
|
||||
{/* Agent action log + revert sheet */}
|
||||
<ActionLogSheet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
|
|||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { activeTabAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { ActionLogButton } from "@/components/agent-action-log/action-log-button";
|
||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||
|
|
@ -64,6 +65,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
|||
|
||||
{/* Right side - Actions */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasThread && <ActionLogButton threadId={currentThreadState.id} />}
|
||||
{hasThread && (
|
||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import dynamic from "next/dynamic";
|
|||
import { startTransition, useEffect } from "react";
|
||||
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
||||
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { citationPanelAtom, closeCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
|
||||
|
|
@ -21,6 +22,14 @@ const EditorPanelContent = dynamic(
|
|||
{ ssr: false, loading: () => null }
|
||||
);
|
||||
|
||||
const CitationPanelContent = dynamic(
|
||||
() =>
|
||||
import("@/components/citation-panel/citation-panel").then((m) => ({
|
||||
default: m.CitationPanelContent,
|
||||
})),
|
||||
{ ssr: false, loading: () => null }
|
||||
);
|
||||
|
||||
const HitlEditPanelContent = dynamic(
|
||||
() =>
|
||||
import("@/components/hitl-edit-panel/hitl-edit-panel").then((m) => ({
|
||||
|
|
@ -74,14 +83,14 @@ export function RightPanelExpandButton() {
|
|||
const reportState = useAtomValue(reportPanelAtom);
|
||||
const editorState = useAtomValue(editorPanelAtom);
|
||||
const hitlEditState = useAtomValue(hitlEditPanelAtom);
|
||||
const citationState = useAtomValue(citationPanelAtom);
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen =
|
||||
editorState.isOpen &&
|
||||
(editorState.kind === "document"
|
||||
? !!editorState.documentId
|
||||
: !!editorState.localFilePath);
|
||||
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath);
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen;
|
||||
const citationOpen = citationState.isOpen && citationState.chunkId != null;
|
||||
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen;
|
||||
|
||||
if (!collapsed || !hasContent) return null;
|
||||
|
||||
|
|
@ -105,7 +114,13 @@ export function RightPanelExpandButton() {
|
|||
);
|
||||
}
|
||||
|
||||
const PANEL_WIDTHS = { sources: 420, report: 640, editor: 640, "hitl-edit": 640 } as const;
|
||||
const PANEL_WIDTHS = {
|
||||
sources: 420,
|
||||
report: 640,
|
||||
editor: 640,
|
||||
"hitl-edit": 640,
|
||||
citation: 560,
|
||||
} as const;
|
||||
|
||||
export function RightPanel({ documentsPanel }: RightPanelProps) {
|
||||
const [activeTab] = useAtom(rightPanelTabAtom);
|
||||
|
|
@ -115,47 +130,69 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
|
|||
const closeEditor = useSetAtom(closeEditorPanelAtom);
|
||||
const hitlEditState = useAtomValue(hitlEditPanelAtom);
|
||||
const closeHitlEdit = useSetAtom(closeHitlEditPanelAtom);
|
||||
const citationState = useAtomValue(citationPanelAtom);
|
||||
const closeCitation = useSetAtom(closeCitationPanelAtom);
|
||||
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||
|
||||
const documentsOpen = documentsPanel?.open ?? false;
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen =
|
||||
editorState.isOpen &&
|
||||
(editorState.kind === "document"
|
||||
? !!editorState.documentId
|
||||
: !!editorState.localFilePath);
|
||||
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath);
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const citationOpen = citationState.isOpen && citationState.chunkId != null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!reportOpen && !editorOpen && !hitlEditOpen) return;
|
||||
if (!reportOpen && !editorOpen && !hitlEditOpen && !citationOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (hitlEditOpen) closeHitlEdit();
|
||||
else if (citationOpen) closeCitation();
|
||||
else if (editorOpen) closeEditor();
|
||||
else if (reportOpen) closeReport();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [reportOpen, editorOpen, hitlEditOpen, closeReport, closeEditor, closeHitlEdit]);
|
||||
}, [
|
||||
reportOpen,
|
||||
editorOpen,
|
||||
hitlEditOpen,
|
||||
citationOpen,
|
||||
closeReport,
|
||||
closeEditor,
|
||||
closeHitlEdit,
|
||||
closeCitation,
|
||||
]);
|
||||
|
||||
const isVisible = (documentsOpen || reportOpen || editorOpen || hitlEditOpen) && !collapsed;
|
||||
const isVisible =
|
||||
(documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen) && !collapsed;
|
||||
|
||||
let effectiveTab = activeTab;
|
||||
if (effectiveTab === "hitl-edit" && !hitlEditOpen) {
|
||||
effectiveTab = editorOpen ? "editor" : reportOpen ? "report" : "sources";
|
||||
} else if (effectiveTab === "editor" && !editorOpen) {
|
||||
effectiveTab = reportOpen ? "report" : "sources";
|
||||
} else if (effectiveTab === "report" && !reportOpen) {
|
||||
effectiveTab = editorOpen ? "editor" : "sources";
|
||||
} else if (effectiveTab === "sources" && !documentsOpen) {
|
||||
effectiveTab = hitlEditOpen
|
||||
? "hitl-edit"
|
||||
effectiveTab = citationOpen
|
||||
? "citation"
|
||||
: editorOpen
|
||||
? "editor"
|
||||
: reportOpen
|
||||
? "report"
|
||||
: "sources";
|
||||
} else if (effectiveTab === "citation" && !citationOpen) {
|
||||
effectiveTab = editorOpen ? "editor" : reportOpen ? "report" : "sources";
|
||||
} else if (effectiveTab === "editor" && !editorOpen) {
|
||||
effectiveTab = citationOpen ? "citation" : reportOpen ? "report" : "sources";
|
||||
} else if (effectiveTab === "report" && !reportOpen) {
|
||||
effectiveTab = citationOpen ? "citation" : editorOpen ? "editor" : "sources";
|
||||
} else if (effectiveTab === "sources" && !documentsOpen) {
|
||||
effectiveTab = hitlEditOpen
|
||||
? "hitl-edit"
|
||||
: citationOpen
|
||||
? "citation"
|
||||
: editorOpen
|
||||
? "editor"
|
||||
: reportOpen
|
||||
? "report"
|
||||
: "sources";
|
||||
}
|
||||
|
||||
const targetWidth = PANEL_WIDTHS[effectiveTab];
|
||||
|
|
@ -214,6 +251,11 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{effectiveTab === "citation" && citationOpen && citationState.chunkId != null && (
|
||||
<div className="h-full flex flex-col">
|
||||
<CitationPanelContent chunkId={citationState.chunkId} onClose={closeCitation} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { Folder, FolderPlus, Search, X } from "lucide-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { Folder, FolderPlus, Search, X } from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { localExpandedFolderKeysAtom } from "@/atoms/documents/folder.atoms";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -14,6 +12,8 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { LocalFilesystemBrowser } from "./LocalFilesystemBrowser";
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import type React from "react";
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
sidebarSelectedDocumentsAtom,
|
||||
mentionedDocumentsAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
|
|
@ -73,7 +73,8 @@ 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 { usePlatform, useElectronAPI } from "@/hooks/use-platform";
|
||||
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
||||
|
|
@ -210,7 +211,8 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
|
||||
const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom);
|
||||
const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom);
|
||||
const isElectron = desktopFeaturesEnabled && typeof window !== "undefined" && !!window.electronAPI;
|
||||
const isElectron =
|
||||
desktopFeaturesEnabled && typeof window !== "undefined" && !!window.electronAPI;
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronAPI?.getAgentFilesystemSettings) return;
|
||||
|
|
@ -252,10 +254,13 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
.filter((rootPath, index, allPaths) => allPaths.indexOf(rootPath) === index)
|
||||
.slice(0, MAX_LOCAL_FILESYSTEM_ROOTS);
|
||||
if (nextLocalRootPaths.length === localRootPaths.length) return;
|
||||
const updated = await electronAPI.setAgentFilesystemSettings({
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: nextLocalRootPaths,
|
||||
}, searchSpaceId);
|
||||
const updated = await electronAPI.setAgentFilesystemSettings(
|
||||
{
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: nextLocalRootPaths,
|
||||
},
|
||||
searchSpaceId
|
||||
);
|
||||
setFilesystemSettings(updated);
|
||||
},
|
||||
[electronAPI, localRootPaths, searchSpaceId]
|
||||
|
|
@ -284,10 +289,13 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
const handleRemoveFilesystemRoot = useCallback(
|
||||
async (rootPathToRemove: string) => {
|
||||
if (!electronAPI?.setAgentFilesystemSettings) return;
|
||||
const updated = await electronAPI.setAgentFilesystemSettings({
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove),
|
||||
}, searchSpaceId);
|
||||
const updated = await electronAPI.setAgentFilesystemSettings(
|
||||
{
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove),
|
||||
},
|
||||
searchSpaceId
|
||||
);
|
||||
setFilesystemSettings(updated);
|
||||
},
|
||||
[electronAPI, localRootPaths, searchSpaceId]
|
||||
|
|
@ -295,19 +303,25 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
|
||||
const handleClearFilesystemRoots = useCallback(async () => {
|
||||
if (!electronAPI?.setAgentFilesystemSettings) return;
|
||||
const updated = await electronAPI.setAgentFilesystemSettings({
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: [],
|
||||
}, searchSpaceId);
|
||||
const updated = await electronAPI.setAgentFilesystemSettings(
|
||||
{
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: [],
|
||||
},
|
||||
searchSpaceId
|
||||
);
|
||||
setFilesystemSettings(updated);
|
||||
}, [electronAPI, searchSpaceId]);
|
||||
|
||||
const handleFilesystemTabChange = useCallback(
|
||||
async (tab: "cloud" | "local") => {
|
||||
if (!electronAPI?.setAgentFilesystemSettings) return;
|
||||
const updated = await electronAPI.setAgentFilesystemSettings({
|
||||
mode: tab === "cloud" ? "cloud" : "desktop_local_folder",
|
||||
}, searchSpaceId);
|
||||
const updated = await electronAPI.setAgentFilesystemSettings(
|
||||
{
|
||||
mode: tab === "cloud" ? "cloud" : "desktop_local_folder",
|
||||
},
|
||||
searchSpaceId
|
||||
);
|
||||
setFilesystemSettings(updated);
|
||||
},
|
||||
[electronAPI, searchSpaceId]
|
||||
|
|
@ -414,8 +428,11 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
}, [refreshWatchedIds]);
|
||||
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
||||
|
||||
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
|
||||
const mentionedDocIds = useMemo(() => new Set(sidebarDocs.map((d) => d.id)), [sidebarDocs]);
|
||||
const [sidebarDocs, setSidebarDocs] = useAtom(mentionedDocumentsAtom);
|
||||
const mentionedDocKeys = useMemo(
|
||||
() => new Set(sidebarDocs.map((d) => getMentionDocKey(d))),
|
||||
[sidebarDocs]
|
||||
);
|
||||
|
||||
// Folder state
|
||||
const [expandedFolderMap, setExpandedFolderMap] = useAtom(expandedFolderIdsAtom);
|
||||
|
|
@ -554,7 +571,9 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
if (!electronAPI) return;
|
||||
|
||||
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
|
||||
const matched = watchedFolders.find((wf: WatchedFolderEntry) => wf.rootFolderId === folder.id);
|
||||
const matched = watchedFolders.find(
|
||||
(wf: WatchedFolderEntry) => wf.rootFolderId === folder.id
|
||||
);
|
||||
if (!matched) {
|
||||
toast.error("This folder is not being watched");
|
||||
return;
|
||||
|
|
@ -584,7 +603,9 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
if (!electronAPI) return;
|
||||
|
||||
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
|
||||
const matched = watchedFolders.find((wf: WatchedFolderEntry) => wf.rootFolderId === folder.id);
|
||||
const matched = watchedFolders.find(
|
||||
(wf: WatchedFolderEntry) => wf.rootFolderId === folder.id
|
||||
);
|
||||
if (!matched) {
|
||||
toast.error("This folder is not being watched");
|
||||
return;
|
||||
|
|
@ -859,12 +880,12 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
|
||||
const handleToggleChatMention = useCallback(
|
||||
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
|
||||
const key = `${doc.document_type}:${doc.id}`;
|
||||
const key = getMentionDocKey(doc);
|
||||
if (isMentioned) {
|
||||
setSidebarDocs((prev) => prev.filter((d) => `${d.document_type}:${d.id}` !== key));
|
||||
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key));
|
||||
} else {
|
||||
setSidebarDocs((prev) => {
|
||||
if (prev.some((d) => `${d.document_type}:${d.id}` === key)) return prev;
|
||||
if (prev.some((d) => getMentionDocKey(d) === key)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
|
||||
|
|
@ -895,9 +916,9 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
|
||||
if (selectAll) {
|
||||
setSidebarDocs((prev) => {
|
||||
const existingDocKeys = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
|
||||
const existingDocKeys = new Set(prev.map((d) => getMentionDocKey(d)));
|
||||
const newDocs = subtreeDocs
|
||||
.filter((d) => !existingDocKeys.has(`${d.document_type}:${d.id}`))
|
||||
.filter((d) => !existingDocKeys.has(getMentionDocKey(d)))
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
|
|
@ -906,10 +927,8 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
return newDocs.length > 0 ? [...prev, ...newDocs] : prev;
|
||||
});
|
||||
} else {
|
||||
const keysToRemove = new Set(subtreeDocs.map((d) => `${d.document_type}:${d.id}`));
|
||||
setSidebarDocs((prev) =>
|
||||
prev.filter((d) => !keysToRemove.has(`${d.document_type}:${d.id}`))
|
||||
);
|
||||
const keysToRemove = new Set(subtreeDocs.map((d) => getMentionDocKey(d)));
|
||||
setSidebarDocs((prev) => prev.filter((d) => !keysToRemove.has(getMentionDocKey(d))));
|
||||
}
|
||||
},
|
||||
[treeDocuments, foldersByParent, setSidebarDocs]
|
||||
|
|
@ -1020,7 +1039,8 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
}, [open, onOpenChange, isMobile, setRightPanelCollapsed]);
|
||||
|
||||
const showFilesystemTabs = !isMobile && !!electronAPI && !!filesystemSettings;
|
||||
const currentFilesystemTab = filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud";
|
||||
const currentFilesystemTab =
|
||||
filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud";
|
||||
const showCloudSkeleton =
|
||||
currentFilesystemTab === "cloud" &&
|
||||
(zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete");
|
||||
|
|
@ -1144,7 +1164,7 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
documents={searchFilteredDocuments}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={toggleFolderExpand}
|
||||
mentionedDocIds={mentionedDocIds}
|
||||
mentionedDocKeys={mentionedDocKeys}
|
||||
onToggleChatMention={handleToggleChatMention}
|
||||
onToggleFolderSelect={handleToggleFolderSelect}
|
||||
onRenameFolder={handleRenameFolder}
|
||||
|
|
@ -1336,8 +1356,8 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Trust this workspace?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Local mode can read and edit files inside the folders you select. Continue only if
|
||||
you trust this workspace and its contents.
|
||||
Local mode can read and edit files inside the folders you select. Continue only if you
|
||||
trust this workspace and its contents.
|
||||
</AlertDialogDescription>
|
||||
{pendingLocalPath && (
|
||||
<AlertDialogDescription className="mt-1 whitespace-pre-wrap break-words font-mono text-xs">
|
||||
|
|
@ -1572,17 +1592,20 @@ function AnonymousDocumentsSidebar({
|
|||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
|
||||
const mentionedDocIds = useMemo(() => new Set(sidebarDocs.map((d) => d.id)), [sidebarDocs]);
|
||||
const [sidebarDocs, setSidebarDocs] = useAtom(mentionedDocumentsAtom);
|
||||
const mentionedDocKeys = useMemo(
|
||||
() => new Set(sidebarDocs.map((d) => getMentionDocKey(d))),
|
||||
[sidebarDocs]
|
||||
);
|
||||
|
||||
const handleToggleChatMention = useCallback(
|
||||
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
|
||||
const key = `${doc.document_type}:${doc.id}`;
|
||||
const key = getMentionDocKey(doc);
|
||||
if (isMentioned) {
|
||||
setSidebarDocs((prev) => prev.filter((d) => `${d.document_type}:${d.id}` !== key));
|
||||
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key));
|
||||
} else {
|
||||
setSidebarDocs((prev) => {
|
||||
if (prev.some((d) => `${d.document_type}:${d.id}` === key)) return prev;
|
||||
if (prev.some((d) => getMentionDocKey(d) === key)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
|
||||
|
|
@ -1802,7 +1825,7 @@ function AnonymousDocumentsSidebar({
|
|||
documents={searchFilteredDocs}
|
||||
expandedIds={new Set()}
|
||||
onToggleExpand={() => {}}
|
||||
mentionedDocIds={mentionedDocIds}
|
||||
mentionedDocKeys={mentionedDocKeys}
|
||||
onToggleChatMention={handleToggleChatMention}
|
||||
onToggleFolderSelect={() => {}}
|
||||
onRenameFolder={() => gate("rename folders")}
|
||||
|
|
|
|||
|
|
@ -141,7 +141,9 @@ export function LocalFilesystemBrowser({
|
|||
}: LocalFilesystemBrowserProps) {
|
||||
const electronAPI = useElectronAPI();
|
||||
const [rootStateMap, setRootStateMap] = useState<Record<string, RootLoadState>>({});
|
||||
const [internalExpandedFolderKeys, setInternalExpandedFolderKeys] = useState<Set<string>>(new Set());
|
||||
const [internalExpandedFolderKeys, setInternalExpandedFolderKeys] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [mountByRootKey, setMountByRootKey] = useState<Map<string, string>>(new Map());
|
||||
const [mountStatus, setMountStatus] = useState<MountLoadStatus>("idle");
|
||||
const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false);
|
||||
|
|
@ -188,10 +190,7 @@ export function LocalFilesystemBrowser({
|
|||
}
|
||||
for (const { rootKey } of rootsToReload) {
|
||||
const nonce = reloadNonceByRoot[rootKey] ?? 0;
|
||||
lastLoadedSignatureByRootRef.current.set(
|
||||
rootKey,
|
||||
`${searchSpaceId}:${rootKey}:${nonce}`
|
||||
);
|
||||
lastLoadedSignatureByRootRef.current.set(rootKey, `${searchSpaceId}:${rootKey}:${nonce}`);
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
|
|
@ -257,35 +256,37 @@ export function LocalFilesystemBrowser({
|
|||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = electronAPI.onAgentFilesystemTreeDirty((event: {
|
||||
searchSpaceId: number | null;
|
||||
reason: "watcher_event" | "safety_poll";
|
||||
rootPath: string;
|
||||
changedPath: string | null;
|
||||
timestamp: number;
|
||||
}) => {
|
||||
if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) {
|
||||
return;
|
||||
const unsubscribe = electronAPI.onAgentFilesystemTreeDirty(
|
||||
(event: {
|
||||
searchSpaceId: number | null;
|
||||
reason: "watcher_event" | "safety_poll";
|
||||
rootPath: string;
|
||||
changedPath: string | null;
|
||||
timestamp: number;
|
||||
}) => {
|
||||
if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) {
|
||||
return;
|
||||
}
|
||||
const eventRootKey = normalizeRootPathForLookup(event.rootPath, isWindowsPlatform);
|
||||
const knownRootKeys = new Set(
|
||||
rootPaths.map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform))
|
||||
);
|
||||
if (!knownRootKeys.has(eventRootKey)) {
|
||||
setReloadNonceByRoot((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const rootKey of knownRootKeys) {
|
||||
next[rootKey] = (prev[rootKey] ?? 0) + 1;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setReloadNonceByRoot((prev) => ({
|
||||
...prev,
|
||||
[eventRootKey]: (prev[eventRootKey] ?? 0) + 1,
|
||||
}));
|
||||
}
|
||||
const eventRootKey = normalizeRootPathForLookup(event.rootPath, isWindowsPlatform);
|
||||
const knownRootKeys = new Set(
|
||||
rootPaths.map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform))
|
||||
);
|
||||
if (!knownRootKeys.has(eventRootKey)) {
|
||||
setReloadNonceByRoot((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const rootKey of knownRootKeys) {
|
||||
next[rootKey] = (prev[rootKey] ?? 0) + 1;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setReloadNonceByRoot((prev) => ({
|
||||
...prev,
|
||||
[eventRootKey]: (prev[eventRootKey] ?? 0) + 1,
|
||||
}));
|
||||
});
|
||||
);
|
||||
void electronAPI.startAgentFilesystemTreeWatch({
|
||||
searchSpaceId,
|
||||
rootPaths,
|
||||
|
|
@ -378,22 +379,25 @@ export function LocalFilesystemBrowser({
|
|||
});
|
||||
}, [rootPaths, rootStateMap, searchQuery]);
|
||||
|
||||
const toggleFolder = useCallback((folderKey: string) => {
|
||||
const update = (prev: Set<string>) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folderKey)) {
|
||||
next.delete(folderKey);
|
||||
} else {
|
||||
next.add(folderKey);
|
||||
const toggleFolder = useCallback(
|
||||
(folderKey: string) => {
|
||||
const update = (prev: Set<string>) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folderKey)) {
|
||||
next.delete(folderKey);
|
||||
} else {
|
||||
next.add(folderKey);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
if (onExpandedFolderKeysChange) {
|
||||
onExpandedFolderKeysChange(update(effectiveExpandedFolderKeys));
|
||||
return;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
if (onExpandedFolderKeysChange) {
|
||||
onExpandedFolderKeysChange(update(effectiveExpandedFolderKeys));
|
||||
return;
|
||||
}
|
||||
setInternalExpandedFolderKeys(update);
|
||||
}, [effectiveExpandedFolderKeys, onExpandedFolderKeysChange]);
|
||||
setInternalExpandedFolderKeys(update);
|
||||
},
|
||||
[effectiveExpandedFolderKeys, onExpandedFolderKeysChange]
|
||||
);
|
||||
|
||||
const renderFolder = useCallback(
|
||||
(folder: LocalFolderNode, depth: number, mount: string) => {
|
||||
|
|
@ -436,9 +440,7 @@ export function LocalFilesystemBrowser({
|
|||
: undefined
|
||||
}
|
||||
className={`flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors ${
|
||||
isOpenable
|
||||
? "hover:bg-muted/60"
|
||||
: "cursor-not-allowed opacity-60"
|
||||
isOpenable ? "hover:bg-muted/60" : "cursor-not-allowed opacity-60"
|
||||
}`}
|
||||
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }}
|
||||
title={
|
||||
|
|
@ -528,7 +530,10 @@ export function LocalFilesystemBrowser({
|
|||
}
|
||||
if (state.error) {
|
||||
return (
|
||||
<div key={rootPath} className="rounded-md border border-destructive/20 bg-destructive/5 p-3">
|
||||
<div
|
||||
key={rootPath}
|
||||
className="rounded-md border border-destructive/20 bg-destructive/5 p-3"
|
||||
>
|
||||
<p className="text-sm font-medium text-destructive">Failed to load local folder</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{state.error}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -308,9 +308,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}
|
||||
>
|
||||
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
|
||||
<Download className="size-3.5" />
|
||||
Download .md
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ const code = createCodePlugin({
|
|||
});
|
||||
|
||||
const math = createMathPlugin({
|
||||
singleDollarTextMath: true,
|
||||
// Disabled so currency like "$3,120.00 and ... $0.00" isn't parsed as
|
||||
// inline LaTeX. convertLatexDelimiters() below normalises any genuine
|
||||
// inline math (\(...\), $...$ starting with a LaTeX command, etc.) to
|
||||
// $$...$$, so this flip doesn't lose any math rendering.
|
||||
singleDollarTextMath: false,
|
||||
});
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import {
|
|||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Pencil,
|
||||
ImageIcon,
|
||||
Layers,
|
||||
Pencil,
|
||||
Plus,
|
||||
ScanEye,
|
||||
Search,
|
||||
|
|
@ -741,9 +741,7 @@ export function ModelSelector({
|
|||
<div
|
||||
className={cn(
|
||||
"shrink-0 border-border/50 flex relative",
|
||||
isMobile
|
||||
? "flex-row items-center border-b border-border/40"
|
||||
: "flex-col w-10 border-r"
|
||||
isMobile ? "flex-row items-center border-b border-border/40" : "flex-col w-10 border-r"
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
|
|
@ -769,9 +767,7 @@ export function ModelSelector({
|
|||
<div
|
||||
className={cn(
|
||||
"absolute left-0 top-0 bottom-0 z-10 w-5 flex items-center justify-center transition-all duration-200 ease-out pointer-events-none",
|
||||
sidebarScrollPos === "top"
|
||||
? "opacity-0 -translate-x-1"
|
||||
: "opacity-100 translate-x-0"
|
||||
sidebarScrollPos === "top" ? "opacity-0 -translate-x-1" : "opacity-100 translate-x-0"
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-3 text-muted-foreground" />
|
||||
|
|
|
|||
|
|
@ -1,719 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
FileQuestionMark,
|
||||
FileText,
|
||||
Hash,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type React from "react";
|
||||
import { forwardRef, memo, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type {
|
||||
GetDocumentByChunkResponse,
|
||||
GetSurfsenseDocsByChunkResponse,
|
||||
} from "@/contracts/types/document.types";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type DocumentData = GetDocumentByChunkResponse | GetSurfsenseDocsByChunkResponse;
|
||||
|
||||
interface SourceDetailPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
chunkId: number;
|
||||
sourceType: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
children?: ReactNode;
|
||||
isDocsChunk?: boolean;
|
||||
}
|
||||
|
||||
const formatDocumentType = (type: string) => {
|
||||
if (!type) return "";
|
||||
return type
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
// Chunk card component
|
||||
// For large documents (>30 chunks), we disable animation to prevent layout shifts
|
||||
// which break auto-scroll functionality
|
||||
interface ChunkCardProps {
|
||||
chunk: { id: number; content: string };
|
||||
localIndex: number;
|
||||
chunkNumber: number;
|
||||
totalChunks: number;
|
||||
isCited: boolean;
|
||||
isActive: boolean;
|
||||
disableLayoutAnimation?: boolean;
|
||||
}
|
||||
|
||||
const ChunkCard = memo(
|
||||
forwardRef<HTMLDivElement, ChunkCardProps>(
|
||||
({ chunk, localIndex, chunkNumber, totalChunks, isCited }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-chunk-index={localIndex}
|
||||
className={cn(
|
||||
"group relative rounded-2xl border-2 transition-all duration-300",
|
||||
isCited
|
||||
? "bg-linear-to-br from-primary/5 via-primary/10 to-primary/5 border-primary shadow-lg shadow-primary/10"
|
||||
: "bg-card border-border/50 hover:border-border hover:shadow-md"
|
||||
)}
|
||||
>
|
||||
{isCited && <div className="absolute inset-0 rounded-2xl bg-primary/5 blur-xl -z-10" />}
|
||||
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-8 h-8 rounded-full text-sm font-semibold transition-colors",
|
||||
isCited
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground group-hover:bg-muted/80"
|
||||
)}
|
||||
>
|
||||
{chunkNumber}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Chunk {chunkNumber} of {totalChunks}
|
||||
</span>
|
||||
</div>
|
||||
{isCited && (
|
||||
<Badge variant="default" className="gap-1.5 px-3 py-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Cited Source
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-5 overflow-hidden">
|
||||
<MarkdownViewer content={chunk.content} maxLength={100_000} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
ChunkCard.displayName = "ChunkCard";
|
||||
|
||||
export function SourceDetailPanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
chunkId,
|
||||
sourceType,
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
children,
|
||||
isDocsChunk = false,
|
||||
}: SourceDetailPanelProps) {
|
||||
const t = useTranslations("dashboard");
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
|
||||
const scrollTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: documentData,
|
||||
isLoading: isDocumentByChunkFetching,
|
||||
error: documentByChunkFetchingError,
|
||||
} = useQuery<DocumentData>({
|
||||
queryKey: isDocsChunk
|
||||
? cacheKeys.documents.byChunk(`doc-${chunkId}`)
|
||||
: cacheKeys.documents.byChunk(chunkId.toString()),
|
||||
queryFn: async () => {
|
||||
if (isDocsChunk) {
|
||||
return documentsApiService.getSurfsenseDocByChunk(chunkId);
|
||||
}
|
||||
return documentsApiService.getDocumentByChunk({ chunk_id: chunkId, chunk_window: 5 });
|
||||
},
|
||||
enabled: !!chunkId && open,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const totalChunks =
|
||||
documentData && "total_chunks" in documentData
|
||||
? (documentData.total_chunks ?? documentData.chunks.length)
|
||||
: (documentData?.chunks?.length ?? 0);
|
||||
const [beforeChunks, setBeforeChunks] = useState<
|
||||
Array<{ id: number; content: string; created_at: string }>
|
||||
>([]);
|
||||
const [afterChunks, setAfterChunks] = useState<
|
||||
Array<{ id: number; content: string; created_at: string }>
|
||||
>([]);
|
||||
const [loadingBefore, setLoadingBefore] = useState(false);
|
||||
const [loadingAfter, setLoadingAfter] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setBeforeChunks([]);
|
||||
setAfterChunks([]);
|
||||
}, [chunkId, open]);
|
||||
|
||||
const chunkStartIndex =
|
||||
documentData && "chunk_start_index" in documentData ? (documentData.chunk_start_index ?? 0) : 0;
|
||||
const initialChunks = documentData?.chunks ?? [];
|
||||
const allChunks = [...beforeChunks, ...initialChunks, ...afterChunks];
|
||||
const absoluteStart = chunkStartIndex - beforeChunks.length;
|
||||
const absoluteEnd = chunkStartIndex + initialChunks.length + afterChunks.length;
|
||||
const canLoadBefore = absoluteStart > 0;
|
||||
const canLoadAfter = absoluteEnd < totalChunks;
|
||||
|
||||
const EXPAND_SIZE = 10;
|
||||
|
||||
const loadBefore = useCallback(async () => {
|
||||
if (!documentData || !("search_space_id" in documentData) || !canLoadBefore) return;
|
||||
setLoadingBefore(true);
|
||||
try {
|
||||
const count = Math.min(EXPAND_SIZE, absoluteStart);
|
||||
const result = await documentsApiService.getDocumentChunks({
|
||||
document_id: documentData.id,
|
||||
page: 0,
|
||||
page_size: count,
|
||||
start_offset: absoluteStart - count,
|
||||
});
|
||||
const existingIds = new Set(allChunks.map((c) => c.id));
|
||||
const newChunks = result.items
|
||||
.filter((c) => !existingIds.has(c.id))
|
||||
.map((c) => ({ id: c.id, content: c.content, created_at: c.created_at }));
|
||||
setBeforeChunks((prev) => [...newChunks, ...prev]);
|
||||
} catch (err) {
|
||||
console.error("Failed to load earlier chunks:", err);
|
||||
} finally {
|
||||
setLoadingBefore(false);
|
||||
}
|
||||
}, [documentData, absoluteStart, canLoadBefore, allChunks]);
|
||||
|
||||
const loadAfter = useCallback(async () => {
|
||||
if (!documentData || !("search_space_id" in documentData) || !canLoadAfter) return;
|
||||
setLoadingAfter(true);
|
||||
try {
|
||||
const result = await documentsApiService.getDocumentChunks({
|
||||
document_id: documentData.id,
|
||||
page: 0,
|
||||
page_size: EXPAND_SIZE,
|
||||
start_offset: absoluteEnd,
|
||||
});
|
||||
const existingIds = new Set(allChunks.map((c) => c.id));
|
||||
const newChunks = result.items
|
||||
.filter((c) => !existingIds.has(c.id))
|
||||
.map((c) => ({ id: c.id, content: c.content, created_at: c.created_at }));
|
||||
setAfterChunks((prev) => [...prev, ...newChunks]);
|
||||
} catch (err) {
|
||||
console.error("Failed to load later chunks:", err);
|
||||
} finally {
|
||||
setLoadingAfter(false);
|
||||
}
|
||||
}, [documentData, absoluteEnd, canLoadAfter, allChunks]);
|
||||
|
||||
const isDirectRenderSource =
|
||||
sourceType === "TAVILY_API" ||
|
||||
sourceType === "LINKUP_API" ||
|
||||
sourceType === "SEARXNG_API" ||
|
||||
sourceType === "BAIDU_SEARCH_API";
|
||||
|
||||
const citedChunkIndex = allChunks.findIndex((chunk) => chunk.id === chunkId);
|
||||
|
||||
// Simple scroll function that scrolls to a chunk by index
|
||||
const scrollToChunkByIndex = useCallback(
|
||||
(chunkIndex: number, smooth = true) => {
|
||||
const scrollContainer = scrollAreaRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const viewport = scrollContainer.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement | null;
|
||||
if (!viewport) return;
|
||||
|
||||
const chunkElement = scrollContainer.querySelector(
|
||||
`[data-chunk-index="${chunkIndex}"]`
|
||||
) as HTMLElement | null;
|
||||
if (!chunkElement) return;
|
||||
|
||||
// Get positions using getBoundingClientRect for accuracy
|
||||
const viewportRect = viewport.getBoundingClientRect();
|
||||
const chunkRect = chunkElement.getBoundingClientRect();
|
||||
|
||||
// Calculate where to scroll to center the chunk
|
||||
const currentScrollTop = viewport.scrollTop;
|
||||
const chunkTopRelativeToViewport = chunkRect.top - viewportRect.top + currentScrollTop;
|
||||
const scrollTarget =
|
||||
chunkTopRelativeToViewport - viewportRect.height / 2 + chunkRect.height / 2;
|
||||
|
||||
viewport.scrollTo({
|
||||
top: Math.max(0, scrollTarget),
|
||||
behavior: smooth && !shouldReduceMotion ? "smooth" : "auto",
|
||||
});
|
||||
|
||||
setActiveChunkIndex(chunkIndex);
|
||||
},
|
||||
[shouldReduceMotion]
|
||||
);
|
||||
|
||||
// Callback ref for the cited chunk - scrolls when the element mounts
|
||||
const citedChunkRefCallback = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (node && !hasScrolledRef.current && open) {
|
||||
hasScrolledRef.current = true; // Mark immediately to prevent duplicate scrolls
|
||||
|
||||
// Store the node reference for the delayed scroll
|
||||
const scrollToCitedChunk = () => {
|
||||
const scrollContainer = scrollAreaRef.current;
|
||||
if (!scrollContainer || !node.isConnected) return false;
|
||||
|
||||
const viewport = scrollContainer.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement | null;
|
||||
if (!viewport) return false;
|
||||
|
||||
// Get positions
|
||||
const viewportRect = viewport.getBoundingClientRect();
|
||||
const chunkRect = node.getBoundingClientRect();
|
||||
|
||||
// Calculate scroll position to center the chunk
|
||||
const currentScrollTop = viewport.scrollTop;
|
||||
const chunkTopRelativeToViewport = chunkRect.top - viewportRect.top + currentScrollTop;
|
||||
const scrollTarget =
|
||||
chunkTopRelativeToViewport - viewportRect.height / 2 + chunkRect.height / 2;
|
||||
|
||||
viewport.scrollTo({
|
||||
top: Math.max(0, scrollTarget),
|
||||
behavior: "auto", // Instant scroll for initial positioning
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Scroll multiple times with delays to handle progressive content rendering
|
||||
// Each subsequent scroll will correct for any layout shifts
|
||||
const scrollAttempts = [50, 150, 300, 600, 1000];
|
||||
|
||||
scrollAttempts.forEach((delay) => {
|
||||
scrollTimersRef.current.push(
|
||||
setTimeout(() => {
|
||||
scrollToCitedChunk();
|
||||
}, delay)
|
||||
);
|
||||
});
|
||||
|
||||
// After final attempt, mark the cited chunk as active
|
||||
scrollTimersRef.current.push(
|
||||
setTimeout(
|
||||
() => {
|
||||
setActiveChunkIndex(citedChunkIndex);
|
||||
},
|
||||
scrollAttempts[scrollAttempts.length - 1] + 50
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[open, citedChunkIndex]
|
||||
);
|
||||
|
||||
// Reset scroll state when panel closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
scrollTimersRef.current.forEach(clearTimeout);
|
||||
scrollTimersRef.current = [];
|
||||
hasScrolledRef.current = false;
|
||||
setActiveChunkIndex(null);
|
||||
}
|
||||
return () => {
|
||||
scrollTimersRef.current.forEach(clearTimeout);
|
||||
scrollTimersRef.current = [];
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
// Prevent body scroll when open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleUrlClick = (e: React.MouseEvent, clickUrl: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(clickUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const scrollToChunk = useCallback(
|
||||
(index: number) => {
|
||||
scrollToChunkByIndex(index, true);
|
||||
},
|
||||
[scrollToChunkByIndex]
|
||||
);
|
||||
|
||||
const panelContent = (
|
||||
<AnimatePresence mode="wait">
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
key="backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<motion.div
|
||||
key="panel"
|
||||
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
damping: 30,
|
||||
stiffness: 300,
|
||||
}}
|
||||
className="fixed inset-3 sm:inset-6 md:inset-10 lg:inset-16 z-50 flex flex-col bg-background rounded-3xl shadow-2xl border overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="flex items-center justify-between px-6 py-5 border-b bg-linear-to-r from-muted/50 to-muted/30"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-xl font-semibold truncate">
|
||||
{documentData?.title || title || "Source Document"}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{documentData && "document_type" in documentData
|
||||
? formatDocumentType(documentData.document_type)
|
||||
: sourceType && formatDocumentType(sourceType)}
|
||||
{totalChunks > 0 && (
|
||||
<span className="ml-2">
|
||||
• {totalChunks} chunk{totalChunks !== 1 ? "s" : ""}
|
||||
{allChunks.length < totalChunks && ` (showing ${allChunks.length})`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{url && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => handleUrlClick(e, url)}
|
||||
className="hidden sm:flex gap-2 rounded-xl"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open Source
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 w-8 rounded-full"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Loading State */}
|
||||
{!isDirectRenderSource && isDocumentByChunkFetching && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<Spinner size="lg" />
|
||||
<p className="text-sm text-muted-foreground font-medium">
|
||||
{t("loading_document")}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{!isDirectRenderSource && documentByChunkFetchingError && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-4 text-center px-6"
|
||||
>
|
||||
<div className="w-20 h-20 rounded-full bg-muted/50 flex items-center justify-center">
|
||||
<FileQuestionMark className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground text-lg">Document unavailable</p>
|
||||
<p className="text-sm text-muted-foreground mt-2 max-w-md">
|
||||
{documentByChunkFetchingError.message ||
|
||||
"An unexpected error occurred. Please try again."}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="mt-2">
|
||||
Close Panel
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Direct render for web search providers */}
|
||||
{isDirectRenderSource && (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
{url && (
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={(e) => handleUrlClick(e, url)}
|
||||
className="w-full mb-6 sm:hidden rounded-xl"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open in Browser
|
||||
</Button>
|
||||
)}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-6 bg-muted/50 rounded-2xl border"
|
||||
>
|
||||
<h3 className="text-base font-semibold mb-4 flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Source Information
|
||||
</h3>
|
||||
<div className="text-sm text-muted-foreground mb-3 font-medium">
|
||||
{title || "Untitled"}
|
||||
</div>
|
||||
<div className="text-sm text-foreground leading-relaxed">
|
||||
{description || "No content available"}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{/* API-fetched document content */}
|
||||
{!isDirectRenderSource && documentData && (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Chunk Navigation Sidebar */}
|
||||
{allChunks.length > 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="hidden lg:flex flex-col w-16 border-r bg-muted/10 overflow-hidden"
|
||||
>
|
||||
<ScrollArea className="flex-1 h-full">
|
||||
<div className="p-2 pt-3 flex flex-col gap-1.5">
|
||||
{allChunks.map((chunk, idx) => {
|
||||
const absNum = absoluteStart + idx + 1;
|
||||
const isCited = chunk.id === chunkId;
|
||||
const isActive = activeChunkIndex === idx;
|
||||
return (
|
||||
<motion.button
|
||||
key={chunk.id}
|
||||
type="button"
|
||||
onClick={() => scrollToChunk(idx)}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: Math.min(idx * 0.02, 0.2) }}
|
||||
className={cn(
|
||||
"relative w-11 h-9 mx-auto rounded-lg text-xs font-semibold transition-all duration-200 flex items-center justify-center",
|
||||
isCited
|
||||
? "bg-primary text-primary-foreground shadow-md"
|
||||
: isActive
|
||||
? "bg-muted text-foreground"
|
||||
: "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
title={isCited ? `Chunk ${absNum} (Cited)` : `Chunk ${absNum}`}
|
||||
>
|
||||
{absNum}
|
||||
{isCited && (
|
||||
<span className="absolute -top-1.5 -right-1.5 flex items-center justify-center w-4 h-4 bg-primary rounded-full border-2 border-background shadow-sm">
|
||||
<Sparkles className="h-2.5 w-2.5 text-primary-foreground" />
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<ScrollArea className="flex-1" ref={scrollAreaRef}>
|
||||
<div className="p-6 lg:p-8 max-w-4xl mx-auto space-y-6">
|
||||
{/* Document Metadata */}
|
||||
{"document_metadata" in documentData &&
|
||||
documentData.document_metadata &&
|
||||
Object.keys(documentData.document_metadata).length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="p-5 bg-muted/30 rounded-2xl border"
|
||||
>
|
||||
<h3 className="text-sm font-semibold mb-4 text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Document Information
|
||||
</h3>
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
{Object.entries(documentData.document_metadata).map(([key, value]) => (
|
||||
<div key={key} className="space-y-1">
|
||||
<dt className="font-medium text-muted-foreground capitalize text-xs">
|
||||
{key.replace(/_/g, " ")}
|
||||
</dt>
|
||||
<dd className="text-foreground wrap-break-word">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Chunks Header */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<Hash className="h-4 w-4" />
|
||||
Chunks {absoluteStart + 1}–{absoluteEnd} of {totalChunks}
|
||||
</h3>
|
||||
{citedChunkIndex !== -1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => scrollToChunk(citedChunkIndex)}
|
||||
className="gap-2 text-primary hover:text-primary"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Jump to cited
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Load Earlier */}
|
||||
{canLoadBefore && (
|
||||
<div className="flex items-center justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadBefore}
|
||||
disabled={loadingBefore}
|
||||
className="gap-2"
|
||||
>
|
||||
{loadingBefore ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{loadingBefore
|
||||
? "Loading..."
|
||||
: `Load ${Math.min(EXPAND_SIZE, absoluteStart)} earlier chunks`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chunks */}
|
||||
<div className="space-y-4">
|
||||
{allChunks.map((chunk, idx) => {
|
||||
const isCited = chunk.id === chunkId;
|
||||
const chunkNumber = absoluteStart + idx + 1;
|
||||
return (
|
||||
<ChunkCard
|
||||
key={chunk.id}
|
||||
ref={isCited ? citedChunkRefCallback : undefined}
|
||||
chunk={chunk}
|
||||
localIndex={idx}
|
||||
chunkNumber={chunkNumber}
|
||||
totalChunks={totalChunks}
|
||||
isCited={isCited}
|
||||
isActive={activeChunkIndex === idx}
|
||||
disableLayoutAnimation={allChunks.length > 30}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Load Later */}
|
||||
{canLoadAfter && (
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadAfter}
|
||||
disabled={loadingAfter}
|
||||
className="gap-2"
|
||||
>
|
||||
{loadingAfter ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{loadingAfter
|
||||
? "Loading..."
|
||||
: `Load ${Math.min(EXPAND_SIZE, totalChunks - absoluteEnd)} later chunks`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
if (!mounted) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{createPortal(panelContent, globalThis.document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -398,7 +398,8 @@ export function ReportPanelContent({
|
|||
</Button>
|
||||
);
|
||||
|
||||
const editingActions = showReportEditingTier &&
|
||||
const editingActions =
|
||||
showReportEditingTier &&
|
||||
!isReadOnly &&
|
||||
(isEditing ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
AlertCircle,
|
||||
Dot,
|
||||
FileText,
|
||||
Info,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { AlertCircle, Dot, FileText, Info, Pencil, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { deleteNewLLMConfigMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@ import { useAtomValue } from "jotai";
|
|||
import {
|
||||
Bot,
|
||||
ChevronRight,
|
||||
ScanEye,
|
||||
Pencil,
|
||||
FileText,
|
||||
Earth,
|
||||
FileText,
|
||||
Image,
|
||||
Logs,
|
||||
type LucideIcon,
|
||||
|
|
@ -16,11 +14,13 @@ import {
|
|||
MessageSquare,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
Unplug,
|
||||
Pencil,
|
||||
ScanEye,
|
||||
Settings,
|
||||
Shield,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
Unplug,
|
||||
Users,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
|
|
@ -462,9 +462,19 @@ function RolesContent({
|
|||
|
||||
return (
|
||||
<div key={role.id} className="rounded-lg border border-border/60 overflow-hidden">
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: row contains nested interactive elements (DropdownMenu); using a <button> would produce invalid nested-button markup */}
|
||||
<div
|
||||
className="flex items-center gap-4 p-4 transition-colors hover:bg-muted/30 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
className="flex items-center gap-4 p-4 transition-colors hover:bg-muted/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setExpandedRoleId(isExpanded ? null : role.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -682,9 +692,19 @@ function PermissionsEditor({
|
|||
|
||||
return (
|
||||
<div key={category} className="rounded-lg border border-border/60 overflow-hidden">
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: row contains a nested interactive Checkbox; using a <button> would produce invalid nested-button markup */}
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/40 transition-colors cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/40 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => toggleCategoryExpanded(category)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggleCategoryExpanded(category);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 flex items-center gap-2.5">
|
||||
<IconComponent className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { Brain, CircleUser, Globe, Keyboard, KeyRound, Monitor, ReceiptText, Sparkles } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
Brain,
|
||||
CircleUser,
|
||||
Globe,
|
||||
Keyboard,
|
||||
KeyRound,
|
||||
Monitor,
|
||||
ReceiptText,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo } from "react";
|
||||
|
|
@ -53,9 +64,9 @@ const DesktopContent = dynamic(
|
|||
);
|
||||
const DesktopShortcutsContent = dynamic(
|
||||
() =>
|
||||
import("@/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent").then(
|
||||
(m) => ({ default: m.DesktopShortcutsContent })
|
||||
),
|
||||
import(
|
||||
"@/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent"
|
||||
).then((m) => ({ default: m.DesktopShortcutsContent })),
|
||||
{ ssr: false }
|
||||
);
|
||||
const MemoryContent = dynamic(
|
||||
|
|
@ -65,6 +76,20 @@ const MemoryContent = dynamic(
|
|||
),
|
||||
{ ssr: false }
|
||||
);
|
||||
const AgentPermissionsContent = dynamic(
|
||||
() =>
|
||||
import(
|
||||
"@/app/dashboard/[search_space_id]/user-settings/components/AgentPermissionsContent"
|
||||
).then((m) => ({ default: m.AgentPermissionsContent })),
|
||||
{ ssr: false }
|
||||
);
|
||||
const AgentStatusContent = dynamic(
|
||||
() =>
|
||||
import("@/app/dashboard/[search_space_id]/user-settings/components/AgentStatusContent").then(
|
||||
(m) => ({ default: m.AgentStatusContent })
|
||||
),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export function UserSettingsDialog() {
|
||||
const t = useTranslations("userSettings");
|
||||
|
|
@ -94,6 +119,16 @@ export function UserSettingsDialog() {
|
|||
label: "Memory",
|
||||
icon: <Brain className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "agent-permissions",
|
||||
label: "Agent Permissions",
|
||||
icon: <ShieldCheck className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "agent-status",
|
||||
label: "Agent Status",
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "purchases",
|
||||
label: "Purchase History",
|
||||
|
|
@ -132,6 +167,8 @@ export function UserSettingsDialog() {
|
|||
{state.initialTab === "prompts" && <PromptsContent />}
|
||||
{state.initialTab === "community-prompts" && <CommunityPromptsContent />}
|
||||
{state.initialTab === "memory" && <MemoryContent />}
|
||||
{state.initialTab === "agent-permissions" && <AgentPermissionsContent />}
|
||||
{state.initialTab === "agent-status" && <AgentStatusContent />}
|
||||
{state.initialTab === "purchases" && <PurchaseHistoryContent />}
|
||||
{state.initialTab === "desktop" && <DesktopContent />}
|
||||
{state.initialTab === "desktop-shortcuts" && <DesktopShortcutsContent />}
|
||||
|
|
|
|||
187
surfsense_web/components/tool-ui/doom-loop-approval.tsx
Normal file
187
surfsense_web/components/tool-ui/doom-loop-approval.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"use client";
|
||||
|
||||
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import { CornerDownLeftIcon, OctagonAlert } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
/**
|
||||
* Specialized HITL card for ``DoomLoopMiddleware`` interrupts. The
|
||||
* backend signals these by setting ``context.permission === "doom_loop"``
|
||||
* on the ``permission_ask`` interrupt.
|
||||
*
|
||||
* The card replaces the generic "approve/reject" framing with a
|
||||
* "continue/stop" affordance that better matches the user's mental
|
||||
* model: the agent is stuck repeating itself, not asking permission
|
||||
* for a destructive action.
|
||||
*/
|
||||
function DoomLoopCard({
|
||||
toolName,
|
||||
args,
|
||||
interruptData,
|
||||
onDecision,
|
||||
}: {
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
|
||||
const context = (interruptData.context ?? {}) as Record<string, unknown>;
|
||||
const threshold = typeof context.threshold === "number" ? context.threshold : 3;
|
||||
const stuckTool = (typeof context.tool === "string" && context.tool) || toolName;
|
||||
const recentSignatures = Array.isArray(context.recent_signatures)
|
||||
? (context.recent_signatures as string[])
|
||||
: [];
|
||||
const displayName = stuckTool.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
const argPreview = useMemo(() => {
|
||||
if (!args || Object.keys(args).length === 0) return null;
|
||||
try {
|
||||
const json = JSON.stringify(args, null, 2);
|
||||
return json.length > 600 ? `${json.slice(0, 600)}…` : json;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [args]);
|
||||
|
||||
const handleContinue = useCallback(() => {
|
||||
if (phase !== "pending") return;
|
||||
setProcessing();
|
||||
onDecision({ type: "approve" });
|
||||
}, [phase, setProcessing, onDecision]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (phase !== "pending") return;
|
||||
setRejected();
|
||||
onDecision({ type: "reject", message: "Doom loop: user requested stop." });
|
||||
}, [phase, setRejected, onDecision]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (phase !== "pending") return;
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleStop();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [phase, handleStop]);
|
||||
|
||||
const isResolved = phase === "complete" || phase === "rejected";
|
||||
|
||||
return (
|
||||
<Alert variant={phase === "rejected" ? "default" : "destructive"} className="my-4 max-w-lg">
|
||||
<OctagonAlert className="size-4" />
|
||||
<AlertTitle className="flex items-center gap-2">
|
||||
<span>
|
||||
{phase === "rejected"
|
||||
? "Stopped"
|
||||
: phase === "processing"
|
||||
? "Continuing…"
|
||||
: phase === "complete"
|
||||
? "Continued"
|
||||
: "I might be stuck"}
|
||||
</span>
|
||||
{!isResolved && (
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
doom-loop
|
||||
</Badge>
|
||||
)}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-3">
|
||||
{phase === "processing" ? (
|
||||
<TextShimmerLoader text="Resuming…" size="sm" />
|
||||
) : phase === "rejected" ? (
|
||||
<p className="text-xs">
|
||||
I stopped retrying <span className="font-medium">{displayName}</span> as you asked.
|
||||
</p>
|
||||
) : phase === "complete" ? (
|
||||
<p className="text-xs">
|
||||
Continuing to call <span className="font-medium">{displayName}</span> as you asked.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs">
|
||||
I called <span className="font-medium">{displayName}</span> {threshold} times in a row
|
||||
with similar arguments. Should I keep going or stop and rethink?
|
||||
</p>
|
||||
)}
|
||||
|
||||
{argPreview && phase === "pending" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Last arguments
|
||||
</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-md bg-muted/50 p-2 text-[11px] text-foreground/80">
|
||||
{argPreview}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{recentSignatures.length > 0 && phase === "pending" && (
|
||||
<details className="text-[11px] text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none">
|
||||
Show repeated signatures ({recentSignatures.length})
|
||||
</summary>
|
||||
<ul className="mt-1 ml-4 list-disc">
|
||||
{recentSignatures.map((sig) => (
|
||||
<li key={sig} className="font-mono break-all">
|
||||
{sig}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{phase === "pending" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="rounded-lg gap-1.5" onClick={handleStop}>
|
||||
Stop and rethink
|
||||
<CornerDownLeftIcon className="size-3 opacity-60" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleContinue}>
|
||||
Continue anyway
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export const DoomLoopApprovalToolUI: ToolCallMessagePartComponent = ({
|
||||
toolName,
|
||||
args,
|
||||
result,
|
||||
}) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result || !isInterruptResult(result)) return null;
|
||||
|
||||
return (
|
||||
<DoomLoopCard
|
||||
toolName={toolName}
|
||||
args={args as Record<string, unknown>}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export function isDoomLoopInterrupt(result: unknown): boolean {
|
||||
if (!isInterruptResult(result)) return false;
|
||||
const ctx = (result.context ?? {}) as Record<string, unknown>;
|
||||
return ctx.permission === "doom_loop";
|
||||
}
|
||||
|
|
@ -118,7 +118,9 @@ function GenericApprovalCard({
|
|||
setProcessing();
|
||||
onDecision({ type: "approve" });
|
||||
connectorsApiService.trustMCPTool(mcpConnectorId, toolName).catch(() => {
|
||||
toast.error("Failed to save 'Always Allow' preference. The tool will still require approval next time.");
|
||||
toast.error(
|
||||
"Failed to save 'Always Allow' preference. The tool will still require approval next time."
|
||||
);
|
||||
});
|
||||
}, [phase, setProcessing, onDecision, isMCPTool, mcpConnectorId, toolName]);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@
|
|||
|
||||
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { ClockIcon, CornerDownLeftIcon, GlobeIcon, MapPinIcon, Pencil, UsersIcon } from "lucide-react";
|
||||
import {
|
||||
ClockIcon,
|
||||
CornerDownLeftIcon,
|
||||
GlobeIcon,
|
||||
MapPinIcon,
|
||||
Pencil,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
|
||||
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
||||
|
|
|
|||
121
surfsense_web/content/docs/connectors/baidu-search.mdx
Normal file
121
surfsense_web/content/docs/connectors/baidu-search.mdx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
---
|
||||
title: Baidu Search
|
||||
description: Search the Chinese web with Baidu AI Search in SurfSense
|
||||
---
|
||||
|
||||
# Baidu Search Integration Setup Guide
|
||||
|
||||
This guide walks you through connecting Baidu AI Search to SurfSense for Chinese web search and AI-powered research.
|
||||
|
||||
## How it works
|
||||
|
||||
The Baidu Search connector uses Baidu AI Search through Qianfan AppBuilder's intelligent search generation API. It is a live search connector: SurfSense queries Baidu when the assistant needs current web results instead of periodically indexing content into your knowledge base.
|
||||
|
||||
- Baidu Search is best for Simplified Chinese queries and China-focused web content.
|
||||
- Results are merged with SurfSense's other configured web search engines.
|
||||
- The connector returns Baidu references as sources that can be cited in chat responses.
|
||||
|
||||
---
|
||||
|
||||
## Authorization
|
||||
|
||||
<Callout type="info" title="API Key Required">
|
||||
You need a Baidu Qianfan AppBuilder API key to use this connector. The key is encrypted and stored securely by SurfSense.
|
||||
</Callout>
|
||||
|
||||
### Step 1: Get Your Baidu AI Search API Key
|
||||
|
||||
1. Open the [Baidu AI Search product page](https://cloud.baidu.com/product/ai-search.html) and sign in with your Baidu Cloud account.
|
||||
2. Open Qianfan AppBuilder or the AI Search console from Baidu Cloud.
|
||||
3. Create or select an application that has access to Baidu AI Search.
|
||||
4. Generate an API key for the application.
|
||||
5. Copy the API key. SurfSense uses it as the `BAIDU_API_KEY` connector setting.
|
||||
|
||||
<Callout type="warn">
|
||||
Keep this key private. Do not paste it into chat messages, issue reports, screenshots, or public repositories.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Connecting to SurfSense
|
||||
|
||||
1. Navigate to **Connectors** → **Add Connector** → **Baidu Search**.
|
||||
2. Fill in the required fields:
|
||||
|
||||
| Field | Description | Example |
|
||||
|-------|-------------|---------|
|
||||
| **Connector Name** | A friendly name to identify this connector | `Baidu Search` |
|
||||
| **Baidu AppBuilder API Key** | Your Qianfan AppBuilder API key | `bce-v3/...` |
|
||||
|
||||
3. Click **Connect** to save the connector.
|
||||
4. Ask a current Chinese web query in chat, such as `今天中国人工智能行业有什么重要新闻?`.
|
||||
|
||||
### Optional Advanced Settings
|
||||
|
||||
SurfSense stores advanced Baidu options in the connector config. If your deployment exposes these fields, use the following values:
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| `BAIDU_MODEL` | The model Baidu AI Search uses for answer generation | `ernie-3.5-8k` |
|
||||
| `BAIDU_SEARCH_SOURCE` | Baidu search source version | `baidu_search_v2` |
|
||||
| `BAIDU_ENABLE_DEEP_SEARCH` | Enables Baidu's deeper search mode when supported by your account | `false` |
|
||||
|
||||
SurfSense calls Baidu's intelligent search generation endpoint:
|
||||
|
||||
```text
|
||||
POST https://qianfan.baidubce.com/v2/ai_search/chat/completions
|
||||
```
|
||||
|
||||
For request and response details, see Baidu's [intelligent search generation API documentation](https://cloud.baidu.com/doc/qianfan/s/Omh4su4s0).
|
||||
|
||||
---
|
||||
|
||||
## When to Use Baidu Search
|
||||
|
||||
| Use Case | Why Baidu Search Helps |
|
||||
|----------|------------------------|
|
||||
| Chinese news and current events | Better coverage for China-focused sources |
|
||||
| Chinese company, product, or policy research | More local web results than global search engines alone |
|
||||
| Mandarin-language fact finding | Native Chinese search and summarization behavior |
|
||||
| Cross-checking web search | Adds another source alongside SearXNG, Tavily, or Linkup |
|
||||
|
||||
<Callout type="info" title="Live Search Connector">
|
||||
Baidu Search does not create indexed documents in your knowledge base. It runs when the assistant calls web search, then returns live sources for that answer.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**No Baidu results appear**
|
||||
|
||||
- Confirm the Baidu Search connector is active in the current search space.
|
||||
- Try a Chinese query with clear search intent, for example `百度智能云千帆 AppBuilder 最新功能`.
|
||||
- Check whether other web search engines are returning results. If none are, review the general [Web Search](/docs/how-to/web-search) setup.
|
||||
|
||||
**Authentication failed**
|
||||
|
||||
- Verify that the API key was copied from Qianfan AppBuilder, not another Baidu Cloud product.
|
||||
- Regenerate the API key if it was rotated, expired, or copied with extra whitespace.
|
||||
- Make sure the related application has access to Baidu AI Search.
|
||||
|
||||
**Requests time out**
|
||||
|
||||
- Baidu AI Search can take longer than ordinary keyword search because it performs search and summarization.
|
||||
- Retry with a narrower query.
|
||||
- If you self-host SurfSense, verify that the backend container can reach `qianfan.baidubce.com`.
|
||||
|
||||
**Results are not relevant**
|
||||
|
||||
- Use Chinese keywords for China-focused topics.
|
||||
- Include entity names, dates, or locations in the query.
|
||||
- Compare with SearXNG or another configured live search connector for broader coverage.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- The Baidu Search connector appears in your connector list.
|
||||
- A Chinese current-events query triggers web search in chat.
|
||||
- Chat responses include Baidu-backed sources with titles and URLs.
|
||||
- Invalid API keys fail without breaking other configured search engines.
|
||||
|
|
@ -83,6 +83,11 @@ Connect SurfSense to your favorite tools and services. Browse the available inte
|
|||
description="Connect your GitHub repositories to SurfSense"
|
||||
href="/docs/connectors/github"
|
||||
/>
|
||||
<Card
|
||||
title="Baidu Search"
|
||||
description="Search the Chinese web with Baidu AI Search"
|
||||
href="/docs/connectors/baidu-search"
|
||||
/>
|
||||
<Card
|
||||
title="Luma"
|
||||
description="Connect your Luma events to SurfSense"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"airtable",
|
||||
"clickup",
|
||||
"github",
|
||||
"baidu-search",
|
||||
"luma",
|
||||
"circleback",
|
||||
"elasticsearch",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ description: How SurfSense web search works and how to configure it for producti
|
|||
|
||||
SurfSense uses [SearXNG](https://docs.searxng.org/) as a bundled meta-search engine to provide web search across all search spaces. SearXNG aggregates results from multiple search engines (Google, DuckDuckGo, Brave, Bing, and more) without requiring any API keys.
|
||||
|
||||
You can also add live search connectors such as Baidu Search, Tavily, and Linkup to a search space. When those connectors are active, SurfSense queries them in parallel with SearXNG and merges the results before passing sources to the assistant.
|
||||
|
||||
## How It Works
|
||||
|
||||
When a user triggers a web search in SurfSense:
|
||||
|
|
@ -14,10 +16,25 @@ When a user triggers a web search in SurfSense:
|
|||
1. The backend sends a query to the bundled SearXNG instance via its JSON API
|
||||
2. SearXNG fans out the query to all enabled search engines simultaneously
|
||||
3. Results are aggregated, deduplicated, and ranked by engine weight
|
||||
4. The backend receives merged results and presents them to the user
|
||||
4. If the current search space has live search connectors, the backend queries them in parallel
|
||||
5. The backend deduplicates the merged results and presents them to the user
|
||||
|
||||
SearXNG runs as a Docker container alongside the backend. It is never exposed to the internet. Only the backend communicates with it over the internal Docker network.
|
||||
|
||||
## Live Search Connectors
|
||||
|
||||
Live search connectors are optional API-backed search providers configured per search space. They are useful when you need a specialized index, authenticated search API, or stronger regional coverage.
|
||||
|
||||
| Connector | Best For | Setup |
|
||||
|-----------|----------|-------|
|
||||
| Baidu Search | Chinese web search and China-focused current information | [Baidu Search connector](/docs/connectors/baidu-search) |
|
||||
| Tavily | General web research through Tavily's search API | Add the Tavily connector from the Connectors dashboard |
|
||||
| Linkup | General web search through Linkup's search API | Add the Linkup connector from the Connectors dashboard |
|
||||
|
||||
<Callout type="info" title="Search Space Scoped">
|
||||
Live search connectors only run for the search space where they are configured. They do not replace SearXNG globally.
|
||||
</Callout>
|
||||
|
||||
## Docker Setup
|
||||
|
||||
SearXNG is included in both `docker-compose.yml` and `docker-compose.dev.yml` and works out of the box with no configuration needed.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import {
|
||||
BookOpen,
|
||||
Brain,
|
||||
FileUser,
|
||||
FileText,
|
||||
FileUser,
|
||||
Film,
|
||||
Globe,
|
||||
ImageIcon,
|
||||
|
|
|
|||
64
surfsense_web/lib/apis/agent-actions-api.service.ts
Normal file
64
surfsense_web/lib/apis/agent-actions-api.service.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { z } from "zod";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
const AgentActionReadSchema = z.object({
|
||||
id: z.number(),
|
||||
thread_id: z.number(),
|
||||
user_id: z.string().nullable(),
|
||||
search_space_id: z.number(),
|
||||
tool_name: z.string(),
|
||||
args: z.record(z.string(), z.unknown()).nullable(),
|
||||
result_id: z.string().nullable(),
|
||||
reversible: z.boolean(),
|
||||
reverse_descriptor: z.record(z.string(), z.unknown()).nullable(),
|
||||
error: z.record(z.string(), z.unknown()).nullable(),
|
||||
reverse_of: z.number().nullable(),
|
||||
reverted_by_action_id: z.number().nullable(),
|
||||
is_revert_action: z.boolean(),
|
||||
created_at: z.string(),
|
||||
});
|
||||
|
||||
export type AgentAction = z.infer<typeof AgentActionReadSchema>;
|
||||
|
||||
const AgentActionListResponseSchema = z.object({
|
||||
items: z.array(AgentActionReadSchema),
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
page_size: z.number(),
|
||||
has_more: z.boolean(),
|
||||
});
|
||||
|
||||
export type AgentActionListResponse = z.infer<typeof AgentActionListResponseSchema>;
|
||||
|
||||
const RevertResponseSchema = z.object({
|
||||
status: z.literal("ok"),
|
||||
message: z.string(),
|
||||
new_action_id: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export type RevertResponse = z.infer<typeof RevertResponseSchema>;
|
||||
|
||||
class AgentActionsApiService {
|
||||
listForThread = async (
|
||||
threadId: number,
|
||||
opts: { page?: number; pageSize?: number } = {}
|
||||
): Promise<AgentActionListResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(opts.page ?? 0));
|
||||
params.set("page_size", String(opts.pageSize ?? 50));
|
||||
return baseApiService.get(
|
||||
`/api/v1/threads/${threadId}/actions?${params.toString()}`,
|
||||
AgentActionListResponseSchema
|
||||
);
|
||||
};
|
||||
|
||||
revert = async (threadId: number, actionId: number): Promise<RevertResponse> => {
|
||||
return baseApiService.post(
|
||||
`/api/v1/threads/${threadId}/revert/${actionId}`,
|
||||
RevertResponseSchema,
|
||||
{ body: {} }
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const agentActionsApiService = new AgentActionsApiService();
|
||||
40
surfsense_web/lib/apis/agent-flags-api.service.ts
Normal file
40
surfsense_web/lib/apis/agent-flags-api.service.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { z } from "zod";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
const AgentFeatureFlagsSchema = z.object({
|
||||
disable_new_agent_stack: z.boolean(),
|
||||
|
||||
enable_context_editing: z.boolean(),
|
||||
enable_compaction_v2: z.boolean(),
|
||||
enable_retry_after: z.boolean(),
|
||||
enable_model_fallback: z.boolean(),
|
||||
enable_model_call_limit: z.boolean(),
|
||||
enable_tool_call_limit: z.boolean(),
|
||||
enable_tool_call_repair: z.boolean(),
|
||||
enable_doom_loop: z.boolean(),
|
||||
|
||||
enable_permission: z.boolean(),
|
||||
enable_busy_mutex: z.boolean(),
|
||||
enable_llm_tool_selector: z.boolean(),
|
||||
|
||||
enable_skills: z.boolean(),
|
||||
enable_specialized_subagents: z.boolean(),
|
||||
enable_kb_planner_runnable: z.boolean(),
|
||||
|
||||
enable_action_log: z.boolean(),
|
||||
enable_revert_route: z.boolean(),
|
||||
|
||||
enable_plugin_loader: z.boolean(),
|
||||
|
||||
enable_otel: z.boolean(),
|
||||
});
|
||||
|
||||
export type AgentFeatureFlags = z.infer<typeof AgentFeatureFlagsSchema>;
|
||||
|
||||
class AgentFlagsApiService {
|
||||
get = async (): Promise<AgentFeatureFlags> => {
|
||||
return baseApiService.get(`/api/v1/agent/flags`, AgentFeatureFlagsSchema);
|
||||
};
|
||||
}
|
||||
|
||||
export const agentFlagsApiService = new AgentFlagsApiService();
|
||||
90
surfsense_web/lib/apis/agent-permissions-api.service.ts
Normal file
90
surfsense_web/lib/apis/agent-permissions-api.service.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { z } from "zod";
|
||||
import { ValidationError } from "@/lib/error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
const ActionEnum = z.enum(["allow", "deny", "ask"]);
|
||||
export type AgentPermissionAction = z.infer<typeof ActionEnum>;
|
||||
|
||||
const AgentPermissionRuleSchema = z.object({
|
||||
id: z.number(),
|
||||
search_space_id: z.number(),
|
||||
user_id: z.string().nullable(),
|
||||
thread_id: z.number().nullable(),
|
||||
permission: z.string(),
|
||||
pattern: z.string(),
|
||||
action: ActionEnum,
|
||||
created_at: z.string(),
|
||||
});
|
||||
|
||||
export type AgentPermissionRule = z.infer<typeof AgentPermissionRuleSchema>;
|
||||
|
||||
const AgentPermissionRuleListSchema = z.array(AgentPermissionRuleSchema);
|
||||
|
||||
const AgentPermissionRuleCreateSchema = z.object({
|
||||
permission: z
|
||||
.string()
|
||||
.min(1, "Permission is required")
|
||||
.max(255)
|
||||
.regex(/^[a-zA-Z0-9_:.\-*]+$/, "Use letters, digits, '.', '_', ':', '-', or '*' wildcards."),
|
||||
pattern: z.string().min(1).max(255).default("*"),
|
||||
action: ActionEnum,
|
||||
user_id: z.string().nullable().optional(),
|
||||
thread_id: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export type AgentPermissionRuleCreate = z.infer<typeof AgentPermissionRuleCreateSchema>;
|
||||
|
||||
const AgentPermissionRuleUpdateSchema = z.object({
|
||||
pattern: z.string().min(1).max(255).optional(),
|
||||
action: ActionEnum.optional(),
|
||||
});
|
||||
|
||||
export type AgentPermissionRuleUpdate = z.infer<typeof AgentPermissionRuleUpdateSchema>;
|
||||
|
||||
class AgentPermissionsApiService {
|
||||
list = async (searchSpaceId: number): Promise<AgentPermissionRule[]> => {
|
||||
return baseApiService.get(
|
||||
`/api/v1/searchspaces/${searchSpaceId}/agent/permissions/rules`,
|
||||
AgentPermissionRuleListSchema
|
||||
);
|
||||
};
|
||||
|
||||
create = async (
|
||||
searchSpaceId: number,
|
||||
payload: AgentPermissionRuleCreate
|
||||
): Promise<AgentPermissionRule> => {
|
||||
const parsed = AgentPermissionRuleCreateSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
return baseApiService.post(
|
||||
`/api/v1/searchspaces/${searchSpaceId}/agent/permissions/rules`,
|
||||
AgentPermissionRuleSchema,
|
||||
{ body: parsed.data }
|
||||
);
|
||||
};
|
||||
|
||||
update = async (
|
||||
searchSpaceId: number,
|
||||
ruleId: number,
|
||||
payload: AgentPermissionRuleUpdate
|
||||
): Promise<AgentPermissionRule> => {
|
||||
const parsed = AgentPermissionRuleUpdateSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
return baseApiService.patch(
|
||||
`/api/v1/searchspaces/${searchSpaceId}/agent/permissions/rules/${ruleId}`,
|
||||
AgentPermissionRuleSchema,
|
||||
{ body: parsed.data }
|
||||
);
|
||||
};
|
||||
|
||||
remove = async (searchSpaceId: number, ruleId: number): Promise<void> => {
|
||||
await baseApiService.delete(
|
||||
`/api/v1/searchspaces/${searchSpaceId}/agent/permissions/rules/${ruleId}`
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const agentPermissionsApiService = new AgentPermissionsApiService();
|
||||
120
surfsense_web/lib/chat/display-media-capture.ts
Normal file
120
surfsense_web/lib/chat/display-media-capture.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/** `getDisplayMedia` → single PNG frame (data URL). */
|
||||
function getImageCaptureCtor():
|
||||
| (new (
|
||||
track: MediaStreamTrack
|
||||
) => { grabFrame: () => Promise<ImageBitmap> })
|
||||
| undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const IC = (
|
||||
window as unknown as {
|
||||
ImageCapture?: new (track: MediaStreamTrack) => { grabFrame: () => Promise<ImageBitmap> };
|
||||
}
|
||||
).ImageCapture;
|
||||
return typeof IC === "function" ? IC : undefined;
|
||||
}
|
||||
|
||||
function stopAllTracks(stream: MediaStream): void {
|
||||
for (const t of stream.getTracks()) {
|
||||
t.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function captureTrackToPngDataUrl(
|
||||
track: MediaStreamTrack,
|
||||
stream: MediaStream
|
||||
): Promise<string | null> {
|
||||
const ImageCtor = getImageCaptureCtor();
|
||||
if (ImageCtor !== undefined) {
|
||||
try {
|
||||
const ic = new ImageCtor(track);
|
||||
const bitmap = await ic.grabFrame();
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
stopAllTracks(stream);
|
||||
return null;
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
stopAllTracks(stream);
|
||||
return canvas.toDataURL("image/png");
|
||||
} finally {
|
||||
if ("close" in bitmap && typeof bitmap.close === "function") {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fall through to <video> */
|
||||
}
|
||||
}
|
||||
|
||||
const videoEl = document.createElement("video");
|
||||
videoEl.srcObject = stream;
|
||||
videoEl.muted = true;
|
||||
const haveCurrentData = 2;
|
||||
const dataReady = new Promise<void>((resolve) => {
|
||||
if (videoEl.readyState >= haveCurrentData) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
videoEl.addEventListener("loadeddata", () => resolve(), { once: true });
|
||||
});
|
||||
await videoEl.play();
|
||||
await Promise.race([
|
||||
dataReady,
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
}),
|
||||
]);
|
||||
const w = videoEl.videoWidth;
|
||||
const h = videoEl.videoHeight;
|
||||
if (!w || !h) {
|
||||
stopAllTracks(stream);
|
||||
return null;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
stopAllTracks(stream);
|
||||
return null;
|
||||
}
|
||||
ctx.drawImage(videoEl, 0, 0);
|
||||
stopAllTracks(stream);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
export async function captureDisplayToPngDataUrl(): Promise<string | null> {
|
||||
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
|
||||
return null;
|
||||
}
|
||||
let stream: MediaStream | null = null;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { frameRate: { ideal: 1, max: 5 } },
|
||||
audio: false,
|
||||
selfBrowserSurface: "exclude",
|
||||
} as Parameters<MediaDevices["getDisplayMedia"]>[0]);
|
||||
|
||||
const track = stream.getVideoTracks()[0];
|
||||
if (!track) {
|
||||
stopAllTracks(stream);
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataUrl = await captureTrackToPngDataUrl(track, stream);
|
||||
stream = null;
|
||||
return dataUrl;
|
||||
} catch (e) {
|
||||
if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
|
||||
console.warn("[captureDisplayToPngDataUrl]", e);
|
||||
}
|
||||
if (stream) {
|
||||
stopAllTracks(stream);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
8
surfsense_web/lib/chat/mention-doc-key.ts
Normal file
8
surfsense_web/lib/chat/mention-doc-key.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
type MentionKeyInput = {
|
||||
id: number;
|
||||
document_type?: string | null;
|
||||
};
|
||||
|
||||
export function getMentionDocKey(doc: MentionKeyInput): string {
|
||||
return `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
|
||||
}
|
||||
56
surfsense_web/lib/chat/user-turn-api-parts.ts
Normal file
56
surfsense_web/lib/chat/user-turn-api-parts.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { AppendMessage } from "@assistant-ui/react";
|
||||
|
||||
const MAX_IMAGES = 4;
|
||||
|
||||
export type NewChatUserImagePayload = {
|
||||
media_type: "image/png" | "image/jpeg" | "image/webp";
|
||||
data: string;
|
||||
};
|
||||
|
||||
function dataUrlToPayload(dataUrl: string): NewChatUserImagePayload | null {
|
||||
const m = /^data:(image\/(?:png|jpeg|webp|jpg));base64,([\s\S]+)$/i.exec(dataUrl.trim());
|
||||
if (!m) return null;
|
||||
let media = m[1].toLowerCase() as string;
|
||||
if (media === "image/jpg") media = "image/jpeg";
|
||||
if (media !== "image/png" && media !== "image/jpeg" && media !== "image/webp") return null;
|
||||
const data = m[2].replace(/\s/g, "");
|
||||
if (!data) return null;
|
||||
return { media_type: media as NewChatUserImagePayload["media_type"], data };
|
||||
}
|
||||
|
||||
function collectImageDataUrlsFromParts(parts: AppendMessage["content"]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (typeof part !== "object" || part === null || !("type" in part)) continue;
|
||||
if (part.type !== "image") continue;
|
||||
const img = "image" in part && typeof part.image === "string" ? part.image : null;
|
||||
if (img && dataUrlToPayload(img)) out.push(img);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function extractUserTurnForNewChatApi(
|
||||
message: AppendMessage,
|
||||
extraDataUrls: readonly string[]
|
||||
): { userQuery: string; userImages: NewChatUserImagePayload[] } {
|
||||
let userQuery = "";
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
userQuery += part.text;
|
||||
}
|
||||
}
|
||||
|
||||
const merged = [...extraDataUrls, ...collectImageDataUrlsFromParts(message.content)];
|
||||
const payloads: NewChatUserImagePayload[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const url of merged) {
|
||||
const p = dataUrlToPayload(url);
|
||||
if (!p) continue;
|
||||
if (seen.has(p.data)) continue;
|
||||
seen.add(p.data);
|
||||
payloads.push(p);
|
||||
if (payloads.length >= MAX_IMAGES) break;
|
||||
}
|
||||
|
||||
return { userQuery, userImages: payloads };
|
||||
}
|
||||
|
|
@ -1,12 +1,39 @@
|
|||
import { loader } from "fumadocs-core/source";
|
||||
import { icons } from "lucide-react";
|
||||
import {
|
||||
BookOpen,
|
||||
ClipboardCheck,
|
||||
Compass,
|
||||
Container,
|
||||
Download,
|
||||
FlaskConical,
|
||||
Heart,
|
||||
Unplug,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { createElement } from "react";
|
||||
import { docs } from "@/.source/server";
|
||||
|
||||
/** Explicit whitelist of Lucide icons used in docs frontmatter / meta.json.
|
||||
* Importing the full `icons` barrel would pull every Lucide icon (~1 400 SVGs)
|
||||
* into the docs bundle even though only a handful are referenced. Add new icons
|
||||
* here as docs pages are added.
|
||||
*/
|
||||
const DOCS_ICONS: Record<string, React.ComponentType> = {
|
||||
BookOpen,
|
||||
ClipboardCheck,
|
||||
Compass,
|
||||
Container,
|
||||
Download,
|
||||
FlaskConical,
|
||||
Heart,
|
||||
Unplug,
|
||||
Wrench,
|
||||
};
|
||||
|
||||
export const source = loader({
|
||||
baseUrl: "/docs",
|
||||
source: docs.toFumadocsSource(),
|
||||
icon(icon) {
|
||||
if (icon && icon in icons) return createElement(icons[icon as keyof typeof icons]);
|
||||
if (icon && icon in DOCS_ICONS) return createElement(DOCS_ICONS[icon]);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 3.5 MiB |
44
surfsense_web/types/window.d.ts
vendored
44
surfsense_web/types/window.d.ts
vendored
|
|
@ -93,6 +93,7 @@ interface ElectronAPI {
|
|||
openExternal: (url: string) => void;
|
||||
getAppVersion: () => Promise<string>;
|
||||
onDeepLink: (callback: (url: string) => void) => () => void;
|
||||
onChatScreenCapture: (callback: (dataUrl: string) => void) => () => void;
|
||||
getQuickAskText: () => Promise<string>;
|
||||
setQuickAskMode: (mode: string) => Promise<void>;
|
||||
getQuickAskMode: () => Promise<string>;
|
||||
|
|
@ -104,20 +105,8 @@ interface ElectronAPI {
|
|||
}>;
|
||||
requestAccessibility: () => Promise<void>;
|
||||
requestScreenRecording: () => Promise<void>;
|
||||
captureFullScreen: () => Promise<string | null>;
|
||||
restartApp: () => Promise<void>;
|
||||
// Autocomplete
|
||||
onAutocompleteContext: (
|
||||
callback: (data: {
|
||||
screenshot: string;
|
||||
searchSpaceId?: string;
|
||||
appName?: string;
|
||||
windowTitle?: string;
|
||||
}) => void
|
||||
) => () => void;
|
||||
acceptSuggestion: (text: string) => Promise<void>;
|
||||
dismissSuggestion: () => Promise<void>;
|
||||
setAutocompleteEnabled: (enabled: boolean) => Promise<void>;
|
||||
getAutocompleteEnabled: () => Promise<boolean>;
|
||||
// Folder sync
|
||||
selectFolder: () => Promise<string | null>;
|
||||
addWatchedFolder: (config: WatchedFolderConfig) => Promise<WatchedFolderConfig[]>;
|
||||
|
|
@ -149,10 +138,18 @@ interface ElectronAPI {
|
|||
getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>;
|
||||
setAuthTokens: (bearer: string, refresh: string) => Promise<void>;
|
||||
// Keyboard shortcut configuration
|
||||
getShortcuts: () => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>;
|
||||
getShortcuts: () => Promise<{
|
||||
generalAssist: string;
|
||||
quickAsk: string;
|
||||
screenshotAssist: string;
|
||||
}>;
|
||||
setShortcuts: (
|
||||
config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }>
|
||||
) => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>;
|
||||
config: Partial<{ generalAssist: string; quickAsk: string; screenshotAssist: string }>
|
||||
) => Promise<{
|
||||
generalAssist: string;
|
||||
quickAsk: string;
|
||||
screenshotAssist: string;
|
||||
}>;
|
||||
// Launch on system startup
|
||||
getAutoLaunch: () => Promise<{
|
||||
enabled: boolean;
|
||||
|
|
@ -179,9 +176,7 @@ interface ElectronAPI {
|
|||
// Agent filesystem mode
|
||||
getAgentFilesystemSettings: (searchSpaceId?: number | null) => Promise<AgentFilesystemSettings>;
|
||||
getAgentFilesystemMounts: (searchSpaceId?: number | null) => Promise<AgentFilesystemMount[]>;
|
||||
listAgentFilesystemFiles: (
|
||||
options: AgentFilesystemListOptions
|
||||
) => Promise<FolderFileEntry[]>;
|
||||
listAgentFilesystemFiles: (options: AgentFilesystemListOptions) => Promise<FolderFileEntry[]>;
|
||||
startAgentFilesystemTreeWatch: (
|
||||
options: AgentFilesystemTreeWatchOptions
|
||||
) => Promise<{ ok: true }>;
|
||||
|
|
@ -189,10 +184,13 @@ interface ElectronAPI {
|
|||
onAgentFilesystemTreeDirty: (
|
||||
callback: (data: AgentFilesystemTreeDirtyEvent) => void
|
||||
) => () => void;
|
||||
setAgentFilesystemSettings: (settings: {
|
||||
mode?: AgentFilesystemMode;
|
||||
localRootPaths?: string[] | null;
|
||||
}, searchSpaceId?: number | null) => Promise<AgentFilesystemSettings>;
|
||||
setAgentFilesystemSettings: (
|
||||
settings: {
|
||||
mode?: AgentFilesystemMode;
|
||||
localRootPaths?: string[] | null;
|
||||
},
|
||||
searchSpaceId?: number | null
|
||||
) => Promise<AgentFilesystemSettings>;
|
||||
pickAgentFilesystemRoot: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue