= ({ 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 (
-
- );
-};
-
import { tryGetHostname } from "@/lib/url";
interface UrlCitationProps {
diff --git a/surfsense_web/components/assistant-ui/markdown-code-block.tsx b/surfsense_web/components/assistant-ui/markdown-code-block.tsx
index e6c735d1e..88b0916b8 100644
--- a/surfsense_web/components/assistant-ui/markdown-code-block.tsx
+++ b/surfsense_web/components/assistant-ui/markdown-code-block.tsx
@@ -6,6 +6,7 @@ import { memo, useEffect, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { materialDark, materialLight } from "react-syntax-highlighter/dist/esm/styles/prism";
+import { MermaidDiagram } from "@/components/assistant-ui/mermaid-diagram";
import { Button } from "@/components/ui/button";
import { cn, copyToClipboard } from "@/lib/utils";
@@ -40,6 +41,7 @@ function MarkdownCodeBlockComponent({
isDarkMode,
}: MarkdownCodeBlockProps) {
const [hasCopied, setHasCopied] = useState(false);
+ const normalizedLanguage = language.toLowerCase();
useEffect(() => {
if (!hasCopied) return;
@@ -47,7 +49,7 @@ function MarkdownCodeBlockComponent({
return () => clearTimeout(timer);
}, [hasCopied]);
- return (
+ const codeBlock = (
{language}
@@ -78,6 +80,12 @@ function MarkdownCodeBlockComponent({
);
+
+ if (normalizedLanguage === "mermaid") {
+ return
;
+ }
+
+ return codeBlock;
}
export const MarkdownCodeBlock = memo(MarkdownCodeBlockComponent);
diff --git a/surfsense_web/components/assistant-ui/mermaid-diagram.tsx b/surfsense_web/components/assistant-ui/mermaid-diagram.tsx
new file mode 100644
index 000000000..50f1dc6de
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/mermaid-diagram.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { CheckIcon, CopyIcon } from "lucide-react";
+import mermaid from "mermaid";
+import { memo, type ReactNode, useEffect, useId, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { copyToClipboard } from "@/lib/utils";
+
+type MermaidDiagramProps = {
+ source: string;
+ isDarkMode: boolean;
+ fallback: ReactNode;
+};
+
+let mermaidInitialized = false;
+
+function initializeMermaid() {
+ if (mermaidInitialized) return;
+
+ mermaid.initialize({
+ startOnLoad: false,
+ securityLevel: "strict",
+ htmlLabels: false,
+ flowchart: { htmlLabels: false },
+ sequence: { useMaxWidth: true },
+ });
+
+ mermaidInitialized = true;
+}
+
+function MermaidDiagramComponent({
+ source,
+ isDarkMode,
+ fallback,
+}: MermaidDiagramProps) {
+ const id = useId();
+ const [svg, setSvg] = useState
(null);
+ const [hasError, setHasError] = useState(false);
+ const [hasCopied, setHasCopied] = useState(false);
+
+ useEffect(() => {
+ let isCurrent = true;
+
+ const renderId = `mermaid-${id.replace(/[^a-zA-Z0-9_-]/g, "")}`;
+
+ setSvg(null);
+ setHasError(false);
+
+ (async () => {
+ try {
+ initializeMermaid();
+
+ // فقط theme اینجا تنظیم میشه (نه re-init کامل)
+ mermaid.initialize({
+ startOnLoad: false,
+ securityLevel: "strict",
+ htmlLabels: false,
+ theme: isDarkMode ? "dark" : "default",
+ flowchart: { htmlLabels: false },
+ sequence: { useMaxWidth: true },
+ });
+
+ await mermaid.parse(source);
+
+ const { svg } = await mermaid.render(renderId, source);
+
+ if (isCurrent) {
+ setSvg(svg);
+ }
+ } catch (error) {
+ console.error("[mermaid] Failed to render diagram", error);
+
+ if (isCurrent) {
+ setHasError(true);
+ }
+ }
+ })();
+
+ return () => {
+ isCurrent = false;
+ };
+ }, [id, isDarkMode, source]);
+
+ useEffect(() => {
+ if (!hasCopied) return;
+
+ const timer = setTimeout(() => setHasCopied(false), 2000);
+ return () => clearTimeout(timer);
+ }, [hasCopied]);
+
+ if (hasError) return fallback;
+
+ return (
+
+
+ mermaid
+
+
+
+
+
+ {svg ? (
+ // biome-ignore lint/performance/noImgElement: svg is in-memory string
+
}`})
+ ) : (
+
+ )}
+
+
+ );
+}
+
+export const MermaidDiagram = memo(MermaidDiagramComponent);
\ No newline at end of file
diff --git a/surfsense_web/components/auth/sign-in-button.tsx b/surfsense_web/components/auth/sign-in-button.tsx
index 581e37603..d0a563a54 100644
--- a/surfsense_web/components/auth/sign-in-button.tsx
+++ b/surfsense_web/components/auth/sign-in-button.tsx
@@ -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 (
+ <>
- );
- }
-
- return (
-
- Sign In
-
+
+ Sign In
+
+ >
);
};
diff --git a/surfsense_web/components/citation-panel/citation-panel.tsx b/surfsense_web/components/citation-panel/citation-panel.tsx
index 9b9a9aaa9..890ac11ac 100644
--- a/surfsense_web/components/citation-panel/citation-panel.tsx
+++ b/surfsense_web/components/citation-panel/citation-panel.tsx
@@ -46,13 +46,6 @@ export const CitationPanelContent: FC = ({
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 = ({
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 = ({
- {citedLineLabel && {citedLineLabel}}
{totalChunks > 0 && {totalChunks} chunks}
{!isLoading && !error && data && (
diff --git a/surfsense_web/components/citations/citation-renderer.tsx b/surfsense_web/components/citations/citation-renderer.tsx
index b0ab13f84..f2de4b27d 100644
--- a/surfsense_web/components/citations/citation-renderer.tsx
+++ b/surfsense_web/components/citations/citation-renderer.tsx
@@ -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 ;
}
- if (token.kind === "line") {
- return (
-
- );
- }
return (
void;
- highlightLines?: { start: number; end: number } | null;
- forceSourceView?: boolean;
}) {
const electronAPI = useElectronAPI();
const [editorDoc, setEditorDoc] = useState(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}
/>
@@ -923,8 +918,6 @@ function DesktopEditorPanel() {
searchSpaceId={panelState.searchSpaceId ?? undefined}
title={panelState.title}
onClose={closePanel}
- highlightLines={panelState.highlightLines}
- forceSourceView={panelState.forceSourceView}
/>
);
@@ -964,8 +957,6 @@ function MobileEditorDrawer() {
memoryScope={panelState.memoryScope ?? undefined}
searchSpaceId={panelState.searchSpaceId ?? undefined}
title={panelState.title}
- highlightLines={panelState.highlightLines}
- forceSourceView={panelState.forceSourceView}
/>
diff --git a/surfsense_web/components/editor/plugins/citation-kit.tsx b/surfsense_web/components/editor/plugins/citation-kit.tsx
index edba9a19e..97e8ec723 100644
--- a/surfsense_web/components/editor/plugins/citation-kit.tsx
+++ b/surfsense_web/components/editor/plugins/citation-kit.tsx
@@ -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> = ({
element,
}) => {
const isUrl = element.kind === "url";
- const isLine =
- element.kind === "line" &&
- element.documentId !== undefined &&
- element.startLine !== undefined &&
- element.endLine !== undefined;
return (
{isUrl && element.url ? (
- ) : isLine ? (
-
) : element.chunkId !== undefined ? (
) : null}
@@ -112,7 +97,10 @@ function copyMarks(textNode: SlateText): Record {
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",
diff --git a/surfsense_web/components/editor/source-code-editor.tsx b/surfsense_web/components/editor/source-code-editor.tsx
index 4af4f2125..9102dffe9 100644
--- a/surfsense_web/components/editor/source-code-editor.tsx
+++ b/surfsense_web/components/editor/source-code-editor.tsx
@@ -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;
- /** 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(null);
- const editorRef = useRef(null);
- const decorationsRef = useRef(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?.();
diff --git a/surfsense_web/components/homepage/hero-section.tsx b/surfsense_web/components/homepage/hero-section.tsx
index 0f3bfe1aa..c9430f098 100644
--- a/surfsense_web/components/homepage/hero-section.tsx
+++ b/surfsense_web/components/homepage/hero-section.tsx
@@ -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 (
+ <>
- );
- }
-
- return (
-
+
+ >
);
}
diff --git a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx
index 6662d7830..5a7588979 100644
--- a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx
+++ b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx
@@ -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({
setCollapsed(true)} />
) : null;
- if (!isVisible || !isDesktop) return null;
+ if (!isVisible) return null;
return (
)}
diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx
index c10bfd862..e4ae427aa 100644
--- a/surfsense_web/components/new-chat/model-selector.tsx
+++ b/surfsense_web/components/new-chat/model-selector.tsx
@@ -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",
diff --git a/surfsense_web/contracts/types/document.types.ts b/surfsense_web/contracts/types/document.types.ts
index a7fa19e18..da1dac537 100644
--- a/surfsense_web/contracts/types/document.types.ts
+++ b/surfsense_web/contracts/types/document.types.ts
@@ -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(),
});
/**
diff --git a/surfsense_web/lib/citations/citation-parser.ts b/surfsense_web/lib/citations/citation-parser.ts
index 0d320956f..533c644c2 100644
--- a/surfsense_web/lib/citations/citation-parser.ts
+++ b/surfsense_web/lib/citations/citation-parser.ts
@@ -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#L-`. */
-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);
diff --git a/surfsense_web/lib/runtime-auth-config.ts b/surfsense_web/lib/runtime-auth-config.ts
new file mode 100644
index 000000000..9e8d1921d
--- /dev/null
+++ b/surfsense_web/lib/runtime-auth-config.ts
@@ -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;
+ }
+}
diff --git a/surfsense_web/proxy.ts b/surfsense_web/proxy.ts
new file mode 100644
index 000000000..b53ce68a7
--- /dev/null
+++ b/surfsense_web/proxy.ts
@@ -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|.*\\..*).*)"],
+};