feat(filesystem): enhance local file handling in editor and IPC integration

This commit is contained in:
Anish Sarkar 2026-04-23 17:23:38 +05:30
parent 4899588cd7
commit 864f6f798a
12 changed files with 350 additions and 47 deletions

View file

@ -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

View file

@ -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(

View file

@ -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',

View file

@ -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;
});

View file

@ -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),
};
}

View file

@ -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),

View file

@ -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);

View file

@ -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(

View file

@ -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 />;
}

View file

@ -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}
/>

View 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",
};
}
}

View file

@ -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>;