mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
feat(filesystem): enhance local file handling in editor and IPC integration
This commit is contained in:
parent
4899588cd7
commit
864f6f798a
12 changed files with 350 additions and 47 deletions
|
|
@ -239,6 +239,9 @@ LLAMA_CLOUD_API_KEY=llx-nnn
|
|||
# DAYTONA_TARGET=us
|
||||
# DAYTONA_SNAPSHOT_ID=
|
||||
|
||||
# Desktop local filesystem mode (chat file tools run against a local folder root)
|
||||
# ENABLE_DESKTOP_LOCAL_FILESYSTEM=FALSE
|
||||
|
||||
# OPTIONAL: Add these for LangSmith Observability
|
||||
LANGSMITH_TRACING=true
|
||||
LANGSMITH_ENDPOINT=https://api.smith.langchain.com
|
||||
|
|
|
|||
|
|
@ -525,11 +525,6 @@ async def get_thread_messages(
|
|||
|
||||
# Check thread-level access based on visibility
|
||||
await check_thread_access(session, thread, user)
|
||||
filesystem_selection = _resolve_filesystem_selection(
|
||||
mode=request.filesystem_mode,
|
||||
client_platform=request.client_platform,
|
||||
local_root=request.local_filesystem_root,
|
||||
)
|
||||
|
||||
# Get messages with their authors and token usage loaded
|
||||
messages_result = await session.execute(
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ export const IPC_CHANNELS = {
|
|||
FOLDER_SYNC_SEED_MTIMES: 'folder-sync:seed-mtimes',
|
||||
BROWSE_FILES: 'browse:files',
|
||||
READ_LOCAL_FILES: 'browse:read-local-files',
|
||||
READ_AGENT_LOCAL_FILE_TEXT: 'agent-filesystem:read-local-file-text',
|
||||
WRITE_AGENT_LOCAL_FILE_TEXT: 'agent-filesystem:write-local-file-text',
|
||||
// Auth token sync across windows
|
||||
GET_AUTH_TOKENS: 'auth:get-tokens',
|
||||
SET_AUTH_TOKENS: 'auth:set-tokens',
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ import {
|
|||
trackEvent,
|
||||
} from '../modules/analytics';
|
||||
import {
|
||||
readAgentLocalFileText,
|
||||
writeAgentLocalFileText,
|
||||
getAgentFilesystemSettings,
|
||||
pickAgentFilesystemRoot,
|
||||
setAgentFilesystemSettings,
|
||||
|
|
@ -123,6 +125,29 @@ export function registerIpcHandlers(): void {
|
|||
readLocalFiles(paths)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, async (_event, virtualPath: string) => {
|
||||
try {
|
||||
const result = await readAgentLocalFileText(virtualPath);
|
||||
return { ok: true, path: result.path, content: result.content };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to read local file';
|
||||
return { ok: false, path: virtualPath, error: message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT,
|
||||
async (_event, virtualPath: string, content: string) => {
|
||||
try {
|
||||
const result = await writeAgentLocalFileText(virtualPath, content);
|
||||
return { ok: true, path: result.path };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to write local file';
|
||||
return { ok: false, path: virtualPath, error: message };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SET_AUTH_TOKENS, (_event, tokens: { bearer: string; refresh: string }) => {
|
||||
authTokens = tokens;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { app, dialog } from "electron";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
|
||||
export type AgentFilesystemMode = "cloud" | "desktop_local_folder";
|
||||
|
||||
|
|
@ -72,3 +72,62 @@ export async function pickAgentFilesystemRoot(): Promise<string | null> {
|
|||
}
|
||||
return result.filePaths[0] ?? null;
|
||||
}
|
||||
|
||||
function resolveVirtualPath(rootPath: string, virtualPath: string): string {
|
||||
if (!virtualPath.startsWith("/")) {
|
||||
throw new Error("Path must start with '/'");
|
||||
}
|
||||
const normalizedRoot = resolve(rootPath);
|
||||
const relativePath = virtualPath.replace(/^\/+/, "");
|
||||
if (!relativePath) {
|
||||
throw new Error("Path must refer to a file under the selected root");
|
||||
}
|
||||
const absolutePath = resolve(normalizedRoot, relativePath);
|
||||
const rel = relative(normalizedRoot, absolutePath);
|
||||
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
||||
throw new Error("Path escapes selected local root");
|
||||
}
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
function toVirtualPath(rootPath: string, absolutePath: string): string {
|
||||
const normalizedRoot = resolve(rootPath);
|
||||
const rel = relative(normalizedRoot, absolutePath);
|
||||
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
||||
return "/";
|
||||
}
|
||||
return `/${rel.replace(/\\/g, "/")}`;
|
||||
}
|
||||
|
||||
async function resolveCurrentRootPath(): Promise<string> {
|
||||
const settings = await getAgentFilesystemSettings();
|
||||
if (!settings.localRootPath) {
|
||||
throw new Error("No local filesystem root selected");
|
||||
}
|
||||
return settings.localRootPath;
|
||||
}
|
||||
|
||||
export async function readAgentLocalFileText(
|
||||
virtualPath: string
|
||||
): Promise<{ path: string; content: string }> {
|
||||
const rootPath = await resolveCurrentRootPath();
|
||||
const absolutePath = resolveVirtualPath(rootPath, virtualPath);
|
||||
const content = await readFile(absolutePath, "utf8");
|
||||
return {
|
||||
path: toVirtualPath(rootPath, absolutePath),
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeAgentLocalFileText(
|
||||
virtualPath: string,
|
||||
content: string
|
||||
): Promise<{ path: string }> {
|
||||
const rootPath = await resolveCurrentRootPath();
|
||||
const absolutePath = resolveVirtualPath(rootPath, virtualPath);
|
||||
await mkdir(dirname(absolutePath), { recursive: true });
|
||||
await writeFile(absolutePath, content, "utf8");
|
||||
return {
|
||||
path: toVirtualPath(rootPath, absolutePath),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
// Browse files via native dialog
|
||||
browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES),
|
||||
readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths),
|
||||
readAgentLocalFileText: (virtualPath: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath),
|
||||
writeAgentLocalFileText: (virtualPath: string, content: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content),
|
||||
|
||||
// Auth token sync across windows
|
||||
getAuthTokens: () => ipcRenderer.invoke(IPC_CHANNELS.GET_AUTH_TOKENS),
|
||||
|
|
|
|||
|
|
@ -3,14 +3,18 @@ import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right
|
|||
|
||||
interface EditorPanelState {
|
||||
isOpen: boolean;
|
||||
kind: "document" | "local_file";
|
||||
documentId: number | null;
|
||||
localFilePath: string | null;
|
||||
searchSpaceId: number | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
const initialState: EditorPanelState = {
|
||||
isOpen: false,
|
||||
kind: "document",
|
||||
documentId: null,
|
||||
localFilePath: null,
|
||||
searchSpaceId: null,
|
||||
title: null,
|
||||
};
|
||||
|
|
@ -26,20 +30,38 @@ export const openEditorPanelAtom = atom(
|
|||
(
|
||||
get,
|
||||
set,
|
||||
{
|
||||
documentId,
|
||||
searchSpaceId,
|
||||
title,
|
||||
}: { documentId: number; searchSpaceId: number; title?: string }
|
||||
payload:
|
||||
| { documentId: number; searchSpaceId: number; title?: string; kind?: "document" }
|
||||
| {
|
||||
kind: "local_file";
|
||||
localFilePath: string;
|
||||
title?: string;
|
||||
searchSpaceId?: number;
|
||||
}
|
||||
) => {
|
||||
if (!get(editorPanelAtom).isOpen) {
|
||||
set(preEditorCollapsedAtom, get(rightPanelCollapsedAtom));
|
||||
}
|
||||
if (payload.kind === "local_file") {
|
||||
set(editorPanelAtom, {
|
||||
isOpen: true,
|
||||
kind: "local_file",
|
||||
documentId: null,
|
||||
localFilePath: payload.localFilePath,
|
||||
searchSpaceId: payload.searchSpaceId ?? null,
|
||||
title: payload.title ?? null,
|
||||
});
|
||||
set(rightPanelTabAtom, "editor");
|
||||
set(rightPanelCollapsedAtom, false);
|
||||
return;
|
||||
}
|
||||
set(editorPanelAtom, {
|
||||
isOpen: true,
|
||||
documentId,
|
||||
searchSpaceId,
|
||||
title: title ?? null,
|
||||
kind: "document",
|
||||
documentId: payload.documentId,
|
||||
localFilePath: null,
|
||||
searchSpaceId: payload.searchSpaceId,
|
||||
title: payload.title ?? null,
|
||||
});
|
||||
set(rightPanelTabAtom, "editor");
|
||||
set(rightPanelCollapsedAtom, false);
|
||||
|
|
|
|||
|
|
@ -7,16 +7,20 @@ import {
|
|||
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
|
||||
useIsMarkdownCodeBlock,
|
||||
} from "@assistant-ui/react-markdown";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { memo, type ReactNode } from "react";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
|
|
@ -222,6 +226,12 @@ function extractDomain(url: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
const LOCAL_FILE_PATH_REGEX = /^\/(?:[^/\s`]+\/)*[^/\s`]+\.[^/\s`]+$/;
|
||||
|
||||
function isVirtualFilePathToken(value: string): boolean {
|
||||
return LOCAL_FILE_PATH_REGEX.test(value);
|
||||
}
|
||||
|
||||
function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {
|
||||
if (!src) return null;
|
||||
|
||||
|
|
@ -392,7 +402,43 @@ const defaultComponents = memoizeMarkdownComponents({
|
|||
code: function Code({ className, children, ...props }) {
|
||||
const isCodeBlock = useIsMarkdownCodeBlock();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||
const params = useParams();
|
||||
const electronAPI = useElectronAPI();
|
||||
if (!isCodeBlock) {
|
||||
const inlineValue = String(children ?? "").trim();
|
||||
const isLocalPath =
|
||||
!!electronAPI && isVirtualFilePathToken(inlineValue) && !inlineValue.startsWith("//");
|
||||
const displayLocalPath = inlineValue.replace(/^\/+/, "");
|
||||
const searchSpaceIdParam = params?.search_space_id;
|
||||
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
|
||||
? Number(searchSpaceIdParam[0])
|
||||
: Number(searchSpaceIdParam);
|
||||
if (isLocalPath) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer font-mono text-[0.9em] font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openEditorPanel({
|
||||
kind: "local_file",
|
||||
localFilePath: inlineValue,
|
||||
title: inlineValue.split("/").pop() || inlineValue,
|
||||
searchSpaceId: Number.isFinite(parsedSearchSpaceId)
|
||||
? parsedSearchSpaceId
|
||||
: undefined,
|
||||
});
|
||||
}}
|
||||
title="Open in editor panel"
|
||||
>
|
||||
{displayLocalPath}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
const PlateEditor = dynamic(
|
||||
|
|
@ -54,16 +55,21 @@ function EditorPanelSkeleton() {
|
|||
}
|
||||
|
||||
export function EditorPanelContent({
|
||||
kind = "document",
|
||||
documentId,
|
||||
localFilePath,
|
||||
searchSpaceId,
|
||||
title,
|
||||
onClose,
|
||||
}: {
|
||||
documentId: number;
|
||||
searchSpaceId: number;
|
||||
kind?: "document" | "local_file";
|
||||
documentId?: number;
|
||||
localFilePath?: string;
|
||||
searchSpaceId?: number;
|
||||
title: string | null;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const electronAPI = useElectronAPI();
|
||||
const [editorDoc, setEditorDoc] = useState<EditorContent | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -75,6 +81,7 @@ export function EditorPanelContent({
|
|||
const initialLoadDone = useRef(false);
|
||||
const changeCountRef = useRef(0);
|
||||
const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
|
||||
const isLocalFileMode = kind === "local_file";
|
||||
|
||||
const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
|
||||
|
||||
|
|
@ -88,13 +95,40 @@ export function EditorPanelContent({
|
|||
changeCountRef.current = 0;
|
||||
|
||||
const doFetch = async () => {
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isLocalFileMode) {
|
||||
if (!localFilePath) {
|
||||
throw new Error("Missing local file path");
|
||||
}
|
||||
if (!electronAPI?.readAgentLocalFileText) {
|
||||
throw new Error("Local file editor is available only in desktop mode.");
|
||||
}
|
||||
const readResult = await electronAPI.readAgentLocalFileText(localFilePath);
|
||||
if (!readResult.ok) {
|
||||
throw new Error(readResult.error || "Failed to read local file");
|
||||
}
|
||||
const inferredTitle = localFilePath.split("/").pop() || localFilePath;
|
||||
const content: EditorContent = {
|
||||
document_id: -1,
|
||||
title: inferredTitle,
|
||||
document_type: "NOTE",
|
||||
source_markdown: readResult.content,
|
||||
};
|
||||
markdownRef.current = content.source_markdown;
|
||||
setDisplayTitle(title || inferredTitle);
|
||||
setEditorDoc(content);
|
||||
initialLoadDone.current = true;
|
||||
return;
|
||||
}
|
||||
if (!documentId || !searchSpaceId) {
|
||||
throw new Error("Missing document context");
|
||||
}
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
|
||||
);
|
||||
|
|
@ -136,7 +170,7 @@ export function EditorPanelContent({
|
|||
|
||||
doFetch().catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [documentId, searchSpaceId, title]);
|
||||
}, [documentId, electronAPI, isLocalFileMode, localFilePath, searchSpaceId, title]);
|
||||
|
||||
const handleMarkdownChange = useCallback((md: string) => {
|
||||
markdownRef.current = md;
|
||||
|
|
@ -147,15 +181,38 @@ export function EditorPanelContent({
|
|||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
toast.error("Please login to save");
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isLocalFileMode) {
|
||||
if (!localFilePath) {
|
||||
throw new Error("Missing local file path");
|
||||
}
|
||||
if (!electronAPI?.writeAgentLocalFileText) {
|
||||
throw new Error("Local file editor is available only in desktop mode.");
|
||||
}
|
||||
const writeResult = await electronAPI.writeAgentLocalFileText(
|
||||
localFilePath,
|
||||
markdownRef.current
|
||||
);
|
||||
if (!writeResult.ok) {
|
||||
throw new Error(writeResult.error || "Failed to save local file");
|
||||
}
|
||||
setEditorDoc((prev) =>
|
||||
prev ? { ...prev, source_markdown: markdownRef.current } : prev
|
||||
);
|
||||
setEditedMarkdown(null);
|
||||
toast.success("File saved");
|
||||
return;
|
||||
}
|
||||
if (!searchSpaceId || !documentId) {
|
||||
throw new Error("Missing document context");
|
||||
}
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
toast.error("Please login to save");
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
|
||||
{
|
||||
|
|
@ -181,10 +238,11 @@ export function EditorPanelContent({
|
|||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [documentId, searchSpaceId]);
|
||||
}, [documentId, electronAPI, isLocalFileMode, localFilePath, searchSpaceId]);
|
||||
|
||||
const isEditableType = editorDoc
|
||||
? EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "") && !isLargeDocument
|
||||
? (isLocalFileMode || EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) &&
|
||||
!isLargeDocument
|
||||
: false;
|
||||
|
||||
return (
|
||||
|
|
@ -197,7 +255,7 @@ export function EditorPanelContent({
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{editorDoc?.document_type && (
|
||||
{!isLocalFileMode && editorDoc?.document_type && documentId && (
|
||||
<VersionHistoryButton documentId={documentId} documentType={editorDoc.document_type} />
|
||||
)}
|
||||
{onClose && (
|
||||
|
|
@ -234,7 +292,7 @@ export function EditorPanelContent({
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isLargeDocument ? (
|
||||
) : isLargeDocument && !isLocalFileMode ? (
|
||||
<div className="h-full overflow-y-auto px-5 py-4">
|
||||
<Alert className="mb-4">
|
||||
<FileText className="size-4" />
|
||||
|
|
@ -252,6 +310,9 @@ export function EditorPanelContent({
|
|||
onClick={async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
if (!searchSpaceId || !documentId) {
|
||||
throw new Error("Missing document context");
|
||||
}
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
|
||||
{ method: "GET" }
|
||||
|
|
@ -289,7 +350,7 @@ export function EditorPanelContent({
|
|||
</div>
|
||||
) : isEditableType ? (
|
||||
<PlateEditor
|
||||
key={documentId}
|
||||
key={isLocalFileMode ? localFilePath ?? "local-file" : documentId}
|
||||
preset="full"
|
||||
markdown={editorDoc.source_markdown}
|
||||
onMarkdownChange={handleMarkdownChange}
|
||||
|
|
@ -324,13 +385,19 @@ function DesktopEditorPanel() {
|
|||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [closePanel]);
|
||||
|
||||
if (!panelState.isOpen || !panelState.documentId || !panelState.searchSpaceId) return null;
|
||||
const hasTarget =
|
||||
panelState.kind === "document"
|
||||
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||
: !!panelState.localFilePath;
|
||||
if (!panelState.isOpen || !hasTarget) return null;
|
||||
|
||||
return (
|
||||
<div className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-sidebar text-sidebar-foreground animate-in slide-in-from-right-4 duration-300 ease-out">
|
||||
<EditorPanelContent
|
||||
documentId={panelState.documentId}
|
||||
searchSpaceId={panelState.searchSpaceId}
|
||||
kind={panelState.kind}
|
||||
documentId={panelState.documentId ?? undefined}
|
||||
localFilePath={panelState.localFilePath ?? undefined}
|
||||
searchSpaceId={panelState.searchSpaceId ?? undefined}
|
||||
title={panelState.title}
|
||||
onClose={closePanel}
|
||||
/>
|
||||
|
|
@ -342,7 +409,11 @@ function MobileEditorDrawer() {
|
|||
const panelState = useAtomValue(editorPanelAtom);
|
||||
const closePanel = useSetAtom(closeEditorPanelAtom);
|
||||
|
||||
if (!panelState.documentId || !panelState.searchSpaceId) return null;
|
||||
const hasTarget =
|
||||
panelState.kind === "document"
|
||||
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||
: !!panelState.localFilePath;
|
||||
if (!hasTarget) return null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
|
|
@ -360,8 +431,10 @@ function MobileEditorDrawer() {
|
|||
<DrawerTitle className="sr-only">{panelState.title || "Editor"}</DrawerTitle>
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
<EditorPanelContent
|
||||
documentId={panelState.documentId}
|
||||
searchSpaceId={panelState.searchSpaceId}
|
||||
kind={panelState.kind}
|
||||
documentId={panelState.documentId ?? undefined}
|
||||
localFilePath={panelState.localFilePath ?? undefined}
|
||||
searchSpaceId={panelState.searchSpaceId ?? undefined}
|
||||
title={panelState.title}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -373,8 +446,12 @@ function MobileEditorDrawer() {
|
|||
export function EditorPanel() {
|
||||
const panelState = useAtomValue(editorPanelAtom);
|
||||
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||
const hasTarget =
|
||||
panelState.kind === "document"
|
||||
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||
: !!panelState.localFilePath;
|
||||
|
||||
if (!panelState.isOpen || !panelState.documentId) return null;
|
||||
if (!panelState.isOpen || !hasTarget) return null;
|
||||
|
||||
if (isDesktop) {
|
||||
return <DesktopEditorPanel />;
|
||||
|
|
@ -386,8 +463,12 @@ export function EditorPanel() {
|
|||
export function MobileEditorPanel() {
|
||||
const panelState = useAtomValue(editorPanelAtom);
|
||||
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||
const hasTarget =
|
||||
panelState.kind === "document"
|
||||
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||
: !!panelState.localFilePath;
|
||||
|
||||
if (isDesktop || !panelState.isOpen || !panelState.documentId) return null;
|
||||
if (isDesktop || !panelState.isOpen || !hasTarget) return null;
|
||||
|
||||
return <MobileEditorDrawer />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,11 @@ export function RightPanelExpandButton() {
|
|||
const editorState = useAtomValue(editorPanelAtom);
|
||||
const hitlEditState = useAtomValue(hitlEditPanelAtom);
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen = editorState.isOpen && !!editorState.documentId;
|
||||
const editorOpen =
|
||||
editorState.isOpen &&
|
||||
(editorState.kind === "document"
|
||||
? !!editorState.documentId
|
||||
: !!editorState.localFilePath);
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen;
|
||||
|
||||
|
|
@ -110,7 +114,11 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
|
|||
|
||||
const documentsOpen = documentsPanel?.open ?? false;
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen = editorState.isOpen && !!editorState.documentId;
|
||||
const editorOpen =
|
||||
editorState.isOpen &&
|
||||
(editorState.kind === "document"
|
||||
? !!editorState.documentId
|
||||
: !!editorState.localFilePath);
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -179,8 +187,10 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
|
|||
{effectiveTab === "editor" && editorOpen && (
|
||||
<div className="h-full flex flex-col">
|
||||
<EditorPanelContent
|
||||
documentId={editorState.documentId as number}
|
||||
searchSpaceId={editorState.searchSpaceId as number}
|
||||
kind={editorState.kind}
|
||||
documentId={editorState.documentId ?? undefined}
|
||||
localFilePath={editorState.localFilePath ?? undefined}
|
||||
searchSpaceId={editorState.searchSpaceId ?? undefined}
|
||||
title={editorState.title}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
|
|
|
|||
44
surfsense_web/lib/agent-filesystem.ts
Normal file
44
surfsense_web/lib/agent-filesystem.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export type AgentFilesystemMode = "cloud" | "desktop_local_folder";
|
||||
export type ClientPlatform = "web" | "desktop";
|
||||
|
||||
export interface AgentFilesystemSelection {
|
||||
filesystem_mode: AgentFilesystemMode;
|
||||
client_platform: ClientPlatform;
|
||||
local_filesystem_root?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SELECTION: AgentFilesystemSelection = {
|
||||
filesystem_mode: "cloud",
|
||||
client_platform: "web",
|
||||
};
|
||||
|
||||
export function getClientPlatform(): ClientPlatform {
|
||||
if (typeof window === "undefined") return "web";
|
||||
return window.electronAPI ? "desktop" : "web";
|
||||
}
|
||||
|
||||
export async function getAgentFilesystemSelection(): Promise<AgentFilesystemSelection> {
|
||||
const platform = getClientPlatform();
|
||||
if (platform !== "desktop" || !window.electronAPI?.getAgentFilesystemSettings) {
|
||||
return { ...DEFAULT_SELECTION, client_platform: platform };
|
||||
}
|
||||
try {
|
||||
const settings = await window.electronAPI.getAgentFilesystemSettings();
|
||||
if (settings.mode === "desktop_local_folder" && settings.localRootPath) {
|
||||
return {
|
||||
filesystem_mode: "desktop_local_folder",
|
||||
client_platform: "desktop",
|
||||
local_filesystem_root: settings.localRootPath,
|
||||
};
|
||||
}
|
||||
return {
|
||||
filesystem_mode: "cloud",
|
||||
client_platform: "desktop",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
filesystem_mode: "cloud",
|
||||
client_platform: "desktop",
|
||||
};
|
||||
}
|
||||
}
|
||||
12
surfsense_web/types/window.d.ts
vendored
12
surfsense_web/types/window.d.ts
vendored
|
|
@ -49,6 +49,13 @@ interface AgentFilesystemSettings {
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface LocalTextFileResult {
|
||||
ok: boolean;
|
||||
path: string;
|
||||
content?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
versions: {
|
||||
electron: string;
|
||||
|
|
@ -102,6 +109,11 @@ interface ElectronAPI {
|
|||
// Browse files/folders via native dialogs
|
||||
browseFiles: () => Promise<string[] | null>;
|
||||
readLocalFiles: (paths: string[]) => Promise<LocalFileData[]>;
|
||||
readAgentLocalFileText: (virtualPath: string) => Promise<LocalTextFileResult>;
|
||||
writeAgentLocalFileText: (
|
||||
virtualPath: string,
|
||||
content: string
|
||||
) => Promise<LocalTextFileResult>;
|
||||
// Auth token sync across windows
|
||||
getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>;
|
||||
setAuthTokens: (bearer: string, refresh: string) => Promise<void>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue