mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-24 21:38:09 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/api-key
This commit is contained in:
commit
3695e1d5c5
64 changed files with 1043 additions and 1852 deletions
|
|
@ -14,7 +14,10 @@ SURFSENSE_BACKEND_INTERNAL_URL=http://backend:8000
|
|||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Runtime configuration (read at runtime by the server, no rebuild needed)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Configure these plain variables for runtime behavior. They are read by server
|
||||
# code when the app starts/serves requests, so changing them requires restarting
|
||||
# the web process but not rebuilding the frontend bundle.
|
||||
#
|
||||
# Authentication method: LOCAL (email/password) or GOOGLE (OAuth).
|
||||
AUTH_TYPE=LOCAL
|
||||
# Document parsing backend: DOCLING, LLAMACLOUD, etc.
|
||||
|
|
@ -22,16 +25,6 @@ ETL_SERVICE=DOCLING
|
|||
# Deployment mode: self-hosted or cloud.
|
||||
DEPLOYMENT_MODE=self-hosted
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Build-time fallbacks for packaged clients (e.g. Electron) without a runtime
|
||||
# config provider. Optional; Docker reads the plain runtime vars above first.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# NEXT_PUBLIC_AUTH_TYPE=GOOGLE
|
||||
# NEXT_PUBLIC_ETL_SERVICE=DOCLING
|
||||
# NEXT_PUBLIC_DEPLOYMENT_MODE=self-hosted
|
||||
# Overrides the app version shown in the UI (defaults to package.json version).
|
||||
# NEXT_PUBLIC_APP_VERSION=
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Database (Contact Form, optional)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -72,3 +65,20 @@ NEXT_PUBLIC_GOOGLE_ADSENSE_SLOT_FREE_HUB_BEFORE_FAQ=
|
|||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
NEXT_PUBLIC_GLOBAL_ANNOUNCEMENT_ENABLED=false
|
||||
NEXT_PUBLIC_GLOBAL_ANNOUNCEMENT_MESSAGE=
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Internal build-time fallbacks
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Most deployments should leave these unset.
|
||||
#
|
||||
# These are only for SurfSense-managed production/cloud builds or packaged
|
||||
# clients that do not have the normal server runtime config available.
|
||||
#
|
||||
# NEXT_PUBLIC_* values are embedded into the browser bundle during `next build`.
|
||||
# Changing them after the bundle is built has no effect.
|
||||
|
||||
# NEXT_PUBLIC_AUTH_TYPE=GOOGLE
|
||||
# NEXT_PUBLIC_ETL_SERVICE=DOCLING
|
||||
# NEXT_PUBLIC_DEPLOYMENT_MODE=self-hosted
|
||||
# NEXT_PUBLIC_APP_VERSION=
|
||||
|
|
@ -58,6 +58,11 @@
|
|||
--highlight: oklch(0.852 0.199 91.936);
|
||||
}
|
||||
|
||||
html[data-surfsense-auth-type="GOOGLE"] .runtime-auth-local,
|
||||
html[data-surfsense-auth-type="LOCAL"] .runtime-auth-google {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
|
|
@ -270,12 +275,6 @@ button {
|
|||
contain-intrinsic-size: 0 40px;
|
||||
}
|
||||
|
||||
/* Monaco whole-line highlight for a cited source span (Phase E). */
|
||||
.citation-line-highlight {
|
||||
background-color: color-mix(in srgb, var(--primary) 16%, transparent);
|
||||
box-shadow: inset 2px 0 0 0 var(--primary);
|
||||
}
|
||||
|
||||
@source "../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}";
|
||||
@source "../node_modules/streamdown/dist/*.js";
|
||||
@source "../node_modules/@streamdown/code/dist/*.js";
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
|
|||
import "./globals.css";
|
||||
import { RootProvider } from "fumadocs-ui/provider/next";
|
||||
import { Roboto } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import { AnnouncementToastProvider } from "@/components/announcements/AnnouncementToastProvider";
|
||||
import { DesktopUpdateToast } from "@/components/desktop/desktop-update-toast";
|
||||
import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider";
|
||||
|
|
@ -16,8 +17,13 @@ import {
|
|||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { LocaleProvider } from "@/contexts/LocaleContext";
|
||||
import { BUILD_TIME_AUTH_TYPE } from "@/lib/env-config";
|
||||
import { PlatformProvider } from "@/contexts/platform-context";
|
||||
import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider";
|
||||
import {
|
||||
getRuntimeAuthInitScript,
|
||||
resolveRuntimeAuthUiMode,
|
||||
} from "@/lib/runtime-auth-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const roboto = Roboto({
|
||||
|
|
@ -131,8 +137,15 @@ export default function RootLayout({
|
|||
// Language can be switched dynamically through LanguageSwitcher component
|
||||
// Locale state is managed by LocaleContext and persisted in localStorage
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html
|
||||
lang="en"
|
||||
data-surfsense-auth-type={resolveRuntimeAuthUiMode(BUILD_TIME_AUTH_TYPE)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<Script id="surfsense-runtime-auth-init" strategy="beforeInteractive">
|
||||
{getRuntimeAuthInitScript(BUILD_TIME_AUTH_TYPE)}
|
||||
</Script>
|
||||
<link rel="preconnect" href="https://api.github.com" />
|
||||
<OrganizationJsonLd />
|
||||
<WebSiteJsonLd />
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import { atom } from "jotai";
|
||||
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
|
||||
|
||||
export interface EditorLineRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface EditorPanelState {
|
||||
isOpen: boolean;
|
||||
kind: "document" | "local_file" | "memory";
|
||||
|
|
@ -14,10 +9,6 @@ interface EditorPanelState {
|
|||
searchSpaceId: number | null;
|
||||
memoryScope: "user" | "team" | null;
|
||||
title: string | null;
|
||||
// Citation line anchor: when set, the editor opens the raw source view
|
||||
// scrolled to and highlighting this 1-based inclusive line range.
|
||||
highlightLines: EditorLineRange | null;
|
||||
forceSourceView: boolean;
|
||||
}
|
||||
|
||||
const initialState: EditorPanelState = {
|
||||
|
|
@ -28,8 +19,6 @@ const initialState: EditorPanelState = {
|
|||
searchSpaceId: null,
|
||||
memoryScope: null,
|
||||
title: null,
|
||||
highlightLines: null,
|
||||
forceSourceView: false,
|
||||
};
|
||||
|
||||
export const editorPanelAtom = atom<EditorPanelState>(initialState);
|
||||
|
|
@ -44,14 +33,7 @@ export const openEditorPanelAtom = atom(
|
|||
get,
|
||||
set,
|
||||
payload:
|
||||
| {
|
||||
documentId: number;
|
||||
searchSpaceId: number;
|
||||
title?: string;
|
||||
kind?: "document";
|
||||
highlightLines?: EditorLineRange | null;
|
||||
forceSourceView?: boolean;
|
||||
}
|
||||
| { documentId: number; searchSpaceId: number; title?: string; kind?: "document" }
|
||||
| {
|
||||
kind: "local_file";
|
||||
localFilePath: string;
|
||||
|
|
@ -77,8 +59,6 @@ export const openEditorPanelAtom = atom(
|
|||
searchSpaceId: payload.searchSpaceId ?? null,
|
||||
memoryScope: null,
|
||||
title: payload.title ?? null,
|
||||
highlightLines: null,
|
||||
forceSourceView: false,
|
||||
});
|
||||
set(rightPanelTabAtom, "editor");
|
||||
set(rightPanelCollapsedAtom, false);
|
||||
|
|
@ -93,8 +73,6 @@ export const openEditorPanelAtom = atom(
|
|||
searchSpaceId: payload.searchSpaceId ?? null,
|
||||
memoryScope: payload.memoryScope,
|
||||
title: payload.title ?? null,
|
||||
highlightLines: null,
|
||||
forceSourceView: false,
|
||||
});
|
||||
set(rightPanelTabAtom, "editor");
|
||||
set(rightPanelCollapsedAtom, false);
|
||||
|
|
@ -108,8 +86,6 @@ export const openEditorPanelAtom = atom(
|
|||
searchSpaceId: payload.searchSpaceId,
|
||||
memoryScope: null,
|
||||
title: payload.title ?? null,
|
||||
highlightLines: payload.highlightLines ?? null,
|
||||
forceSourceView: payload.forceSourceView ?? false,
|
||||
});
|
||||
set(rightPanelTabAtom, "editor");
|
||||
set(rightPanelCollapsedAtom, false);
|
||||
|
|
|
|||
|
|
@ -2,11 +2,9 @@
|
|||
|
||||
import { useSetAtom } from "jotai";
|
||||
import { FileText } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import type { FC } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
|
||||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
|
||||
import { CitationPanelContent } from "@/components/citation-panel/citation-panel";
|
||||
import { Citation } from "@/components/tool-ui/citation";
|
||||
|
|
@ -110,50 +108,6 @@ const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
|
|||
);
|
||||
};
|
||||
|
||||
interface LineCitationProps {
|
||||
documentId: number;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline citation for a knowledge-base document line range
|
||||
* (`[citation:d<documentId>#L<start>-<end>]`). Clicking opens the document in
|
||||
* the editor's read-only source view, scrolled to and highlighting the cited
|
||||
* lines — the same anchor the citation panel uses for chunk citations.
|
||||
*/
|
||||
export const LineCitation: FC<LineCitationProps> = ({ documentId, startLine, endLine }) => {
|
||||
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||
const params = useParams();
|
||||
const searchSpaceId = Number(params?.search_space_id);
|
||||
|
||||
const label = startLine === endLine ? `L${startLine}` : `L${startLine}-${endLine}`;
|
||||
|
||||
const handleClick = () => {
|
||||
if (!Number.isFinite(searchSpaceId)) return;
|
||||
openEditorPanel({
|
||||
documentId,
|
||||
searchSpaceId,
|
||||
highlightLines: { start: startLine, end: endLine },
|
||||
forceSourceView: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClick}
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline"
|
||||
title={`View cited lines ${startLine}–${endLine}`}
|
||||
aria-label={`View cited document lines ${startLine} to ${endLine}`}
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
import { tryGetHostname } from "@/lib/url";
|
||||
|
||||
interface UrlCitationProps {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BUILD_TIME_AUTH_TYPE, buildBackendUrl } from "@/lib/env-config";
|
||||
import { buildBackendUrl } from "@/lib/env-config";
|
||||
import { trackLoginAttempt } from "@/lib/posthog/events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -46,7 +46,6 @@ interface SignInButtonProps {
|
|||
}
|
||||
|
||||
export const SignInButton = ({ variant = "desktop" }: SignInButtonProps) => {
|
||||
const isGoogleAuth = BUILD_TIME_AUTH_TYPE === "GOOGLE";
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
|
|
@ -56,44 +55,45 @@ export const SignInButton = ({ variant = "desktop" }: SignInButtonProps) => {
|
|||
window.location.href = buildBackendUrl("/auth/google/authorize-redirect");
|
||||
};
|
||||
|
||||
const getClassName = () => {
|
||||
const getGoogleClassName = () => {
|
||||
if (variant === "desktop") {
|
||||
return isGoogleAuth
|
||||
? "hidden rounded-full border border-white bg-white px-5 py-2 text-sm font-medium text-[#1f1f1f] shadow-sm hover:bg-zinc-100 hover:text-[#1f1f1f] md:flex dark:border-white"
|
||||
: "hidden rounded-full bg-black px-8 py-2 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] md:block dark:bg-white dark:text-black";
|
||||
return "hidden rounded-full border border-white bg-white px-5 py-2 text-sm font-medium text-[#1f1f1f] shadow-sm hover:bg-zinc-100 hover:text-[#1f1f1f] md:flex dark:border-white";
|
||||
}
|
||||
if (variant === "compact") {
|
||||
return isGoogleAuth
|
||||
? "rounded-full border border-white bg-white px-4 py-1.5 text-sm font-medium text-[#1f1f1f] shadow-sm hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-white"
|
||||
: "rounded-full bg-black px-6 py-1.5 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black";
|
||||
return "rounded-full border border-white bg-white px-4 py-1.5 text-sm font-medium text-[#1f1f1f] shadow-sm hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-white";
|
||||
}
|
||||
// mobile
|
||||
return isGoogleAuth
|
||||
? "w-full rounded-lg border border-white bg-white px-8 py-2.5 font-medium text-[#1f1f1f] shadow-sm hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-white touch-manipulation"
|
||||
: "w-full rounded-lg bg-black px-8 py-2 font-medium text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black text-center touch-manipulation";
|
||||
return "w-full rounded-lg border border-white bg-white px-8 py-2.5 font-medium text-[#1f1f1f] shadow-sm hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-white touch-manipulation";
|
||||
};
|
||||
|
||||
if (isGoogleAuth) {
|
||||
return (
|
||||
const getLocalClassName = () => {
|
||||
if (variant === "desktop") {
|
||||
return "hidden rounded-full bg-black px-8 py-2 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] md:block dark:bg-white dark:text-black";
|
||||
}
|
||||
if (variant === "compact") {
|
||||
return "rounded-full bg-black px-6 py-1.5 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black";
|
||||
}
|
||||
return "w-full rounded-lg bg-black px-8 py-2 font-medium text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black text-center touch-manipulation";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isRedirecting}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2 transition-colors duration-200 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
getClassName()
|
||||
"runtime-auth-google flex items-center justify-center gap-2 transition-colors duration-200 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
getGoogleClassName()
|
||||
)}
|
||||
>
|
||||
<GoogleLogo className="h-4 w-4" />
|
||||
<span>Sign In</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href="/login" className={getClassName()}>
|
||||
Sign In
|
||||
</Link>
|
||||
<Link href="/login" className={cn("runtime-auth-local", getLocalClassName())}>
|
||||
Sign In
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -46,13 +46,6 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({
|
|||
|
||||
const cited = useMemo(() => data?.chunks.find((c) => c.id === chunkId) ?? null, [data, chunkId]);
|
||||
|
||||
const citedLineLabel = useMemo(() => {
|
||||
const start = data?.cited_start_line;
|
||||
const end = data?.cited_end_line;
|
||||
if (start == null || end == null) return null;
|
||||
return start === end ? `Line ${start}` : `Lines ${start}–${end}`;
|
||||
}, [data?.cited_start_line, data?.cited_end_line]);
|
||||
|
||||
const totalChunks = data?.total_chunks ?? data?.chunks.length ?? 0;
|
||||
const startIndex = data?.chunk_start_index ?? 0;
|
||||
const hasMoreAbove = startIndex > 0;
|
||||
|
|
@ -82,15 +75,10 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({
|
|||
|
||||
const handleOpenFullDocument = () => {
|
||||
if (!data) return;
|
||||
const hasLineAnchor = data.cited_start_line != null && data.cited_end_line != null;
|
||||
openEditorPanel({
|
||||
documentId: data.id,
|
||||
searchSpaceId: data.search_space_id,
|
||||
title: data.title,
|
||||
highlightLines: hasLineAnchor
|
||||
? { start: data.cited_start_line as number, end: data.cited_end_line as number }
|
||||
: null,
|
||||
forceSourceView: hasLineAnchor,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -122,7 +110,6 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 text-[11px] text-muted-foreground">
|
||||
{citedLineLabel && <span>{citedLineLabel}</span>}
|
||||
{totalChunks > 0 && <span>{totalChunks} chunks</span>}
|
||||
{!isLoading && !error && data && (
|
||||
<Button
|
||||
|
|
@ -185,9 +172,7 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({
|
|||
Chunk #{chunk.id}
|
||||
</span>
|
||||
{isCited && (
|
||||
<span className="text-[11px] font-semibold text-primary">
|
||||
{citedLineLabel ? `Cited chunk · ${citedLineLabel}` : "Cited chunk"}
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold text-primary">Cited chunk</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { InlineCitation, LineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
|
||||
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
|
||||
import {
|
||||
type CitationToken,
|
||||
type CitationUrlMap,
|
||||
|
|
@ -21,16 +21,6 @@ export function renderCitationToken(token: CitationToken, ordinalKey: number): R
|
|||
if (token.kind === "url") {
|
||||
return <UrlCitation key={`citation-url-${ordinalKey}`} url={token.url} />;
|
||||
}
|
||||
if (token.kind === "line") {
|
||||
return (
|
||||
<LineCitation
|
||||
key={`citation-line-${token.documentId}-${token.startLine}-${ordinalKey}`}
|
||||
documentId={token.documentId}
|
||||
startLine={token.startLine}
|
||||
endLine={token.endLine}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<InlineCitation
|
||||
key={`citation-${token.isDocsChunk ? "doc-" : ""}${token.chunkId}-${ordinalKey}`}
|
||||
|
|
|
|||
|
|
@ -149,8 +149,6 @@ export function EditorPanelContent({
|
|||
searchSpaceId,
|
||||
title,
|
||||
onClose,
|
||||
highlightLines = null,
|
||||
forceSourceView = false,
|
||||
}: {
|
||||
kind?: "document" | "local_file" | "memory";
|
||||
documentId?: number;
|
||||
|
|
@ -159,8 +157,6 @@ export function EditorPanelContent({
|
|||
searchSpaceId?: number;
|
||||
title: string | null;
|
||||
onClose?: () => void;
|
||||
highlightLines?: { start: number; end: number } | null;
|
||||
forceSourceView?: boolean;
|
||||
}) {
|
||||
const electronAPI = useElectronAPI();
|
||||
const [editorDoc, setEditorDoc] = useState<EditorContent | null>(null);
|
||||
|
|
@ -209,7 +205,7 @@ export function EditorPanelContent({
|
|||
const isLargeDocument = docSizeBytes > plateMaxBytes || docLineCount > plateMaxLines;
|
||||
const viewerMode: ViewerMode = isMemoryMode
|
||||
? "plate"
|
||||
: editorDoc?.viewer_mode === "monaco" || isLargeDocument || forceSourceView
|
||||
: editorDoc?.viewer_mode === "monaco" || isLargeDocument
|
||||
? "monaco"
|
||||
: "plate";
|
||||
|
||||
|
|
@ -832,7 +828,6 @@ export function EditorPanelContent({
|
|||
value={editorDoc.source_markdown}
|
||||
readOnly
|
||||
onChange={() => {}}
|
||||
highlightLines={highlightLines}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -923,8 +918,6 @@ function DesktopEditorPanel() {
|
|||
searchSpaceId={panelState.searchSpaceId ?? undefined}
|
||||
title={panelState.title}
|
||||
onClose={closePanel}
|
||||
highlightLines={panelState.highlightLines}
|
||||
forceSourceView={panelState.forceSourceView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -964,8 +957,6 @@ function MobileEditorDrawer() {
|
|||
memoryScope={panelState.memoryScope ?? undefined}
|
||||
searchSpaceId={panelState.searchSpaceId ?? undefined}
|
||||
title={panelState.title}
|
||||
highlightLines={panelState.highlightLines}
|
||||
forceSourceView={panelState.forceSourceView}
|
||||
/>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@
|
|||
import { type Descendant, KEYS } from "platejs";
|
||||
import { createPlatePlugin, type PlateElementProps } from "platejs/react";
|
||||
import type { FC } from "react";
|
||||
import { InlineCitation, LineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
|
||||
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
|
||||
import {
|
||||
CITATION_REGEX,
|
||||
type CitationToken,
|
||||
type CitationUrlMap,
|
||||
parseTextWithCitations,
|
||||
} from "@/lib/citations/citation-parser";
|
||||
|
|
@ -18,12 +17,9 @@ import {
|
|||
*/
|
||||
export type CitationElementNode = {
|
||||
type: "citation";
|
||||
kind: "chunk" | "doc" | "url" | "line";
|
||||
kind: "chunk" | "doc" | "url";
|
||||
chunkId?: number;
|
||||
url?: string;
|
||||
documentId?: number;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
/** Original literal token that produced this citation node. */
|
||||
rawText: string;
|
||||
children: [{ text: "" }];
|
||||
|
|
@ -37,22 +33,11 @@ const CitationElement: FC<PlateElementProps<CitationElementNode>> = ({
|
|||
element,
|
||||
}) => {
|
||||
const isUrl = element.kind === "url";
|
||||
const isLine =
|
||||
element.kind === "line" &&
|
||||
element.documentId !== undefined &&
|
||||
element.startLine !== undefined &&
|
||||
element.endLine !== undefined;
|
||||
return (
|
||||
<span {...attributes} className="inline-flex align-baseline">
|
||||
<span contentEditable={false}>
|
||||
{isUrl && element.url ? (
|
||||
<UrlCitation url={element.url} />
|
||||
) : isLine ? (
|
||||
<LineCitation
|
||||
documentId={element.documentId as number}
|
||||
startLine={element.startLine as number}
|
||||
endLine={element.endLine as number}
|
||||
/>
|
||||
) : element.chunkId !== undefined ? (
|
||||
<InlineCitation chunkId={element.chunkId} isDocsChunk={element.kind === "doc"} />
|
||||
) : null}
|
||||
|
|
@ -112,7 +97,10 @@ function copyMarks(textNode: SlateText): Record<string, unknown> {
|
|||
return marks;
|
||||
}
|
||||
|
||||
function makeCitationElement(rawText: string, segment: CitationToken): CitationElementNode {
|
||||
function makeCitationElement(
|
||||
rawText: string,
|
||||
segment: { kind: "url"; url: string } | { kind: "chunk"; chunkId: number; isDocsChunk: boolean }
|
||||
): CitationElementNode {
|
||||
if (segment.kind === "url") {
|
||||
return {
|
||||
type: CITATION_TYPE,
|
||||
|
|
@ -122,17 +110,6 @@ function makeCitationElement(rawText: string, segment: CitationToken): CitationE
|
|||
children: [{ text: "" }],
|
||||
};
|
||||
}
|
||||
if (segment.kind === "line") {
|
||||
return {
|
||||
type: CITATION_TYPE,
|
||||
kind: "line",
|
||||
documentId: segment.documentId,
|
||||
startLine: segment.startLine,
|
||||
endLine: segment.endLine,
|
||||
rawText,
|
||||
children: [{ text: "" }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: CITATION_TYPE,
|
||||
kind: segment.isDocsChunk ? "doc" : "chunk",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
|
||||
|
|
@ -17,8 +17,6 @@ interface SourceCodeEditorProps {
|
|||
readOnly?: boolean;
|
||||
fontSize?: number;
|
||||
onSave?: () => Promise<void> | void;
|
||||
/** 1-based inclusive line range to reveal and highlight (e.g. a citation). */
|
||||
highlightLines?: { start: number; end: number } | null;
|
||||
}
|
||||
|
||||
export function SourceCodeEditor({
|
||||
|
|
@ -29,45 +27,10 @@ export function SourceCodeEditor({
|
|||
readOnly = false,
|
||||
fontSize = 12,
|
||||
onSave,
|
||||
highlightLines = null,
|
||||
}: SourceCodeEditorProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const onSaveRef = useRef(onSave);
|
||||
const monacoRef = useRef<any>(null);
|
||||
const editorRef = useRef<any>(null);
|
||||
const decorationsRef = useRef<any>(null);
|
||||
const highlightLinesRef = useRef(highlightLines);
|
||||
highlightLinesRef.current = highlightLines;
|
||||
|
||||
const applyHighlight = useCallback(() => {
|
||||
const editor = editorRef.current;
|
||||
const monaco = monacoRef.current;
|
||||
if (!editor || !monaco) return;
|
||||
if (decorationsRef.current) {
|
||||
decorationsRef.current.clear();
|
||||
decorationsRef.current = null;
|
||||
}
|
||||
const range = highlightLinesRef.current;
|
||||
if (!range) return;
|
||||
const lineCount = editor.getModel()?.getLineCount() ?? range.end;
|
||||
const start = Math.min(Math.max(1, Math.floor(range.start)), lineCount);
|
||||
const end = Math.min(Math.max(start, Math.floor(range.end)), lineCount);
|
||||
try {
|
||||
decorationsRef.current = editor.createDecorationsCollection([
|
||||
{
|
||||
range: new monaco.Range(start, 1, end, 1),
|
||||
options: { isWholeLine: true, className: "citation-line-highlight" },
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
// Decoration failure must not block the reveal below.
|
||||
}
|
||||
editor.revealLinesInCenter(start, end, monaco.editor.ScrollType.Immediate);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyHighlight();
|
||||
}, [applyHighlight, highlightLines?.start, highlightLines?.end]);
|
||||
const normalizedModelPath = (() => {
|
||||
const raw = (path || "local-file.txt").trim();
|
||||
const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
||||
|
|
@ -141,16 +104,7 @@ export function SourceCodeEditor({
|
|||
}}
|
||||
onMount={(editor, monaco) => {
|
||||
monacoRef.current = monaco;
|
||||
editorRef.current = editor;
|
||||
applySidebarTheme(monaco);
|
||||
// Reveal now, then once more after the first layout settles:
|
||||
// the panel slide-in animation means the editor often has no
|
||||
// usable viewport height on the initial frame.
|
||||
applyHighlight();
|
||||
const layoutSub = editor.onDidLayoutChange(() => {
|
||||
applyHighlight();
|
||||
layoutSub.dispose();
|
||||
});
|
||||
if (!isManualSaveEnabled) return;
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
void onSaveRef.current?.();
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import {
|
|||
getAssetLabel,
|
||||
usePrimaryDownload,
|
||||
} from "@/lib/desktop-download-utils";
|
||||
import { BUILD_TIME_AUTH_TYPE, buildBackendUrl } from "@/lib/env-config";
|
||||
import { buildBackendUrl } from "@/lib/env-config";
|
||||
import { trackLoginAttempt } from "@/lib/posthog/events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -314,7 +314,6 @@ export function HeroSection() {
|
|||
}
|
||||
|
||||
function GetStartedButton() {
|
||||
const isGoogleAuth = BUILD_TIME_AUTH_TYPE === "GOOGLE";
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
|
|
@ -324,29 +323,26 @@ function GetStartedButton() {
|
|||
window.location.href = buildBackendUrl("/auth/google/authorize-redirect");
|
||||
};
|
||||
|
||||
if (isGoogleAuth) {
|
||||
return (
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isRedirecting}
|
||||
className="h-14 w-full cursor-pointer gap-3 rounded-lg border border-white bg-white text-center text-base font-medium text-[#1f1f1f] shadow-sm transition duration-150 hover:bg-zinc-100 hover:text-[#1f1f1f] sm:w-56 dark:border-white"
|
||||
className="runtime-auth-google h-14 w-full cursor-pointer gap-3 rounded-lg border border-white bg-white text-center text-base font-medium text-[#1f1f1f] shadow-sm transition duration-150 hover:bg-zinc-100 hover:text-[#1f1f1f] sm:w-56 dark:border-white"
|
||||
>
|
||||
<GoogleLogo className="h-5 w-5" />
|
||||
<span>Continue with Google</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="h-14 w-full rounded-lg bg-black text-center text-base font-medium text-white shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 hover:bg-black sm:w-52 dark:bg-white dark:text-black dark:hover:bg-white"
|
||||
>
|
||||
<Link href="/login">Get Started</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="runtime-auth-local h-14 w-full rounded-lg bg-black text-center text-base font-medium text-white shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 hover:bg-black sm:w-52 dark:bg-white dark:text-black dark:hover:bg-white"
|
||||
>
|
||||
<Link href="/login">Get Started</Link>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/features/chat-messages/hitl";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DocumentsSidebar } from "../sidebar";
|
||||
|
||||
|
|
@ -197,9 +196,6 @@ export function RightPanel({
|
|||
const citationState = useAtomValue(citationPanelAtom);
|
||||
const closeCitation = useSetAtom(closeCitationPanelAtom);
|
||||
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||
// Desktop-only surface; mobile uses the dedicated Mobile* drawers. Without
|
||||
// this guard both render together and two editors fight over one model.
|
||||
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||
|
||||
const documentsOpen = documentsPanel?.open ?? false;
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
|
|
@ -271,7 +267,7 @@ export function RightPanel({
|
|||
<CollapseButton onClick={() => setCollapsed(true)} />
|
||||
) : null;
|
||||
|
||||
if (!isVisible || !isDesktop) return null;
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
|
|
@ -312,8 +308,6 @@ export function RightPanel({
|
|||
searchSpaceId={editorState.searchSpaceId ?? undefined}
|
||||
title={editorState.title}
|
||||
onClose={closeEditor}
|
||||
highlightLines={editorState.highlightLines}
|
||||
forceSourceView={editorState.forceSourceView}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -272,6 +272,7 @@ export function ModelSelector({
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Select chat model"
|
||||
className={cn(
|
||||
"h-8 min-w-0 gap-2 rounded-md px-3 text-muted-foreground transition-colors",
|
||||
"select-none",
|
||||
|
|
|
|||
|
|
@ -70,15 +70,10 @@ export const documentWithChunks = document.extend({
|
|||
id: z.number(),
|
||||
content: z.string(),
|
||||
created_at: z.string(),
|
||||
start_char: z.number().nullable().optional(),
|
||||
end_char: z.number().nullable().optional(),
|
||||
})
|
||||
),
|
||||
total_chunks: z.number().optional().default(0),
|
||||
chunk_start_index: z.number().optional().default(0),
|
||||
// 1-based inclusive line range of the cited chunk within source_markdown.
|
||||
cited_start_line: z.number().nullable().optional(),
|
||||
cited_end_line: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,16 +18,12 @@ import { FENCED_OR_INLINE_CODE } from "@/lib/markdown/code-regions";
|
|||
* sometimes emit.
|
||||
*/
|
||||
export const CITATION_REGEX =
|
||||
/[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+|urlcite\d+|d\d+#L\d+-\d+|(?:doc-)?-?\d+(?:\s*,\s*(?:doc-)?-?\d+)*)\s*\u200B?[\]】]/g;
|
||||
|
||||
/** Matches the knowledge-base line-citation form `d<documentId>#L<start>-<end>`. */
|
||||
const LINE_CITATION_REGEX = /^d(\d+)#L(\d+)-(\d+)$/;
|
||||
/[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+|urlcite\d+|(?:doc-)?-?\d+(?:\s*,\s*(?:doc-)?-?\d+)*)\s*\u200B?[\]】]/g;
|
||||
|
||||
/** A single parsed citation reference. */
|
||||
export type CitationToken =
|
||||
| { kind: "url"; url: string }
|
||||
| { kind: "chunk"; chunkId: number; isDocsChunk: boolean }
|
||||
| { kind: "line"; documentId: number; startLine: number; endLine: number };
|
||||
| { kind: "chunk"; chunkId: number; isDocsChunk: boolean };
|
||||
|
||||
/** Output of `parseTextWithCitations` — interleaved text + citation tokens. */
|
||||
export type ParsedSegment = string | CitationToken;
|
||||
|
|
@ -99,15 +95,7 @@ export function parseTextWithCitations(text: string, urlMap: CitationUrlMap): Pa
|
|||
|
||||
const captured = match[1];
|
||||
|
||||
const lineMatch = LINE_CITATION_REGEX.exec(captured);
|
||||
if (lineMatch) {
|
||||
segments.push({
|
||||
kind: "line",
|
||||
documentId: Number.parseInt(lineMatch[1], 10),
|
||||
startLine: Number.parseInt(lineMatch[2], 10),
|
||||
endLine: Number.parseInt(lineMatch[3], 10),
|
||||
});
|
||||
} else if (captured.startsWith("http://") || captured.startsWith("https://")) {
|
||||
if (captured.startsWith("http://") || captured.startsWith("https://")) {
|
||||
segments.push({ kind: "url", url: captured.trim() });
|
||||
} else if (captured.startsWith("urlcite")) {
|
||||
const url = urlMap.get(captured);
|
||||
|
|
|
|||
52
surfsense_web/lib/runtime-auth-config.ts
Normal file
52
surfsense_web/lib/runtime-auth-config.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
export const RUNTIME_AUTH_TYPE_COOKIE_NAME = "surfsense_auth_type";
|
||||
|
||||
export type RuntimeAuthUiMode = "GOOGLE" | "LOCAL";
|
||||
|
||||
export function resolveRuntimeAuthUiMode(
|
||||
value: string | null | undefined,
|
||||
fallback: string | null | undefined = "GOOGLE"
|
||||
): RuntimeAuthUiMode {
|
||||
const candidate = value?.trim().toUpperCase();
|
||||
if (candidate === "GOOGLE") return "GOOGLE";
|
||||
if (candidate === "LOCAL") return "LOCAL";
|
||||
|
||||
const fallbackCandidate = fallback?.trim().toUpperCase();
|
||||
return fallbackCandidate === "GOOGLE" ? "GOOGLE" : "LOCAL";
|
||||
}
|
||||
|
||||
export function getRuntimeAuthInitScript(fallbackAuthType: string): string {
|
||||
const fallback = resolveRuntimeAuthUiMode(fallbackAuthType);
|
||||
const cookieName = JSON.stringify(RUNTIME_AUTH_TYPE_COOKIE_NAME);
|
||||
const fallbackValue = JSON.stringify(fallback);
|
||||
|
||||
return `
|
||||
(function() {
|
||||
try {
|
||||
var cookieName = ${cookieName};
|
||||
var fallback = ${fallbackValue};
|
||||
var prefix = cookieName + "=";
|
||||
var rawValue = fallback;
|
||||
var cookies = document.cookie ? document.cookie.split(";") : [];
|
||||
for (var i = 0; i < cookies.length; i++) {
|
||||
var cookie = cookies[i].trim();
|
||||
if (cookie.indexOf(prefix) === 0) {
|
||||
rawValue = decodeURIComponent(cookie.slice(prefix.length));
|
||||
break;
|
||||
}
|
||||
}
|
||||
var normalized = String(rawValue || fallback).toUpperCase() === "GOOGLE" ? "GOOGLE" : "LOCAL";
|
||||
window.__SURFSENSE_AUTH_TYPE__ = normalized;
|
||||
document.documentElement.setAttribute("data-surfsense-auth-type", normalized);
|
||||
} catch (_) {
|
||||
window.__SURFSENSE_AUTH_TYPE__ = ${fallbackValue};
|
||||
document.documentElement.setAttribute("data-surfsense-auth-type", ${fallbackValue});
|
||||
}
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__SURFSENSE_AUTH_TYPE__?: RuntimeAuthUiMode;
|
||||
}
|
||||
}
|
||||
24
surfsense_web/proxy.ts
Normal file
24
surfsense_web/proxy.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { BUILD_TIME_AUTH_TYPE } from "@/lib/env-config";
|
||||
import {
|
||||
RUNTIME_AUTH_TYPE_COOKIE_NAME,
|
||||
resolveRuntimeAuthUiMode,
|
||||
} from "@/lib/runtime-auth-config";
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const response = NextResponse.next();
|
||||
const authType = resolveRuntimeAuthUiMode(process.env.AUTH_TYPE, BUILD_TIME_AUTH_TYPE);
|
||||
|
||||
response.cookies.set(RUNTIME_AUTH_TYPE_COOKIE_NAME, authType, {
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: "lax",
|
||||
secure: request.nextUrl.protocol === "https:",
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|auth|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue