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
fd31ac34fd
61 changed files with 1984 additions and 435 deletions
|
|
@ -270,6 +270,12 @@ 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";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
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";
|
||||
|
|
@ -9,6 +14,10 @@ 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 = {
|
||||
|
|
@ -19,6 +28,8 @@ const initialState: EditorPanelState = {
|
|||
searchSpaceId: null,
|
||||
memoryScope: null,
|
||||
title: null,
|
||||
highlightLines: null,
|
||||
forceSourceView: false,
|
||||
};
|
||||
|
||||
export const editorPanelAtom = atom<EditorPanelState>(initialState);
|
||||
|
|
@ -33,7 +44,14 @@ export const openEditorPanelAtom = atom(
|
|||
get,
|
||||
set,
|
||||
payload:
|
||||
| { documentId: number; searchSpaceId: number; title?: string; kind?: "document" }
|
||||
| {
|
||||
documentId: number;
|
||||
searchSpaceId: number;
|
||||
title?: string;
|
||||
kind?: "document";
|
||||
highlightLines?: EditorLineRange | null;
|
||||
forceSourceView?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "local_file";
|
||||
localFilePath: string;
|
||||
|
|
@ -59,6 +77,8 @@ export const openEditorPanelAtom = atom(
|
|||
searchSpaceId: payload.searchSpaceId ?? null,
|
||||
memoryScope: null,
|
||||
title: payload.title ?? null,
|
||||
highlightLines: null,
|
||||
forceSourceView: false,
|
||||
});
|
||||
set(rightPanelTabAtom, "editor");
|
||||
set(rightPanelCollapsedAtom, false);
|
||||
|
|
@ -73,6 +93,8 @@ 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);
|
||||
|
|
@ -86,6 +108,8 @@ 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);
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ export interface ChatViewportProps {
|
|||
export const ChatViewport: FC<ChatViewportProps> = ({ children, footer }) => (
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
autoScroll={false}
|
||||
scrollToBottomOnRunStart={false}
|
||||
autoScroll
|
||||
scrollToBottomOnRunStart
|
||||
scrollToBottomOnInitialize
|
||||
scrollToBottomOnThreadSwitch
|
||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 scroll-smooth"
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
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";
|
||||
|
|
@ -108,6 +110,50 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ const MarkdownTextImpl = () => {
|
|||
return (
|
||||
<CitationUrlMapContext.Provider value={urlMapRef}>
|
||||
<MarkdownTextPrimitive
|
||||
smooth={false}
|
||||
smooth
|
||||
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
className="aui-md"
|
||||
|
|
|
|||
|
|
@ -1577,7 +1577,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
<span>Select a model</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2">
|
||||
<ChatHeader
|
||||
searchSpaceId={searchSpaceId}
|
||||
className="h-9 max-w-[44vw] px-2 sm:max-w-[220px] sm:px-3"
|
||||
|
|
@ -1600,7 +1600,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
variant="default"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"aui-composer-send size-9 rounded-full",
|
||||
"aui-composer-send size-9 shrink-0 rounded-full",
|
||||
isSendDisabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
aria-label="Send message"
|
||||
|
|
@ -1617,7 +1617,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
type="button"
|
||||
variant="default"
|
||||
size="icon"
|
||||
className="aui-composer-cancel size-9 rounded-full"
|
||||
className="aui-composer-cancel size-9 shrink-0 rounded-full"
|
||||
aria-label="Stop generating"
|
||||
>
|
||||
<SquareIcon className="aui-composer-cancel-icon size-3.5 fill-current" />
|
||||
|
|
|
|||
|
|
@ -46,6 +46,13 @@ 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;
|
||||
|
|
@ -75,10 +82,15 @@ 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,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -110,6 +122,7 @@ 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
|
||||
|
|
@ -172,7 +185,9 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({
|
|||
Chunk #{chunk.id}
|
||||
</span>
|
||||
{isCited && (
|
||||
<span className="text-[11px] font-semibold text-primary">Cited chunk</span>
|
||||
<span className="text-[11px] font-semibold text-primary">
|
||||
{citedLineLabel ? `Cited chunk · ${citedLineLabel}` : "Cited chunk"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
|
||||
import { InlineCitation, LineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
|
||||
import {
|
||||
type CitationToken,
|
||||
type CitationUrlMap,
|
||||
|
|
@ -21,6 +21,16 @@ 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,6 +149,8 @@ export function EditorPanelContent({
|
|||
searchSpaceId,
|
||||
title,
|
||||
onClose,
|
||||
highlightLines = null,
|
||||
forceSourceView = false,
|
||||
}: {
|
||||
kind?: "document" | "local_file" | "memory";
|
||||
documentId?: number;
|
||||
|
|
@ -157,6 +159,8 @@ 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);
|
||||
|
|
@ -205,7 +209,7 @@ export function EditorPanelContent({
|
|||
const isLargeDocument = docSizeBytes > plateMaxBytes || docLineCount > plateMaxLines;
|
||||
const viewerMode: ViewerMode = isMemoryMode
|
||||
? "plate"
|
||||
: editorDoc?.viewer_mode === "monaco" || isLargeDocument
|
||||
: editorDoc?.viewer_mode === "monaco" || isLargeDocument || forceSourceView
|
||||
? "monaco"
|
||||
: "plate";
|
||||
|
||||
|
|
@ -828,6 +832,7 @@ export function EditorPanelContent({
|
|||
value={editorDoc.source_markdown}
|
||||
readOnly
|
||||
onChange={() => {}}
|
||||
highlightLines={highlightLines}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -918,6 +923,8 @@ function DesktopEditorPanel() {
|
|||
searchSpaceId={panelState.searchSpaceId ?? undefined}
|
||||
title={panelState.title}
|
||||
onClose={closePanel}
|
||||
highlightLines={panelState.highlightLines}
|
||||
forceSourceView={panelState.forceSourceView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -957,6 +964,8 @@ function MobileEditorDrawer() {
|
|||
memoryScope={panelState.memoryScope ?? undefined}
|
||||
searchSpaceId={panelState.searchSpaceId ?? undefined}
|
||||
title={panelState.title}
|
||||
highlightLines={panelState.highlightLines}
|
||||
forceSourceView={panelState.forceSourceView}
|
||||
/>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@
|
|||
import { type Descendant, KEYS } from "platejs";
|
||||
import { createPlatePlugin, type PlateElementProps } from "platejs/react";
|
||||
import type { FC } from "react";
|
||||
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
|
||||
import { InlineCitation, LineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
|
||||
import {
|
||||
CITATION_REGEX,
|
||||
type CitationToken,
|
||||
type CitationUrlMap,
|
||||
parseTextWithCitations,
|
||||
} from "@/lib/citations/citation-parser";
|
||||
|
|
@ -17,9 +18,12 @@ import {
|
|||
*/
|
||||
export type CitationElementNode = {
|
||||
type: "citation";
|
||||
kind: "chunk" | "doc" | "url";
|
||||
kind: "chunk" | "doc" | "url" | "line";
|
||||
chunkId?: number;
|
||||
url?: string;
|
||||
documentId?: number;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
/** Original literal token that produced this citation node. */
|
||||
rawText: string;
|
||||
children: [{ text: "" }];
|
||||
|
|
@ -33,11 +37,22 @@ 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}
|
||||
|
|
@ -97,10 +112,7 @@ function copyMarks(textNode: SlateText): Record<string, unknown> {
|
|||
return marks;
|
||||
}
|
||||
|
||||
function makeCitationElement(
|
||||
rawText: string,
|
||||
segment: { kind: "url"; url: string } | { kind: "chunk"; chunkId: number; isDocsChunk: boolean }
|
||||
): CitationElementNode {
|
||||
function makeCitationElement(rawText: string, segment: CitationToken): CitationElementNode {
|
||||
if (segment.kind === "url") {
|
||||
return {
|
||||
type: CITATION_TYPE,
|
||||
|
|
@ -110,6 +122,17 @@ function makeCitationElement(
|
|||
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 { useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
|
||||
|
|
@ -17,6 +17,8 @@ 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({
|
||||
|
|
@ -27,10 +29,45 @@ 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}`;
|
||||
|
|
@ -104,7 +141,16 @@ 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?.();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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";
|
||||
|
||||
|
|
@ -196,6 +197,9 @@ 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;
|
||||
|
|
@ -267,7 +271,7 @@ export function RightPanel({
|
|||
<CollapseButton onClick={() => setCollapsed(true)} />
|
||||
) : null;
|
||||
|
||||
if (!isVisible) return null;
|
||||
if (!isVisible || !isDesktop) return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
|
|
@ -308,6 +312,8 @@ export function RightPanel({
|
|||
searchSpaceId={editorState.searchSpaceId ?? undefined}
|
||||
title={editorState.title}
|
||||
onClose={closeEditor}
|
||||
highlightLines={editorState.highlightLines}
|
||||
forceSourceView={editorState.forceSourceView}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,13 +11,13 @@ interface ChatHeaderProps {
|
|||
|
||||
export function ChatHeader({ searchSpaceId, className, onChatModelSelected }: ChatHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-2">
|
||||
<ModelSelector
|
||||
searchSpaceId={searchSpaceId}
|
||||
className={className}
|
||||
onChatModelSelected={onChatModelSelected}
|
||||
/>
|
||||
<ImageModelSelector searchSpaceId={searchSpaceId} className={className} />
|
||||
<ImageModelSelector searchSpaceId={searchSpaceId} className={className} mobileIconOnly />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { providerDisplay } from "../settings/model-connections/provider-metadata
|
|||
interface ImageModelSelectorProps {
|
||||
searchSpaceId: number;
|
||||
className?: string;
|
||||
mobileIconOnly?: boolean;
|
||||
}
|
||||
|
||||
type ImageModel = ModelRead & {
|
||||
|
|
@ -95,7 +96,11 @@ function groupedModels(models: ImageModel[]) {
|
|||
}, {});
|
||||
}
|
||||
|
||||
export function ImageModelSelector({ searchSpaceId, className }: ImageModelSelectorProps) {
|
||||
export function ImageModelSelector({
|
||||
searchSpaceId,
|
||||
className,
|
||||
mobileIconOnly = false,
|
||||
}: ImageModelSelectorProps) {
|
||||
const router = useRouter();
|
||||
const isMobile = useIsMobile();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -126,6 +131,7 @@ export function ImageModelSelector({ searchSpaceId, className }: ImageModelSelec
|
|||
const groups = useMemo(() => groupedModels(visibleImageModels), [visibleImageModels]);
|
||||
const loading = globalLoading || connectionsLoading;
|
||||
const hasSearchQuery = search.trim().length > 0;
|
||||
const showIconOnlyTrigger = isMobile && mobileIconOnly;
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen) setSearch("");
|
||||
|
|
@ -252,12 +258,14 @@ export function ImageModelSelector({ searchSpaceId, className }: ImageModelSelec
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Select image model"
|
||||
className={cn(
|
||||
"h-8 min-w-0 gap-2 rounded-md px-3 text-muted-foreground transition-colors",
|
||||
"select-none",
|
||||
"hover:bg-foreground/10 hover:text-foreground",
|
||||
"data-[state=open]:bg-foreground/10 data-[state=open]:text-foreground",
|
||||
className
|
||||
className,
|
||||
showIconOnlyTrigger && "h-9 w-auto shrink-0 justify-center gap-1 px-2"
|
||||
)}
|
||||
>
|
||||
{selected ? (
|
||||
|
|
@ -265,9 +273,11 @@ export function ImageModelSelector({ searchSpaceId, className }: ImageModelSelec
|
|||
) : (
|
||||
<ImagePlus className="size-4 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{selected ? modelName(selected) : "Auto"}
|
||||
</span>
|
||||
{showIconOnlyTrigger ? null : (
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{selected ? modelName(selected) : "Auto"}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
</Button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ export function ModelSelector({
|
|||
const groups = useMemo(() => groupedModels(visibleChatModels), [visibleChatModels]);
|
||||
const loading = globalLoading || connectionsLoading;
|
||||
const hasSearchQuery = search.trim().length > 0;
|
||||
const showIconOnlyTrigger = isMobile;
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen) setSearch("");
|
||||
|
|
@ -276,15 +277,18 @@ export function ModelSelector({
|
|||
"select-none",
|
||||
"hover:bg-foreground/10 hover:text-foreground",
|
||||
"data-[state=open]:bg-foreground/10 data-[state=open]:text-foreground",
|
||||
className
|
||||
className,
|
||||
showIconOnlyTrigger && "h-9 w-auto shrink-0 justify-center gap-1 px-2"
|
||||
)}
|
||||
>
|
||||
{selected
|
||||
? getProviderIcon(selected.provider, { className: "size-4 shrink-0" })
|
||||
: getProviderIcon(AUTO_PROVIDER_ICON_KEY, { className: "size-4 shrink-0" })}
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{selected ? modelName(selected) : "Auto"}
|
||||
</span>
|
||||
{showIconOnlyTrigger ? null : (
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{selected ? modelName(selected) : "Auto"}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
</Button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -70,10 +70,15 @@ 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,12 +18,16 @@ import { FENCED_OR_INLINE_CODE } from "@/lib/markdown/code-regions";
|
|||
* sometimes emit.
|
||||
*/
|
||||
export const CITATION_REGEX =
|
||||
/[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+|urlcite\d+|(?:doc-)?-?\d+(?:\s*,\s*(?:doc-)?-?\d+)*)\s*\u200B?[\]】]/g;
|
||||
/[[【]\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+)$/;
|
||||
|
||||
/** A single parsed citation reference. */
|
||||
export type CitationToken =
|
||||
| { kind: "url"; url: string }
|
||||
| { kind: "chunk"; chunkId: number; isDocsChunk: boolean };
|
||||
| { kind: "chunk"; chunkId: number; isDocsChunk: boolean }
|
||||
| { kind: "line"; documentId: number; startLine: number; endLine: number };
|
||||
|
||||
/** Output of `parseTextWithCitations` — interleaved text + citation tokens. */
|
||||
export type ParsedSegment = string | CitationToken;
|
||||
|
|
@ -95,7 +99,15 @@ export function parseTextWithCitations(text: string, urlMap: CitationUrlMap): Pa
|
|||
|
||||
const captured = match[1];
|
||||
|
||||
if (captured.startsWith("http://") || captured.startsWith("https://")) {
|
||||
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://")) {
|
||||
segments.push({ kind: "url", url: captured.trim() });
|
||||
} else if (captured.startsWith("urlcite")) {
|
||||
const url = urlMap.get(captured);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue