mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-25 19:15:18 +02:00
Merge pull request #1414 from AnishSarkar22/feature/memory-support-document-panel
feat: improve memory extraction & add document-panel memory editing
This commit is contained in:
commit
55cce4ea59
56 changed files with 1898 additions and 2185 deletions
|
|
@ -14,6 +14,7 @@ import {
|
|||
ClipboardPaste,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
Dot,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
MessageCircleReply,
|
||||
|
|
@ -330,9 +331,14 @@ const MessageInfoDropdown: FC<{ chatTurnId: string | null | undefined }> = ({ ch
|
|||
{icon}
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{counts.total_tokens.toLocaleString()} tokens
|
||||
{costMicros && costMicros > 0 ? ` · ${formatTurnCost(costMicros)}` : ""}
|
||||
<span className="flex items-center text-xs text-muted-foreground">
|
||||
<span>{counts.total_tokens.toLocaleString()} tokens</span>
|
||||
{costMicros && costMicros > 0 ? (
|
||||
<>
|
||||
<Dot className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span>{formatTurnCost(costMicros)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</ActionBarMorePrimitive.Item>
|
||||
);
|
||||
|
|
@ -342,11 +348,14 @@ const MessageInfoDropdown: FC<{ chatTurnId: string | null | undefined }> = ({ ch
|
|||
className="focus:bg-accent focus:text-accent-foreground relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{usage.total_tokens.toLocaleString()} tokens
|
||||
{usage.cost_micros && usage.cost_micros > 0
|
||||
? ` · ${formatTurnCost(usage.cost_micros)}`
|
||||
: ""}
|
||||
<span className="flex items-center text-xs text-muted-foreground">
|
||||
<span>{usage.total_tokens.toLocaleString()} tokens</span>
|
||||
{usage.cost_micros && usage.cost_micros > 0 ? (
|
||||
<>
|
||||
<Dot className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span>{formatTurnCost(usage.cost_micros)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</ActionBarMorePrimitive.Item>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
MoreHorizontal,
|
||||
Move,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
|
|
@ -61,8 +62,13 @@ interface DocumentNodeProps {
|
|||
onEdit: (doc: DocumentNodeDoc) => void;
|
||||
onDelete: (doc: DocumentNodeDoc) => void;
|
||||
onMove: (doc: DocumentNodeDoc) => void;
|
||||
onReset?: (doc: DocumentNodeDoc) => void;
|
||||
onExport?: (doc: DocumentNodeDoc, format: string) => void;
|
||||
onVersionHistory?: (doc: DocumentNodeDoc) => void;
|
||||
canDelete?: boolean;
|
||||
canMove?: boolean;
|
||||
canMention?: boolean;
|
||||
canEdit?: boolean;
|
||||
contextMenuOpen?: boolean;
|
||||
onContextMenuOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
|
@ -76,8 +82,13 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
onEdit,
|
||||
onDelete,
|
||||
onMove,
|
||||
onReset,
|
||||
onExport,
|
||||
onVersionHistory,
|
||||
canDelete = true,
|
||||
canMove = true,
|
||||
canMention = true,
|
||||
canEdit = true,
|
||||
contextMenuOpen,
|
||||
onContextMenuOpenChange,
|
||||
}: DocumentNodeProps) {
|
||||
|
|
@ -85,8 +96,13 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
const isFailed = statusState === "failed";
|
||||
const isProcessing = statusState === "pending" || statusState === "processing";
|
||||
const isUnavailable = isProcessing || isFailed;
|
||||
const isSelectable = !isUnavailable;
|
||||
const isEditable = EDITABLE_DOCUMENT_TYPES.has(doc.document_type) && !isUnavailable;
|
||||
const isMemoryDocument =
|
||||
doc.document_type === "USER_MEMORY" || doc.document_type === "TEAM_MEMORY";
|
||||
const isSelectable = canMention && !isUnavailable;
|
||||
const isEditable =
|
||||
canEdit &&
|
||||
(isMemoryDocument || EDITABLE_DOCUMENT_TYPES.has(doc.document_type)) &&
|
||||
!isUnavailable;
|
||||
|
||||
const handleCheckChange = useCallback(() => {
|
||||
if (isSelectable) {
|
||||
|
|
@ -94,13 +110,22 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
}
|
||||
}, [doc, isMentioned, isSelectable, onToggleChatMention]);
|
||||
|
||||
const handlePrimaryClick = useCallback(() => {
|
||||
if (canMention) {
|
||||
handleCheckChange();
|
||||
return;
|
||||
}
|
||||
onPreview(doc);
|
||||
}, [canMention, doc, handleCheckChange, onPreview]);
|
||||
|
||||
const [{ isDragging }, drag] = useDrag(
|
||||
() => ({
|
||||
type: DND_TYPES.DOCUMENT,
|
||||
item: { id: doc.id },
|
||||
canDrag: canMove,
|
||||
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
|
||||
}),
|
||||
[doc.id]
|
||||
[canMove, doc.id]
|
||||
);
|
||||
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
|
@ -130,9 +155,11 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
const attachRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
(rowRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
drag(node);
|
||||
if (canMove) {
|
||||
drag(node);
|
||||
}
|
||||
},
|
||||
[drag]
|
||||
[canMove, drag]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -187,12 +214,32 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
);
|
||||
}
|
||||
return (
|
||||
<Checkbox
|
||||
checked={isMentioned}
|
||||
onCheckedChange={handleCheckChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
<>
|
||||
{isMemoryDocument ? (
|
||||
<span aria-disabled="true" className="h-3.5 w-3.5 shrink-0 cursor-default">
|
||||
<Checkbox
|
||||
checked={false}
|
||||
disabled
|
||||
aria-disabled
|
||||
className="h-3.5 w-3.5 pointer-events-none"
|
||||
/>
|
||||
</span>
|
||||
) : canMention ? (
|
||||
<Checkbox
|
||||
checked={isMentioned}
|
||||
onCheckedChange={handleCheckChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
{getDocumentTypeIcon(
|
||||
doc.document_type as DocumentTypeEnum,
|
||||
"h-3.5 w-3.5 text-muted-foreground"
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
|
|
@ -205,8 +252,8 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-disabled={!isSelectable}
|
||||
onClick={handleCheckChange}
|
||||
aria-disabled={canMention ? !isSelectable : false}
|
||||
onClick={handlePrimaryClick}
|
||||
className="h-full min-w-0 flex-1 justify-start bg-transparent px-0 py-0 text-left font-normal text-inherit hover:bg-transparent hover:text-inherit"
|
||||
>
|
||||
<span ref={titleRef} className="min-w-0 flex-1 truncate">
|
||||
|
|
@ -268,11 +315,18 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onMove(doc)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</DropdownMenuItem>
|
||||
{onExport && (
|
||||
{canMove && (
|
||||
<DropdownMenuItem onClick={() => onMove(doc)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onExport && isMemoryDocument ? (
|
||||
<DropdownMenuItem disabled={isUnavailable} onClick={() => handleExport("md")}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export as MD
|
||||
</DropdownMenuItem>
|
||||
) : onExport ? (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={isUnavailable}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
|
|
@ -282,17 +336,25 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
<ExportDropdownItems onExport={handleExport} exporting={exporting} />
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
) : null}
|
||||
{onVersionHistory && isVersionableType(doc.document_type) && (
|
||||
<DropdownMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Versions
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
{isMemoryDocument && onReset && (
|
||||
<DropdownMenuItem onClick={() => onReset(doc)}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reset
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
|
|
@ -311,11 +373,18 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
Edit
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => onMove(doc)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</ContextMenuItem>
|
||||
{onExport && (
|
||||
{canMove && (
|
||||
<ContextMenuItem onClick={() => onMove(doc)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onExport && isMemoryDocument ? (
|
||||
<ContextMenuItem disabled={isUnavailable} onClick={() => handleExport("md")}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export as MD
|
||||
</ContextMenuItem>
|
||||
) : onExport ? (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger disabled={isUnavailable}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
|
|
@ -325,17 +394,25 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
<ExportContextItems onExport={handleExport} exporting={exporting} />
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
)}
|
||||
) : null}
|
||||
{onVersionHistory && isVersionableType(doc.document_type) && (
|
||||
<ContextMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Versions
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
{isMemoryDocument && onReset && (
|
||||
<ContextMenuItem onClick={() => onReset(doc)}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reset
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<ContextMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
)}
|
||||
</ContextMenu>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ interface FolderTreeViewProps {
|
|||
onEditDocument: (doc: DocumentNodeDoc) => void;
|
||||
onDeleteDocument: (doc: DocumentNodeDoc) => void;
|
||||
onMoveDocument: (doc: DocumentNodeDoc) => void;
|
||||
onResetDocument?: (doc: DocumentNodeDoc) => void;
|
||||
onExportDocument?: (doc: DocumentNodeDoc, format: string) => void;
|
||||
onVersionHistory?: (doc: DocumentNodeDoc) => void;
|
||||
activeTypes: DocumentTypeEnum[];
|
||||
|
|
@ -74,6 +75,7 @@ export function FolderTreeView({
|
|||
onEditDocument,
|
||||
onDeleteDocument,
|
||||
onMoveDocument,
|
||||
onResetDocument,
|
||||
onExportDocument,
|
||||
onVersionHistory,
|
||||
activeTypes,
|
||||
|
|
@ -236,6 +238,47 @@ export function FolderTreeView({
|
|||
return states;
|
||||
}, [folders, docsByFolder, foldersByParent, folderMap]);
|
||||
|
||||
const renderDocumentNode = useCallback(
|
||||
(d: DocumentNodeDoc, depth: number) => {
|
||||
const isMemoryDocument =
|
||||
d.document_type === "USER_MEMORY" || d.document_type === "TEAM_MEMORY";
|
||||
return (
|
||||
<DocumentNode
|
||||
key={`doc-${d.id}`}
|
||||
doc={d}
|
||||
depth={depth}
|
||||
isMentioned={!isMemoryDocument && mentionedDocKeys.has(getMentionDocKey(d))}
|
||||
onToggleChatMention={onToggleChatMention}
|
||||
onPreview={onPreviewDocument}
|
||||
onEdit={onEditDocument}
|
||||
onDelete={onDeleteDocument}
|
||||
onMove={onMoveDocument}
|
||||
onReset={onResetDocument}
|
||||
onExport={onExportDocument}
|
||||
onVersionHistory={isMemoryDocument ? undefined : onVersionHistory}
|
||||
canDelete={!isMemoryDocument}
|
||||
canMove={!isMemoryDocument}
|
||||
canMention={!isMemoryDocument}
|
||||
canEdit
|
||||
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
|
||||
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
mentionedDocKeys,
|
||||
onDeleteDocument,
|
||||
onEditDocument,
|
||||
onExportDocument,
|
||||
onMoveDocument,
|
||||
onPreviewDocument,
|
||||
onResetDocument,
|
||||
onToggleChatMention,
|
||||
onVersionHistory,
|
||||
openContextMenuId,
|
||||
]
|
||||
);
|
||||
|
||||
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
|
||||
const key = parentId ?? "root";
|
||||
const childFolders = (foldersByParent[key] ?? []).slice().sort((a, b) => {
|
||||
|
|
@ -263,23 +306,7 @@ export function FolderTreeView({
|
|||
return state === "pending" || state === "processing";
|
||||
});
|
||||
for (const d of processingDocs) {
|
||||
nodes.push(
|
||||
<DocumentNode
|
||||
key={`doc-${d.id}`}
|
||||
doc={d}
|
||||
depth={depth}
|
||||
isMentioned={mentionedDocKeys.has(getMentionDocKey(d))}
|
||||
onToggleChatMention={onToggleChatMention}
|
||||
onPreview={onPreviewDocument}
|
||||
onEdit={onEditDocument}
|
||||
onDelete={onDeleteDocument}
|
||||
onMove={onMoveDocument}
|
||||
onExport={onExportDocument}
|
||||
onVersionHistory={onVersionHistory}
|
||||
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
|
||||
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
|
||||
/>
|
||||
);
|
||||
nodes.push(renderDocumentNode(d, depth));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -343,23 +370,7 @@ export function FolderTreeView({
|
|||
: childDocs;
|
||||
|
||||
for (const d of remainingDocs) {
|
||||
nodes.push(
|
||||
<DocumentNode
|
||||
key={`doc-${d.id}`}
|
||||
doc={d}
|
||||
depth={depth}
|
||||
isMentioned={mentionedDocKeys.has(getMentionDocKey(d))}
|
||||
onToggleChatMention={onToggleChatMention}
|
||||
onPreview={onPreviewDocument}
|
||||
onEdit={onEditDocument}
|
||||
onDelete={onDeleteDocument}
|
||||
onMove={onMoveDocument}
|
||||
onExport={onExportDocument}
|
||||
onVersionHistory={onVersionHistory}
|
||||
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
|
||||
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
|
||||
/>
|
||||
);
|
||||
nodes.push(renderDocumentNode(d, depth));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
|
|
|
|||
|
|
@ -17,10 +17,17 @@ import { toast } from "sonner";
|
|||
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { VersionHistoryButton } from "@/components/documents/version-history";
|
||||
import { SourceCodeEditor } from "@/components/editor/source-code-editor";
|
||||
import {
|
||||
fetchMemoryEditorDocument,
|
||||
getMemoryLimitState,
|
||||
type MemoryLimits,
|
||||
saveMemoryMarkdown,
|
||||
} from "@/components/editor-panel/memory";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
|
|
@ -107,13 +114,15 @@ export function EditorPanelContent({
|
|||
kind = "document",
|
||||
documentId,
|
||||
localFilePath,
|
||||
memoryScope,
|
||||
searchSpaceId,
|
||||
title,
|
||||
onClose,
|
||||
}: {
|
||||
kind?: "document" | "local_file";
|
||||
kind?: "document" | "local_file" | "memory";
|
||||
documentId?: number;
|
||||
localFilePath?: string;
|
||||
memoryScope?: "user" | "team";
|
||||
searchSpaceId?: number;
|
||||
title: string | null;
|
||||
onClose?: () => void;
|
||||
|
|
@ -125,6 +134,7 @@ export function EditorPanelContent({
|
|||
const [saving, setSaving] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [memoryLimits, setMemoryLimits] = useState<MemoryLimits | null>(null);
|
||||
|
||||
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
|
||||
const [localFileContent, setLocalFileContent] = useState("");
|
||||
|
|
@ -135,6 +145,7 @@ export function EditorPanelContent({
|
|||
const changeCountRef = useRef(0);
|
||||
const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
|
||||
const isLocalFileMode = kind === "local_file";
|
||||
const isMemoryMode = kind === "memory";
|
||||
const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown";
|
||||
|
||||
const resolveLocalVirtualPath = useCallback(
|
||||
|
|
@ -165,6 +176,7 @@ export function EditorPanelContent({
|
|||
setLocalFileContent("");
|
||||
setHasCopied(false);
|
||||
setIsEditing(false);
|
||||
setMemoryLimits(null);
|
||||
initialLoadDone.current = false;
|
||||
changeCountRef.current = 0;
|
||||
|
||||
|
|
@ -199,6 +211,24 @@ export function EditorPanelContent({
|
|||
initialLoadDone.current = true;
|
||||
return;
|
||||
}
|
||||
if (isMemoryMode) {
|
||||
if (!memoryScope) throw new Error("Missing memory context");
|
||||
const { document, limits } = await fetchMemoryEditorDocument({
|
||||
scope: memoryScope,
|
||||
searchSpaceId,
|
||||
title,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
setMemoryLimits(limits);
|
||||
const content: EditorContent = document;
|
||||
markdownRef.current = content.source_markdown;
|
||||
setDisplayTitle(content.title);
|
||||
setEditorDoc(content);
|
||||
initialLoadDone.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!documentId || !searchSpaceId) {
|
||||
throw new Error("Missing document context");
|
||||
}
|
||||
|
|
@ -253,7 +283,9 @@ export function EditorPanelContent({
|
|||
documentId,
|
||||
electronAPI,
|
||||
isLocalFileMode,
|
||||
isMemoryMode,
|
||||
localFilePath,
|
||||
memoryScope,
|
||||
resolveLocalVirtualPath,
|
||||
searchSpaceId,
|
||||
title,
|
||||
|
|
@ -267,13 +299,20 @@ export function EditorPanelContent({
|
|||
};
|
||||
}, []);
|
||||
|
||||
const handleMarkdownChange = useCallback((md: string) => {
|
||||
markdownRef.current = md;
|
||||
if (!initialLoadDone.current) return;
|
||||
changeCountRef.current += 1;
|
||||
if (changeCountRef.current <= 1) return;
|
||||
setEditedMarkdown(md);
|
||||
}, []);
|
||||
const handleMarkdownChange = useCallback(
|
||||
(md: string) => {
|
||||
if (!isEditing) return;
|
||||
|
||||
markdownRef.current = md;
|
||||
if (!initialLoadDone.current) return;
|
||||
changeCountRef.current += 1;
|
||||
if (changeCountRef.current <= 1) return;
|
||||
|
||||
const savedContent = editorDoc?.source_markdown ?? "";
|
||||
setEditedMarkdown(md === savedContent ? null : md);
|
||||
},
|
||||
[editorDoc?.source_markdown, isEditing]
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -316,6 +355,23 @@ export function EditorPanelContent({
|
|||
setEditedMarkdown(markdownRef.current === contentToSave ? null : markdownRef.current);
|
||||
return true;
|
||||
}
|
||||
if (isMemoryMode) {
|
||||
if (!memoryScope) throw new Error("Missing memory context");
|
||||
const { markdown: savedContent, limits } = await saveMemoryMarkdown({
|
||||
scope: memoryScope,
|
||||
searchSpaceId,
|
||||
markdown: markdownRef.current,
|
||||
});
|
||||
markdownRef.current = savedContent;
|
||||
setMemoryLimits(limits ?? memoryLimits);
|
||||
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: savedContent } : prev));
|
||||
setEditedMarkdown(null);
|
||||
if (!options?.silent) {
|
||||
toast.success("Memory saved");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!searchSpaceId || !documentId) {
|
||||
throw new Error("Missing document context");
|
||||
}
|
||||
|
|
@ -361,14 +417,18 @@ export function EditorPanelContent({
|
|||
documentId,
|
||||
electronAPI,
|
||||
isLocalFileMode,
|
||||
isMemoryMode,
|
||||
localFilePath,
|
||||
memoryLimits,
|
||||
memoryScope,
|
||||
resolveLocalVirtualPath,
|
||||
searchSpaceId,
|
||||
]
|
||||
);
|
||||
|
||||
const isEditableType = editorDoc
|
||||
? (editorRenderMode === "source_code" ||
|
||||
? (isMemoryMode ||
|
||||
editorRenderMode === "source_code" ||
|
||||
EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) &&
|
||||
!isLargeDocument
|
||||
: false;
|
||||
|
|
@ -381,6 +441,17 @@ export function EditorPanelContent({
|
|||
const showDesktopHeader = !!onClose;
|
||||
const showEditingActions = isEditableType && isEditing;
|
||||
const localFileLanguage = inferMonacoLanguageFromPath(localFilePath);
|
||||
const activeMarkdown = editedMarkdown ?? editorDoc?.source_markdown ?? "";
|
||||
const memoryLimitState = isMemoryMode
|
||||
? getMemoryLimitState(activeMarkdown.length, memoryLimits)
|
||||
: null;
|
||||
const memoryCounterClassName =
|
||||
memoryLimitState?.level === "error"
|
||||
? "text-red-500"
|
||||
: memoryLimitState?.level === "warning"
|
||||
? "text-orange-500"
|
||||
: "text-muted-foreground";
|
||||
const saveDisabled = saving || !hasUnsavedChanges || (memoryLimitState?.isOverLimit ?? false);
|
||||
|
||||
const handleCancelEditing = useCallback(() => {
|
||||
const savedContent = editorDoc?.source_markdown ?? "";
|
||||
|
|
@ -466,6 +537,17 @@ export function EditorPanelContent({
|
|||
<div className="grid h-10 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b px-4">
|
||||
<div className="min-w-0 flex flex-1 items-center gap-2">
|
||||
<p className="truncate text-sm text-muted-foreground">{displayTitle}</p>
|
||||
{memoryLimitState && (
|
||||
<>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-1 bg-border data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px dark:bg-white/10"
|
||||
/>
|
||||
<span className={`shrink-0 text-xs ${memoryCounterClassName}`}>
|
||||
{memoryLimitState.label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{showEditingActions ? (
|
||||
|
|
@ -487,7 +569,7 @@ export function EditorPanelContent({
|
|||
const saveSucceeded = await handleSave({ silent: true });
|
||||
if (saveSucceeded) setIsEditing(false);
|
||||
}}
|
||||
disabled={saving || !hasUnsavedChanges}
|
||||
disabled={saveDisabled}
|
||||
>
|
||||
<span className={saving ? "opacity-0" : ""}>Save</span>
|
||||
{saving && <Spinner size="xs" className="absolute" />}
|
||||
|
|
@ -495,7 +577,7 @@ export function EditorPanelContent({
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
{!isLocalFileMode && editorDoc?.document_type && documentId && (
|
||||
{!isLocalFileMode && !isMemoryMode && editorDoc?.document_type && documentId && (
|
||||
<VersionHistoryButton
|
||||
documentId={documentId}
|
||||
documentType={editorDoc.document_type}
|
||||
|
|
@ -539,6 +621,17 @@ export function EditorPanelContent({
|
|||
<div className="flex h-14 items-center justify-between border-b px-4 shrink-0">
|
||||
<div className="flex flex-1 min-w-0 items-center gap-2">
|
||||
<h2 className="text-sm font-semibold truncate">{displayTitle}</h2>
|
||||
{memoryLimitState && (
|
||||
<>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-1 bg-border data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px dark:bg-white/10"
|
||||
/>
|
||||
<span className={`shrink-0 text-xs ${memoryCounterClassName}`}>
|
||||
{memoryLimitState.label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{showEditingActions ? (
|
||||
|
|
@ -560,7 +653,7 @@ export function EditorPanelContent({
|
|||
const saveSucceeded = await handleSave({ silent: true });
|
||||
if (saveSucceeded) setIsEditing(false);
|
||||
}}
|
||||
disabled={saving || !hasUnsavedChanges}
|
||||
disabled={saveDisabled}
|
||||
>
|
||||
<span className={saving ? "opacity-0" : ""}>Save</span>
|
||||
{saving && <Spinner size="xs" className="absolute" />}
|
||||
|
|
@ -568,7 +661,7 @@ export function EditorPanelContent({
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
{!isLocalFileMode && editorDoc?.document_type && documentId && (
|
||||
{!isLocalFileMode && !isMemoryMode && editorDoc?.document_type && documentId && (
|
||||
<VersionHistoryButton
|
||||
documentId={documentId}
|
||||
documentType={editorDoc.document_type}
|
||||
|
|
@ -664,7 +757,13 @@ export function EditorPanelContent({
|
|||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<PlateEditor
|
||||
key={`${isLocalFileMode ? (localFilePath ?? "local-file") : documentId}-${isEditing ? "editing" : "viewing"}`}
|
||||
key={`${
|
||||
isMemoryMode
|
||||
? `memory-${memoryScope ?? "user"}`
|
||||
: isLocalFileMode
|
||||
? (localFilePath ?? "local-file")
|
||||
: documentId
|
||||
}-${isEditing ? "editing" : "viewing"}`}
|
||||
preset="full"
|
||||
markdown={editorDoc.source_markdown}
|
||||
onMarkdownChange={handleMarkdownChange}
|
||||
|
|
@ -672,14 +771,14 @@ export function EditorPanelContent({
|
|||
placeholder="Start writing..."
|
||||
editorVariant="default"
|
||||
allowModeToggle={false}
|
||||
reserveToolbarSpace={isEditing}
|
||||
reserveToolbarSpace
|
||||
defaultEditing={isEditing}
|
||||
className="**:[[role=toolbar]]:bg-sidebar!"
|
||||
// Render `[citation:N]` badges in view mode only.
|
||||
// Edit mode keeps raw text so the user can edit/delete
|
||||
// tokens directly. `local_file` never reaches this branch
|
||||
// (handled by the source_code editor above).
|
||||
enableCitations={!isEditing && !isLocalFileMode}
|
||||
enableCitations={!isEditing && !isLocalFileMode && !isMemoryMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -708,7 +807,9 @@ function DesktopEditorPanel() {
|
|||
const hasTarget =
|
||||
panelState.kind === "document"
|
||||
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||
: !!panelState.localFilePath;
|
||||
: panelState.kind === "local_file"
|
||||
? !!panelState.localFilePath
|
||||
: !!panelState.memoryScope;
|
||||
if (!panelState.isOpen || !hasTarget) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -717,6 +818,7 @@ function DesktopEditorPanel() {
|
|||
kind={panelState.kind}
|
||||
documentId={panelState.documentId ?? undefined}
|
||||
localFilePath={panelState.localFilePath ?? undefined}
|
||||
memoryScope={panelState.memoryScope ?? undefined}
|
||||
searchSpaceId={panelState.searchSpaceId ?? undefined}
|
||||
title={panelState.title}
|
||||
onClose={closePanel}
|
||||
|
|
@ -734,7 +836,7 @@ function MobileEditorDrawer() {
|
|||
const hasTarget =
|
||||
panelState.kind === "document"
|
||||
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||
: !!panelState.localFilePath;
|
||||
: !!panelState.memoryScope;
|
||||
if (!hasTarget) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -756,6 +858,7 @@ function MobileEditorDrawer() {
|
|||
kind={panelState.kind}
|
||||
documentId={panelState.documentId ?? undefined}
|
||||
localFilePath={panelState.localFilePath ?? undefined}
|
||||
memoryScope={panelState.memoryScope ?? undefined}
|
||||
searchSpaceId={panelState.searchSpaceId ?? undefined}
|
||||
title={panelState.title}
|
||||
/>
|
||||
|
|
@ -771,7 +874,9 @@ export function EditorPanel() {
|
|||
const hasTarget =
|
||||
panelState.kind === "document"
|
||||
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||
: !!panelState.localFilePath;
|
||||
: panelState.kind === "local_file"
|
||||
? !!panelState.localFilePath
|
||||
: !!panelState.memoryScope;
|
||||
|
||||
if (!panelState.isOpen || !hasTarget) return null;
|
||||
if (!isDesktop && panelState.kind === "local_file") return null;
|
||||
|
|
@ -789,7 +894,9 @@ export function MobileEditorPanel() {
|
|||
const hasTarget =
|
||||
panelState.kind === "document"
|
||||
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||
: !!panelState.localFilePath;
|
||||
: panelState.kind === "local_file"
|
||||
? !!panelState.localFilePath
|
||||
: !!panelState.memoryScope;
|
||||
|
||||
if (isDesktop || !panelState.isOpen || !hasTarget || panelState.kind === "local_file")
|
||||
return null;
|
||||
|
|
|
|||
116
surfsense_web/components/editor-panel/memory.ts
Normal file
116
surfsense_web/components/editor-panel/memory.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"use client";
|
||||
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export type MemoryScope = "user" | "team";
|
||||
|
||||
export interface MemoryLimits {
|
||||
soft: number;
|
||||
hard: number;
|
||||
}
|
||||
|
||||
export type MemoryLimitLevel = "ok" | "warning" | "error";
|
||||
|
||||
export interface MemoryEditorDocument {
|
||||
document_id: number;
|
||||
title: string;
|
||||
document_type: "USER_MEMORY" | "TEAM_MEMORY";
|
||||
source_markdown: string;
|
||||
}
|
||||
|
||||
interface MemoryReadResponse {
|
||||
memory_md?: string;
|
||||
limits?: MemoryLimits;
|
||||
}
|
||||
|
||||
function getMemoryPath(scope: MemoryScope, searchSpaceId?: number | null) {
|
||||
if (scope === "user") return "/api/v1/users/me/memory";
|
||||
if (!searchSpaceId) throw new Error("Missing search space context");
|
||||
return `/api/v1/searchspaces/${searchSpaceId}/memory`;
|
||||
}
|
||||
|
||||
function getBackendUrl(path: string) {
|
||||
return `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${path}`;
|
||||
}
|
||||
|
||||
export function getMemoryLimitState(length: number, limits?: MemoryLimits | null) {
|
||||
if (!limits) {
|
||||
return {
|
||||
level: "ok" as MemoryLimitLevel,
|
||||
label: `${length.toLocaleString()} chars`,
|
||||
isOverLimit: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isOverLimit = length > limits.hard;
|
||||
const isNearLimit = length > limits.soft;
|
||||
const level: MemoryLimitLevel = isOverLimit ? "error" : isNearLimit ? "warning" : "ok";
|
||||
const suffix = isOverLimit ? " - Exceeds limit" : isNearLimit ? " - Approaching limit" : "";
|
||||
|
||||
return {
|
||||
level,
|
||||
label: `${length.toLocaleString()}/${limits.hard.toLocaleString()} chars${suffix}`,
|
||||
isOverLimit,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchMemoryEditorDocument({
|
||||
scope,
|
||||
searchSpaceId,
|
||||
title,
|
||||
signal,
|
||||
}: {
|
||||
scope: MemoryScope;
|
||||
searchSpaceId?: number | null;
|
||||
title?: string | null;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const response = await authenticatedFetch(getBackendUrl(getMemoryPath(scope, searchSpaceId)), {
|
||||
method: "GET",
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: "Failed to fetch memory" }));
|
||||
throw new Error(errorData.detail || "Failed to fetch memory");
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MemoryReadResponse;
|
||||
const isTeamMemory = scope === "team";
|
||||
|
||||
return {
|
||||
limits: data.limits ?? null,
|
||||
document: {
|
||||
document_id: isTeamMemory ? -1002 : -1001,
|
||||
title: title || (isTeamMemory ? "Team Memory" : "Personal Memory"),
|
||||
document_type: isTeamMemory ? "TEAM_MEMORY" : "USER_MEMORY",
|
||||
source_markdown: data.memory_md ?? "",
|
||||
} satisfies MemoryEditorDocument,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveMemoryMarkdown({
|
||||
scope,
|
||||
searchSpaceId,
|
||||
markdown,
|
||||
}: {
|
||||
scope: MemoryScope;
|
||||
searchSpaceId?: number | null;
|
||||
markdown: string;
|
||||
}) {
|
||||
const response = await authenticatedFetch(getBackendUrl(getMemoryPath(scope, searchSpaceId)), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ memory_md: markdown }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: "Failed to save memory" }));
|
||||
throw new Error(errorData.detail || "Failed to save memory");
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MemoryReadResponse;
|
||||
|
||||
return {
|
||||
markdown: data.memory_md ?? markdown,
|
||||
limits: data.limits,
|
||||
};
|
||||
}
|
||||
|
|
@ -103,7 +103,11 @@ export function RightPanelToggleButton({
|
|||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen =
|
||||
editorState.isOpen &&
|
||||
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath);
|
||||
(editorState.kind === "document"
|
||||
? !!editorState.documentId
|
||||
: editorState.kind === "memory"
|
||||
? !!editorState.memoryScope
|
||||
: !!editorState.localFilePath);
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const citationOpen = citationState.isOpen && citationState.chunkId != null;
|
||||
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen;
|
||||
|
|
@ -151,7 +155,11 @@ export function RightPanelExpandButton() {
|
|||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen =
|
||||
editorState.isOpen &&
|
||||
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath);
|
||||
(editorState.kind === "document"
|
||||
? !!editorState.documentId
|
||||
: editorState.kind === "memory"
|
||||
? !!editorState.memoryScope
|
||||
: !!editorState.localFilePath);
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const citationOpen = citationState.isOpen && citationState.chunkId != null;
|
||||
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen;
|
||||
|
|
@ -193,7 +201,11 @@ export function RightPanel({
|
|||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen =
|
||||
editorState.isOpen &&
|
||||
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath);
|
||||
(editorState.kind === "document"
|
||||
? !!editorState.documentId
|
||||
: editorState.kind === "memory"
|
||||
? !!editorState.memoryScope
|
||||
: !!editorState.localFilePath);
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const citationOpen = citationState.isOpen && citationState.chunkId != null;
|
||||
|
||||
|
|
@ -292,6 +304,7 @@ export function RightPanel({
|
|||
kind={editorState.kind}
|
||||
documentId={editorState.documentId ?? undefined}
|
||||
localFilePath={editorState.localFilePath ?? undefined}
|
||||
memoryScope={editorState.memoryScope ?? undefined}
|
||||
searchSpaceId={editorState.searchSpaceId ?? undefined}
|
||||
title={editorState.title}
|
||||
onClose={closeEditor}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,43 @@ const DesktopLocalTabContent = dynamic(
|
|||
{ ssr: false }
|
||||
);
|
||||
|
||||
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["SURFSENSE_DOCS"];
|
||||
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = [
|
||||
"SURFSENSE_DOCS",
|
||||
"USER_MEMORY",
|
||||
"TEAM_MEMORY",
|
||||
];
|
||||
const MEMORY_DOCUMENTS: DocumentNodeDoc[] = [
|
||||
{
|
||||
id: -1001,
|
||||
title: "MEMORY.md",
|
||||
document_type: "USER_MEMORY",
|
||||
folderId: null,
|
||||
status: { state: "ready" },
|
||||
},
|
||||
{
|
||||
id: -1002,
|
||||
title: "TEAM_MEMORY.md",
|
||||
document_type: "TEAM_MEMORY",
|
||||
folderId: null,
|
||||
status: { state: "ready" },
|
||||
},
|
||||
];
|
||||
|
||||
function isMemoryDocument(doc: { document_type: string }) {
|
||||
return doc.document_type === "USER_MEMORY" || doc.document_type === "TEAM_MEMORY";
|
||||
}
|
||||
|
||||
function downloadTextFile(content: string, fileName: string, type = "text/markdown;charset=utf-8") {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1";
|
||||
const MAX_LOCAL_FILESYSTEM_ROOTS = 10;
|
||||
|
||||
|
|
@ -784,6 +820,30 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
|
||||
const handleExportDocument = useCallback(
|
||||
async (doc: DocumentNodeDoc, format: string) => {
|
||||
if (isMemoryDocument(doc)) {
|
||||
try {
|
||||
const endpoint =
|
||||
doc.document_type === "USER_MEMORY"
|
||||
? `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/users/me/memory`
|
||||
: `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/memory`;
|
||||
const response = await authenticatedFetch(endpoint, { method: "GET" });
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: "Export failed" }));
|
||||
throw new Error(errorData.detail || "Export failed");
|
||||
}
|
||||
const data = (await response.json()) as { memory_md?: string };
|
||||
downloadTextFile(
|
||||
data.memory_md ?? "",
|
||||
doc.title.endsWith(".md") ? doc.title : `${doc.title}.md`
|
||||
);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error("Memory export failed:", err);
|
||||
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const safeTitle =
|
||||
doc.title
|
||||
.replace(/[^a-zA-Z0-9 _-]/g, "_")
|
||||
|
|
@ -879,6 +939,7 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
|
||||
const handleToggleChatMention = useCallback(
|
||||
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
|
||||
if (isMemoryDocument(doc)) return;
|
||||
const key = getMentionDocKey({ ...doc, kind: "doc" });
|
||||
if (isMentioned) {
|
||||
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key));
|
||||
|
|
@ -927,11 +988,66 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
[treeFolders, setSidebarDocs]
|
||||
);
|
||||
|
||||
const treeDocumentsWithMemory = useMemo(
|
||||
() => [...MEMORY_DOCUMENTS, ...treeDocuments],
|
||||
[treeDocuments]
|
||||
);
|
||||
|
||||
const searchFilteredDocuments = useMemo(() => {
|
||||
const query = debouncedSearch.trim().toLowerCase();
|
||||
if (!query) return treeDocuments;
|
||||
return treeDocuments.filter((d) => d.title.toLowerCase().includes(query));
|
||||
}, [treeDocuments, debouncedSearch]);
|
||||
if (!query) return treeDocumentsWithMemory;
|
||||
return treeDocumentsWithMemory.filter((d) => d.title.toLowerCase().includes(query));
|
||||
}, [treeDocumentsWithMemory, debouncedSearch]);
|
||||
|
||||
const openMemoryDocument = useCallback(
|
||||
(doc: DocumentNodeDoc) => {
|
||||
if (doc.document_type === "USER_MEMORY") {
|
||||
openEditorPanel({
|
||||
kind: "memory",
|
||||
memoryScope: "user",
|
||||
searchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (doc.document_type === "TEAM_MEMORY") {
|
||||
openEditorPanel({
|
||||
kind: "memory",
|
||||
memoryScope: "team",
|
||||
searchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[openEditorPanel, searchSpaceId]
|
||||
);
|
||||
|
||||
const handleResetMemoryDocument = useCallback(
|
||||
async (doc: DocumentNodeDoc) => {
|
||||
if (!isMemoryDocument(doc)) return;
|
||||
if (!window.confirm(`Reset ${doc.title.toLowerCase()}? This clears the memory document.`)) {
|
||||
return;
|
||||
}
|
||||
const endpoint =
|
||||
doc.document_type === "USER_MEMORY"
|
||||
? `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/users/me/memory/reset`
|
||||
: `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/memory/reset`;
|
||||
try {
|
||||
const response = await authenticatedFetch(endpoint, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: "Reset failed" }));
|
||||
throw new Error(errorData.detail || "Reset failed");
|
||||
}
|
||||
toast.success(`${doc.title} reset`);
|
||||
openMemoryDocument(doc);
|
||||
} catch (error) {
|
||||
toast.error((error as Error)?.message || `Failed to reset ${doc.title.toLowerCase()}`);
|
||||
}
|
||||
},
|
||||
[openMemoryDocument, searchSpaceId]
|
||||
);
|
||||
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts: Partial<Record<string, number>> = {};
|
||||
|
|
@ -1169,6 +1285,7 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
onCreateFolder={handleCreateFolder}
|
||||
searchQuery={debouncedSearch.trim() || undefined}
|
||||
onPreviewDocument={(doc) => {
|
||||
if (openMemoryDocument(doc)) return;
|
||||
openEditorPanel({
|
||||
documentId: doc.id,
|
||||
searchSpaceId,
|
||||
|
|
@ -1176,6 +1293,7 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
});
|
||||
}}
|
||||
onEditDocument={(doc) => {
|
||||
if (openMemoryDocument(doc)) return;
|
||||
openEditorPanel({
|
||||
documentId: doc.id,
|
||||
searchSpaceId,
|
||||
|
|
@ -1184,6 +1302,7 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
}}
|
||||
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
|
||||
onMoveDocument={handleMoveDocument}
|
||||
onResetDocument={handleResetMemoryDocument}
|
||||
onExportDocument={handleExportDocument}
|
||||
onVersionHistory={(doc) => setVersionDocId(doc.id)}
|
||||
activeTypes={activeTypes}
|
||||
|
|
|
|||
|
|
@ -1,299 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowUp, ChevronDown, ClipboardCopy, Download, Info, Pencil } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
const MEMORY_HARD_LIMIT = 25_000;
|
||||
|
||||
const SearchSpaceSchema = z
|
||||
.object({
|
||||
shared_memory_md: z.string().optional().default(""),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
interface TeamMemoryManagerProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
export function TeamMemoryManager({ searchSpaceId }: TeamMemoryManagerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: searchSpace, isLoading: loading } = useQuery({
|
||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
|
||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
|
||||
enabled: !!searchSpaceId,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateSearchSpace } = useAtomValue(updateSearchSpaceMutationAtom);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editQuery, setEditQuery] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const textareaRef = useRef<HTMLInputElement>(null);
|
||||
const inputContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const memory = searchSpace?.shared_memory_md || "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!showInput) return;
|
||||
|
||||
const handlePointerDownOutside = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (inputContainerRef.current?.contains(target)) return;
|
||||
|
||||
setShowInput(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDownOutside);
|
||||
document.addEventListener("touchstart", handlePointerDownOutside, { passive: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDownOutside);
|
||||
document.removeEventListener("touchstart", handlePointerDownOutside);
|
||||
};
|
||||
}, [showInput]);
|
||||
|
||||
const handleClear = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await updateSearchSpace({
|
||||
id: searchSpaceId,
|
||||
data: { shared_memory_md: "" },
|
||||
});
|
||||
toast.success("Team memory cleared");
|
||||
} catch {
|
||||
toast.error("Failed to clear team memory");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
const query = editQuery.trim();
|
||||
if (!query) return;
|
||||
|
||||
try {
|
||||
setEditing(true);
|
||||
await baseApiService.post(
|
||||
`/api/v1/searchspaces/${searchSpaceId}/memory/edit`,
|
||||
SearchSpaceSchema,
|
||||
{ body: { query } }
|
||||
);
|
||||
setEditQuery("");
|
||||
setShowInput(false);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
|
||||
});
|
||||
toast.success("Team memory updated");
|
||||
} catch {
|
||||
toast.error("Failed to edit team memory");
|
||||
} finally {
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openInput = () => {
|
||||
setShowInput(true);
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!memory) return;
|
||||
try {
|
||||
const blob = new Blob([memory], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "team-memory.md";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast.error("Failed to download team memory");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyMarkdown = async () => {
|
||||
if (!memory) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(memory);
|
||||
toast.success("Copied to clipboard");
|
||||
} catch {
|
||||
toast.error("Failed to copy team memory");
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const displayMemory = memory.replace(/\(\d{4}-\d{2}-\d{2}\)\s*\[(fact|pref|instr)\]\s*/g, "");
|
||||
const charCount = memory.length;
|
||||
|
||||
const getCounterColor = () => {
|
||||
if (charCount > MEMORY_HARD_LIMIT) return "text-red-500";
|
||||
if (charCount > 15_000) return "text-orange-500";
|
||||
if (charCount > 10_000) return "text-yellow-500";
|
||||
return "text-muted-foreground";
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!memory) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<h3 className="text-base font-medium text-foreground">
|
||||
What does SurfSense remember about your team?
|
||||
</h3>
|
||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||
Nothing yet. SurfSense picks up on team decisions and conventions as your team chats.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
<p>
|
||||
SurfSense uses this shared memory to provide team-wide context across all conversations
|
||||
in this search space.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="relative h-[380px] rounded-lg border bg-background">
|
||||
<div className="h-full overflow-y-auto scrollbar-thin">
|
||||
<PlateEditor
|
||||
markdown={displayMemory}
|
||||
readOnly
|
||||
preset="readonly"
|
||||
variant="default"
|
||||
editorVariant="none"
|
||||
className="px-5 py-4 text-sm min-h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showInput ? (
|
||||
<div className="absolute bottom-3 inset-x-3 z-10">
|
||||
<div
|
||||
ref={inputContainerRef}
|
||||
className="relative flex h-[54px] items-center gap-2 rounded-[9999px] border bg-muted/60 backdrop-blur-sm pl-4 pr-1 shadow-sm"
|
||||
>
|
||||
<input
|
||||
ref={textareaRef}
|
||||
type="text"
|
||||
value={editQuery}
|
||||
onChange={(e) => setEditQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Tell SurfSense what to remember or forget about your team"
|
||||
disabled={editing}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/70"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleEdit}
|
||||
disabled={editing || !editQuery.trim()}
|
||||
className={`h-11 w-11 shrink-0 rounded-full ${
|
||||
editing
|
||||
? ""
|
||||
: "bg-muted-foreground/15 hover:bg-accent hover:text-accent-foreground"
|
||||
}`}
|
||||
>
|
||||
{editing ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<ArrowUp className="!h-5 !w-5 text-foreground" strokeWidth={2.25} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
onClick={openInput}
|
||||
className="absolute bottom-3 right-3 z-10 h-[54px] w-[54px] rounded-full border bg-muted/60 backdrop-blur-sm shadow-sm"
|
||||
>
|
||||
<Pencil className="!h-5 !w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={`text-xs shrink-0 ${getCounterColor()}`}>
|
||||
{charCount.toLocaleString()} / {MEMORY_HARD_LIMIT.toLocaleString()}
|
||||
<span className="hidden sm:inline"> characters</span>
|
||||
<span className="sm:hidden"> chars</span>
|
||||
{charCount > 15_000 && charCount <= MEMORY_HARD_LIMIT && " - Approaching limit"}
|
||||
{charCount > MEMORY_HARD_LIMIT && " - Exceeds limit"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm"
|
||||
onClick={handleClear}
|
||||
disabled={saving || editing || !memory}
|
||||
>
|
||||
<span className="hidden sm:inline">Reset Memory</span>
|
||||
<span className="sm:hidden">Reset</span>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="secondary" size="sm" disabled={!memory}>
|
||||
Export
|
||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleCopyMarkdown}>
|
||||
<ClipboardCopy className="h-4 w-4 mr-2" />
|
||||
Copy as Markdown
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleDownload}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download as Markdown
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ export function FixedToolbar({
|
|||
return (
|
||||
<Toolbar
|
||||
className={cn(
|
||||
"scrollbar-hide absolute top-0 left-0 z-40 w-full justify-between overflow-x-auto border-b bg-background p-1",
|
||||
"scrollbar-hide sticky top-0 z-40 w-full shrink-0 justify-between overflow-x-auto border-b bg-background p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue