mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-23 19:05:16 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/ui-revamp
This commit is contained in:
commit
f65bc81509
603 changed files with 45035 additions and 4652 deletions
|
|
@ -5,12 +5,12 @@ export const metadata: Metadata = {
|
|||
title: "What's New | SurfSense",
|
||||
description: "Latest product updates, feature releases, and news from SurfSense.",
|
||||
alternates: {
|
||||
canonical: "https://surfsense.com/announcements",
|
||||
canonical: "https://www.surfsense.com/announcements",
|
||||
},
|
||||
openGraph: {
|
||||
title: "What's New | SurfSense",
|
||||
description: "Latest product updates, feature releases, and news from SurfSense.",
|
||||
url: "https://surfsense.com/announcements",
|
||||
url: "https://www.surfsense.com/announcements",
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import Image from "next/image";
|
|||
import { notFound } from "next/navigation";
|
||||
import { blog } from "@/.source/server";
|
||||
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
|
||||
import { ArticleJsonLd } from "@/components/seo/json-ld";
|
||||
import { ArticleJsonLd, FAQJsonLd } from "@/components/seo/json-ld";
|
||||
import { extractFaqFromBlogPost } from "@/lib/blog-faq";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { getMDXComponents } from "@/mdx-components";
|
||||
|
||||
|
|
@ -21,6 +22,8 @@ interface BlogData {
|
|||
author?: string;
|
||||
authorAvatar?: string;
|
||||
tags?: string[];
|
||||
// Populated by Fumadocs when `lastModifiedTime: "git"` is set in source.config.ts.
|
||||
lastModified?: Date;
|
||||
body: React.ComponentType<{
|
||||
components?: Record<string, React.ComponentType>;
|
||||
}>;
|
||||
|
|
@ -50,7 +53,7 @@ export async function generateMetadata(props: {
|
|||
title: `${page.data.title} | SurfSense Blog`,
|
||||
description: page.data.description,
|
||||
alternates: {
|
||||
canonical: `https://surfsense.com/blog/${slug}`,
|
||||
canonical: `https://www.surfsense.com/blog/${slug}`,
|
||||
},
|
||||
openGraph: {
|
||||
title: page.data.title,
|
||||
|
|
@ -78,17 +81,23 @@ export default async function BlogPostPage(props: { params: Promise<{ slug: stri
|
|||
|
||||
const MDX = page.data.body;
|
||||
const date = new Date(page.data.date);
|
||||
const dateModified = page.data.lastModified
|
||||
? new Date(page.data.lastModified).toISOString()
|
||||
: undefined;
|
||||
const faqEntries = await extractFaqFromBlogPost(slug);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative pt-20">
|
||||
<ArticleJsonLd
|
||||
title={page.data.title}
|
||||
description={page.data.description}
|
||||
url={`https://surfsense.com/blog/${slug}`}
|
||||
url={`https://www.surfsense.com/blog/${slug}`}
|
||||
datePublished={page.data.date}
|
||||
dateModified={dateModified}
|
||||
author={page.data.author ?? "SurfSense Team"}
|
||||
image={page.data.image ? `https://surfsense.com${page.data.image}` : undefined}
|
||||
image={page.data.image ? `https://www.surfsense.com${page.data.image}` : undefined}
|
||||
/>
|
||||
{faqEntries.length > 0 && <FAQJsonLd questions={faqEntries} />}
|
||||
<div className="max-w-3xl mx-auto px-6 lg:px-10 pt-10 pb-20">
|
||||
<BreadcrumbNav
|
||||
items={[
|
||||
|
|
|
|||
|
|
@ -35,9 +35,7 @@ function SearchIcon({ className }: { className?: string }) {
|
|||
}
|
||||
|
||||
export function BlogWithSearchMagazine({ blogs }: { blogs: BlogEntry[] }) {
|
||||
const featured = blogs[0];
|
||||
|
||||
if (!featured) {
|
||||
if (blogs.length === 0) {
|
||||
return (
|
||||
<div className="relative overflow-hidden bg-neutral-50 px-4 md:px-8 dark:bg-neutral-950">
|
||||
<Container className="relative pt-12 pb-24 md:pt-20">
|
||||
|
|
@ -47,6 +45,17 @@ export function BlogWithSearchMagazine({ blogs }: { blogs: BlogEntry[] }) {
|
|||
);
|
||||
}
|
||||
|
||||
// `blogs` arrives pre-sorted from the server: explicitly featured posts
|
||||
// first (ordered by `featured_order` asc, then date desc), then the rest
|
||||
// by date desc. If nothing is explicitly featured, fall back to treating
|
||||
// the newest post as the cover so the layout never feels empty up top.
|
||||
// `MagazineSearchGrid` re-filters using `heroSlugs` so the hero/featured
|
||||
// posts never duplicate into the archive grid.
|
||||
const explicitlyFeatured = blogs.filter((b) => b.featured);
|
||||
const heroBlogs = explicitlyFeatured.length > 0 ? explicitlyFeatured : blogs.slice(0, 1);
|
||||
const heroSlugs = new Set(heroBlogs.map((b) => b.slug));
|
||||
const [coverStory, ...secondaryFeatured] = heroBlogs;
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden bg-neutral-50 px-4 pt-20 md:px-8 dark:bg-neutral-950">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(120,119,198,0.15),transparent)] dark:bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(120,119,198,0.12),transparent)]" />
|
||||
|
|
@ -57,14 +66,36 @@ export function BlogWithSearchMagazine({ blogs }: { blogs: BlogEntry[] }) {
|
|||
</h1>
|
||||
</header>
|
||||
|
||||
<MagazineFeatured blog={featured} />
|
||||
<MagazineFeatured blog={coverStory} />
|
||||
|
||||
<MagazineSearchGrid blogs={blogs} featuredSlug={featured.slug} />
|
||||
{secondaryFeatured.length > 0 ? <MoreFeatured blogs={secondaryFeatured} /> : null}
|
||||
|
||||
<MagazineSearchGrid blogs={blogs} excludedSlugs={heroSlugs} />
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoreFeatured({ blogs }: { blogs: BlogEntry[] }) {
|
||||
return (
|
||||
<section aria-labelledby="more-featured-heading" className="mb-14">
|
||||
<h2
|
||||
id="more-featured-heading"
|
||||
className="mb-6 font-serif text-2xl font-medium text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
More featured
|
||||
</h2>
|
||||
<ul className="grid gap-6 sm:grid-cols-2">
|
||||
{blogs.map((blog) => (
|
||||
<li key={blog.slug}>
|
||||
<MagazineCard blog={blog} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MagazineFeatured({ blog }: { blog: BlogEntry }) {
|
||||
return (
|
||||
<Link
|
||||
|
|
@ -112,10 +143,11 @@ function MagazineFeatured({ blog }: { blog: BlogEntry }) {
|
|||
|
||||
function MagazineSearchGrid({
|
||||
blogs: allBlogs,
|
||||
featuredSlug,
|
||||
excludedSlugs,
|
||||
}: {
|
||||
blogs: BlogEntry[];
|
||||
featuredSlug: string;
|
||||
/** Slugs already shown above the archive (cover story + "More featured"). */
|
||||
excludedSlugs: Set<string>;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
|
|
@ -128,12 +160,15 @@ function MagazineSearchGrid({
|
|||
);
|
||||
|
||||
const gridItems = useMemo(() => {
|
||||
// When the reader is searching, surface every match (including
|
||||
// featured posts they may be looking for); otherwise hide the posts
|
||||
// that are already rendered as featured above the archive.
|
||||
const results = search.trim() ? searcher.search(search) : allBlogs;
|
||||
if (search.trim()) {
|
||||
return results;
|
||||
}
|
||||
return results.filter((b) => b.slug !== featuredSlug);
|
||||
}, [search, searcher, allBlogs, featuredSlug]);
|
||||
return results.filter((b) => !excludedSlugs.has(b.slug));
|
||||
}, [search, searcher, allBlogs, excludedSlugs]);
|
||||
|
||||
return (
|
||||
<section aria-labelledby="archive-heading">
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export const metadata: Metadata = {
|
|||
title: "Blog | SurfSense - AI Search & Knowledge Management",
|
||||
description: "Product updates, tutorials, and tips from the SurfSense team.",
|
||||
alternates: {
|
||||
canonical: "https://surfsense.com/blog",
|
||||
canonical: "https://www.surfsense.com/blog",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -25,6 +25,8 @@ export interface BlogEntry {
|
|||
image: string;
|
||||
author: string;
|
||||
authorAvatar: string;
|
||||
featured: boolean;
|
||||
featuredOrder?: number;
|
||||
}
|
||||
|
||||
export default async function BlogPage() {
|
||||
|
|
@ -38,6 +40,8 @@ export default async function BlogPage() {
|
|||
image?: string;
|
||||
author?: string;
|
||||
authorAvatar?: string;
|
||||
featured?: boolean;
|
||||
featured_order?: number;
|
||||
};
|
||||
}>;
|
||||
|
||||
|
|
@ -51,8 +55,20 @@ export default async function BlogPage() {
|
|||
image: page.data.image ?? "/og-image.png",
|
||||
author: page.data.author ?? "SurfSense Team",
|
||||
authorAvatar: page.data.authorAvatar ?? "/logo.png",
|
||||
featured: page.data.featured ?? false,
|
||||
featuredOrder: page.data.featured_order,
|
||||
}))
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
.sort((a, b) => {
|
||||
// Featured first; then by `featured_order` asc within featured;
|
||||
// then by `date` desc as the universal tie-breaker.
|
||||
if (a.featured !== b.featured) return a.featured ? -1 : 1;
|
||||
if (a.featured && b.featured) {
|
||||
const aOrder = a.featuredOrder ?? Number.POSITIVE_INFINITY;
|
||||
const bOrder = b.featuredOrder ?? Number.POSITIVE_INFINITY;
|
||||
if (aOrder !== bOrder) return aOrder - bOrder;
|
||||
}
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||
});
|
||||
|
||||
return <BlogWithSearchMagazine blogs={blogs} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const metadata: Metadata = {
|
|||
title: "Changelog | SurfSense",
|
||||
description: "See what's new in SurfSense. Latest updates, features, and improvements.",
|
||||
alternates: {
|
||||
canonical: "https://surfsense.com/changelog",
|
||||
canonical: "https://www.surfsense.com/changelog",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export const metadata: Metadata = {
|
|||
description:
|
||||
"Get in touch with the SurfSense team for enterprise AI search, knowledge management, or partnership inquiries.",
|
||||
alternates: {
|
||||
canonical: "https://surfsense.com/contact",
|
||||
canonical: "https://www.surfsense.com/contact",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||
|
||||
const title = buildSeoTitle(model);
|
||||
const description = buildSeoDescription(model);
|
||||
const canonicalUrl = `https://surfsense.com/free/${model.seo_slug}`;
|
||||
const canonicalUrl = `https://www.surfsense.com/free/${model.seo_slug}`;
|
||||
const modelNameLower = model.name.toLowerCase();
|
||||
|
||||
return {
|
||||
|
|
@ -161,7 +161,7 @@ export default async function FreeModelPage({ params }: PageProps) {
|
|||
"@type": "WebApplication",
|
||||
name: `${model.name} Free Chat Without Login - SurfSense`,
|
||||
description,
|
||||
url: `https://surfsense.com/free/${model.seo_slug}`,
|
||||
url: `https://www.surfsense.com/free/${model.seo_slug}`,
|
||||
applicationCategory: "ChatApplication",
|
||||
operatingSystem: "Web",
|
||||
offers: {
|
||||
|
|
@ -173,12 +173,12 @@ export default async function FreeModelPage({ params }: PageProps) {
|
|||
provider: {
|
||||
"@type": "Organization",
|
||||
name: "SurfSense",
|
||||
url: "https://surfsense.com",
|
||||
url: "https://www.surfsense.com",
|
||||
},
|
||||
isPartOf: {
|
||||
"@type": "WebSite",
|
||||
name: "SurfSense",
|
||||
url: "https://surfsense.com",
|
||||
url: "https://www.surfsense.com",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -64,13 +64,13 @@ export const metadata: Metadata = {
|
|||
"notebooklm alternative",
|
||||
],
|
||||
alternates: {
|
||||
canonical: "https://surfsense.com/free",
|
||||
canonical: "https://www.surfsense.com/free",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Free AI Chat, No Login Required | SurfSense",
|
||||
description:
|
||||
"Use ChatGPT free online without login. Chat with GPT-4, Claude AI, Gemini and 100+ AI models. Open source NotebookLM alternative.",
|
||||
url: "https://surfsense.com/free",
|
||||
url: "https://www.surfsense.com/free",
|
||||
siteName: "SurfSense",
|
||||
type: "website",
|
||||
images: [
|
||||
|
|
@ -164,8 +164,8 @@ export default async function FreeHubPage() {
|
|||
name: "ChatGPT Free Online Without Login - SurfSense",
|
||||
description:
|
||||
"Use ChatGPT, Claude AI, Gemini and more AI models free online without login or sign-up. Open source NotebookLM alternative with no login required.",
|
||||
url: "https://surfsense.com/free",
|
||||
isPartOf: { "@type": "WebSite", name: "SurfSense", url: "https://surfsense.com" },
|
||||
url: "https://www.surfsense.com/free",
|
||||
isPartOf: { "@type": "WebSite", name: "SurfSense", url: "https://www.surfsense.com" },
|
||||
mainEntity: {
|
||||
"@type": "ItemList",
|
||||
numberOfItems: seoModels.length,
|
||||
|
|
@ -173,7 +173,7 @@ export default async function FreeHubPage() {
|
|||
"@type": "ListItem",
|
||||
position: i + 1,
|
||||
name: m.name,
|
||||
url: `https://surfsense.com/free/${m.seo_slug}`,
|
||||
url: `https://www.surfsense.com/free/${m.seo_slug}`,
|
||||
})),
|
||||
},
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export const metadata: Metadata = {
|
|||
description:
|
||||
"Explore SurfSense plans and pricing. Start free with 500 pages & $5 in premium credits. Use ChatGPT, Claude AI, and premium AI models. Pay as you go at provider cost.",
|
||||
alternates: {
|
||||
canonical: "https://surfsense.com/pricing",
|
||||
canonical: "https://www.surfsense.com/pricing",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export const metadata: Metadata = {
|
|||
title: "Privacy Policy | SurfSense",
|
||||
description: "Privacy Policy for SurfSense application",
|
||||
alternates: {
|
||||
canonical: "https://surfsense.com/privacy",
|
||||
canonical: "https://www.surfsense.com/privacy",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export const metadata: Metadata = {
|
|||
title: "Terms of Service | SurfSense",
|
||||
description: "Terms of Service for SurfSense application",
|
||||
alternates: {
|
||||
canonical: "https://surfsense.com/terms",
|
||||
canonical: "https://www.surfsense.com/terms",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,11 @@ import {
|
|||
type TokenUsageData,
|
||||
TokenUsageProvider,
|
||||
} from "@/components/assistant-ui/token-usage-context";
|
||||
import { type HitlDecision, PendingInterruptProvider } from "@/features/chat-messages/hitl";
|
||||
import {
|
||||
type HitlDecision,
|
||||
PendingInterruptProvider,
|
||||
type PendingInterruptState,
|
||||
} from "@/features/chat-messages/hitl";
|
||||
import { TimelineDataUI } from "@/features/chat-messages/timeline";
|
||||
import {
|
||||
applyActionLogSse,
|
||||
|
|
@ -206,7 +210,10 @@ const MentionedDocumentInfoSchema = z.object({
|
|||
id: z.number(),
|
||||
title: z.string(),
|
||||
document_type: z.string(),
|
||||
kind: z.union([z.literal("doc"), z.literal("folder")]).optional().default("doc"),
|
||||
kind: z
|
||||
.union([z.literal("doc"), z.literal("folder")])
|
||||
.optional()
|
||||
.default("doc"),
|
||||
});
|
||||
|
||||
const MentionedDocumentsPartSchema = z.object({
|
||||
|
|
@ -267,12 +274,16 @@ export default function NewChatPage() {
|
|||
const [tokenUsageStore] = useState(() => createTokenUsageStore());
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const recentCancelRequestedAtRef = useRef(0);
|
||||
const [pendingInterrupt, setPendingInterrupt] = useState<{
|
||||
threadId: number;
|
||||
assistantMsgId: string;
|
||||
interruptData: Record<string, unknown>;
|
||||
bundleToolCallIds: string[];
|
||||
} | null>(null);
|
||||
// One entry per paused subagent, in receipt order (which matches the
|
||||
// backend's ``state.interrupts`` traversal — and therefore the order
|
||||
// ``slice_decisions_by_tool_call`` consumes on resume). Cleared on submit
|
||||
// or on a fresh user turn.
|
||||
const [pendingInterrupts, setPendingInterrupts] = useState<PendingInterruptState[]>([]);
|
||||
// Per-card staged decisions held until every pending card has submitted,
|
||||
// at which point we batch them into one ``hitl-decision`` event in the
|
||||
// same order as ``pendingInterrupts``. Using a ref because partial
|
||||
// progress should not re-render the page.
|
||||
const stagedDecisionsByInterruptIdRef = useRef<Map<string, HitlDecision[]>>(new Map());
|
||||
const toolsWithUI = TOOLS_WITH_UI_ALL;
|
||||
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
|
||||
|
||||
|
|
@ -1027,9 +1038,7 @@ export default function NewChatPage() {
|
|||
mentioned_surfsense_doc_ids: hasSurfsenseDocIds
|
||||
? mentionedDocumentIds.surfsense_doc_ids
|
||||
: undefined,
|
||||
mentioned_folder_ids: hasFolderIds
|
||||
? mentionedDocumentIds.folder_ids
|
||||
: undefined,
|
||||
mentioned_folder_ids: hasFolderIds ? mentionedDocumentIds.folder_ids : undefined,
|
||||
// Full mention metadata (docs + folders, with
|
||||
// ``kind`` discriminator) so the BE can embed a
|
||||
// ``mentioned-documents`` ContentPart on the
|
||||
|
|
@ -1191,12 +1200,24 @@ export default function NewChatPage() {
|
|||
)
|
||||
);
|
||||
if (currentThreadId) {
|
||||
setPendingInterrupt({
|
||||
threadId: currentThreadId,
|
||||
assistantMsgId,
|
||||
interruptData,
|
||||
bundleToolCallIds,
|
||||
});
|
||||
// ``tool_call_id`` is stamped on the backend by
|
||||
// ``checkpointed_subagent_middleware``. Without it we
|
||||
// can't address the paused subagent on resume — skip
|
||||
// rather than fabricate a synthetic key.
|
||||
const interruptId = String(interruptData.tool_call_id ?? "");
|
||||
if (interruptId) {
|
||||
const incoming: PendingInterruptState = {
|
||||
interruptId,
|
||||
threadId: currentThreadId,
|
||||
assistantMsgId,
|
||||
interruptData,
|
||||
bundleToolCallIds,
|
||||
};
|
||||
setPendingInterrupts((prev) => {
|
||||
const without = prev.filter((p) => p.interruptId !== interruptId);
|
||||
return [...without, incoming];
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -1272,7 +1293,7 @@ export default function NewChatPage() {
|
|||
// by ``persist_assistant_shell``. Rename the optimistic
|
||||
// id, migrate ``tokenUsageStore`` so any pending
|
||||
// ``data-token-usage`` payload binds to the new id,
|
||||
// remap any in-flight ``pendingInterrupt`` reference,
|
||||
// remap any in-flight ``pendingInterrupts`` entries,
|
||||
// and reassign the closure variable so the in-stream
|
||||
// flush callback (line ~1074) keeps writing to the
|
||||
// renamed message.
|
||||
|
|
@ -1288,10 +1309,12 @@ export default function NewChatPage() {
|
|||
: m
|
||||
)
|
||||
);
|
||||
setPendingInterrupt((prev) =>
|
||||
prev && prev.assistantMsgId === oldAssistantMsgId
|
||||
? { ...prev, assistantMsgId: newAssistantMsgId }
|
||||
: prev
|
||||
setPendingInterrupts((prev) =>
|
||||
prev.map((p) =>
|
||||
p.assistantMsgId === oldAssistantMsgId
|
||||
? { ...p, assistantMsgId: newAssistantMsgId }
|
||||
: p
|
||||
)
|
||||
);
|
||||
assistantMsgId = newAssistantMsgId;
|
||||
break;
|
||||
|
|
@ -1378,14 +1401,23 @@ export default function NewChatPage() {
|
|||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}>
|
||||
) => {
|
||||
if (!pendingInterrupt) return;
|
||||
const { threadId: resumeThreadId } = pendingInterrupt;
|
||||
if (pendingInterrupts.length === 0) return;
|
||||
// All cards in this turn share the same threadId/assistantMsgId
|
||||
// (they're siblings of one parent agent step), so reading from
|
||||
// the first entry is safe.
|
||||
const resumeThreadId = pendingInterrupts[0].threadId;
|
||||
// Destructured separately as ``let`` so the SSE
|
||||
// ``data-assistant-message-id`` handler (resume always
|
||||
// allocates a fresh server-side row) can rename it to
|
||||
// the canonical ``msg-{db_id}`` mid-stream.
|
||||
let assistantMsgId = pendingInterrupt.assistantMsgId;
|
||||
setPendingInterrupt(null);
|
||||
let assistantMsgId = pendingInterrupts[0].assistantMsgId;
|
||||
// Concatenate every card's tool-call ids in pendingInterrupts order;
|
||||
// this matches the ``decisions`` ordering produced by
|
||||
// ``handleApprovalSubmit`` and the backend slicer's traversal of
|
||||
// ``state.interrupts``.
|
||||
const allBundleToolCallIds = pendingInterrupts.flatMap((p) => p.bundleToolCallIds);
|
||||
setPendingInterrupts([]);
|
||||
stagedDecisionsByInterruptIdRef.current.clear();
|
||||
setIsRunning(true);
|
||||
|
||||
const token = getBearerToken();
|
||||
|
|
@ -1462,7 +1494,7 @@ export default function NewChatPage() {
|
|||
// collapse onto ``decisions[0]``. Cards outside the bundle are
|
||||
// untouched. Mirrors the host ``hitl-decision`` handler.
|
||||
const decisionByTcId = new Map<string, (typeof decisions)[number]>();
|
||||
const tcIds = pendingInterrupt.bundleToolCallIds;
|
||||
const tcIds = allBundleToolCallIds;
|
||||
if (decisions.length === tcIds.length) {
|
||||
for (let i = 0; i < tcIds.length; i++) decisionByTcId.set(tcIds[i], decisions[i]);
|
||||
}
|
||||
|
|
@ -1474,7 +1506,7 @@ export default function NewChatPage() {
|
|||
if (!d) continue;
|
||||
if (typeof part.result !== "object" || part.result === null) continue;
|
||||
if (!("__interrupt__" in (part.result as Record<string, unknown>))) continue;
|
||||
const decided = d.type as "approve" | "reject" | "edit";
|
||||
const decided = d.type;
|
||||
if (decided === "edit" && d.edited_action) {
|
||||
const mergedArgs = { ...part.args, ...d.edited_action.args };
|
||||
part.args = mergedArgs;
|
||||
|
|
@ -1594,12 +1626,22 @@ export default function NewChatPage() {
|
|||
: m
|
||||
)
|
||||
);
|
||||
setPendingInterrupt({
|
||||
threadId: resumeThreadId,
|
||||
assistantMsgId,
|
||||
interruptData,
|
||||
bundleToolCallIds,
|
||||
});
|
||||
{
|
||||
const interruptId = String(interruptData.tool_call_id ?? "");
|
||||
if (interruptId) {
|
||||
const incoming: PendingInterruptState = {
|
||||
interruptId,
|
||||
threadId: resumeThreadId,
|
||||
assistantMsgId,
|
||||
interruptData,
|
||||
bundleToolCallIds,
|
||||
};
|
||||
setPendingInterrupts((prev) => {
|
||||
const without = prev.filter((p) => p.interruptId !== interruptId);
|
||||
return [...without, incoming];
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -1677,7 +1719,7 @@ export default function NewChatPage() {
|
|||
}
|
||||
},
|
||||
[
|
||||
pendingInterrupt,
|
||||
pendingInterrupts,
|
||||
messages,
|
||||
searchSpaceId,
|
||||
localFilesystemEnabled,
|
||||
|
|
@ -1698,17 +1740,19 @@ export default function NewChatPage() {
|
|||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}>;
|
||||
};
|
||||
if (!detail?.decisions || !pendingInterrupt) return;
|
||||
if (!detail?.decisions || pendingInterrupts.length === 0) return;
|
||||
const incoming = detail.decisions;
|
||||
if (incoming.length === 0) return;
|
||||
const tcIds = pendingInterrupt.bundleToolCallIds;
|
||||
// Concatenated tool-call ids across every pending card, in the
|
||||
// order ``handleApprovalSubmit`` produced ``incoming``.
|
||||
const tcIds = pendingInterrupts.flatMap((p) => p.bundleToolCallIds);
|
||||
const N = tcIds.length;
|
||||
|
||||
// Bundles must submit exactly one decision per action_request.
|
||||
// Refuse rather than silently broadcast a single decision across
|
||||
// the bundle (would mis-apply rejects/edits and diverge from
|
||||
// what handleResume sends to /resume).
|
||||
if (N > 1 && incoming.length !== N) {
|
||||
// Refuse rather than silently broadcast or drop. The orchestrator
|
||||
// only fires ``hitl-decision`` once every pending card has
|
||||
// submitted, so a count mismatch indicates a contract drift
|
||||
// (and would later make the backend slicer raise).
|
||||
if (incoming.length !== N) {
|
||||
toast.error(
|
||||
`Cannot resume: ${incoming.length} decision(s) submitted for ${N} pending actions.`
|
||||
);
|
||||
|
|
@ -1730,9 +1774,12 @@ export default function NewChatPage() {
|
|||
submittedDecisions.push(decision);
|
||||
}
|
||||
|
||||
// All pending cards belong to the same assistant message, so a
|
||||
// single content-update pass suffices.
|
||||
const targetAssistantMsgId = pendingInterrupts[0].assistantMsgId;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => {
|
||||
if (m.id !== pendingInterrupt.assistantMsgId) return m;
|
||||
if (m.id !== targetAssistantMsgId) return m;
|
||||
const parts = m.content as unknown as Array<Record<string, unknown>>;
|
||||
const newContent = parts.map((part) => {
|
||||
const tcId = part.toolCallId as string | undefined;
|
||||
|
|
@ -1740,7 +1787,7 @@ export default function NewChatPage() {
|
|||
if (!d || part.type !== "tool-call") return part;
|
||||
if (typeof part.result !== "object" || part.result === null) return part;
|
||||
if (!("__interrupt__" in (part.result as Record<string, unknown>))) return part;
|
||||
const decided = d.type as "approve" | "reject" | "edit";
|
||||
const decided = d.type;
|
||||
if (decided === "edit" && d.edited_action) {
|
||||
return {
|
||||
...part,
|
||||
|
|
@ -1769,7 +1816,7 @@ export default function NewChatPage() {
|
|||
};
|
||||
window.addEventListener("hitl-decision", handler);
|
||||
return () => window.removeEventListener("hitl-decision", handler);
|
||||
}, [handleResume, pendingInterrupt]);
|
||||
}, [handleResume, pendingInterrupts]);
|
||||
|
||||
// Convert message (pass through since already in correct format)
|
||||
const convertMessage = useCallback(
|
||||
|
|
@ -1909,12 +1956,10 @@ export default function NewChatPage() {
|
|||
filesystem_mode: selection.filesystem_mode,
|
||||
client_platform: selection.client_platform,
|
||||
local_filesystem_mounts: selection.local_filesystem_mounts,
|
||||
mentioned_document_ids:
|
||||
regenerateDocIds.length > 0 ? regenerateDocIds : undefined,
|
||||
mentioned_document_ids: regenerateDocIds.length > 0 ? regenerateDocIds : undefined,
|
||||
mentioned_surfsense_doc_ids:
|
||||
regenerateSurfsenseDocIds.length > 0 ? regenerateSurfsenseDocIds : undefined,
|
||||
mentioned_folder_ids:
|
||||
regenerateFolderIds.length > 0 ? regenerateFolderIds : undefined,
|
||||
mentioned_folder_ids: regenerateFolderIds.length > 0 ? regenerateFolderIds : undefined,
|
||||
// Full mention metadata for the regenerate-specific
|
||||
// source list. Only meaningful for edit (the BE only
|
||||
// re-persists a user row when ``user_query`` is set);
|
||||
|
|
@ -2293,11 +2338,32 @@ export default function NewChatPage() {
|
|||
[handleRegenerate, messages, agentActionItems]
|
||||
);
|
||||
|
||||
const handleApprovalSubmit = useCallback((orderedDecisions: HitlDecision[]) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: orderedDecisions } })
|
||||
);
|
||||
}, []);
|
||||
const handleApprovalSubmit = useCallback(
|
||||
(interruptId: string, decisions: HitlDecision[]) => {
|
||||
// Stage this card's decisions; only fire the resume once every
|
||||
// pending card in the current turn has submitted, so the
|
||||
// backend slicer sees a single concatenated decisions list
|
||||
// whose total matches the parent state's pending action count.
|
||||
stagedDecisionsByInterruptIdRef.current.set(interruptId, decisions);
|
||||
if (stagedDecisionsByInterruptIdRef.current.size < pendingInterrupts.length) {
|
||||
return;
|
||||
}
|
||||
const ordered: HitlDecision[] = [];
|
||||
for (const pi of pendingInterrupts) {
|
||||
const staged = stagedDecisionsByInterruptIdRef.current.get(pi.interruptId);
|
||||
if (!staged) {
|
||||
// Defensive: a missing entry means the staging map and
|
||||
// the pending list disagreed for one cycle. Bail rather
|
||||
// than dispatch a count-mismatched batch.
|
||||
return;
|
||||
}
|
||||
ordered.push(...staged);
|
||||
}
|
||||
stagedDecisionsByInterruptIdRef.current.clear();
|
||||
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions: ordered } }));
|
||||
},
|
||||
[pendingInterrupts]
|
||||
);
|
||||
|
||||
const handleEditDialogChoice = useCallback(
|
||||
async (choice: EditMessageDialogChoice) => {
|
||||
|
|
@ -2369,7 +2435,7 @@ export default function NewChatPage() {
|
|||
<TimelineDataUI />
|
||||
<StepSeparatorDataUI />
|
||||
<PendingInterruptProvider
|
||||
pendingInterrupt={pendingInterrupt}
|
||||
pendingInterrupts={pendingInterrupts}
|
||||
onSubmit={handleApprovalSubmit}
|
||||
>
|
||||
<div key={searchSpaceId} className="flex h-full overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export async function generateMetadata(props: { params: Promise<{ slug?: string[
|
|||
title: `${page.data.title} | SurfSense Docs`,
|
||||
description: page.data.description,
|
||||
alternates: {
|
||||
canonical: `https://surfsense.com/docs${slugPath ? `/${slugPath}` : ""}`,
|
||||
canonical: `https://www.surfsense.com/docs${slugPath ? `/${slugPath}` : ""}`,
|
||||
},
|
||||
openGraph: {
|
||||
title: `${page.data.title} | SurfSense Docs`,
|
||||
|
|
|
|||
|
|
@ -41,9 +41,9 @@ export const viewport: Viewport = {
|
|||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://surfsense.com"),
|
||||
metadataBase: new URL("https://www.surfsense.com"),
|
||||
alternates: {
|
||||
canonical: "https://surfsense.com",
|
||||
canonical: "https://www.surfsense.com",
|
||||
},
|
||||
title: "SurfSense – Open Source, Privacy-Focused NotebookLM Alternative for Teams",
|
||||
description:
|
||||
|
|
@ -90,7 +90,7 @@ export const metadata: Metadata = {
|
|||
title: "SurfSense – Open Source, Privacy-Focused NotebookLM Alternative for Teams",
|
||||
description:
|
||||
"Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude, and any AI model for free.",
|
||||
url: "https://surfsense.com",
|
||||
url: "https://www.surfsense.com",
|
||||
siteName: "SurfSense",
|
||||
type: "website",
|
||||
images: [
|
||||
|
|
|
|||
|
|
@ -17,6 +17,6 @@ export default function robots(): MetadataRoute.Robots {
|
|||
],
|
||||
},
|
||||
],
|
||||
sitemap: "https://surfsense.com/sitemap.xml",
|
||||
sitemap: "https://www.surfsense.com/sitemap.xml",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue