mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: add processing mode support for document uploads and ETL pipeline, improded error handling ux
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions
- Introduced a `ProcessingMode` enum to differentiate between basic and premium processing modes. - Updated `EtlRequest` to include a `processing_mode` field, defaulting to basic. - Enhanced ETL pipeline services to utilize the selected processing mode for Azure Document Intelligence and LlamaCloud parsing. - Modified various routes and services to handle processing mode, affecting document upload and indexing tasks. - Improved error handling and logging to include processing mode details. - Added tests to validate processing mode functionality and its impact on ETL operations.
This commit is contained in:
parent
b659f41bab
commit
656e061f84
104 changed files with 1900 additions and 909 deletions
|
|
@ -42,9 +42,7 @@ export async function generateMetadata(props: {
|
|||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await props.params;
|
||||
const page = (source.getPages() as BlogPageItem[]).find(
|
||||
(p) => p.slugs.join("/") === slug,
|
||||
);
|
||||
const page = (source.getPages() as BlogPageItem[]).find((p) => p.slugs.join("/") === slug);
|
||||
|
||||
if (!page) return {};
|
||||
|
||||
|
|
@ -72,13 +70,9 @@ export async function generateMetadata(props: {
|
|||
};
|
||||
}
|
||||
|
||||
export default async function BlogPostPage(props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
export default async function BlogPostPage(props: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await props.params;
|
||||
const page = (source.getPages() as BlogPageItem[]).find(
|
||||
(p) => p.slugs.join("/") === slug,
|
||||
);
|
||||
const page = (source.getPages() as BlogPageItem[]).find((p) => p.slugs.join("/") === slug);
|
||||
|
||||
if (!page) notFound();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { Container } from "@/components/container";
|
||||
import { format } from "date-fns";
|
||||
import FuzzySearch from "fuzzy-search";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import FuzzySearch from "fuzzy-search";
|
||||
import { Container } from "@/components/container";
|
||||
import type { BlogEntry } from "./page";
|
||||
|
||||
function truncate(text: string, length: number) {
|
||||
|
|
@ -24,8 +24,10 @@ function SearchIcon({ className }: { className?: string }) {
|
|||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
>
|
||||
<title>Search</title>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
|
|
@ -100,9 +102,7 @@ function MagazineFeatured({ blog }: { blog: BlogEntry }) {
|
|||
{blog.author}
|
||||
</span>
|
||||
<span className="text-white/50">·</span>
|
||||
<time dateTime={blog.date}>
|
||||
{format(new Date(blog.date), "MMMM d, yyyy")}
|
||||
</time>
|
||||
<time dateTime={blog.date}>{format(new Date(blog.date), "MMMM d, yyyy")}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -124,7 +124,7 @@ function MagazineSearchGrid({
|
|||
new FuzzySearch(allBlogs, ["title", "description"], {
|
||||
caseSensitive: false,
|
||||
}),
|
||||
[allBlogs],
|
||||
[allBlogs]
|
||||
);
|
||||
|
||||
const [results, setResults] = useState(allBlogs);
|
||||
|
|
@ -192,9 +192,7 @@ function MagazineCard({ blog }: { blog: BlogEntry }) {
|
|||
className="h-full w-full object-cover transition duration-300 group-hover/card:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-neutral-400">
|
||||
No image
|
||||
</div>
|
||||
<div className="flex h-full items-center justify-center text-neutral-400">No image</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-5">
|
||||
|
|
@ -218,9 +216,7 @@ function MagazineCard({ blog }: { blog: BlogEntry }) {
|
|||
height={24}
|
||||
className="h-6 w-6 rounded-full object-cover"
|
||||
/>
|
||||
<span className="text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{blog.author}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-600 dark:text-neutral-300">{blog.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@ import { BlogWithSearchMagazine } from "./blog-magazine";
|
|||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog | SurfSense - AI Search & Knowledge Management",
|
||||
description:
|
||||
"Product updates, tutorials, and tips from the SurfSense team.",
|
||||
description: "Product updates, tutorials, and tips from the SurfSense team.",
|
||||
alternates: {
|
||||
canonical: "https://surfsense.com/blog",
|
||||
},
|
||||
|
|
@ -53,9 +52,7 @@ export default async function BlogPage() {
|
|||
author: page.data.author ?? "SurfSense Team",
|
||||
authorAvatar: page.data.authorAvatar ?? "/logo.png",
|
||||
}))
|
||||
.sort(
|
||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
|
||||
);
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
|
||||
return <BlogWithSearchMagazine blogs={blogs} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { ContactFormGridWithDetails } from "@/components/contact/contact-form";
|
|||
|
||||
export const metadata: Metadata = {
|
||||
title: "Contact | SurfSense",
|
||||
description: "Get in touch with the SurfSense team for enterprise AI search, knowledge management, or partnership inquiries.",
|
||||
description:
|
||||
"Get in touch with the SurfSense team for enterprise AI search, knowledge management, or partnership inquiries.",
|
||||
alternates: {
|
||||
canonical: "https://surfsense.com/contact",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -174,31 +174,31 @@ export function LocalLoginForm() {
|
|||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
{t("password")}
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={`block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
|
||||
error.title
|
||||
? "border-destructive focus:border-destructive focus:ring-destructive/40"
|
||||
: "border-border focus:border-primary focus:ring-primary/40"
|
||||
}`}
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPassword ? t("hide_password") : t("show_password")}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={`block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
|
||||
error.title
|
||||
? "border-destructive focus:border-destructive focus:ring-destructive/40"
|
||||
: "border-border focus:border-primary focus:ring-primary/40"
|
||||
}`}
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPassword ? t("hide_password") : t("show_password")}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import dynamic from "next/dynamic";
|
||||
import { HeroSection } from "@/components/homepage/hero-section";
|
||||
import { AuthRedirect } from "@/components/homepage/auth-redirect";
|
||||
import { FeaturesCards } from "@/components/homepage/features-card";
|
||||
import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid";
|
||||
import { FeaturesCards } from "@/components/homepage/features-card";
|
||||
import { HeroSection } from "@/components/homepage/hero-section";
|
||||
|
||||
const WhySurfSense = dynamic(
|
||||
() => import("@/components/homepage/why-surfsense").then((m) => ({ default: m.WhySurfSense })),
|
||||
const WhySurfSense = dynamic(() =>
|
||||
import("@/components/homepage/why-surfsense").then((m) => ({ default: m.WhySurfSense }))
|
||||
);
|
||||
|
||||
const ExternalIntegrations = dynamic(() => import("@/components/homepage/integrations"));
|
||||
|
||||
const CTAHomepage = dynamic(
|
||||
() => import("@/components/homepage/cta").then((m) => ({ default: m.CTAHomepage })),
|
||||
const CTAHomepage = dynamic(() =>
|
||||
import("@/components/homepage/cta").then((m) => ({ default: m.CTAHomepage }))
|
||||
);
|
||||
|
||||
export default function HomePage() {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Metadata } from "next";
|
||||
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
|
||||
import PricingBasic from "@/components/pricing/pricing-section";
|
||||
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pricing | SurfSense - Free AI Search Plans",
|
||||
|
|
|
|||
|
|
@ -38,11 +38,15 @@ import { removeChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.ato
|
|||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
|
||||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import {
|
||||
createTokenUsageStore,
|
||||
type TokenUsageData,
|
||||
TokenUsageProvider,
|
||||
} 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 { getBearerToken } from "@/lib/auth-utils";
|
||||
import { createTokenUsageStore, TokenUsageProvider, type TokenUsageData } from "@/components/assistant-ui/token-usage-context";
|
||||
import { convertToThreadMessage } from "@/lib/chat/message-utils";
|
||||
import {
|
||||
isPodcastGenerating,
|
||||
|
|
@ -823,7 +827,14 @@ export default function NewChatPage() {
|
|||
});
|
||||
} else {
|
||||
const tcId = `interrupt-${action.name}`;
|
||||
addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args, true);
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
TOOLS_WITH_UI,
|
||||
tcId,
|
||||
action.name,
|
||||
action.args,
|
||||
true
|
||||
);
|
||||
updateToolCall(contentPartsState, tcId, {
|
||||
result: { __interrupt__: true, ...interruptData },
|
||||
});
|
||||
|
|
@ -872,7 +883,7 @@ export default function NewChatPage() {
|
|||
const newMsgId = `msg-${savedMessage.id}`;
|
||||
tokenUsageStore.rename(assistantMsgId, newMsgId);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m)),
|
||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
||||
);
|
||||
|
||||
// Update pending interrupt with the new persisted message ID
|
||||
|
|
@ -1159,7 +1170,14 @@ export default function NewChatPage() {
|
|||
});
|
||||
} else {
|
||||
const tcId = `interrupt-${action.name}`;
|
||||
addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args, true);
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
TOOLS_WITH_UI,
|
||||
tcId,
|
||||
action.name,
|
||||
action.args,
|
||||
true
|
||||
);
|
||||
updateToolCall(contentPartsState, tcId, {
|
||||
result: {
|
||||
__interrupt__: true,
|
||||
|
|
@ -1206,7 +1224,7 @@ export default function NewChatPage() {
|
|||
const newMsgId = `msg-${savedMessage.id}`;
|
||||
tokenUsageStore.rename(assistantMsgId, newMsgId);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m)),
|
||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to persist resumed assistant message:", err);
|
||||
|
|
@ -1512,7 +1530,7 @@ export default function NewChatPage() {
|
|||
const newMsgId = `msg-${savedMessage.id}`;
|
||||
tokenUsageStore.rename(assistantMsgId, newMsgId);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m)),
|
||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
||||
);
|
||||
|
||||
trackChatResponseReceived(searchSpaceId, threadId);
|
||||
|
|
@ -1617,17 +1635,17 @@ export default function NewChatPage() {
|
|||
|
||||
return (
|
||||
<TokenUsageProvider store={tokenUsageStore}>
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<ThinkingStepsDataUI />
|
||||
<div key={searchSpaceId} className="flex h-full overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<Thread />
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<ThinkingStepsDataUI />
|
||||
<div key={searchSpaceId} className="flex h-full overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<Thread />
|
||||
</div>
|
||||
<MobileReportPanel />
|
||||
<MobileEditorPanel />
|
||||
<MobileHitlEditPanel />
|
||||
</div>
|
||||
<MobileReportPanel />
|
||||
<MobileEditorPanel />
|
||||
<MobileHitlEditPanel />
|
||||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
</AssistantRuntimeProvider>
|
||||
</TokenUsageProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { buildIssueUrl } from "@/lib/error-toast";
|
||||
|
||||
export default function DashboardError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: globalThis.Error & { digest?: string };
|
||||
error: globalThis.Error & { digest?: string; code?: string; requestId?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
|
|
@ -18,12 +20,24 @@ export default function DashboardError({
|
|||
.catch(() => {});
|
||||
}, [error]);
|
||||
|
||||
const issueUrl = useMemo(() => buildIssueUrl(error), [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<h2 className="text-xl font-semibold">Something went wrong</h2>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
An error occurred in this section. Your dashboard is still available.
|
||||
An error occurred in this section. Your dashboard is still available. If this keeps
|
||||
happening, please report it so we can fix it.
|
||||
</p>
|
||||
|
||||
{(error.digest || error.code || error.requestId) && (
|
||||
<div className="rounded-md border bg-muted/50 px-4 py-2 text-xs text-muted-foreground font-mono max-w-md">
|
||||
{error.code && <span>Code: {error.code}</span>}
|
||||
{error.requestId && <span className="ml-3">ID: {error.requestId}</span>}
|
||||
{error.digest && <span className="ml-3">Digest: {error.digest}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -38,6 +52,15 @@ export default function DashboardError({
|
|||
>
|
||||
Go to dashboard home
|
||||
</Link>
|
||||
<a
|
||||
href={issueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Report Issue
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { buildIssueUrl } from "@/lib/error-toast";
|
||||
|
||||
export default function ErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: globalThis.Error & { digest?: string };
|
||||
error: globalThis.Error & { digest?: string; code?: string; requestId?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
|
|
@ -17,19 +19,41 @@ export default function ErrorPage({
|
|||
.catch(() => {});
|
||||
}, [error]);
|
||||
|
||||
const issueUrl = useMemo(() => buildIssueUrl(error), [error]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 text-center">
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 text-center px-4">
|
||||
<h2 className="text-2xl font-semibold">Something went wrong</h2>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
An unexpected error occurred. Please try again.
|
||||
An unexpected error occurred. Please try again, or report this issue if it persists.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
|
||||
{(error.digest || error.code || error.requestId) && (
|
||||
<div className="rounded-md border bg-muted/50 px-4 py-2 text-xs text-muted-foreground font-mono max-w-md">
|
||||
{error.code && <span>Code: {error.code}</span>}
|
||||
{error.requestId && <span className="ml-3">ID: {error.requestId}</span>}
|
||||
{error.digest && <span className="ml-3">Digest: {error.digest}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<a
|
||||
href={issueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Report Issue
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,32 @@
|
|||
|
||||
import "./globals.css";
|
||||
import posthog from "posthog-js";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const ISSUES_URL = "https://github.com/MODSetter/SurfSense/issues/new";
|
||||
|
||||
function buildBasicIssueUrl(error: Error & { digest?: string }) {
|
||||
const params = new URLSearchParams();
|
||||
const lines = [
|
||||
"## Bug Report",
|
||||
"",
|
||||
"**Describe what happened:**",
|
||||
"",
|
||||
"",
|
||||
"## Diagnostics (auto-filled)",
|
||||
"",
|
||||
`- **Error:** ${error.message}`,
|
||||
...(error.digest ? [`- **Digest:** \`${error.digest}\``] : []),
|
||||
`- **Timestamp:** ${new Date().toISOString()}`,
|
||||
`- **Page:** \`${typeof window !== "undefined" ? window.location.pathname : "unknown"}\``,
|
||||
`- **User Agent:** \`${typeof navigator !== "undefined" ? navigator.userAgent : "unknown"}\``,
|
||||
];
|
||||
params.set("body", lines.join("\n"));
|
||||
params.set("labels", "bug");
|
||||
return `${ISSUES_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
|
|
@ -16,13 +39,32 @@ export default function GlobalError({
|
|||
posthog.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
const issueUrl = useMemo(() => buildBasicIssueUrl(error), [error]);
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4 text-center">
|
||||
<h2 className="text-xl font-semibold">Something went wrong</h2>
|
||||
<p className="text-sm text-muted-foreground">An unexpected error occurred.</p>
|
||||
<Button onClick={reset}>Try again</Button>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
An unexpected error occurred. Please try again, or report this issue if it persists.
|
||||
</p>
|
||||
|
||||
{error.digest && (
|
||||
<p className="text-xs text-muted-foreground font-mono">Digest: {error.digest}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={reset}>Try again</Button>
|
||||
<a
|
||||
href={issueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
Report Issue
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -7,13 +7,17 @@ import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvi
|
|||
import { I18nProvider } from "@/components/providers/I18nProvider";
|
||||
import { PostHogProvider } from "@/components/providers/PostHogProvider";
|
||||
import { ZeroProvider } from "@/components/providers/ZeroProvider";
|
||||
import {
|
||||
OrganizationJsonLd,
|
||||
SoftwareApplicationJsonLd,
|
||||
WebSiteJsonLd,
|
||||
} from "@/components/seo/json-ld";
|
||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { LocaleProvider } from "@/contexts/LocaleContext";
|
||||
import { PlatformProvider } from "@/contexts/platform-context";
|
||||
import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { OrganizationJsonLd, SoftwareApplicationJsonLd, WebSiteJsonLd } from "@/components/seo/json-ld";
|
||||
|
||||
const roboto = Roboto({
|
||||
subsets: ["latin"],
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import Link from "next/link";
|
|||
|
||||
export const metadata: Metadata = {
|
||||
title: "Page Not Found | SurfSense",
|
||||
description: "The page you're looking for doesn't exist. Explore SurfSense - open source enterprise AI search and knowledge management.",
|
||||
description:
|
||||
"The page you're looking for doesn't exist. Explore SurfSense - open source enterprise AI search and knowledge management.",
|
||||
};
|
||||
|
||||
export default function NotFound() {
|
||||
|
|
|
|||
|
|
@ -4,13 +4,7 @@ import { Bell, ExternalLink, Info, type LucideIcon, Rocket, Wrench, Zap } from "
|
|||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader } from "@/components/ui/card";
|
||||
import type { AnnouncementCategory } from "@/contracts/types/announcement.types";
|
||||
import type { AnnouncementWithState } from "@/hooks/use-announcements";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
|
|
@ -66,7 +60,9 @@ export function AnnouncementCard({ announcement }: { announcement: AnnouncementW
|
|||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-base font-semibold leading-tight tracking-tight">{announcement.title}</h2>
|
||||
<h2 className="text-base font-semibold leading-tight tracking-tight">
|
||||
{announcement.title}
|
||||
</h2>
|
||||
<Badge variant={config.badgeVariant} className="text-[10px] px-1.5 py-0">
|
||||
{config.label}
|
||||
</Badge>
|
||||
|
|
|
|||
|
|
@ -33,11 +33,17 @@ import {
|
|||
useAllCitationMetadata,
|
||||
} from "@/components/assistant-ui/citation-metadata-context";
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { useTokenUsage } from "@/components/assistant-ui/token-usage-context";
|
||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
|
||||
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
|
||||
import type { SerializableCitation } from "@/components/tool-ui/citation";
|
||||
import {
|
||||
openSafeNavigationHref,
|
||||
resolveSafeNavigationHref,
|
||||
} from "@/components/tool-ui/shared/media";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
|
|
@ -46,14 +52,11 @@ import {
|
|||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { DropdownMenuLabel } from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useComments } from "@/hooks/use-comments";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { useTokenUsage } from "@/components/assistant-ui/token-usage-context";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { openSafeNavigationHref, resolveSafeNavigationHref } from "@/components/tool-ui/shared/media";
|
||||
|
||||
// Captured once at module load — survives client-side navigations that strip the query param.
|
||||
const IS_QUICK_ASSIST_WINDOW =
|
||||
|
|
@ -440,7 +443,11 @@ const MessageInfoDropdown: FC = () => {
|
|||
models.map(([model, counts]) => {
|
||||
const { name, icon } = resolveModel(model);
|
||||
return (
|
||||
<ActionBarMorePrimitive.Item key={model} className="focus:bg-neutral-200 dark:focus:bg-neutral-700 relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none" onSelect={(e) => e.preventDefault()}>
|
||||
<ActionBarMorePrimitive.Item
|
||||
key={model}
|
||||
className="focus:bg-neutral-200 dark:focus:bg-neutral-700 relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium">
|
||||
{icon}
|
||||
{name}
|
||||
|
|
@ -452,7 +459,10 @@ const MessageInfoDropdown: FC = () => {
|
|||
);
|
||||
})
|
||||
) : (
|
||||
<ActionBarMorePrimitive.Item className="focus:bg-neutral-200 dark:focus:bg-neutral-700 relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none" onSelect={(e) => e.preventDefault()}>
|
||||
<ActionBarMorePrimitive.Item
|
||||
className="focus:bg-neutral-200 dark:focus:bg-neutral-700 relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{usage.total_tokens.toLocaleString()} tokens
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -282,7 +282,10 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
connector.connector_type === "DROPBOX_CONNECTOR" ||
|
||||
connector.connector_type === "ONEDRIVE_CONNECTOR") && (
|
||||
<VisionLLMConfig enabled={enableVisionLlm} onEnabledChange={onEnableVisionLlmChange} />
|
||||
<VisionLLMConfig
|
||||
enabled={enableVisionLlm}
|
||||
onEnabledChange={onEnableVisionLlmChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */}
|
||||
|
|
|
|||
|
|
@ -168,7 +168,10 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
config.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
config.connectorType === "DROPBOX_CONNECTOR" ||
|
||||
config.connectorType === "ONEDRIVE_CONNECTOR") && (
|
||||
<VisionLLMConfig enabled={enableVisionLlm} onEnabledChange={onEnableVisionLlmChange} />
|
||||
<VisionLLMConfig
|
||||
enabled={enableVisionLlm}
|
||||
onEnabledChange={onEnableVisionLlmChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,17 @@ import {
|
|||
updateConnectorMutationAtom,
|
||||
} from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import {
|
||||
folderWatchDialogOpenAtom,
|
||||
folderWatchInitialFolderAtom,
|
||||
} from "@/atoms/folder-sync/folder-sync.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { searchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { usePlatform } from "@/hooks/use-platform";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { isSelfHosted } from "@/lib/env-config";
|
||||
import {
|
||||
trackConnectorConnected,
|
||||
trackConnectorDeleted,
|
||||
|
|
@ -33,12 +39,6 @@ import {
|
|||
OAUTH_CONNECTORS,
|
||||
OTHER_CONNECTORS,
|
||||
} from "../constants/connector-constants";
|
||||
import { usePlatform } from "@/hooks/use-platform";
|
||||
import { isSelfHosted } from "@/lib/env-config";
|
||||
import {
|
||||
folderWatchDialogOpenAtom,
|
||||
folderWatchInitialFolderAtom,
|
||||
} from "@/atoms/folder-sync/folder-sync.atoms";
|
||||
|
||||
import {
|
||||
dateRangeSchema,
|
||||
|
|
@ -73,7 +73,6 @@ export const useConnectorDialog = () => {
|
|||
const { isDesktop } = usePlatform();
|
||||
const selfHosted = isSelfHosted();
|
||||
|
||||
|
||||
// Use global atom for dialog open state so it can be controlled from anywhere
|
||||
const [isOpen, setIsOpen] = useAtom(connectorDialogOpenAtom);
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
|
|
@ -463,10 +462,16 @@ export const useConnectorDialog = () => {
|
|||
|
||||
setConnectingConnectorType(connectorType);
|
||||
},
|
||||
[searchSpaceId, selfHosted, isDesktop, setIsOpen, setFolderWatchOpen, setFolderWatchInitialFolder]
|
||||
[
|
||||
searchSpaceId,
|
||||
selfHosted,
|
||||
isDesktop,
|
||||
setIsOpen,
|
||||
setFolderWatchOpen,
|
||||
setFolderWatchInitialFolder,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
// Handle submitting connect form
|
||||
const handleSubmitConnectForm = useCallback(
|
||||
async (
|
||||
|
|
@ -787,8 +792,8 @@ export const useConnectorDialog = () => {
|
|||
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
|
||||
|
||||
// Update connector with summary, periodic sync settings, and config changes
|
||||
if (enableSummary || enableVisionLlm || periodicEnabled || indexingConnectorConfig) {
|
||||
const frequency = periodicEnabled ? parseInt(frequencyMinutes, 10) : undefined;
|
||||
if (enableSummary || enableVisionLlm || periodicEnabled || indexingConnectorConfig) {
|
||||
const frequency = periodicEnabled ? parseInt(frequencyMinutes, 10) : undefined;
|
||||
await updateConnector({
|
||||
id: indexingConfig.connectorId,
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import { Search, Unplug } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
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, OAUTH_CONNECTORS } from "../constants/connector-constants";
|
||||
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
||||
|
|
|
|||
|
|
@ -60,7 +60,6 @@ import {
|
|||
} from "@/components/assistant-ui/inline-mention-editor";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { UserMessage } from "@/components/assistant-ui/user-message";
|
||||
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events";
|
||||
import {
|
||||
DocumentMentionPicker,
|
||||
type DocumentMentionPickerRef,
|
||||
|
|
@ -90,6 +89,7 @@ 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 { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs";
|
||||
|
|
@ -132,9 +132,9 @@ const ThreadContent: FC = () => {
|
|||
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<ThreadScrollToBottom />
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<Composer />
|
||||
</AuiIf>
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<Composer />
|
||||
</AuiIf>
|
||||
</ThreadPrimitive.ViewportFooter>
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ThreadPrimitive.Root>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, useCallback, useSyncExternalStore, type FC, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
type FC,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
|
||||
export interface TokenUsageData {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
usage?: Record<string, { prompt_tokens: number; completion_tokens: number; total_tokens: number }>;
|
||||
model_breakdown?: Record<string, { prompt_tokens: number; completion_tokens: number; total_tokens: number }>;
|
||||
usage?: Record<
|
||||
string,
|
||||
{ prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||
>;
|
||||
model_breakdown?: Record<
|
||||
string,
|
||||
{ prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||
>;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
|
@ -51,9 +64,10 @@ class TokenUsageStore {
|
|||
|
||||
const TokenUsageContext = createContext<TokenUsageStore | null>(null);
|
||||
|
||||
export const TokenUsageProvider: FC<{ store: TokenUsageStore; children: ReactNode }> = ({ store, children }) => (
|
||||
<TokenUsageContext.Provider value={store}>{children}</TokenUsageContext.Provider>
|
||||
);
|
||||
export const TokenUsageProvider: FC<{ store: TokenUsageStore; children: ReactNode }> = ({
|
||||
store,
|
||||
children,
|
||||
}) => <TokenUsageContext.Provider value={store}>{children}</TokenUsageContext.Provider>;
|
||||
|
||||
export function useTokenUsageStore(): TokenUsageStore {
|
||||
const store = useContext(TokenUsageContext);
|
||||
|
|
@ -65,11 +79,11 @@ export function useTokenUsage(messageId: string | undefined): TokenUsageData | u
|
|||
const store = useContext(TokenUsageContext);
|
||||
const getSnapshot = useCallback(
|
||||
() => (store && messageId ? store.get(messageId) : undefined),
|
||||
[store, messageId],
|
||||
[store, messageId]
|
||||
);
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) => (store ? store.subscribe(onStoreChange) : () => {}),
|
||||
[store],
|
||||
[store]
|
||||
);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ import { useEffect, useRef, useState } from "react";
|
|||
import { clearTargetCommentIdAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { convertRenderedToDisplay } from "@/lib/comments/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommentComposer } from "../comment-composer/comment-composer";
|
||||
import { CommentActions } from "./comment-actions";
|
||||
import { convertRenderedToDisplay } from "@/lib/comments/utils";
|
||||
import type { CommentItemProps } from "./types";
|
||||
|
||||
function getInitials(name: string | null, email: string): string {
|
||||
|
|
@ -70,7 +70,6 @@ function formatTimestamp(dateString: string): string {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
function renderMentions(content: string): React.ReactNode {
|
||||
// Match @{DisplayName} format from backend
|
||||
const mentionPattern = /@\{([^}]+)\}/g;
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ export function ContactFormGridWithDetails() {
|
|||
<IconMailFilled className="h-6 w-6 text-blue-500" />
|
||||
</FeatureIconContainer>
|
||||
</div>
|
||||
<h1 className="mt-9 bg-gradient-to-b from-neutral-800 to-neutral-900 bg-clip-text text-center text-xl font-bold text-transparent md:text-3xl lg:text-5xl dark:from-neutral-200 dark:to-neutral-300">
|
||||
Contact
|
||||
</h1>
|
||||
<h1 className="mt-9 bg-gradient-to-b from-neutral-800 to-neutral-900 bg-clip-text text-center text-xl font-bold text-transparent md:text-3xl lg:text-5xl dark:from-neutral-200 dark:to-neutral-300">
|
||||
Contact
|
||||
</h1>
|
||||
<p className="mt-8 max-w-lg text-center text-base text-neutral-600 dark:text-neutral-400">
|
||||
We'd love to hear from you!
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -86,8 +86,7 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
const isProcessing = statusState === "pending" || statusState === "processing";
|
||||
const isUnavailable = isProcessing || isFailed;
|
||||
const isSelectable = !isUnavailable;
|
||||
const isEditable =
|
||||
EDITABLE_DOCUMENT_TYPES.has(doc.document_type) && !isUnavailable;
|
||||
const isEditable = EDITABLE_DOCUMENT_TYPES.has(doc.document_type) && !isUnavailable;
|
||||
|
||||
const handleCheckChange = useCallback(() => {
|
||||
if (isSelectable) {
|
||||
|
|
@ -261,38 +260,38 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
className="w-40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
{isEditable && (
|
||||
<DropdownMenuItem onClick={() => onEdit(doc)}>
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
<DropdownMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onMove(doc)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</DropdownMenuItem>
|
||||
{onExport && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={isUnavailable}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[180px]">
|
||||
<ExportDropdownItems onExport={handleExport} exporting={exporting} />
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{onVersionHistory && isVersionableType(doc.document_type) && (
|
||||
<DropdownMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Versions
|
||||
{isEditable && (
|
||||
<DropdownMenuItem onClick={() => onEdit(doc)}>
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onMove(doc)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
|
||||
{onExport && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={isUnavailable}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[180px]">
|
||||
<ExportDropdownItems onExport={handleExport} exporting={exporting} />
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{onVersionHistory && isVersionableType(doc.document_type) && (
|
||||
<DropdownMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Versions
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -304,38 +303,38 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
|
||||
{contextMenuOpen && (
|
||||
<ContextMenuContent className="w-40" onClick={(e) => e.stopPropagation()}>
|
||||
<ContextMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
</ContextMenuItem>
|
||||
{isEditable && (
|
||||
<ContextMenuItem onClick={() => onEdit(doc)}>
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
<ContextMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => onMove(doc)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</ContextMenuItem>
|
||||
{onExport && (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger disabled={isUnavailable}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="min-w-[180px]">
|
||||
<ExportContextItems onExport={handleExport} exporting={exporting} />
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
)}
|
||||
{onVersionHistory && isVersionableType(doc.document_type) && (
|
||||
<ContextMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Versions
|
||||
{isEditable && (
|
||||
<ContextMenuItem onClick={() => onEdit(doc)}>
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => onMove(doc)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
|
||||
{onExport && (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger disabled={isUnavailable}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="min-w-[180px]">
|
||||
<ExportContextItems onExport={handleExport} exporting={exporting} />
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
)}
|
||||
{onVersionHistory && isVersionableType(doc.document_type) && (
|
||||
<ContextMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Versions
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ export function getDocumentTypeIcon(type: string, className?: string): React.Rea
|
|||
return getConnectorIcon(type, className);
|
||||
}
|
||||
|
||||
|
||||
export function DocumentTypeChip({ type, className }: { type: string; className?: string }) {
|
||||
const icon = getDocumentTypeIcon(type, "h-4 w-4");
|
||||
const fullLabel = getDocumentTypeLabel(type);
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { getDocumentTypeIcon } from "./DocumentTypeIcon";
|
||||
import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getDocumentTypeIcon } from "./DocumentTypeIcon";
|
||||
|
||||
export function DocumentsFilters({
|
||||
typeCounts: typeCountsRecord,
|
||||
|
|
@ -81,18 +81,18 @@ export function DocumentsFilters({
|
|||
<TooltipTrigger asChild>
|
||||
<ToggleGroupItem
|
||||
value="folder"
|
||||
className="h-9 w-9 shrink-0 border-sidebar-border text-muted-foreground hover:text-foreground hover:border-sidebar-border bg-sidebar"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onCreateFolder();
|
||||
className="h-9 w-9 shrink-0 border-sidebar-border text-muted-foreground hover:text-foreground hover:border-sidebar-border bg-sidebar"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onCreateFolder();
|
||||
}}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</ToggleGroupItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>New folder</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{onToggleAiSort && (
|
||||
<Tooltip>
|
||||
|
|
@ -114,9 +114,9 @@ export function DocumentsFilters({
|
|||
aria-label={aiSortEnabled ? "Disable AI sort" : "Enable AI sort"}
|
||||
aria-pressed={aiSortEnabled}
|
||||
>
|
||||
{aiSortBusy ? (
|
||||
<Spinner size="xs" />
|
||||
) : aiSortEnabled ? (
|
||||
{aiSortBusy ? (
|
||||
<Spinner size="xs" />
|
||||
) : aiSortEnabled ? (
|
||||
<IconBinaryTreeFilled size={16} />
|
||||
) : (
|
||||
<IconBinaryTree size={16} />
|
||||
|
|
|
|||
|
|
@ -247,10 +247,8 @@ export function FolderTreeView({
|
|||
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
|
||||
const key = parentId ?? "root";
|
||||
const childFolders = (foldersByParent[key] ?? []).slice().sort((a, b) => {
|
||||
const aIsDate =
|
||||
a.metadata?.ai_sort === true && a.metadata?.ai_sort_level === 2;
|
||||
const bIsDate =
|
||||
b.metadata?.ai_sort === true && b.metadata?.ai_sort_level === 2;
|
||||
const aIsDate = a.metadata?.ai_sort === true && a.metadata?.ai_sort_level === 2;
|
||||
const bIsDate = b.metadata?.ai_sort === true && b.metadata?.ai_sort_level === 2;
|
||||
if (aIsDate && bIsDate) {
|
||||
return b.name.localeCompare(a.name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,7 @@ const FENCED_OR_INLINE_CODE = /(```[\s\S]*?```|`[^`\n]+`)/g;
|
|||
// - <!-- PageBreak --> becomes a thematic break (---)
|
||||
// - All other HTML comments are removed
|
||||
function stripHtmlComments(md: string): string {
|
||||
return md
|
||||
.replace(/<!--\s*PageBreak\s*-->/gi, "\n---\n")
|
||||
.replace(/<!--[\s\S]*?-->/g, "");
|
||||
return md.replace(/<!--\s*PageBreak\s*-->/gi, "\n---\n").replace(/<!--[\s\S]*?-->/g, "");
|
||||
}
|
||||
|
||||
// Convert <figure>...</figure> blocks to plain text blockquotes.
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import { motion, useMotionValue, useSpring } from "motion/react";
|
||||
import * as React from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-digit scrolling wheel
|
||||
|
|
@ -249,10 +249,7 @@ function NavbarGitHubStars({
|
|||
const { data: stars = 0, isLoading } = useQuery({
|
||||
queryKey: cacheKeys.github.repoStars(username, repo),
|
||||
queryFn: async ({ signal }) => {
|
||||
const res = await fetch(
|
||||
`https://api.github.com/repos/${username}/${repo}`,
|
||||
{ signal },
|
||||
);
|
||||
const res = await fetch(`https://api.github.com/repos/${username}/${repo}`, { signal });
|
||||
const data = await res.json();
|
||||
if (data && typeof data.stargazers_count === "number") {
|
||||
return data.stargazers_count as number;
|
||||
|
|
|
|||
|
|
@ -153,13 +153,14 @@ export function HeroSection() {
|
|||
</h1>
|
||||
<div className="mt-4 flex w-full flex-col items-start justify-between gap-4 md:mt-12 md:flex-row md:items-end md:gap-10">
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"relative mb-8 max-w-2xl text-left text-sm tracking-wide text-neutral-600 antialiased sm:text-base md:text-xl dark:text-neutral-400"
|
||||
)}
|
||||
>
|
||||
A free, open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"relative mb-8 max-w-2xl text-left text-sm tracking-wide text-neutral-600 antialiased sm:text-base md:text-xl dark:text-neutral-400"
|
||||
)}
|
||||
>
|
||||
A free, open source NotebookLM alternative for teams with no data limits. Use ChatGPT,
|
||||
Claude AI, and any AI model for free.
|
||||
</p>
|
||||
|
||||
<div className="relative mb-4 flex w-full flex-col justify-center gap-y-2 sm:flex-row sm:justify-start sm:space-y-0 sm:space-x-4">
|
||||
<DownloadButton />
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
link: string;
|
||||
name: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface NavbarProps {
|
||||
|
|
@ -20,15 +20,15 @@ interface NavbarProps {
|
|||
}
|
||||
|
||||
interface DesktopNavProps {
|
||||
navItems: NavItem[];
|
||||
isScrolled: boolean;
|
||||
scrolledBgClassName?: string;
|
||||
navItems: NavItem[];
|
||||
isScrolled: boolean;
|
||||
scrolledBgClassName?: string;
|
||||
}
|
||||
|
||||
interface MobileNavProps {
|
||||
navItems: NavItem[];
|
||||
isScrolled: boolean;
|
||||
scrolledBgClassName?: string;
|
||||
navItems: NavItem[];
|
||||
isScrolled: boolean;
|
||||
scrolledBgClassName?: string;
|
||||
}
|
||||
|
||||
export const Navbar = ({ scrolledBgClassName }: NavbarProps = {}) => {
|
||||
|
|
|
|||
|
|
@ -167,7 +167,6 @@ export function DocumentsSidebar({
|
|||
setFolderWatchOpen(true);
|
||||
}, [setWatchInitialFolder, setFolderWatchOpen]);
|
||||
|
||||
|
||||
const refreshWatchedIds = useCallback(async () => {
|
||||
if (!electronAPI?.getWatchedFolders) return;
|
||||
const api = electronAPI;
|
||||
|
|
@ -675,10 +674,10 @@ export function DocumentsSidebar({
|
|||
function collectSubtreeDocs(parentId: number): DocumentNodeDoc[] {
|
||||
const directDocs = (treeDocuments ?? []).filter(
|
||||
(d) =>
|
||||
d.folderId === parentId &&
|
||||
d.status?.state !== "pending" &&
|
||||
d.status?.state !== "processing" &&
|
||||
d.status?.state !== "failed"
|
||||
d.folderId === parentId &&
|
||||
d.status?.state !== "pending" &&
|
||||
d.status?.state !== "processing" &&
|
||||
d.status?.state !== "failed"
|
||||
);
|
||||
const childFolders = foldersByParent[String(parentId)] ?? [];
|
||||
const descendantDocs = childFolders.flatMap((cf) => collectSubtreeDocs(cf.id));
|
||||
|
|
@ -1123,16 +1122,14 @@ export function DocumentsSidebar({
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Enable AI File Sorting?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
All documents in this search space will be organized into folders by
|
||||
connector type, date, and AI-generated categories. New documents will
|
||||
also be sorted automatically. You can disable this at any time.
|
||||
All documents in this search space will be organized into folders by connector type,
|
||||
date, and AI-generated categories. New documents will also be sorted automatically.
|
||||
You can disable this at any time.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmEnableAiSort}>
|
||||
Enable
|
||||
</AlertDialogAction>
|
||||
<AlertDialogAction onClick={handleConfirmEnableAiSort}>Enable</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ import { useParams, useRouter } from "next/navigation";
|
|||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { convertRenderedToDisplay } from "@/lib/comments/utils";
|
||||
import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -57,6 +55,8 @@ import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
|||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||
import { convertRenderedToDisplay } from "@/lib/comments/utils";
|
||||
import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { useCallback, useEffect } from "react";
|
|||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events";
|
||||
|
||||
|
||||
interface SidebarSlideOutPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
|
|
|
|||
|
|
@ -67,13 +67,7 @@ export const DocumentMentionPicker = forwardRef<
|
|||
DocumentMentionPickerRef,
|
||||
DocumentMentionPickerProps
|
||||
>(function DocumentMentionPicker(
|
||||
{
|
||||
searchSpaceId,
|
||||
onSelectionChange,
|
||||
onDone,
|
||||
initialSelectedDocuments = [],
|
||||
externalSearch = "",
|
||||
},
|
||||
{ searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" },
|
||||
ref
|
||||
) {
|
||||
// Debounced search value to minimize API calls and prevent race conditions
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Bot,
|
||||
|
|
@ -11,12 +10,13 @@ import {
|
|||
ChevronUp,
|
||||
Edit3,
|
||||
ImageIcon,
|
||||
ScanEye,
|
||||
Layers,
|
||||
Plus,
|
||||
ScanEye,
|
||||
Search,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -178,8 +178,7 @@ function formatProviderName(provider: string): string {
|
|||
const key = provider.toUpperCase();
|
||||
return (
|
||||
PROVIDER_NAMES[key] ??
|
||||
provider.charAt(0).toUpperCase() +
|
||||
provider.slice(1).toLowerCase().replace(/_/g, " ")
|
||||
provider.charAt(0).toUpperCase() + provider.slice(1).toLowerCase().replace(/_/g, " ")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -202,14 +201,12 @@ interface ConfigBase {
|
|||
function filterAndScore<T extends ConfigBase>(
|
||||
configs: T[],
|
||||
selectedProvider: string,
|
||||
searchQuery: string,
|
||||
searchQuery: string
|
||||
): T[] {
|
||||
let result = configs;
|
||||
|
||||
if (selectedProvider !== "all") {
|
||||
result = result.filter(
|
||||
(c) => c.provider.toUpperCase() === selectedProvider,
|
||||
);
|
||||
result = result.filter((c) => c.provider.toUpperCase() === selectedProvider);
|
||||
}
|
||||
|
||||
if (!searchQuery.trim()) return result;
|
||||
|
|
@ -218,9 +215,7 @@ function filterAndScore<T extends ConfigBase>(
|
|||
const tokens = normalized.split(/\s+/).filter(Boolean);
|
||||
|
||||
const scored = result.map((c) => {
|
||||
const aggregate = normalizeText(
|
||||
[c.name, c.model_name, c.provider].join(" "),
|
||||
);
|
||||
const aggregate = normalizeText([c.name, c.model_name, c.provider].join(" "));
|
||||
let score = 0;
|
||||
if (aggregate.includes(normalized)) score += 5;
|
||||
for (const token of tokens) {
|
||||
|
|
@ -244,20 +239,11 @@ interface DisplayItem {
|
|||
// ─── Component ──────────────────────────────────────────────────────
|
||||
|
||||
interface ModelSelectorProps {
|
||||
onEditLLM: (
|
||||
config: NewLLMConfigPublic | GlobalNewLLMConfig,
|
||||
isGlobal: boolean,
|
||||
) => void;
|
||||
onEditLLM: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void;
|
||||
onAddNewLLM: (provider?: string) => void;
|
||||
onEditImage?: (
|
||||
config: ImageGenerationConfig | GlobalImageGenConfig,
|
||||
isGlobal: boolean,
|
||||
) => void;
|
||||
onEditImage?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void;
|
||||
onAddNewImage?: (provider?: string) => void;
|
||||
onEditVision?: (
|
||||
config: VisionLLMConfig | GlobalVisionLLMConfig,
|
||||
isGlobal: boolean,
|
||||
) => void;
|
||||
onEditVision?: (config: VisionLLMConfig | GlobalVisionLLMConfig, isGlobal: boolean) => void;
|
||||
onAddNewVision?: (provider?: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
|
@ -272,9 +258,7 @@ export function ModelSelector({
|
|||
className,
|
||||
}: ModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"llm" | "image" | "vision">(
|
||||
"llm",
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<"llm" | "image" | "vision">("llm");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>("all");
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
|
|
@ -292,18 +276,21 @@ export function ModelSelector({
|
|||
setModelScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}, []);
|
||||
|
||||
const handleSidebarScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
if (isMobile) {
|
||||
const atStart = el.scrollLeft <= 2;
|
||||
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
|
||||
setSidebarScrollPos(atStart ? "top" : atEnd ? "bottom" : "middle");
|
||||
} else {
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setSidebarScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}
|
||||
}, [isMobile]);
|
||||
const handleSidebarScroll = useCallback(
|
||||
(e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
if (isMobile) {
|
||||
const atStart = el.scrollLeft <= 2;
|
||||
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
|
||||
setSidebarScrollPos(atStart ? "top" : atEnd ? "bottom" : "middle");
|
||||
} else {
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setSidebarScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}
|
||||
},
|
||||
[isMobile]
|
||||
);
|
||||
|
||||
// Reset search + provider when tab changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger
|
||||
|
|
@ -344,22 +331,18 @@ export function ModelSelector({
|
|||
}, [open, isMobile, activeTab]);
|
||||
|
||||
// ─── Data ───
|
||||
const { data: llmUserConfigs, isLoading: llmUserLoading } =
|
||||
useAtomValue(newLLMConfigsAtom);
|
||||
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
|
||||
const { data: llmGlobalConfigs, isLoading: llmGlobalLoading } =
|
||||
useAtomValue(globalNewLLMConfigsAtom);
|
||||
const { data: preferences, isLoading: prefsLoading } =
|
||||
useAtomValue(llmPreferencesAtom);
|
||||
const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom);
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(
|
||||
updateLLMPreferencesMutationAtom,
|
||||
);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
const { data: imageGlobalConfigs, isLoading: imageGlobalLoading } =
|
||||
useAtomValue(globalImageGenConfigsAtom);
|
||||
const { data: imageUserConfigs, isLoading: imageUserLoading } =
|
||||
useAtomValue(imageGenConfigsAtom);
|
||||
const { data: visionGlobalConfigs, isLoading: visionGlobalLoading } =
|
||||
useAtomValue(globalVisionLLMConfigsAtom);
|
||||
const { data: imageUserConfigs, isLoading: imageUserLoading } = useAtomValue(imageGenConfigsAtom);
|
||||
const { data: visionGlobalConfigs, isLoading: visionGlobalLoading } = useAtomValue(
|
||||
globalVisionLLMConfigsAtom
|
||||
);
|
||||
const { data: visionUserConfigs, isLoading: visionUserLoading } =
|
||||
useAtomValue(visionLLMConfigsAtom);
|
||||
|
||||
|
|
@ -382,9 +365,7 @@ export function ModelSelector({
|
|||
}, [preferences, llmGlobalConfigs, llmUserConfigs]);
|
||||
|
||||
const isLLMAutoMode =
|
||||
currentLLMConfig &&
|
||||
"is_auto_mode" in currentLLMConfig &&
|
||||
currentLLMConfig.is_auto_mode;
|
||||
currentLLMConfig && "is_auto_mode" in currentLLMConfig && currentLLMConfig.is_auto_mode;
|
||||
|
||||
const currentImageConfig = useMemo(() => {
|
||||
if (!preferences) return null;
|
||||
|
|
@ -398,9 +379,7 @@ export function ModelSelector({
|
|||
}, [preferences, imageGlobalConfigs, imageUserConfigs]);
|
||||
|
||||
const isImageAutoMode =
|
||||
currentImageConfig &&
|
||||
"is_auto_mode" in currentImageConfig &&
|
||||
currentImageConfig.is_auto_mode;
|
||||
currentImageConfig && "is_auto_mode" in currentImageConfig && currentImageConfig.is_auto_mode;
|
||||
|
||||
const currentVisionConfig = useMemo(() => {
|
||||
if (!preferences) return null;
|
||||
|
|
@ -420,83 +399,47 @@ export function ModelSelector({
|
|||
|
||||
// ─── Filtered configs (separate global / user for section headers) ───
|
||||
const filteredLLMGlobal = useMemo(
|
||||
() =>
|
||||
filterAndScore(llmGlobalConfigs ?? [], selectedProvider, searchQuery),
|
||||
[llmGlobalConfigs, selectedProvider, searchQuery],
|
||||
() => filterAndScore(llmGlobalConfigs ?? [], selectedProvider, searchQuery),
|
||||
[llmGlobalConfigs, selectedProvider, searchQuery]
|
||||
);
|
||||
const filteredLLMUser = useMemo(
|
||||
() =>
|
||||
filterAndScore(llmUserConfigs ?? [], selectedProvider, searchQuery),
|
||||
[llmUserConfigs, selectedProvider, searchQuery],
|
||||
() => filterAndScore(llmUserConfigs ?? [], selectedProvider, searchQuery),
|
||||
[llmUserConfigs, selectedProvider, searchQuery]
|
||||
);
|
||||
const filteredImageGlobal = useMemo(
|
||||
() =>
|
||||
filterAndScore(
|
||||
imageGlobalConfigs ?? [],
|
||||
selectedProvider,
|
||||
searchQuery,
|
||||
),
|
||||
[imageGlobalConfigs, selectedProvider, searchQuery],
|
||||
() => filterAndScore(imageGlobalConfigs ?? [], selectedProvider, searchQuery),
|
||||
[imageGlobalConfigs, selectedProvider, searchQuery]
|
||||
);
|
||||
const filteredImageUser = useMemo(
|
||||
() =>
|
||||
filterAndScore(
|
||||
imageUserConfigs ?? [],
|
||||
selectedProvider,
|
||||
searchQuery,
|
||||
),
|
||||
[imageUserConfigs, selectedProvider, searchQuery],
|
||||
() => filterAndScore(imageUserConfigs ?? [], selectedProvider, searchQuery),
|
||||
[imageUserConfigs, selectedProvider, searchQuery]
|
||||
);
|
||||
const filteredVisionGlobal = useMemo(
|
||||
() =>
|
||||
filterAndScore(
|
||||
visionGlobalConfigs ?? [],
|
||||
selectedProvider,
|
||||
searchQuery,
|
||||
),
|
||||
[visionGlobalConfigs, selectedProvider, searchQuery],
|
||||
() => filterAndScore(visionGlobalConfigs ?? [], selectedProvider, searchQuery),
|
||||
[visionGlobalConfigs, selectedProvider, searchQuery]
|
||||
);
|
||||
const filteredVisionUser = useMemo(
|
||||
() =>
|
||||
filterAndScore(
|
||||
visionUserConfigs ?? [],
|
||||
selectedProvider,
|
||||
searchQuery,
|
||||
),
|
||||
[visionUserConfigs, selectedProvider, searchQuery],
|
||||
() => filterAndScore(visionUserConfigs ?? [], selectedProvider, searchQuery),
|
||||
[visionUserConfigs, selectedProvider, searchQuery]
|
||||
);
|
||||
|
||||
// Combined display list for keyboard navigation
|
||||
const currentDisplayItems: DisplayItem[] = useMemo(() => {
|
||||
const toItems = (
|
||||
configs: ConfigBase[],
|
||||
isGlobal: boolean,
|
||||
): DisplayItem[] =>
|
||||
const toItems = (configs: ConfigBase[], isGlobal: boolean): DisplayItem[] =>
|
||||
configs.map((c) => ({
|
||||
config: c as ConfigBase & Record<string, unknown>,
|
||||
isGlobal,
|
||||
isAutoMode:
|
||||
isGlobal &&
|
||||
"is_auto_mode" in c &&
|
||||
!!(c as Record<string, unknown>).is_auto_mode,
|
||||
isGlobal && "is_auto_mode" in c && !!(c as Record<string, unknown>).is_auto_mode,
|
||||
}));
|
||||
|
||||
switch (activeTab) {
|
||||
case "llm":
|
||||
return [
|
||||
...toItems(filteredLLMGlobal, true),
|
||||
...toItems(filteredLLMUser, false),
|
||||
];
|
||||
return [...toItems(filteredLLMGlobal, true), ...toItems(filteredLLMUser, false)];
|
||||
case "image":
|
||||
return [
|
||||
...toItems(filteredImageGlobal, true),
|
||||
...toItems(filteredImageUser, false),
|
||||
];
|
||||
return [...toItems(filteredImageGlobal, true), ...toItems(filteredImageUser, false)];
|
||||
case "vision":
|
||||
return [
|
||||
...toItems(filteredVisionGlobal, true),
|
||||
...toItems(filteredVisionUser, false),
|
||||
];
|
||||
return [...toItems(filteredVisionGlobal, true), ...toItems(filteredVisionUser, false)];
|
||||
}
|
||||
}, [
|
||||
activeTab,
|
||||
|
|
@ -513,19 +456,10 @@ export function ModelSelector({
|
|||
const configuredProviderSet = useMemo(() => {
|
||||
const configs =
|
||||
activeTab === "llm"
|
||||
? [
|
||||
...(llmGlobalConfigs ?? []),
|
||||
...(llmUserConfigs ?? []),
|
||||
]
|
||||
? [...(llmGlobalConfigs ?? []), ...(llmUserConfigs ?? [])]
|
||||
: activeTab === "image"
|
||||
? [
|
||||
...(imageGlobalConfigs ?? []),
|
||||
...(imageUserConfigs ?? []),
|
||||
]
|
||||
: [
|
||||
...(visionGlobalConfigs ?? []),
|
||||
...(visionUserConfigs ?? []),
|
||||
];
|
||||
? [...(imageGlobalConfigs ?? []), ...(imageUserConfigs ?? [])]
|
||||
: [...(visionGlobalConfigs ?? []), ...(visionUserConfigs ?? [])];
|
||||
const set = new Set<string>();
|
||||
for (const c of configs) {
|
||||
if (c.provider) set.add(c.provider.toUpperCase());
|
||||
|
|
@ -544,31 +478,18 @@ export function ModelSelector({
|
|||
// Show only providers valid for the active tab; configured ones first
|
||||
const activeProviders = useMemo(() => {
|
||||
const tabKeys = PROVIDER_KEYS_BY_TAB[activeTab] ?? LLM_PROVIDER_KEYS;
|
||||
const configured = tabKeys.filter((p) =>
|
||||
configuredProviderSet.has(p),
|
||||
);
|
||||
const unconfigured = tabKeys.filter(
|
||||
(p) => !configuredProviderSet.has(p),
|
||||
);
|
||||
const configured = tabKeys.filter((p) => configuredProviderSet.has(p));
|
||||
const unconfigured = tabKeys.filter((p) => !configuredProviderSet.has(p));
|
||||
return ["all", ...configured, ...unconfigured];
|
||||
}, [activeTab, configuredProviderSet]);
|
||||
|
||||
const providerModelCounts = useMemo(() => {
|
||||
const allConfigs =
|
||||
activeTab === "llm"
|
||||
? [
|
||||
...(llmGlobalConfigs ?? []),
|
||||
...(llmUserConfigs ?? []),
|
||||
]
|
||||
? [...(llmGlobalConfigs ?? []), ...(llmUserConfigs ?? [])]
|
||||
: activeTab === "image"
|
||||
? [
|
||||
...(imageGlobalConfigs ?? []),
|
||||
...(imageUserConfigs ?? []),
|
||||
]
|
||||
: [
|
||||
...(visionGlobalConfigs ?? []),
|
||||
...(visionUserConfigs ?? []),
|
||||
];
|
||||
? [...(imageGlobalConfigs ?? []), ...(imageUserConfigs ?? [])]
|
||||
: [...(visionGlobalConfigs ?? []), ...(visionUserConfigs ?? [])];
|
||||
const counts: Record<string, number> = { all: allConfigs.length };
|
||||
for (const c of allConfigs) {
|
||||
const p = c.provider.toUpperCase();
|
||||
|
|
@ -607,7 +528,7 @@ export function ModelSelector({
|
|||
toast.error("Failed to switch model");
|
||||
}
|
||||
},
|
||||
[currentLLMConfig, searchSpaceId, updatePreferences],
|
||||
[currentLLMConfig, searchSpaceId, updatePreferences]
|
||||
);
|
||||
|
||||
const handleSelectImage = useCallback(
|
||||
|
|
@ -631,7 +552,7 @@ export function ModelSelector({
|
|||
toast.error("Failed to switch image model");
|
||||
}
|
||||
},
|
||||
[currentImageConfig, searchSpaceId, updatePreferences],
|
||||
[currentImageConfig, searchSpaceId, updatePreferences]
|
||||
);
|
||||
|
||||
const handleSelectVision = useCallback(
|
||||
|
|
@ -655,16 +576,14 @@ export function ModelSelector({
|
|||
toast.error("Failed to switch vision model");
|
||||
}
|
||||
},
|
||||
[currentVisionConfig, searchSpaceId, updatePreferences],
|
||||
[currentVisionConfig, searchSpaceId, updatePreferences]
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback(
|
||||
(item: DisplayItem) => {
|
||||
switch (activeTab) {
|
||||
case "llm":
|
||||
handleSelectLLM(
|
||||
item.config as NewLLMConfigPublic | GlobalNewLLMConfig,
|
||||
);
|
||||
handleSelectLLM(item.config as NewLLMConfigPublic | GlobalNewLLMConfig);
|
||||
break;
|
||||
case "image":
|
||||
handleSelectImage(item.config.id);
|
||||
|
|
@ -674,7 +593,7 @@ export function ModelSelector({
|
|||
break;
|
||||
}
|
||||
},
|
||||
[activeTab, handleSelectLLM, handleSelectImage, handleSelectVision],
|
||||
[activeTab, handleSelectLLM, handleSelectImage, handleSelectVision]
|
||||
);
|
||||
|
||||
const handleEditItem = useCallback(
|
||||
|
|
@ -683,26 +602,17 @@ export function ModelSelector({
|
|||
setOpen(false);
|
||||
switch (activeTab) {
|
||||
case "llm":
|
||||
onEditLLM(
|
||||
item.config as NewLLMConfigPublic | GlobalNewLLMConfig,
|
||||
item.isGlobal,
|
||||
);
|
||||
onEditLLM(item.config as NewLLMConfigPublic | GlobalNewLLMConfig, item.isGlobal);
|
||||
break;
|
||||
case "image":
|
||||
onEditImage?.(
|
||||
item.config as ImageGenerationConfig | GlobalImageGenConfig,
|
||||
item.isGlobal,
|
||||
);
|
||||
onEditImage?.(item.config as ImageGenerationConfig | GlobalImageGenConfig, item.isGlobal);
|
||||
break;
|
||||
case "vision":
|
||||
onEditVision?.(
|
||||
item.config as VisionLLMConfig | GlobalVisionLLMConfig,
|
||||
item.isGlobal,
|
||||
);
|
||||
onEditVision?.(item.config as VisionLLMConfig | GlobalVisionLLMConfig, item.isGlobal);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[activeTab, onEditLLM, onEditImage, onEditVision],
|
||||
[activeTab, onEditLLM, onEditImage, onEditVision]
|
||||
);
|
||||
|
||||
// ─── Keyboard navigation ───
|
||||
|
|
@ -713,8 +623,7 @@ export function ModelSelector({
|
|||
|
||||
useEffect(() => {
|
||||
if (focusedIndex < 0 || !modelListRef.current) return;
|
||||
const items =
|
||||
modelListRef.current.querySelectorAll("[data-model-index]");
|
||||
const items = modelListRef.current.querySelectorAll("[data-model-index]");
|
||||
items[focusedIndex]?.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
|
|
@ -734,13 +643,11 @@ export function ModelSelector({
|
|||
if (e.key === "ArrowLeft") {
|
||||
next = idx > 0 ? idx - 1 : providers.length - 1;
|
||||
} else {
|
||||
next =
|
||||
idx < providers.length - 1 ? idx + 1 : 0;
|
||||
next = idx < providers.length - 1 ? idx + 1 : 0;
|
||||
}
|
||||
setSelectedProvider(providers[next]);
|
||||
if (providerSidebarRef.current) {
|
||||
const buttons =
|
||||
providerSidebarRef.current.querySelectorAll("button");
|
||||
const buttons = providerSidebarRef.current.querySelectorAll("button");
|
||||
buttons[next]?.scrollIntoView({
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
|
|
@ -755,15 +662,11 @@ export function ModelSelector({
|
|||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) =>
|
||||
prev < count - 1 ? prev + 1 : 0,
|
||||
);
|
||||
setFocusedIndex((prev) => (prev < count - 1 ? prev + 1 : 0));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : count - 1,
|
||||
);
|
||||
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : count - 1));
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
|
|
@ -781,13 +684,7 @@ export function ModelSelector({
|
|||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
currentDisplayItems,
|
||||
focusedIndex,
|
||||
activeProviders,
|
||||
selectedProvider,
|
||||
handleSelectItem,
|
||||
],
|
||||
[currentDisplayItems, focusedIndex, activeProviders, selectedProvider, handleSelectItem]
|
||||
);
|
||||
|
||||
// ─── Render: Provider sidebar ───
|
||||
|
|
@ -798,7 +695,7 @@ export function ModelSelector({
|
|||
<div
|
||||
className={cn(
|
||||
"shrink-0 border-border/50 flex",
|
||||
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 && sidebarScrollPos !== "top" && (
|
||||
|
|
@ -817,29 +714,29 @@ export function ModelSelector({
|
|||
className={cn(
|
||||
isMobile
|
||||
? "flex flex-row gap-0.5 px-1 py-1.5 overflow-x-auto [&::-webkit-scrollbar]:h-0 [&::-webkit-scrollbar-track]:bg-transparent"
|
||||
: "flex flex-col gap-0.5 p-1 overflow-y-auto flex-1 [&::-webkit-scrollbar]:w-0 [&::-webkit-scrollbar-track]:bg-transparent",
|
||||
: "flex flex-col gap-0.5 p-1 overflow-y-auto flex-1 [&::-webkit-scrollbar]:w-0 [&::-webkit-scrollbar-track]:bg-transparent"
|
||||
)}
|
||||
style={isMobile ? {
|
||||
maskImage: `linear-gradient(to right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
} : {
|
||||
maskImage: `linear-gradient(to bottom, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 32px, black calc(100% - 32px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 32px, black calc(100% - 32px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
style={
|
||||
isMobile
|
||||
? {
|
||||
maskImage: `linear-gradient(to right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}
|
||||
: {
|
||||
maskImage: `linear-gradient(to bottom, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 32px, black calc(100% - 32px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 32px, black calc(100% - 32px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{activeProviders.map((provider, idx) => {
|
||||
const isAll = provider === "all";
|
||||
const isActive = selectedProvider === provider;
|
||||
const count = providerModelCounts[provider] || 0;
|
||||
const isConfigured =
|
||||
isAll || configuredProviderSet.has(provider);
|
||||
const isConfigured = isAll || configuredProviderSet.has(provider);
|
||||
|
||||
// Separator between configured and unconfigured providers
|
||||
// idx 0 is "all", configured run from 1..configuredCount, unconfigured start at configuredCount+1
|
||||
const showSeparator =
|
||||
!isAll &&
|
||||
idx === configuredCount + 1 &&
|
||||
configuredCount > 0;
|
||||
const showSeparator = !isAll && idx === configuredCount + 1 && configuredCount > 0;
|
||||
|
||||
return (
|
||||
<Fragment key={provider}>
|
||||
|
|
@ -853,20 +750,16 @@ export function ModelSelector({
|
|||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedProvider(provider)
|
||||
}
|
||||
onClick={() => setSelectedProvider(provider)}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center rounded-md transition-all duration-150",
|
||||
isMobile
|
||||
? "p-2 shrink-0"
|
||||
: "p-1.5 w-full",
|
||||
isMobile ? "p-2 shrink-0" : "p-1.5 w-full",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: isConfigured
|
||||
? "hover:bg-accent/60 text-muted-foreground hover:text-foreground"
|
||||
: "opacity-50 hover:opacity-80 hover:bg-accent/40 text-muted-foreground",
|
||||
: "opacity-50 hover:opacity-80 hover:bg-accent/40 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isAll ? (
|
||||
|
|
@ -878,19 +771,9 @@ export function ModelSelector({
|
|||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={
|
||||
isMobile ? "bottom" : "right"
|
||||
}
|
||||
>
|
||||
{isAll
|
||||
? "All Models"
|
||||
: formatProviderName(
|
||||
provider,
|
||||
)}
|
||||
{isConfigured
|
||||
? ` (${count})`
|
||||
: " (not configured)"}
|
||||
<TooltipContent side={isMobile ? "bottom" : "right"}>
|
||||
{isAll ? "All Models" : formatProviderName(provider)}
|
||||
{isConfigured ? ` (${count})` : " (not configured)"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Fragment>
|
||||
|
|
@ -927,8 +810,7 @@ export function ModelSelector({
|
|||
const { config, isAutoMode } = item;
|
||||
const isSelected = getSelectedId() === config.id;
|
||||
const isFocused = focusedIndex === index;
|
||||
const hasCitations =
|
||||
"citations_enabled" in config && !!config.citations_enabled;
|
||||
const hasCitations = "citations_enabled" in config && !!config.citations_enabled;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -938,19 +820,23 @@ export function ModelSelector({
|
|||
tabIndex={isMobile ? -1 : 0}
|
||||
aria-selected={isSelected}
|
||||
onClick={() => handleSelectItem(item)}
|
||||
onKeyDown={isMobile ? undefined : (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelectItem(item);
|
||||
}
|
||||
}}
|
||||
onKeyDown={
|
||||
isMobile
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelectItem(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
onMouseEnter={() => setFocusedIndex(index)}
|
||||
className={cn(
|
||||
"group flex items-center gap-2.5 px-3 py-2 rounded-xl cursor-pointer",
|
||||
"transition-all duration-150 mx-2",
|
||||
"hover:bg-accent/40",
|
||||
isSelected && "bg-primary/6 dark:bg-primary/8",
|
||||
isFocused && "bg-accent/50",
|
||||
isFocused && "bg-accent/50"
|
||||
)}
|
||||
>
|
||||
{/* Provider icon */}
|
||||
|
|
@ -964,9 +850,7 @@ export function ModelSelector({
|
|||
{/* Model info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{config.name}
|
||||
</span>
|
||||
<span className="font-medium text-sm truncate">{config.name}</span>
|
||||
{isAutoMode && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
|
|
@ -978,9 +862,7 @@ export function ModelSelector({
|
|||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{isAutoMode
|
||||
? "Auto Mode"
|
||||
: (config.model_name as string)}
|
||||
{isAutoMode ? "Auto Mode" : (config.model_name as string)}
|
||||
</span>
|
||||
{!isAutoMode && hasCitations && (
|
||||
<Badge
|
||||
|
|
@ -1005,9 +887,7 @@ export function ModelSelector({
|
|||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
{isSelected && (
|
||||
<Check className="size-4 text-primary shrink-0" />
|
||||
)}
|
||||
{isSelected && <Check className="size-4 text-primary shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1021,11 +901,7 @@ export function ModelSelector({
|
|||
const userStartIdx = globalItems.length;
|
||||
|
||||
const addHandler =
|
||||
activeTab === "llm"
|
||||
? onAddNewLLM
|
||||
: activeTab === "image"
|
||||
? onAddNewImage
|
||||
: onAddNewVision;
|
||||
activeTab === "llm" ? onAddNewLLM : activeTab === "image" ? onAddNewImage : onAddNewVision;
|
||||
const addLabel =
|
||||
activeTab === "llm"
|
||||
? "Add Model"
|
||||
|
|
@ -1065,7 +941,7 @@ export function ModelSelector({
|
|||
"flex items-center justify-center gap-1.5 text-sm font-medium transition-all duration-200 border-b-[1.5px]",
|
||||
activeTab === value
|
||||
? "border-foreground dark:border-white text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground/70",
|
||||
: "border-transparent text-muted-foreground hover:text-foreground/70"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
|
|
@ -1076,14 +952,7 @@ export function ModelSelector({
|
|||
</div>
|
||||
|
||||
{/* Two-pane layout */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
isMobile
|
||||
? "flex-col h-[60vh]"
|
||||
: "flex-row h-[380px]",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex", isMobile ? "flex-col h-[60vh]" : "flex-row h-[380px]")}>
|
||||
{/* Provider sidebar */}
|
||||
{renderProviderSidebar()}
|
||||
|
||||
|
|
@ -1096,9 +965,7 @@ export function ModelSelector({
|
|||
ref={searchInputRef}
|
||||
placeholder="Search models"
|
||||
value={searchQuery}
|
||||
onChange={(e) =>
|
||||
setSearchQuery(e.target.value)
|
||||
}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={isMobile ? undefined : handleKeyDown}
|
||||
role="combobox"
|
||||
aria-expanded={true}
|
||||
|
|
@ -1106,7 +973,7 @@ export function ModelSelector({
|
|||
className={cn(
|
||||
"w-full pl-8 pr-3 py-2.5 text-sm bg-transparent",
|
||||
"focus:outline-none",
|
||||
"placeholder:text-muted-foreground",
|
||||
"placeholder:text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1117,13 +984,9 @@ export function ModelSelector({
|
|||
{getProviderIcon(selectedProvider, {
|
||||
className: "size-4",
|
||||
})}
|
||||
<span className="text-sm font-medium">
|
||||
{formatProviderName(selectedProvider)}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{formatProviderName(selectedProvider)}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{configuredProviderSet.has(
|
||||
selectedProvider,
|
||||
)
|
||||
{configuredProviderSet.has(selectedProvider)
|
||||
? `${providerModelCounts[selectedProvider] || 0} models`
|
||||
: "Not configured"}
|
||||
</span>
|
||||
|
|
@ -1144,30 +1007,18 @@ export function ModelSelector({
|
|||
>
|
||||
{currentDisplayItems.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 px-4">
|
||||
{selectedProvider !== "all" &&
|
||||
!configuredProviderSet.has(
|
||||
selectedProvider,
|
||||
) ? (
|
||||
{selectedProvider !== "all" && !configuredProviderSet.has(selectedProvider) ? (
|
||||
<>
|
||||
<div className="opacity-40">
|
||||
{getProviderIcon(
|
||||
selectedProvider,
|
||||
{
|
||||
className:
|
||||
"size-10",
|
||||
},
|
||||
)}
|
||||
{getProviderIcon(selectedProvider, {
|
||||
className: "size-10",
|
||||
})}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
No{" "}
|
||||
{formatProviderName(
|
||||
selectedProvider,
|
||||
)}{" "}
|
||||
models configured
|
||||
No {formatProviderName(selectedProvider)} models configured
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60 text-center">
|
||||
Add a model with this
|
||||
provider to get started
|
||||
Add a model with this provider to get started
|
||||
</p>
|
||||
{addHandler && (
|
||||
<Button
|
||||
|
|
@ -1186,12 +1037,9 @@ export function ModelSelector({
|
|||
) : searchQuery ? (
|
||||
<>
|
||||
<Search className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No models found
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">No models found</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Try a different search
|
||||
term
|
||||
Try a different search term
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -1212,29 +1060,18 @@ export function ModelSelector({
|
|||
<div className="flex items-center gap-2 px-3 py-1.5 text-[12px] font-semibold text-muted-foreground tracking-wider">
|
||||
Global Models
|
||||
</div>
|
||||
{globalItems.map((item, i) =>
|
||||
renderModelCard(
|
||||
item,
|
||||
globalStartIdx + i,
|
||||
),
|
||||
)}
|
||||
{globalItems.map((item, i) => renderModelCard(item, globalStartIdx + i))}
|
||||
</>
|
||||
)}
|
||||
{globalItems.length > 0 &&
|
||||
userItems.length > 0 && (
|
||||
<div className="my-1.5 mx-4 h-px bg-border/60" />
|
||||
)}
|
||||
{globalItems.length > 0 && userItems.length > 0 && (
|
||||
<div className="my-1.5 mx-4 h-px bg-border/60" />
|
||||
)}
|
||||
{userItems.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-[12px] font-semibold text-muted-foreground tracking-wider">
|
||||
Your Configurations
|
||||
</div>
|
||||
{userItems.map((item, i) =>
|
||||
renderModelCard(
|
||||
item,
|
||||
userStartIdx + i,
|
||||
),
|
||||
)}
|
||||
{userItems.map((item, i) => renderModelCard(item, userStartIdx + i))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -1254,9 +1091,7 @@ export function ModelSelector({
|
|||
}}
|
||||
>
|
||||
<Plus className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium">
|
||||
{addLabel}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{addLabel}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1275,18 +1110,13 @@ export function ModelSelector({
|
|||
aria-expanded={open}
|
||||
className={cn(
|
||||
"h-8 gap-2 px-3 text-sm bg-main-panel hover:bg-accent/50 dark:hover:bg-white/[0.06] border border-border/40 select-none",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<span className="text-muted-foreground hidden md:inline">
|
||||
Loading
|
||||
</span>
|
||||
<Spinner size="sm" className="text-muted-foreground" />
|
||||
<span className="text-muted-foreground hidden md:inline">Loading</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -1303,9 +1133,7 @@ export function ModelSelector({
|
|||
) : (
|
||||
<>
|
||||
<Bot className="size-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground hidden md:inline">
|
||||
Select Model
|
||||
</span>
|
||||
<span className="text-muted-foreground hidden md:inline">Select Model</span>
|
||||
</>
|
||||
)}
|
||||
<div className="h-4 w-px bg-border/60 dark:bg-white/10 mx-0.5" />
|
||||
|
|
@ -1352,9 +1180,7 @@ export function ModelSelector({
|
|||
<DrawerHeader className="pb-0">
|
||||
<DrawerTitle>Select Model</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{renderContent()}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">{renderContent()}</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -182,7 +182,9 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
|
|||
onMouseEnter={() => setHighlightedIndex(createPromptIndex)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors rounded-md cursor-pointer text-muted-foreground",
|
||||
highlightedIndex === createPromptIndex ? "bg-accent text-foreground" : "hover:text-foreground hover:bg-accent/50"
|
||||
highlightedIndex === createPromptIndex
|
||||
? "bg-accent text-foreground"
|
||||
: "hover:text-foreground hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Pricing } from "@/components/pricing";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -79,10 +80,15 @@ const faqData: FAQSection[] = [
|
|||
title: "Pages & Billing",
|
||||
items: [
|
||||
{
|
||||
question: "What exactly is a \"page\" in SurfSense?",
|
||||
question: 'What exactly is a "page" in SurfSense?',
|
||||
answer:
|
||||
"A page is a simple billing unit that measures how much content you add to your knowledge base. For PDFs, one page equals one real PDF page. For other document types like Word, PowerPoint, and Excel files, pages are automatically estimated based on the file. Every file uses at least 1 page.",
|
||||
},
|
||||
{
|
||||
question: "What are Basic and Premium processing modes?",
|
||||
answer:
|
||||
"When uploading documents, you can choose between two processing modes. Basic mode uses standard extraction and costs 1 page credit per page, great for most documents. Premium mode uses advanced extraction optimized for complex financial, medical, and legal documents with intricate tables, layouts, and formatting. Premium costs 10 page credits per page but delivers significantly higher fidelity output for these specialized document types.",
|
||||
},
|
||||
{
|
||||
question: "How does the Pay As You Go plan work?",
|
||||
answer:
|
||||
|
|
@ -111,7 +117,7 @@ const faqData: FAQSection[] = [
|
|||
{
|
||||
question: "How are pages consumed?",
|
||||
answer:
|
||||
"Pages are deducted whenever a document file is successfully indexed into your knowledge base, whether through direct uploads or file-based connector syncs (Google Drive, OneDrive, Dropbox, Local Folder). SurfSense checks your remaining pages before processing and only charges you after the file is indexed. Duplicate documents are automatically detected and won't cost you extra pages.",
|
||||
"Pages are deducted whenever a document file is successfully indexed into your knowledge base, whether through direct uploads or file-based connector syncs (Google Drive, OneDrive, Dropbox, Local Folder). In Basic mode, each page costs 1 page credit; in Premium mode, each page costs 10 page credits. SurfSense checks your remaining credits before processing and only charges you after the file is indexed. Duplicate documents are automatically detected and won't cost you extra pages.",
|
||||
},
|
||||
{
|
||||
question: "Do connectors like Slack, Notion, or Gmail use pages?",
|
||||
|
|
@ -132,13 +138,7 @@ const faqData: FAQSection[] = [
|
|||
},
|
||||
];
|
||||
|
||||
const GridLineHorizontal = ({
|
||||
className,
|
||||
offset,
|
||||
}: {
|
||||
className?: string;
|
||||
offset?: string;
|
||||
}) => {
|
||||
const GridLineHorizontal = ({ className, offset }: { className?: string; offset?: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
|
|
@ -162,19 +162,13 @@ const GridLineHorizontal = ({
|
|||
"mask-exclude",
|
||||
"z-30",
|
||||
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const GridLineVertical = ({
|
||||
className,
|
||||
offset,
|
||||
}: {
|
||||
className?: string;
|
||||
offset?: string;
|
||||
}) => {
|
||||
const GridLineVertical = ({ className, offset }: { className?: string; offset?: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
|
|
@ -197,7 +191,7 @@ const GridLineVertical = ({
|
|||
"mask-exclude",
|
||||
"z-30",
|
||||
"dark:bg-[linear-gradient(to_bottom,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
|
@ -209,10 +203,7 @@ function PricingFAQ() {
|
|||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setActiveId(null);
|
||||
}
|
||||
}
|
||||
|
|
@ -232,21 +223,15 @@ function PricingFAQ() {
|
|||
Frequently Asked Questions
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
|
||||
Everything you need to know about SurfSense pages and billing.
|
||||
Can't find what you need? Reach out at{" "}
|
||||
<a
|
||||
href="mailto:rohan@surfsense.com"
|
||||
className="text-blue-500 underline"
|
||||
>
|
||||
Everything you need to know about SurfSense pages and billing. Can't find what you
|
||||
need? Reach out at{" "}
|
||||
<a href="mailto:rohan@surfsense.com" className="text-blue-500 underline">
|
||||
rohan@surfsense.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative mt-16 flex w-full flex-col gap-12 px-4 md:px-8"
|
||||
>
|
||||
<div ref={containerRef} className="relative mt-16 flex w-full flex-col gap-12 px-4 md:px-8">
|
||||
{faqData.map((section) => (
|
||||
<div key={section.title + "faq"}>
|
||||
<h3 className="mb-6 text-lg font-medium text-neutral-800 dark:text-neutral-200">
|
||||
|
|
@ -264,30 +249,19 @@ function PricingFAQ() {
|
|||
"relative rounded-lg transition-all duration-200",
|
||||
isActive
|
||||
? "bg-white shadow-sm ring-1 shadow-black/10 ring-black/10 dark:bg-neutral-900 dark:shadow-white/5 dark:ring-white/10"
|
||||
: "hover:bg-neutral-50 dark:hover:bg-neutral-900",
|
||||
: "hover:bg-neutral-50 dark:hover:bg-neutral-900"
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute inset-0">
|
||||
<GridLineHorizontal
|
||||
className="-top-[2px]"
|
||||
offset="100px"
|
||||
/>
|
||||
<GridLineHorizontal
|
||||
className="-bottom-[2px]"
|
||||
offset="100px"
|
||||
/>
|
||||
<GridLineVertical
|
||||
className="-left-[2px]"
|
||||
offset="100px"
|
||||
/>
|
||||
<GridLineVertical
|
||||
className="-right-[2px] left-auto"
|
||||
offset="100px"
|
||||
/>
|
||||
<GridLineHorizontal className="-top-[2px]" offset="100px" />
|
||||
<GridLineHorizontal className="-bottom-[2px]" offset="100px" />
|
||||
<GridLineVertical className="-left-[2px]" offset="100px" />
|
||||
<GridLineVertical className="-right-[2px] left-auto" offset="100px" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleQuestion(id)}
|
||||
className="flex w-full items-center justify-between px-4 py-4 text-left"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -80,9 +80,7 @@ export function PublicChatSnapshotRow({
|
|||
size="icon"
|
||||
className={cn(
|
||||
"absolute right-0 h-6 w-6 shrink-0 hover:bg-transparent",
|
||||
dropdownOpen
|
||||
? "opacity-100"
|
||||
: "sm:opacity-0 sm:group-hover:opacity-100"
|
||||
dropdownOpen ? "opacity-100" : "sm:opacity-0 sm:group-hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
|
|
@ -100,10 +98,7 @@ export function PublicChatSnapshotRow({
|
|||
</a>
|
||||
</DropdownMenuItem>
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(snapshot)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => onDelete(snapshot)} disabled={isDeleting}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -27,18 +27,13 @@ export function BreadcrumbNav({ items, className }: BreadcrumbNavProps) {
|
|||
const isLast = index === items.length - 1;
|
||||
return (
|
||||
<li key={item.href} className="flex items-center gap-1.5">
|
||||
{index > 0 && (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
)}
|
||||
{index > 0 && <ChevronRight className="h-3.5 w-3.5 shrink-0" aria-hidden />}
|
||||
{isLast ? (
|
||||
<span className="font-medium text-foreground" aria-current="page">
|
||||
{item.name}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={item.href}
|
||||
className="transition-colors hover:text-foreground"
|
||||
>
|
||||
<Link href={item.href} className="transition-colors hover:text-foreground">
|
||||
{item.name}
|
||||
</Link>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@ interface JsonLdProps {
|
|||
|
||||
export function JsonLd({ data }: JsonLdProps) {
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
|
||||
/>
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: JSON-LD structured data requires dangerouslySetInnerHTML for script injection
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -20,12 +18,9 @@ export function OrganizationJsonLd() {
|
|||
name: "SurfSense",
|
||||
url: "https://surfsense.com",
|
||||
logo: "https://surfsense.com/logo.png",
|
||||
description:
|
||||
"Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.",
|
||||
sameAs: [
|
||||
"https://github.com/MODSetter/SurfSense",
|
||||
"https://discord.gg/Cg2M4GUJ",
|
||||
],
|
||||
description:
|
||||
"Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.",
|
||||
sameAs: ["https://github.com/MODSetter/SurfSense", "https://discord.gg/Cg2M4GUJ"],
|
||||
contactPoint: {
|
||||
"@type": "ContactPoint",
|
||||
email: "rohan@surfsense.com",
|
||||
|
|
@ -44,8 +39,8 @@ export function WebSiteJsonLd() {
|
|||
"@type": "WebSite",
|
||||
name: "SurfSense",
|
||||
url: "https://surfsense.com",
|
||||
description:
|
||||
"Open source NotebookLM alternative for teams with no data limits. Free ChatGPT, Claude AI, and any AI model.",
|
||||
description:
|
||||
"Open source NotebookLM alternative for teams with no data limits. Free ChatGPT, Claude AI, and any AI model.",
|
||||
potentialAction: {
|
||||
"@type": "SearchAction",
|
||||
target: {
|
||||
|
|
@ -74,22 +69,22 @@ export function SoftwareApplicationJsonLd() {
|
|||
priceCurrency: "USD",
|
||||
description: "Free plan with 500 pages included",
|
||||
},
|
||||
description:
|
||||
"Open source NotebookLM alternative with free access to ChatGPT, Claude AI, and any model. Connect Slack, Google Drive, Notion, Confluence, GitHub, and dozens more data sources.",
|
||||
description:
|
||||
"Open source NotebookLM alternative with free access to ChatGPT, Claude AI, and any model. Connect Slack, Google Drive, Notion, Confluence, GitHub, and dozens more data sources.",
|
||||
url: "https://surfsense.com",
|
||||
downloadUrl: "https://github.com/MODSetter/SurfSense/releases",
|
||||
featureList: [
|
||||
"Free access to ChatGPT, Claude AI, and any AI model",
|
||||
"AI-powered semantic search across all connected tools",
|
||||
"Federated search across Slack, Google Drive, Notion, Confluence, GitHub",
|
||||
"No data limits with open source self-hosting",
|
||||
"Real-time collaborative team chats",
|
||||
"Document Q&A with citations",
|
||||
"Report generation",
|
||||
"Podcast and video generation from sources",
|
||||
"Enterprise knowledge management",
|
||||
"Self-hostable and privacy-focused",
|
||||
],
|
||||
featureList: [
|
||||
"Free access to ChatGPT, Claude AI, and any AI model",
|
||||
"AI-powered semantic search across all connected tools",
|
||||
"Federated search across Slack, Google Drive, Notion, Confluence, GitHub",
|
||||
"No data limits with open source self-hosting",
|
||||
"Real-time collaborative team chats",
|
||||
"Document Q&A with citations",
|
||||
"Report generation",
|
||||
"Podcast and video generation from sources",
|
||||
"Enterprise knowledge management",
|
||||
"Self-hostable and privacy-focused",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
@ -141,11 +136,7 @@ export function ArticleJsonLd({
|
|||
);
|
||||
}
|
||||
|
||||
export function BreadcrumbJsonLd({
|
||||
items,
|
||||
}: {
|
||||
items: { name: string; url: string }[];
|
||||
}) {
|
||||
export function BreadcrumbJsonLd({ items }: { items: { name: string; url: string }[] }) {
|
||||
return (
|
||||
<JsonLd
|
||||
data={{
|
||||
|
|
@ -162,11 +153,7 @@ export function BreadcrumbJsonLd({
|
|||
);
|
||||
}
|
||||
|
||||
export function FAQJsonLd({
|
||||
questions,
|
||||
}: {
|
||||
questions: { question: string; answer: string }[];
|
||||
}) {
|
||||
export function FAQJsonLd({ questions }: { questions: { question: string; answer: string }[] }) {
|
||||
return (
|
||||
<JsonLd
|
||||
data={{
|
||||
|
|
|
|||
|
|
@ -145,18 +145,14 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search-space-name">
|
||||
{t("general_name_label")}
|
||||
</Label>
|
||||
<Label htmlFor="search-space-name">{t("general_name_label")}</Label>
|
||||
<Input
|
||||
id="search-space-name"
|
||||
placeholder={t("general_name_placeholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("general_name_description")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("general_name_description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
@ -170,9 +166,7 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("general_description_description")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("general_description_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -189,28 +183,28 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
</div>
|
||||
</form>
|
||||
|
||||
<div className="border-t pt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Export knowledge base</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Download all documents in this search space as a ZIP of markdown files.
|
||||
</p>
|
||||
<div className="border-t pt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Export knowledge base</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Download all documents in this search space as a ZIP of markdown files.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={isExporting}
|
||||
onClick={handleExportKB}
|
||||
className="relative w-fit shrink-0"
|
||||
>
|
||||
<span className={isExporting ? "opacity-0" : ""}>
|
||||
<FolderArchive className="h-3 w-3 opacity-60" />
|
||||
</span>
|
||||
<span className={isExporting ? "opacity-0" : ""}>Export</span>
|
||||
{isExporting && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={isExporting}
|
||||
onClick={handleExportKB}
|
||||
className="relative w-fit shrink-0"
|
||||
>
|
||||
<span className={isExporting ? "opacity-0" : ""}>
|
||||
<FolderArchive className="h-3 w-3 opacity-60" />
|
||||
</span>
|
||||
<span className={isExporting ? "opacity-0" : ""}>Export</span>
|
||||
{isExporting && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ import {
|
|||
Bot,
|
||||
CircleCheck,
|
||||
CircleDashed,
|
||||
ScanEye,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
RefreshCw,
|
||||
ScanEye,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
|
|||
|
|
@ -554,9 +554,7 @@ function RolesContent({
|
|||
>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<IconComponent className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">{config.label}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{actions.map((action) => (
|
||||
|
|
@ -685,7 +683,10 @@ function PermissionsEditor({
|
|||
onClick={() => toggleCategoryExpanded(category)}
|
||||
>
|
||||
<div
|
||||
className={cn("transition-transform duration-200", isExpanded && "rotate-180")}
|
||||
className={cn(
|
||||
"transition-transform duration-200",
|
||||
isExpanded && "rotate-180"
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import {
|
|||
Brain,
|
||||
CircleUser,
|
||||
Earth,
|
||||
ScanEye,
|
||||
ImageIcon,
|
||||
ListChecks,
|
||||
ScanEye,
|
||||
UserKey,
|
||||
} from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
|
|
|||
|
|
@ -201,7 +201,9 @@ export function ModelConfigDialog({
|
|||
onSubmit={handleSubmit}
|
||||
mode="create"
|
||||
formId="model-config-form"
|
||||
initialData={defaultProvider ? { provider: defaultProvider as LiteLLMProvider } : undefined}
|
||||
initialData={
|
||||
defaultProvider ? { provider: defaultProvider as LiteLLMProvider } : undefined
|
||||
}
|
||||
/>
|
||||
) : isGlobal && config ? (
|
||||
<div className="space-y-6">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { ChevronDown, Dot, File as FileIcon, FolderOpen, Upload, X } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Crown,
|
||||
Dot,
|
||||
File as FileIcon,
|
||||
FolderOpen,
|
||||
Upload,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
|
@ -25,6 +34,7 @@ import {
|
|||
import { Progress } from "@/components/ui/progress";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { ProcessingMode } from "@/contracts/types/document.types";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import {
|
||||
|
|
@ -137,6 +147,7 @@ export function DocumentUploadTab({
|
|||
const [accordionValue, setAccordionValue] = useState<string>("");
|
||||
const [shouldSummarize, setShouldSummarize] = useState(false);
|
||||
const [useVisionLlm, setUseVisionLlm] = useState(false);
|
||||
const [processingMode, setProcessingMode] = useState<ProcessingMode>("basic");
|
||||
const [uploadDocumentMutation] = useAtom(uploadDocumentMutationAtom);
|
||||
const { mutate: uploadDocuments, isPending: isUploading } = uploadDocumentMutation;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -363,6 +374,7 @@ export function DocumentUploadTab({
|
|||
root_folder_id: rootFolderId,
|
||||
enable_summary: shouldSummarize,
|
||||
use_vision_llm: useVisionLlm,
|
||||
processing_mode: processingMode,
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -410,6 +422,7 @@ export function DocumentUploadTab({
|
|||
search_space_id: Number(searchSpaceId),
|
||||
should_summarize: shouldSummarize,
|
||||
use_vision_llm: useVisionLlm,
|
||||
processing_mode: processingMode,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
|
@ -533,35 +546,29 @@ export function DocumentUploadTab({
|
|||
</button>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer w-full bg-transparent outline-none select-none"
|
||||
onClick={() => {
|
||||
if (!isElectron) fileInputRef.current?.click();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer w-full bg-transparent outline-none select-none"
|
||||
onClick={() => {
|
||||
if (!isElectron) fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Upload className="h-10 w-10 text-muted-foreground" />
|
||||
<div className="text-center space-y-1.5">
|
||||
<p className="text-base font-medium">
|
||||
{isElectron ? t("select_files_or_folder") : t("tap_select_files_or_folder")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
|
||||
</div>
|
||||
<fieldset
|
||||
className="w-full mt-1 border-none p-0 m-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
}}
|
||||
>
|
||||
{renderBrowseButton({ fullWidth: true })}
|
||||
</fieldset>
|
||||
</div>
|
||||
<Upload className="h-10 w-10 text-muted-foreground" />
|
||||
<div className="text-center space-y-1.5">
|
||||
<p className="text-base font-medium">
|
||||
{isElectron ? t("select_files_or_folder") : t("tap_select_files_or_folder")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
|
||||
</div>
|
||||
<fieldset
|
||||
className="w-full mt-1 border-none p-0 m-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{renderBrowseButton({ fullWidth: true })}
|
||||
</fieldset>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -709,6 +716,46 @@ export function DocumentUploadTab({
|
|||
<Switch checked={useVisionLlm} onCheckedChange={setUseVisionLlm} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="font-medium text-sm px-1">{t("processing_mode")}</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProcessingMode("basic")}
|
||||
className={`flex items-start gap-2.5 rounded-lg border p-3 text-left transition-colors ${
|
||||
processingMode === "basic"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-muted-foreground/50"
|
||||
}`}
|
||||
>
|
||||
<Zap
|
||||
className={`h-4 w-4 mt-0.5 shrink-0 ${processingMode === "basic" ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
<p className="font-medium text-sm">{t("basic_mode")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("basic_mode_desc")}</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProcessingMode("premium")}
|
||||
className={`flex items-start gap-2.5 rounded-lg border p-3 text-left transition-colors ${
|
||||
processingMode === "premium"
|
||||
? "border-amber-500 bg-amber-500/5"
|
||||
: "border-border hover:border-muted-foreground/50"
|
||||
}`}
|
||||
>
|
||||
<Crown
|
||||
className={`h-4 w-4 mt-0.5 shrink-0 ${processingMode === "premium" ? "text-amber-500" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
<p className="font-medium text-sm">{t("premium_mode")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("premium_mode_desc")}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleUpload}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface ConfluenceAccount {
|
||||
id: number;
|
||||
|
|
@ -36,7 +36,7 @@ type CreateConfluencePageInterruptContext = {
|
|||
accounts?: ConfluenceAccount[];
|
||||
spaces?: ConfluenceSpace[];
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
type DeleteConfluencePageInterruptContext = {
|
||||
account?: {
|
||||
|
|
@ -26,7 +26,7 @@ type DeleteConfluencePageInterruptContext = {
|
|||
indexed_at?: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
|||
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
type UpdateConfluencePageInterruptContext = {
|
||||
account?: {
|
||||
|
|
@ -29,7 +29,7 @@ type UpdateConfluencePageInterruptContext = {
|
|||
indexed_at?: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface DropboxAccount {
|
||||
id: number;
|
||||
|
|
@ -36,7 +36,7 @@ type DropboxCreateFileContext = {
|
|||
parent_folders?: Record<number, Array<{ folder_path: string; name: string }>>;
|
||||
supported_types?: SupportedType[];
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
@ -57,7 +57,11 @@ interface AuthErrorResult {
|
|||
connector_type?: string;
|
||||
}
|
||||
|
||||
type CreateDropboxFileResult = InterruptResult<DropboxCreateFileContext> | SuccessResult | ErrorResult | AuthErrorResult;
|
||||
type CreateDropboxFileResult =
|
||||
| InterruptResult<DropboxCreateFileContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface DropboxAccount {
|
||||
id: number;
|
||||
|
|
@ -28,7 +28,7 @@ type DropboxTrashFileContext = {
|
|||
account?: DropboxAccount;
|
||||
file?: DropboxFile;
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import { Input } from "@/components/ui/input";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
function ParamEditor({
|
||||
params,
|
||||
|
|
@ -216,11 +216,7 @@ function GenericApprovalCard({
|
|||
</Button>
|
||||
)}
|
||||
{isMCPTool && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={handleAlwaysAllow}
|
||||
>
|
||||
<Button size="sm" className="rounded-lg" onClick={handleAlwaysAllow}>
|
||||
Always Allow
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface GmailAccount {
|
||||
id: number;
|
||||
|
|
@ -30,7 +30,7 @@ interface GmailAccount {
|
|||
type GmailCreateDraftContext = {
|
||||
accounts?: GmailAccount[];
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface GmailAccount {
|
||||
id: number;
|
||||
|
|
@ -30,7 +30,7 @@ interface GmailAccount {
|
|||
type GmailSendEmailContext = {
|
||||
accounts?: GmailAccount[];
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface GmailAccount {
|
||||
id: number;
|
||||
|
|
@ -31,7 +31,7 @@ type GmailTrashEmailContext = {
|
|||
account?: GmailAccount;
|
||||
email?: GmailMessage;
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
|||
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface GmailAccount {
|
||||
id: number;
|
||||
|
|
@ -36,7 +36,7 @@ type GmailUpdateDraftContext = {
|
|||
draft_id?: string;
|
||||
existing_body?: string;
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface GoogleCalendarAccount {
|
||||
id: number;
|
||||
|
|
@ -37,7 +37,7 @@ type CalendarCreateEventContext = {
|
|||
calendars?: CalendarEntry[];
|
||||
timezone?: string;
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface GoogleCalendarAccount {
|
||||
id: number;
|
||||
|
|
@ -33,7 +33,7 @@ type CalendarDeleteEventContext = {
|
|||
account?: GoogleCalendarAccount;
|
||||
event?: CalendarEvent;
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
|||
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface GoogleCalendarAccount {
|
||||
id: number;
|
||||
|
|
@ -43,7 +43,7 @@ type CalendarUpdateEventContext = {
|
|||
account?: GoogleCalendarAccount;
|
||||
event?: CalendarEvent;
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface GoogleDriveAccount {
|
||||
id: number;
|
||||
|
|
@ -30,7 +30,7 @@ type DriveCreateFileContext = {
|
|||
supported_types?: string[];
|
||||
parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>;
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface GoogleDriveAccount {
|
||||
id: number;
|
||||
|
|
@ -27,7 +27,7 @@ type DriveTrashFileContext = {
|
|||
account?: GoogleDriveAccount;
|
||||
file?: GoogleDriveFile;
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface JiraAccount {
|
||||
id: number;
|
||||
|
|
@ -48,7 +48,7 @@ type CreateJiraIssueInterruptContext = {
|
|||
issue_types?: JiraIssueType[];
|
||||
priorities?: JiraPriority[];
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface JiraAccount {
|
||||
id: number;
|
||||
|
|
@ -29,7 +29,7 @@ type DeleteJiraIssueInterruptContext = {
|
|||
account?: JiraAccount;
|
||||
issue?: JiraIssue;
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface JiraIssue {
|
||||
issue_id: string;
|
||||
|
|
@ -50,7 +50,7 @@ type UpdateJiraIssueInterruptContext = {
|
|||
issue?: JiraIssue;
|
||||
priorities?: JiraPriority[];
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface LinearLabel {
|
||||
id: string;
|
||||
|
|
@ -69,7 +69,7 @@ interface LinearWorkspace {
|
|||
type LinearCreateIssueContext = {
|
||||
workspaces?: LinearWorkspace[];
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
@ -91,7 +91,11 @@ interface AuthErrorResult {
|
|||
connector_type: string;
|
||||
}
|
||||
|
||||
type CreateLinearIssueResult = InterruptResult<LinearCreateIssueContext> | SuccessResult | ErrorResult | AuthErrorResult;
|
||||
type CreateLinearIssueResult =
|
||||
| InterruptResult<LinearCreateIssueContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
type LinearDeleteIssueContext = {
|
||||
workspace?: { id: number; organization_name: string };
|
||||
|
|
@ -21,7 +21,7 @@ type LinearDeleteIssueContext = {
|
|||
indexed_at?: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface LinearLabel {
|
||||
id: string;
|
||||
|
|
@ -72,7 +72,7 @@ type LinearUpdateIssueContext = {
|
|||
labels: LinearLabel[];
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
type NotionCreatePageContext = {
|
||||
accounts?: Array<{
|
||||
|
|
@ -37,7 +37,7 @@ type NotionCreatePageContext = {
|
|||
}>
|
||||
>;
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
@ -61,7 +61,11 @@ interface AuthErrorResult {
|
|||
connector_type: string;
|
||||
}
|
||||
|
||||
type CreateNotionPageResult = InterruptResult<NotionCreatePageContext> | SuccessResult | ErrorResult | AuthErrorResult;
|
||||
type CreateNotionPageResult =
|
||||
| InterruptResult<NotionCreatePageContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
type NotionDeletePageContext = {
|
||||
account?: {
|
||||
|
|
@ -23,7 +23,7 @@ type NotionDeletePageContext = {
|
|||
document_id?: number;
|
||||
indexed_at?: string;
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import { PlateEditor } from "@/components/editor/plate-editor";
|
|||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
type NotionUpdatePageContext = {
|
||||
account?: {
|
||||
|
|
@ -25,7 +25,7 @@ type NotionUpdatePageContext = {
|
|||
document_id?: number;
|
||||
indexed_at?: string;
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface OneDriveAccount {
|
||||
id: number;
|
||||
|
|
@ -30,7 +30,7 @@ type OneDriveCreateFileContext = {
|
|||
accounts?: OneDriveAccount[];
|
||||
parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>;
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
@ -51,7 +51,11 @@ interface AuthErrorResult {
|
|||
connector_type?: string;
|
||||
}
|
||||
|
||||
type CreateOneDriveFileResult = InterruptResult<OneDriveCreateFileContext> | SuccessResult | ErrorResult | AuthErrorResult;
|
||||
type CreateOneDriveFileResult =
|
||||
| InterruptResult<OneDriveCreateFileContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface OneDriveAccount {
|
||||
id: number;
|
||||
|
|
@ -28,7 +28,7 @@ type OneDriveTrashFileContext = {
|
|||
account?: OneDriveAccount;
|
||||
file?: OneDriveFile;
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface SuccessResult {
|
||||
status: "success";
|
||||
|
|
|
|||
|
|
@ -144,11 +144,14 @@ export const createDocumentResponse = z.object({
|
|||
/**
|
||||
* Upload documents
|
||||
*/
|
||||
export const processingModeEnum = z.enum(["basic", "premium"]);
|
||||
|
||||
export const uploadDocumentRequest = z.object({
|
||||
files: z.array(z.instanceof(File)),
|
||||
search_space_id: z.number(),
|
||||
should_summarize: z.boolean().default(false),
|
||||
use_vision_llm: z.boolean().default(false),
|
||||
processing_mode: processingModeEnum.default("basic"),
|
||||
});
|
||||
|
||||
export const uploadDocumentResponse = z.object({
|
||||
|
|
@ -362,3 +365,4 @@ export type GetSurfsenseDocsResponse = z.infer<typeof getSurfsenseDocsResponse>;
|
|||
export type GetDocumentChunksRequest = z.infer<typeof getDocumentChunksRequest>;
|
||||
export type GetDocumentChunksResponse = z.infer<typeof getDocumentChunksResponse>;
|
||||
export type ChunkRead = z.infer<typeof chunkRead>;
|
||||
export type ProcessingMode = z.infer<typeof processingModeEnum>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import type { ZodType } from "zod";
|
||||
import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils";
|
||||
import { AppError, AuthenticationError, AuthorizationError, NotFoundError } from "../error";
|
||||
import {
|
||||
AbortedError,
|
||||
AppError,
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
NetworkError,
|
||||
NotFoundError,
|
||||
} from "../error";
|
||||
|
||||
enum ResponseType {
|
||||
JSON = "json",
|
||||
|
|
@ -137,12 +144,21 @@ class BaseApiService {
|
|||
throw new AppError("Failed to parse response", response.status, response.statusText);
|
||||
}
|
||||
|
||||
// Extract structured fields from new envelope or legacy shape
|
||||
const envelope = typeof data === "object" && data?.error;
|
||||
const errorMessage: string =
|
||||
envelope?.message ??
|
||||
(typeof data === "object" && typeof data?.detail === "string" ? data.detail : "");
|
||||
const errorCode: string | undefined = envelope?.code;
|
||||
const requestId: string | undefined =
|
||||
envelope?.request_id ?? response.headers.get("X-Request-ID") ?? undefined;
|
||||
const reportUrl: string | undefined = envelope?.report_url;
|
||||
|
||||
// Handle 401 - try to refresh token first (only once)
|
||||
if (response.status === 401) {
|
||||
if (!options?._isRetry) {
|
||||
const newToken = await refreshAccessToken();
|
||||
if (newToken) {
|
||||
// Retry the request with the new token
|
||||
return this.request(url, responseSchema, {
|
||||
...mergedOptions,
|
||||
headers: {
|
||||
|
|
@ -153,34 +169,37 @@ class BaseApiService {
|
|||
} as RequestOptions & { responseType?: R });
|
||||
}
|
||||
}
|
||||
// Refresh failed or retry failed, redirect to login
|
||||
handleUnauthorized();
|
||||
throw new AuthenticationError(
|
||||
typeof data === "object" && "detail" in data
|
||||
? data.detail
|
||||
: "You are not authenticated. Please login again.",
|
||||
errorMessage || "You are not authenticated. Please login again.",
|
||||
response.status,
|
||||
response.statusText
|
||||
);
|
||||
}
|
||||
|
||||
// For fastapi errors response
|
||||
if (typeof data === "object" && "detail" in data) {
|
||||
throw new AppError(data.detail, response.status, response.statusText);
|
||||
}
|
||||
|
||||
// Map status to typed error
|
||||
switch (response.status) {
|
||||
case 403:
|
||||
throw new AuthorizationError(
|
||||
"You don't have permission to access this resource.",
|
||||
errorMessage || "You don't have permission to access this resource.",
|
||||
response.status,
|
||||
response.statusText
|
||||
);
|
||||
case 404:
|
||||
throw new NotFoundError("Resource not found", response.status, response.statusText);
|
||||
// Add more cases as needed
|
||||
throw new NotFoundError(
|
||||
errorMessage || "Resource not found",
|
||||
response.status,
|
||||
response.statusText
|
||||
);
|
||||
default:
|
||||
throw new AppError("Something went wrong", response.status, response.statusText);
|
||||
throw new AppError(
|
||||
errorMessage || "Something went wrong",
|
||||
response.status,
|
||||
response.statusText,
|
||||
errorCode,
|
||||
requestId,
|
||||
reportUrl
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -231,6 +250,16 @@ class BaseApiService {
|
|||
|
||||
return data;
|
||||
} catch (error) {
|
||||
// Normalize browser-level fetch failures before anything else
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw new AbortedError();
|
||||
}
|
||||
if (error instanceof TypeError && !(error instanceof AppError)) {
|
||||
throw new NetworkError(
|
||||
"Unable to connect to the server. Check your internet connection and try again."
|
||||
);
|
||||
}
|
||||
|
||||
console.error("Request failed:", JSON.stringify(error));
|
||||
if (!(error instanceof AuthenticationError)) {
|
||||
import("posthog-js")
|
||||
|
|
@ -241,11 +270,12 @@ class BaseApiService {
|
|||
...(error instanceof AppError && {
|
||||
status_code: error.status,
|
||||
status_text: error.statusText,
|
||||
error_code: error.code,
|
||||
request_id: error.requestId,
|
||||
}),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// PostHog is not available in the current environment
|
||||
console.error("Failed to capture exception in PostHog");
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,7 +127,8 @@ class DocumentsApiService {
|
|||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { files, search_space_id, should_summarize, use_vision_llm } = parsedRequest.data;
|
||||
const { files, search_space_id, should_summarize, use_vision_llm, processing_mode } =
|
||||
parsedRequest.data;
|
||||
const UPLOAD_BATCH_SIZE = 5;
|
||||
|
||||
const batches: File[][] = [];
|
||||
|
|
@ -147,6 +148,7 @@ class DocumentsApiService {
|
|||
formData.append("search_space_id", String(search_space_id));
|
||||
formData.append("should_summarize", String(should_summarize));
|
||||
formData.append("use_vision_llm", String(use_vision_llm));
|
||||
formData.append("processing_mode", processing_mode);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 120_000);
|
||||
|
|
@ -444,6 +446,7 @@ class DocumentsApiService {
|
|||
root_folder_id?: number | null;
|
||||
enable_summary?: boolean;
|
||||
use_vision_llm?: boolean;
|
||||
processing_mode?: "basic" | "premium";
|
||||
},
|
||||
signal?: AbortSignal
|
||||
): Promise<{ message: string; status: string; root_folder_id: number; file_count: number }> => {
|
||||
|
|
@ -459,6 +462,7 @@ class DocumentsApiService {
|
|||
}
|
||||
formData.append("enable_summary", String(metadata.enable_summary ?? false));
|
||||
formData.append("use_vision_llm", String(metadata.use_vision_llm ?? false));
|
||||
formData.append("processing_mode", metadata.processing_mode ?? "basic");
|
||||
|
||||
const totalSize = files.reduce((acc, f) => acc + f.size, 0);
|
||||
const timeoutMs = Math.min(Math.max((totalSize / (1024 * 1024)) * 5000, 30_000), 600_000);
|
||||
|
|
|
|||
|
|
@ -39,19 +39,20 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
|||
content = [{ type: "text", text: String(msg.content) }];
|
||||
}
|
||||
|
||||
const metadata = (msg.author_id || msg.token_usage)
|
||||
? {
|
||||
custom: {
|
||||
...(msg.author_id && {
|
||||
author: {
|
||||
displayName: msg.author_display_name ?? null,
|
||||
avatarUrl: msg.author_avatar_url ?? null,
|
||||
},
|
||||
}),
|
||||
...(msg.token_usage && { usage: msg.token_usage }),
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
const metadata =
|
||||
msg.author_id || msg.token_usage
|
||||
? {
|
||||
custom: {
|
||||
...(msg.author_id && {
|
||||
author: {
|
||||
displayName: msg.author_display_name ?? null,
|
||||
avatarUrl: msg.author_avatar_url ?? null,
|
||||
},
|
||||
}),
|
||||
...(msg.token_usage && { usage: msg.token_usage }),
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: `msg-${msg.id}`,
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ export function addToolCall(
|
|||
toolCallId: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
force = false,
|
||||
force = false
|
||||
): void {
|
||||
if (force || toolsWithUI.has(toolName)) {
|
||||
state.contentParts.push({
|
||||
|
|
@ -241,11 +241,19 @@ export type SSEEvent =
|
|||
| {
|
||||
type: "data-token-usage";
|
||||
data: {
|
||||
usage: Record<string, { prompt_tokens: number; completion_tokens: number; total_tokens: number }>;
|
||||
usage: Record<
|
||||
string,
|
||||
{ prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||
>;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
call_details: Array<{ model: string; prompt_tokens: number; completion_tokens: number; total_tokens: number }>;
|
||||
call_details: Array<{
|
||||
model: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
| { type: "error"; errorText: string };
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ export interface TokenUsageSummary {
|
|||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
model_breakdown?: Record<string, { prompt_tokens: number; completion_tokens: number; total_tokens: number }> | null;
|
||||
model_breakdown?: Record<
|
||||
string,
|
||||
{ prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||
> | null;
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
|
|
|
|||
72
surfsense_web/lib/error-toast.ts
Normal file
72
surfsense_web/lib/error-toast.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { toast } from "sonner";
|
||||
import { AbortedError, AppError, AuthenticationError, SURFSENSE_ISSUES_URL } from "./error";
|
||||
|
||||
/**
|
||||
* Build a GitHub issue URL pre-filled with diagnostic context.
|
||||
* Avoids including PII — only structural metadata.
|
||||
*/
|
||||
export function buildIssueUrl(error: unknown): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const lines: string[] = ["## Bug Report", "", "**Describe what happened:**", "", ""];
|
||||
|
||||
if (error instanceof AppError) {
|
||||
lines.push("## Diagnostics (auto-filled)", "");
|
||||
if (error.code) lines.push(`- **Error code:** \`${error.code}\``);
|
||||
if (error.requestId) lines.push(`- **Request ID:** \`${error.requestId}\``);
|
||||
if (error.status) lines.push(`- **HTTP status:** ${error.status}`);
|
||||
lines.push(`- **Message:** ${error.message}`);
|
||||
} else if (error instanceof Error) {
|
||||
lines.push("## Diagnostics (auto-filled)", "");
|
||||
lines.push(`- **Error:** ${error.message}`);
|
||||
}
|
||||
|
||||
lines.push(`- **Timestamp:** ${new Date().toISOString()}`);
|
||||
lines.push(
|
||||
`- **Page:** \`${typeof window !== "undefined" ? window.location.pathname : "unknown"}\``
|
||||
);
|
||||
lines.push(
|
||||
`- **User Agent:** \`${typeof navigator !== "undefined" ? navigator.userAgent : "unknown"}\``
|
||||
);
|
||||
|
||||
params.set("body", lines.join("\n"));
|
||||
params.set("labels", "bug");
|
||||
|
||||
return `${SURFSENSE_ISSUES_URL}/new?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a standardized error toast with a "Report Issue" action.
|
||||
*
|
||||
* Suppressed for:
|
||||
* - AbortedError (user-initiated cancellation)
|
||||
* - AuthenticationError (handled by redirect)
|
||||
*/
|
||||
export function showErrorToast(error: unknown, fallbackMessage?: string) {
|
||||
if (error instanceof AbortedError || error instanceof AuthenticationError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
error instanceof AppError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: (fallbackMessage ?? "An unexpected error occurred.");
|
||||
|
||||
const code = error instanceof AppError ? error.code : undefined;
|
||||
const requestId = error instanceof AppError ? error.requestId : undefined;
|
||||
|
||||
const descParts: string[] = [];
|
||||
if (code) descParts.push(`Error: ${code}`);
|
||||
if (requestId) descParts.push(`ID: ${requestId}`);
|
||||
|
||||
toast.error(message, {
|
||||
description: descParts.length > 0 ? descParts.join(" | ") : undefined,
|
||||
duration: 8000,
|
||||
action: {
|
||||
label: "Report Issue",
|
||||
onClick: () => window.open(buildIssueUrl(error), "_blank"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,40 +1,61 @@
|
|||
export const SURFSENSE_ISSUES_URL = "https://github.com/MODSetter/SurfSense/issues";
|
||||
|
||||
export class AppError extends Error {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
constructor(message: string, status?: number, statusText?: string) {
|
||||
code?: string;
|
||||
requestId?: string;
|
||||
reportUrl?: string;
|
||||
constructor(
|
||||
message: string,
|
||||
status?: number,
|
||||
statusText?: string,
|
||||
code?: string,
|
||||
requestId?: string,
|
||||
reportUrl?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = this.constructor.name; // User friendly
|
||||
this.name = this.constructor.name;
|
||||
this.status = status;
|
||||
this.statusText = statusText; // Dev friendly
|
||||
this.statusText = statusText;
|
||||
this.code = code;
|
||||
this.requestId = requestId;
|
||||
this.reportUrl = reportUrl ?? SURFSENSE_ISSUES_URL;
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends AppError {
|
||||
constructor(message: string, status?: number, statusText?: string) {
|
||||
super(message, status, statusText);
|
||||
super(message, status, statusText, "NETWORK_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class AbortedError extends AppError {
|
||||
constructor(message = "Request was cancelled.") {
|
||||
super(message, undefined, undefined, "REQUEST_ABORTED");
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message: string, status?: number, statusText?: string) {
|
||||
super(message, status, statusText);
|
||||
super(message, status, statusText, "VALIDATION_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthenticationError extends AppError {
|
||||
constructor(message: string, status?: number, statusText?: string) {
|
||||
super(message, status, statusText);
|
||||
super(message, status, statusText, "UNAUTHORIZED");
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthorizationError extends AppError {
|
||||
constructor(message: string, status?: number, statusText?: string) {
|
||||
super(message, status, statusText);
|
||||
super(message, status, statusText, "FORBIDDEN");
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(message: string, status?: number, statusText?: string) {
|
||||
super(message, status, statusText);
|
||||
super(message, status, statusText, "NOT_FOUND");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export interface FolderSyncParams {
|
|||
excludePatterns: string[];
|
||||
fileExtensions: string[];
|
||||
enableSummary: boolean;
|
||||
processingMode?: "basic" | "premium";
|
||||
rootFolderId?: number | null;
|
||||
onProgress?: (progress: FolderSyncProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
|
|
@ -62,6 +63,7 @@ async function uploadBatchesWithConcurrency(
|
|||
searchSpaceId: number;
|
||||
rootFolderId: number | null;
|
||||
enableSummary: boolean;
|
||||
processingMode?: "basic" | "premium";
|
||||
signal?: AbortSignal;
|
||||
onBatchComplete?: (filesInBatch: number) => void;
|
||||
}
|
||||
|
|
@ -99,6 +101,7 @@ async function uploadBatchesWithConcurrency(
|
|||
relative_paths: batch.map((e) => e.relativePath),
|
||||
root_folder_id: resolvedRootFolderId,
|
||||
enable_summary: params.enableSummary,
|
||||
processing_mode: params.processingMode,
|
||||
},
|
||||
params.signal
|
||||
);
|
||||
|
|
@ -145,6 +148,7 @@ export async function uploadFolderScan(params: FolderSyncParams): Promise<number
|
|||
excludePatterns,
|
||||
fileExtensions,
|
||||
enableSummary,
|
||||
processingMode,
|
||||
signal,
|
||||
} = params;
|
||||
let rootFolderId = params.rootFolderId ?? null;
|
||||
|
|
@ -190,6 +194,7 @@ export async function uploadFolderScan(params: FolderSyncParams): Promise<number
|
|||
searchSpaceId,
|
||||
rootFolderId: rootFolderId ?? null,
|
||||
enableSummary,
|
||||
processingMode,
|
||||
signal,
|
||||
onBatchComplete: (count) => {
|
||||
uploaded += count;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
export { isInterruptResult } from "./types";
|
||||
export type {
|
||||
HitlDecision,
|
||||
InterruptActionRequest,
|
||||
InterruptResult,
|
||||
InterruptReviewConfig,
|
||||
} from "./types";
|
||||
export { isInterruptResult } from "./types";
|
||||
export { useHitlDecision } from "./use-hitl-decision";
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@ import type { HitlDecision } from "./types";
|
|||
|
||||
export function useHitlDecision() {
|
||||
const dispatch = useCallback((decisions: HitlDecision[]) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions } }),
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } }));
|
||||
}, []);
|
||||
|
||||
return { dispatch };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import { showErrorToast } from "../error-toast";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -7,4 +8,16 @@ export const queryClient = new QueryClient({
|
|||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
queryCache: new QueryCache({
|
||||
onError: (error, query) => {
|
||||
if (query.meta?.suppressGlobalErrorToast) return;
|
||||
showErrorToast(error);
|
||||
},
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error, _variables, _context, mutation) => {
|
||||
if (mutation.meta?.suppressGlobalErrorToast) return;
|
||||
showErrorToast(error);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -399,7 +399,12 @@
|
|||
"uploading_folder": "Uploading folder…",
|
||||
"upload_folder_button": "Upload Folder ({count} {count, plural, one {file} other {files}})",
|
||||
"select_files_or_folder": "Select files or folder",
|
||||
"tap_select_files_or_folder": "Tap to select files or folder"
|
||||
"tap_select_files_or_folder": "Tap to select files or folder",
|
||||
"processing_mode": "Processing Mode",
|
||||
"basic_mode": "Basic",
|
||||
"basic_mode_desc": "Standard processing (1 credit per page)",
|
||||
"premium_mode": "Premium",
|
||||
"premium_mode_desc": "Enhanced for financial, medical & legal docs (10 credits per page)"
|
||||
},
|
||||
"add_webpage": {
|
||||
"title": "Add Webpages for Crawling",
|
||||
|
|
|
|||
|
|
@ -399,7 +399,12 @@
|
|||
"uploading_folder": "Subiendo carpeta…",
|
||||
"upload_folder_button": "Subir carpeta ({count} {count, plural, one {archivo} other {archivos}})",
|
||||
"select_files_or_folder": "Seleccionar archivos o carpeta",
|
||||
"tap_select_files_or_folder": "Toca para seleccionar archivos o carpeta"
|
||||
"tap_select_files_or_folder": "Toca para seleccionar archivos o carpeta",
|
||||
"processing_mode": "Modo de procesamiento",
|
||||
"basic_mode": "Básico",
|
||||
"basic_mode_desc": "Procesamiento estándar (1 crédito por página)",
|
||||
"premium_mode": "Premium",
|
||||
"premium_mode_desc": "Mejorado para documentos financieros, médicos y legales (10 créditos por página)"
|
||||
},
|
||||
"add_webpage": {
|
||||
"title": "Agregar páginas web para rastreo",
|
||||
|
|
|
|||
|
|
@ -399,7 +399,12 @@
|
|||
"uploading_folder": "फ़ोल्डर अपलोड हो रहा है…",
|
||||
"upload_folder_button": "फ़ोल्डर अपलोड करें ({count} {count, plural, one {फ़ाइल} other {फ़ाइलें}})",
|
||||
"select_files_or_folder": "फ़ाइलें या फ़ोल्डर चुनें",
|
||||
"tap_select_files_or_folder": "फ़ाइलें या फ़ोल्डर चुनने के लिए टैप करें"
|
||||
"tap_select_files_or_folder": "फ़ाइलें या फ़ोल्डर चुनने के लिए टैप करें",
|
||||
"processing_mode": "प्रोसेसिंग मोड",
|
||||
"basic_mode": "बेसिक",
|
||||
"basic_mode_desc": "मानक प्रोसेसिंग (1 क्रेडिट प्रति पेज)",
|
||||
"premium_mode": "प्रीमियम",
|
||||
"premium_mode_desc": "वित्तीय, चिकित्सा और कानूनी दस्तावेज़ों के लिए उन्नत (10 क्रेडिट प्रति पेज)"
|
||||
},
|
||||
"add_webpage": {
|
||||
"title": "क्रॉलिंग के लिए वेबपेज जोड़ें",
|
||||
|
|
|
|||
|
|
@ -399,7 +399,12 @@
|
|||
"uploading_folder": "Enviando pasta…",
|
||||
"upload_folder_button": "Enviar pasta ({count} {count, plural, one {arquivo} other {arquivos}})",
|
||||
"select_files_or_folder": "Selecionar arquivos ou pasta",
|
||||
"tap_select_files_or_folder": "Toque para selecionar arquivos ou pasta"
|
||||
"tap_select_files_or_folder": "Toque para selecionar arquivos ou pasta",
|
||||
"processing_mode": "Modo de processamento",
|
||||
"basic_mode": "Básico",
|
||||
"basic_mode_desc": "Processamento padrão (1 crédito por página)",
|
||||
"premium_mode": "Premium",
|
||||
"premium_mode_desc": "Aprimorado para documentos financeiros, médicos e jurídicos (10 créditos por página)"
|
||||
},
|
||||
"add_webpage": {
|
||||
"title": "Adicionar páginas web para rastreamento",
|
||||
|
|
|
|||
|
|
@ -383,7 +383,12 @@
|
|||
"uploading_folder": "正在上传文件夹…",
|
||||
"upload_folder_button": "上传文件夹({count}个文件)",
|
||||
"select_files_or_folder": "选择文件或文件夹",
|
||||
"tap_select_files_or_folder": "点击选择文件或文件夹"
|
||||
"tap_select_files_or_folder": "点击选择文件或文件夹",
|
||||
"processing_mode": "处理模式",
|
||||
"basic_mode": "基础",
|
||||
"basic_mode_desc": "标准处理(每页 1 积分)",
|
||||
"premium_mode": "高级",
|
||||
"premium_mode_desc": "增强处理财务、医疗和法律文档(每页 10 积分)"
|
||||
},
|
||||
"add_webpage": {
|
||||
"title": "添加网页爬取",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue