+
-
+
);
}
diff --git a/surfsense_web/components/new-chat/image-model-selector.tsx b/surfsense_web/components/new-chat/image-model-selector.tsx
index e90a46c09..5cd898afc 100644
--- a/surfsense_web/components/new-chat/image-model-selector.tsx
+++ b/surfsense_web/components/new-chat/image-model-selector.tsx
@@ -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
) : (
)}
-
- {selected ? modelName(selected) : "Auto"}
-
+ {showIconOnlyTrigger ? null : (
+
+ {selected ? modelName(selected) : "Auto"}
+
+ )}
);
diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx
index 22d86aa92..c10bfd862 100644
--- a/surfsense_web/components/new-chat/model-selector.tsx
+++ b/surfsense_web/components/new-chat/model-selector.tsx
@@ -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" })}
-
- {selected ? modelName(selected) : "Auto"}
-
+ {showIconOnlyTrigger ? null : (
+
+ {selected ? modelName(selected) : "Auto"}
+
+ )}
);
diff --git a/surfsense_web/contracts/types/document.types.ts b/surfsense_web/contracts/types/document.types.ts
index da1dac537..a7fa19e18 100644
--- a/surfsense_web/contracts/types/document.types.ts
+++ b/surfsense_web/contracts/types/document.types.ts
@@ -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(),
});
/**
diff --git a/surfsense_web/lib/citations/citation-parser.ts b/surfsense_web/lib/citations/citation-parser.ts
index 533c644c2..0d320956f 100644
--- a/surfsense_web/lib/citations/citation-parser.ts
+++ b/surfsense_web/lib/citations/citation-parser.ts
@@ -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
#L-`. */
+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);