Merge remote-tracking branch 'upstream/dev' into fix/auth

This commit is contained in:
Anish Sarkar 2026-02-10 11:36:06 +05:30
commit 2dec643cb4
80 changed files with 2968 additions and 2379 deletions

View file

@ -8,6 +8,7 @@ import {
type GetDocumentByChunkRequest,
type GetDocumentRequest,
type GetDocumentsRequest,
type GetDocumentsStatusRequest,
type GetDocumentTypeCountsRequest,
type GetSurfsenseDocsRequest,
getDocumentByChunkRequest,
@ -16,6 +17,8 @@ import {
getDocumentResponse,
getDocumentsRequest,
getDocumentsResponse,
getDocumentsStatusRequest,
getDocumentsStatusResponse,
getDocumentTypeCountsRequest,
getDocumentTypeCountsResponse,
getSurfsenseDocsByChunkResponse,
@ -130,6 +133,30 @@ class DocumentsApiService {
});
};
/**
* Batch document status for async processing tracking
*/
getDocumentsStatus = async (request: GetDocumentsStatusRequest) => {
const parsedRequest = getDocumentsStatusRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const { search_space_id, document_ids } = parsedRequest.data.queryParams;
const params = new URLSearchParams({
search_space_id: String(search_space_id),
document_ids: document_ids.join(","),
});
return baseApiService.get(
`/api/v1/documents/status?${params.toString()}`,
getDocumentsStatusResponse
);
};
/**
* Search documents by title
*/

View file

@ -2,12 +2,12 @@ import {
type CreateImageGenConfigRequest,
createImageGenConfigRequest,
createImageGenConfigResponse,
deleteImageGenConfigResponse,
getGlobalImageGenConfigsResponse,
getImageGenConfigsResponse,
type UpdateImageGenConfigRequest,
updateImageGenConfigRequest,
updateImageGenConfigResponse,
deleteImageGenConfigResponse,
getImageGenConfigsResponse,
getGlobalImageGenConfigsResponse,
} from "@/contracts/types/new-llm-config.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";

View file

@ -10,28 +10,53 @@ const REFRESH_TOKEN_KEY = "surfsense_refresh_token";
let isRefreshing = false;
let refreshPromise: Promise<string | null> | null = null;
/** Path prefixes for routes that do not require auth (no current-user fetch, no redirect on 401) */
const PUBLIC_ROUTE_PREFIXES = [
"/login",
"/register",
"/auth",
"/docs",
"/public",
"/invite",
"/contact",
"/pricing",
"/privacy",
"/terms",
"/changelog",
];
/**
* Saves the current path and redirects to login page
* Call this when a 401 response is received
* Returns true if the pathname is a public route where we should not run auth checks
* or redirect to login on 401.
*/
export function isPublicRoute(pathname: string): boolean {
if (pathname === "/" || pathname === "") return true;
return PUBLIC_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}
/**
* Clears tokens and optionally redirects to login.
* Call this when a 401 response is received.
* Only redirects when the current route is protected; on public routes we just clear tokens.
*/
export function handleUnauthorized(): void {
if (typeof window === "undefined") return;
// Save the current path (including search params and hash) for redirect after login
const currentPath = window.location.pathname + window.location.search + window.location.hash;
const pathname = window.location.pathname;
// Don't save auth-related paths
const excludedPaths = ["/auth", "/auth/callback", "/"];
if (!excludedPaths.includes(window.location.pathname)) {
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
}
// Clear both tokens
// Always clear tokens
localStorage.removeItem(BEARER_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
// Redirect to home page (which has login options)
window.location.href = "/login";
// Only redirect on protected routes; stay on public pages (e.g. /docs)
if (!isPublicRoute(pathname)) {
const currentPath = pathname + window.location.search + window.location.hash;
const excludedPaths = ["/auth", "/auth/callback", "/"];
if (!excludedPaths.includes(pathname)) {
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
}
window.location.href = "/login";
}
}
/**
@ -179,7 +204,6 @@ export function getAuthHeaders(additionalHeaders?: Record<string, string>): Reco
/**
* Attempts to refresh the access token using the stored refresh token.
* Returns the new access token if successful, null otherwise.
* Exported for use by API services.
*/
export async function refreshAccessToken(): Promise<string | null> {
// If already refreshing, wait for that request to complete

View file

@ -1,324 +0,0 @@
/**
* Attachment adapter for assistant-ui
*
* This adapter handles file uploads by:
* 1. Uploading the file to the backend /attachments/process endpoint
* 2. The backend extracts markdown content using the configured ETL service
* 3. The extracted content is stored in the attachment and sent with messages
*/
import type { AttachmentAdapter, CompleteAttachment, PendingAttachment } from "@assistant-ui/react";
import { getBearerToken } from "@/lib/auth-utils";
/**
* Supported file types for the attachment adapter
*
* - Text/Markdown: .md, .markdown, .txt
* - Audio (if STT configured): .mp3, .mp4, .mpeg, .mpga, .m4a, .wav, .webm
* - Documents (depends on ETL service): .pdf, .docx, .doc, .pptx, .xlsx, .html
* - Images: .jpg, .jpeg, .png, .gif, .webp
*/
const ACCEPTED_FILE_TYPES = [
// Text/Markdown (always supported)
".md",
".markdown",
".txt",
// Audio files
".mp3",
".mp4",
".mpeg",
".mpga",
".m4a",
".wav",
".webm",
// Document files (depends on ETL service)
".pdf",
".docx",
".doc",
".pptx",
".xlsx",
".html",
// Image files
".jpg",
".jpeg",
".png",
".gif",
".webp",
].join(",");
/**
* Response from the attachment processing endpoint
*/
interface ProcessAttachmentResponse {
id: string;
name: string;
type: "document" | "image" | "file";
content: string;
contentLength: number;
}
/**
* Extended CompleteAttachment with our custom extractedContent field
* We store the extracted text in a custom field so we can access it in onNew
* For images, we also store the data URL so it can be displayed after persistence
*/
export interface ChatAttachment extends CompleteAttachment {
extractedContent: string;
imageDataUrl?: string; // Base64 data URL for images (persists across page reloads)
}
/**
* Process a file through the backend ETL service
*/
async function processAttachment(file: File): Promise<ProcessAttachmentResponse> {
const token = getBearerToken();
if (!token) {
throw new Error("Not authenticated");
}
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`${backendUrl}/api/v1/attachments/process`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
console.error("[processAttachment] Error response:", errorText);
let errorDetail = "Unknown error";
try {
const errorJson = JSON.parse(errorText);
// FastAPI validation errors return detail as array
if (Array.isArray(errorJson.detail)) {
errorDetail = errorJson.detail
.map((err: { msg?: string; loc?: string[] }) => {
const field = err.loc?.join(".") || "unknown";
return `${field}: ${err.msg || "validation error"}`;
})
.join("; ");
} else if (typeof errorJson.detail === "string") {
errorDetail = errorJson.detail;
} else {
errorDetail = JSON.stringify(errorJson);
}
} catch {
errorDetail = errorText || `HTTP ${response.status}`;
}
throw new Error(errorDetail);
}
return response.json();
}
// Store processed results for the send() method
const processedAttachments = new Map<string, ProcessAttachmentResponse>();
// Store image data URLs for attachments (so they persist after File objects are lost)
const imageDataUrls = new Map<string, string>();
/**
* Convert a File to a data URL (base64) for images
*/
async function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/**
* Create the attachment adapter for assistant-ui
*
* This adapter:
* 1. Accepts file upload
* 2. Processes the file through the backend ETL service
* 3. Returns the attachment with extracted markdown content
*
* The content is stored in the attachment and will be sent with the message.
*/
export function createAttachmentAdapter(): AttachmentAdapter {
return {
accept: ACCEPTED_FILE_TYPES,
/**
* Async generator that yields pending states while processing
* and returns a pending attachment when done.
*
* IMPORTANT: The generator should return status: { type: "running", progress: 100 }
* NOT status: { type: "complete" }. The "complete" status is set by send().
* Returning "complete" from the generator will prevent send() from being called!
*
* This pattern allows the UI to show a loading indicator
* while the file is being processed by the backend.
* The send() method is called to finalize the attachment.
*/
async *add(input: File | { file: File }): AsyncGenerator<PendingAttachment, void> {
// Handle both direct File and { file: File } patterns
const file = input instanceof File ? input : input.file;
if (!file) {
console.error("[AttachmentAdapter] No file found in input:", input);
throw new Error("No file provided");
}
// Generate a unique ID for this attachment
const id = crypto.randomUUID();
// Determine attachment type from file
const attachmentType = file.type.startsWith("image/") ? "image" : "document";
// Yield initial pending state with "running" status (0% progress)
// This triggers the loading indicator in the UI
yield {
id,
type: attachmentType,
name: file.name,
file,
status: { type: "running", reason: "uploading", progress: 0 },
} as PendingAttachment;
try {
// For images, convert to data URL so we can display them after persistence
if (attachmentType === "image") {
const dataUrl = await fileToDataUrl(file);
imageDataUrls.set(id, dataUrl);
}
// Process the file through the backend ETL service
const result = await processAttachment(file);
// Verify we have the required fields
if (!result.content) {
console.error("[AttachmentAdapter] WARNING: No content received from backend!");
}
// Store the processed result for send()
processedAttachments.set(id, result);
// Create the final pending attachment
// IMPORTANT: Use "running" status with progress: 100 to indicate processing is done
// but attachment is still pending. The "complete" status will be set by send().
// Yield the final state to ensure it gets processed by the UI
yield {
id,
type: result.type,
name: result.name,
file,
status: { type: "running", reason: "uploading", progress: 100 },
} as PendingAttachment;
} catch (error) {
console.error("[AttachmentAdapter] Failed to process attachment:", error);
throw error;
}
},
/**
* Called when user sends the message.
* Converts the pending attachment to a complete attachment.
*/
async send(pendingAttachment: PendingAttachment): Promise<ChatAttachment> {
const result = processedAttachments.get(pendingAttachment.id);
const imageDataUrl = imageDataUrls.get(pendingAttachment.id);
if (result) {
// Clean up stored result
processedAttachments.delete(pendingAttachment.id);
if (imageDataUrl) {
imageDataUrls.delete(pendingAttachment.id);
}
return {
id: result.id,
type: result.type,
name: result.name,
contentType: "text/markdown",
status: { type: "complete" },
content: [
{
type: "text",
text: result.content,
},
],
extractedContent: result.content,
imageDataUrl, // Store data URL for images so they can be displayed after persistence
};
}
// Fallback if no processed result found
console.warn(
"[AttachmentAdapter] send() - No processed result found for attachment:",
pendingAttachment.id
);
return {
id: pendingAttachment.id,
type: pendingAttachment.type,
name: pendingAttachment.name,
contentType: "text/plain",
status: { type: "complete" },
content: [],
extractedContent: "",
imageDataUrl, // Still include data URL if available
};
},
async remove() {
// No server-side cleanup needed since we don't persist attachments
},
};
}
/**
* Extract attachment content for chat request
*
* This function extracts the content from attachments to be sent with the chat request.
* Only attachments that have been fully processed (have content) will be included.
*/
export function extractAttachmentContent(
attachments: Array<unknown>
): Array<{ id: string; name: string; type: string; content: string }> {
return attachments
.filter((att): att is ChatAttachment => {
if (!att || typeof att !== "object") return false;
const a = att as Record<string, unknown>;
// Check for our custom extractedContent field first
if (typeof a.extractedContent === "string" && a.extractedContent.length > 0) {
return true;
}
// Fallback: check if content array has text content
if (Array.isArray(a.content)) {
const textContent = (a.content as Array<{ type: string; text?: string }>).find(
(c) => c.type === "text" && typeof c.text === "string" && c.text.length > 0
);
return Boolean(textContent);
}
return false;
})
.map((att) => {
// Get content from extractedContent or from content array
let content = "";
if (typeof att.extractedContent === "string") {
content = att.extractedContent;
} else if (Array.isArray(att.content)) {
const textContent = (att.content as Array<{ type: string; text?: string }>).find(
(c) => c.type === "text"
);
content = textContent?.text || "";
}
return {
id: att.id,
name: att.name,
type: att.type,
content,
};
});
}

View file

@ -1,46 +1,9 @@
import type { ThreadMessageLike } from "@assistant-ui/react";
import { z } from "zod";
import type { MessageRecord } from "./thread-persistence";
/**
* Zod schema for persisted attachment info
*/
const PersistedAttachmentSchema = z.object({
id: z.string(),
name: z.string(),
type: z.string(),
contentType: z.string().optional(),
imageDataUrl: z.string().optional(),
extractedContent: z.string().optional(),
});
const AttachmentsPartSchema = z.object({
type: z.literal("attachments"),
items: z.array(PersistedAttachmentSchema),
});
type PersistedAttachment = z.infer<typeof PersistedAttachmentSchema>;
/**
* Extract persisted attachments from message content (type-safe with Zod)
*/
function extractPersistedAttachments(content: unknown): PersistedAttachment[] {
if (!Array.isArray(content)) return [];
for (const part of content) {
const result = AttachmentsPartSchema.safeParse(part);
if (result.success) {
return result.data.items;
}
}
return [];
}
/**
* Convert backend message to assistant-ui ThreadMessageLike format
* Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps
* Restores attachments for user messages from persisted data
*/
export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
let content: ThreadMessageLike["content"];
@ -52,7 +15,7 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
const filteredContent = msg.content.filter((part: unknown) => {
if (typeof part !== "object" || part === null || !("type" in part)) return true;
const partType = (part as { type: string }).type;
// Filter out thinking-steps, mentioned-documents, and attachments
// Filter out metadata parts not directly renderable by assistant-ui
return (
partType !== "thinking-steps" &&
partType !== "mentioned-documents" &&
@ -67,25 +30,6 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
content = [{ type: "text", text: String(msg.content) }];
}
// Restore attachments for user messages
let attachments: ThreadMessageLike["attachments"];
if (msg.role === "user") {
const persistedAttachments = extractPersistedAttachments(msg.content);
if (persistedAttachments.length > 0) {
attachments = persistedAttachments.map((att) => ({
id: att.id,
name: att.name,
type: att.type as "document" | "image" | "file",
contentType: att.contentType || "application/octet-stream",
status: { type: "complete" as const },
content: [],
// Custom fields for our ChatAttachment interface
imageDataUrl: att.imageDataUrl,
extractedContent: att.extractedContent,
}));
}
}
// Build metadata.custom for author display in shared chats
const metadata = msg.author_id
? {
@ -103,7 +47,6 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
role: msg.role,
content,
createdAt: new Date(msg.created_at),
attachments,
metadata,
};
}

View file

@ -1,15 +1,18 @@
// Helper function to get connector type display name
export const getConnectorTypeDisplay = (type: string): string => {
const typeMap: Record<string, string> = {
SERPER_API: "Serper API",
TAVILY_API: "Tavily API",
SEARXNG_API: "SearxNG",
LINKUP_API: "Linkup",
BAIDU_SEARCH_API: "Baidu Search",
SLACK_CONNECTOR: "Slack",
TEAMS_CONNECTOR: "Microsoft Teams",
NOTION_CONNECTOR: "Notion",
GITHUB_CONNECTOR: "GitHub",
LINEAR_CONNECTOR: "Linear",
JIRA_CONNECTOR: "Jira",
DISCORD_CONNECTOR: "Discord",
LINKUP_API: "Linkup",
CONFLUENCE_CONNECTOR: "Confluence",
BOOKSTACK_CONNECTOR: "BookStack",
CLICKUP_CONNECTOR: "ClickUp",
@ -23,8 +26,10 @@ export const getConnectorTypeDisplay = (type: string): string => {
LUMA_CONNECTOR: "Luma",
ELASTICSEARCH_CONNECTOR: "Elasticsearch",
WEBCRAWLER_CONNECTOR: "Web Pages",
YOUTUBE_CONNECTOR: "YouTube",
CIRCLEBACK_CONNECTOR: "Circleback",
OBSIDIAN_CONNECTOR: "Obsidian",
MCP_CONNECTOR: "MCP Server",
};
return typeMap[type] || type;
};