mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/ui-fixes
This commit is contained in:
commit
c75a080997
42 changed files with 5354 additions and 7404 deletions
|
|
@ -243,6 +243,38 @@ export function getConnectorTitle(connectorType: string): string {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary way a user interacts with a connector.
|
||||
* Drives the two top-level groupings in the connector catalog UI.
|
||||
*/
|
||||
export type ConnectorCategory = "knowledge_base" | "tools_live";
|
||||
|
||||
export const CONNECTOR_CATEGORY_LABELS: Record<ConnectorCategory, string> = {
|
||||
knowledge_base: "Knowledge Base",
|
||||
tools_live: "Tools & Live Sources",
|
||||
};
|
||||
|
||||
const KNOWLEDGE_BASE_CONNECTOR_TYPES = new Set<string>([
|
||||
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
|
||||
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||
EnumConnectorName.ONEDRIVE_CONNECTOR,
|
||||
EnumConnectorName.DROPBOX_CONNECTOR,
|
||||
EnumConnectorName.NOTION_CONNECTOR,
|
||||
EnumConnectorName.CONFLUENCE_CONNECTOR,
|
||||
EnumConnectorName.YOUTUBE_CONNECTOR,
|
||||
EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
EnumConnectorName.BOOKSTACK_CONNECTOR,
|
||||
EnumConnectorName.GITHUB_CONNECTOR,
|
||||
EnumConnectorName.ELASTICSEARCH_CONNECTOR,
|
||||
EnumConnectorName.CIRCLEBACK_CONNECTOR,
|
||||
EnumConnectorName.OBSIDIAN_CONNECTOR,
|
||||
]);
|
||||
|
||||
/** Unmapped connectors surface under Tools & Live Sources. */
|
||||
export function getConnectorCategory(connectorType: string): ConnectorCategory {
|
||||
return KNOWLEDGE_BASE_CONNECTOR_TYPES.has(connectorType) ? "knowledge_base" : "tools_live";
|
||||
}
|
||||
|
||||
// Composio Toolkits (available integrations via Composio)
|
||||
export const COMPOSIO_TOOLKITS = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import { isSelfHosted } from "@/lib/env-config";
|
|||
import { ConnectorCard } from "../components/connector-card";
|
||||
import {
|
||||
COMPOSIO_CONNECTORS,
|
||||
CONNECTOR_CATEGORY_LABELS,
|
||||
type ConnectorCategory,
|
||||
CRAWLERS,
|
||||
getConnectorCategory,
|
||||
OAUTH_CONNECTORS,
|
||||
OTHER_CONNECTORS,
|
||||
} from "../constants/connector-constants";
|
||||
|
|
@ -20,19 +23,6 @@ type ComposioConnector = (typeof COMPOSIO_CONNECTORS)[number];
|
|||
type OtherConnector = (typeof OTHER_CONNECTORS)[number];
|
||||
type CrawlerConnector = (typeof CRAWLERS)[number];
|
||||
|
||||
const DOCUMENT_FILE_CONNECTOR_TYPES = new Set<string>([
|
||||
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
|
||||
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||
EnumConnectorName.ONEDRIVE_CONNECTOR,
|
||||
EnumConnectorName.DROPBOX_CONNECTOR,
|
||||
]);
|
||||
|
||||
const OTHER_DOCUMENT_CONNECTOR_TYPES = new Set<string>([
|
||||
EnumConnectorName.YOUTUBE_CONNECTOR,
|
||||
EnumConnectorName.NOTION_CONNECTOR,
|
||||
EnumConnectorName.AIRTABLE_CONNECTOR,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract the display name from a full connector name.
|
||||
* Full names are in format "Base Name - identifier" (e.g., "Gmail - john@example.com").
|
||||
|
|
@ -106,45 +96,23 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
c.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const nativeGoogleDriveConnectors = filteredOAuth.filter(
|
||||
(c) => c.connectorType === EnumConnectorName.GOOGLE_DRIVE_CONNECTOR
|
||||
);
|
||||
const composioGoogleDriveConnectors = filteredComposio.filter(
|
||||
(c) => c.connectorType === EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR
|
||||
);
|
||||
const fileStorageConnectors = filteredOAuth.filter(
|
||||
(c) =>
|
||||
c.connectorType === EnumConnectorName.ONEDRIVE_CONNECTOR ||
|
||||
c.connectorType === EnumConnectorName.DROPBOX_CONNECTOR
|
||||
);
|
||||
const inCategory =
|
||||
(category: ConnectorCategory) =>
|
||||
<T extends { connectorType?: string }>(connector: T): boolean =>
|
||||
!!connector.connectorType && getConnectorCategory(connector.connectorType) === category;
|
||||
|
||||
const otherDocumentYouTubeConnectors = filteredCrawlers.filter(
|
||||
(c) => c.connectorType === EnumConnectorName.YOUTUBE_CONNECTOR
|
||||
);
|
||||
const otherDocumentNotionConnectors = filteredOAuth.filter(
|
||||
(c) => c.connectorType === EnumConnectorName.NOTION_CONNECTOR
|
||||
);
|
||||
const otherDocumentAirtableConnectors = filteredOAuth.filter(
|
||||
(c) => c.connectorType === EnumConnectorName.AIRTABLE_CONNECTOR
|
||||
);
|
||||
|
||||
const moreIntegrationsComposio = filteredComposio.filter(
|
||||
(c) =>
|
||||
!DOCUMENT_FILE_CONNECTOR_TYPES.has(c.connectorType) &&
|
||||
!OTHER_DOCUMENT_CONNECTOR_TYPES.has(c.connectorType)
|
||||
);
|
||||
const moreIntegrationsOAuth = filteredOAuth.filter(
|
||||
(c) =>
|
||||
!DOCUMENT_FILE_CONNECTOR_TYPES.has(c.connectorType) &&
|
||||
!OTHER_DOCUMENT_CONNECTOR_TYPES.has(c.connectorType)
|
||||
);
|
||||
const moreIntegrationsOther = filteredOther;
|
||||
const moreIntegrationsCrawlers = filteredCrawlers.filter(
|
||||
(c) =>
|
||||
!c.connectorType ||
|
||||
(!DOCUMENT_FILE_CONNECTOR_TYPES.has(c.connectorType) &&
|
||||
!OTHER_DOCUMENT_CONNECTOR_TYPES.has(c.connectorType))
|
||||
);
|
||||
const knowledgeBase = {
|
||||
oauth: filteredOAuth.filter(inCategory("knowledge_base")),
|
||||
composio: filteredComposio.filter(inCategory("knowledge_base")),
|
||||
other: filteredOther.filter(inCategory("knowledge_base")),
|
||||
crawlers: filteredCrawlers.filter(inCategory("knowledge_base")),
|
||||
};
|
||||
const toolsLive = {
|
||||
oauth: filteredOAuth.filter(inCategory("tools_live")),
|
||||
composio: filteredComposio.filter(inCategory("tools_live")),
|
||||
other: filteredOther.filter(inCategory("tools_live")),
|
||||
crawlers: filteredCrawlers.filter(inCategory("tools_live")),
|
||||
};
|
||||
|
||||
const renderOAuthCard = (connector: OAuthConnector | ComposioConnector) => {
|
||||
const isConnected = connectedTypes.has(connector.connectorType);
|
||||
|
|
@ -275,20 +243,18 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const hasDocumentFileConnectors =
|
||||
nativeGoogleDriveConnectors.length > 0 ||
|
||||
composioGoogleDriveConnectors.length > 0 ||
|
||||
fileStorageConnectors.length > 0;
|
||||
const hasMoreIntegrations =
|
||||
otherDocumentYouTubeConnectors.length > 0 ||
|
||||
otherDocumentNotionConnectors.length > 0 ||
|
||||
otherDocumentAirtableConnectors.length > 0 ||
|
||||
moreIntegrationsComposio.length > 0 ||
|
||||
moreIntegrationsOAuth.length > 0 ||
|
||||
moreIntegrationsOther.length > 0 ||
|
||||
moreIntegrationsCrawlers.length > 0;
|
||||
const hasKnowledgeBase =
|
||||
knowledgeBase.oauth.length > 0 ||
|
||||
knowledgeBase.composio.length > 0 ||
|
||||
knowledgeBase.other.length > 0 ||
|
||||
knowledgeBase.crawlers.length > 0;
|
||||
const hasToolsLive =
|
||||
toolsLive.oauth.length > 0 ||
|
||||
toolsLive.composio.length > 0 ||
|
||||
toolsLive.other.length > 0 ||
|
||||
toolsLive.crawlers.length > 0;
|
||||
|
||||
const hasAnyResults = hasDocumentFileConnectors || hasMoreIntegrations;
|
||||
const hasAnyResults = hasKnowledgeBase || hasToolsLive;
|
||||
|
||||
if (!hasAnyResults && searchQuery) {
|
||||
return (
|
||||
|
|
@ -302,36 +268,34 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* File Storage Integrations */}
|
||||
{hasDocumentFileConnectors && (
|
||||
{hasKnowledgeBase && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
File Storage Integrations
|
||||
{CONNECTOR_CATEGORY_LABELS.knowledge_base}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{nativeGoogleDriveConnectors.map(renderOAuthCard)}
|
||||
{composioGoogleDriveConnectors.map(renderOAuthCard)}
|
||||
{fileStorageConnectors.map(renderOAuthCard)}
|
||||
{knowledgeBase.oauth.map(renderOAuthCard)}
|
||||
{knowledgeBase.composio.map(renderOAuthCard)}
|
||||
{knowledgeBase.crawlers.map(renderCrawlerCard)}
|
||||
{knowledgeBase.other.map(renderOtherCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* More Integrations */}
|
||||
{hasMoreIntegrations && (
|
||||
{hasToolsLive && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">More Integrations</h3>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
{CONNECTOR_CATEGORY_LABELS.tools_live}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{otherDocumentYouTubeConnectors.map(renderCrawlerCard)}
|
||||
{otherDocumentNotionConnectors.map(renderOAuthCard)}
|
||||
{otherDocumentAirtableConnectors.map(renderOAuthCard)}
|
||||
{moreIntegrationsComposio.map(renderOAuthCard)}
|
||||
{moreIntegrationsOAuth.map(renderOAuthCard)}
|
||||
{moreIntegrationsOther.map(renderOtherCard)}
|
||||
{moreIntegrationsCrawlers.map(renderCrawlerCard)}
|
||||
{toolsLive.oauth.map(renderOAuthCard)}
|
||||
{toolsLive.composio.map(renderOAuthCard)}
|
||||
{toolsLive.crawlers.map(renderCrawlerCard)}
|
||||
{toolsLive.other.map(renderOtherCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
|
||||
import { Download } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
|
||||
interface DownloadOriginalButtonProps {
|
||||
documentId: number;
|
||||
}
|
||||
|
||||
/** Renders only when the document has a stored ORIGINAL file; downloads it on click. */
|
||||
export function DownloadOriginalButton({ documentId }: DownloadOriginalButtonProps) {
|
||||
const [originalFilename, setOriginalFilename] = useState<string | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
documentsApiService
|
||||
.getDocumentFiles(documentId)
|
||||
.then((files) => {
|
||||
if (!active) return;
|
||||
const original = files.find((file) => file.kind === "ORIGINAL");
|
||||
setOriginalFilename(original?.original_filename ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setOriginalFilename(null);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [documentId]);
|
||||
|
||||
if (!originalFilename) return null;
|
||||
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${BACKEND_URL}/api/v1/documents/${documentId}/download-original`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
if (!response.ok) throw new Error("Download failed");
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = originalFilename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Download started");
|
||||
} catch {
|
||||
toast.error("Failed to download original file");
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
title={`Download original (${originalFilename})`}
|
||||
>
|
||||
{downloading ? <Spinner size="xs" /> : <Download className="size-3.5" />}
|
||||
<span className="sr-only">Download original file</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import dynamic from "next/dynamic";
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { DownloadOriginalButton } from "@/components/documents/download-original-button";
|
||||
import { VersionHistoryButton } from "@/components/documents/version-history";
|
||||
import { SourceCodeEditor } from "@/components/editor/source-code-editor";
|
||||
import {
|
||||
|
|
@ -584,6 +585,9 @@ export function EditorPanelContent({
|
|||
documentType={editorDoc.document_type}
|
||||
/>
|
||||
)}
|
||||
{!isLocalFileMode && !isMemoryMode && documentId && (
|
||||
<DownloadOriginalButton documentId={documentId} />
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -668,6 +672,9 @@ export function EditorPanelContent({
|
|||
documentType={editorDoc.document_type}
|
||||
/>
|
||||
)}
|
||||
{!isLocalFileMode && !isMemoryMode && documentId && (
|
||||
<DownloadOriginalButton documentId={documentId} />
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
|
|||
|
|
@ -281,6 +281,23 @@ export const deleteDocumentResponse = z.object({
|
|||
message: z.literal("Document deleted successfully"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Document files (stored originals / derived artifacts)
|
||||
*/
|
||||
export const documentFileKindEnum = z.enum(["ORIGINAL", "REDACTED", "FILLED_FORM"]);
|
||||
|
||||
export const documentFileRead = z.object({
|
||||
id: z.number(),
|
||||
document_id: z.number(),
|
||||
kind: documentFileKindEnum,
|
||||
original_filename: z.string(),
|
||||
mime_type: z.string().nullable().optional(),
|
||||
size_bytes: z.number(),
|
||||
created_at: z.string(),
|
||||
});
|
||||
|
||||
export const getDocumentFilesResponse = z.array(documentFileRead);
|
||||
|
||||
export type Document = z.infer<typeof document>;
|
||||
export type DocumentTitleRead = z.infer<typeof documentTitleRead>;
|
||||
export type GetDocumentsRequest = z.infer<typeof getDocumentsRequest>;
|
||||
|
|
@ -314,3 +331,6 @@ export type GetDocumentChunksRequest = z.infer<typeof getDocumentChunksRequest>;
|
|||
export type GetDocumentChunksResponse = z.infer<typeof getDocumentChunksResponse>;
|
||||
export type ChunkRead = z.infer<typeof chunkRead>;
|
||||
export type ProcessingMode = z.infer<typeof processingModeEnum>;
|
||||
export type DocumentFileKind = z.infer<typeof documentFileKindEnum>;
|
||||
export type DocumentFileRead = z.infer<typeof documentFileRead>;
|
||||
export type GetDocumentFilesResponse = z.infer<typeof getDocumentFilesResponse>;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ import {
|
|||
searchDocumentsResponse,
|
||||
searchDocumentTitlesRequest,
|
||||
searchDocumentTitlesResponse,
|
||||
type DocumentFileRead,
|
||||
getDocumentFilesResponse,
|
||||
type UpdateDocumentRequest,
|
||||
type UploadDocumentRequest,
|
||||
updateDocumentRequest,
|
||||
|
|
@ -381,6 +383,14 @@ class DocumentsApiService {
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* List the stored files for a document (e.g. its original upload).
|
||||
* Used to gate the "Download original" affordance.
|
||||
*/
|
||||
getDocumentFiles = async (documentId: number): Promise<DocumentFileRead[]> => {
|
||||
return baseApiService.get(`/api/v1/documents/${documentId}/files`, getDocumentFilesResponse);
|
||||
};
|
||||
|
||||
listDocumentVersions = async (documentId: number) => {
|
||||
return baseApiService.get(`/api/v1/documents/${documentId}/versions`);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue