Skip to content

Commit

Permalink
wip file explorer
Browse files Browse the repository at this point in the history
  • Loading branch information
paulclindo committed Jan 7, 2025
1 parent ac28f16 commit 671f3ac
Show file tree
Hide file tree
Showing 5 changed files with 266 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ import { useStore } from 'zustand/index';
type ChatStore = {
selectedArtifact: Artifact | null;
setSelectedArtifact: (selectedArtifact: Artifact | null) => void;
fileExplorerOpen: boolean;
setFileExplorerOpen: (fileExplorerOpen: boolean) => void;
};

const createChatStore = () =>
createStore<ChatStore>((set) => ({
selectedArtifact: null,
setSelectedArtifact: (selectedArtifact: Artifact | null) =>
set({ selectedArtifact }),

fileExplorerOpen: false,
setFileExplorerOpen: (fileExplorerOpen: boolean) =>
set({ fileExplorerOpen }),
}));

const ChatContext = createContext<ReturnType<typeof createChatStore> | null>(
Expand Down
65 changes: 44 additions & 21 deletions apps/shinkai-desktop/src/components/chat/conversation-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useParams } from 'react-router-dom';
import { useGetCurrentInbox } from '../../hooks/use-current-inbox';
import { useAuth } from '../../store/auth';
import { useSettings } from '../../store/settings';
import { useChatStore } from './context/chat-context';
import { useSetJobScope } from './context/set-job-scope-context';

const ConversationHeaderEmpty = () => {
Expand Down Expand Up @@ -118,6 +119,10 @@ const ConversationHeaderWithInboxId = () => {
(state) => state.setSetJobScopeOpen,
);

const setFileExplorerOpen = useChatStore(
(state) => state.setFileExplorerOpen,
);

const selectedKeys = useSetJobScope((state) => state.selectedKeys);
const onSelectedKeysChange = useSetJobScope(
(state) => state.onSelectedKeysChange,
Expand Down Expand Up @@ -224,27 +229,45 @@ const ConversationHeaderWithInboxId = () => {
</span>
</div>
{hasConversationContext ? (
<Button
className={cn(
'flex h-auto w-auto items-center gap-2 rounded-lg bg-gray-400 px-2.5 py-1.5',
)}
onClick={() => {
setSetJobScopeOpen(true);
}}
size="auto"
type="button"
variant="ghost"
>
<div className="flex items-center gap-2">
<FilesIcon className="h-4 w-4" />
<p className="text-xs text-white">{t('vectorFs.localFiles')}</p>
</div>
{filesAndFoldersCount > 0 && (
<Badge className="bg-brand inline-flex h-5 w-5 items-center justify-center rounded-full border-gray-200 p-0 text-center text-gray-50">
{filesAndFoldersCount}
</Badge>
)}
</Button>
<div className="flex items-center gap-2">
<Button
className={cn(
'flex h-auto w-auto items-center gap-2 rounded-lg bg-gray-400 px-2.5 py-1.5',
)}
onClick={() => {
setSetJobScopeOpen(true);
}}
size="auto"
type="button"
variant="ghost"
>
<div className="flex items-center gap-2">
<FilesIcon className="h-4 w-4" />
<p className="text-xs text-white">{t('vectorFs.localFiles')}</p>
</div>
{filesAndFoldersCount > 0 && (
<Badge className="bg-brand inline-flex h-5 w-5 items-center justify-center rounded-full border-gray-200 p-0 text-center text-gray-50">
{filesAndFoldersCount}
</Badge>
)}
</Button>
<Button
className={cn(
'flex h-auto w-auto items-center gap-2 rounded-lg bg-gray-400 px-2.5 py-1.5',
)}
onClick={() => {
setFileExplorerOpen(true);
}}
size="auto"
type="button"
variant="ghost"
>
<div className="flex items-center gap-2">
<FilesIcon className="h-4 w-4" />
<p>File Explorer</p>
</div>
</Button>
</div>
) : (
<Button
className={cn(
Expand Down
191 changes: 191 additions & 0 deletions apps/shinkai-desktop/src/components/chat/file-explorer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { transformDataToTreeNodes } from '@shinkai_network/shinkai-node-state/lib/utils/files';
import { useGetListDirectoryContents } from '@shinkai_network/shinkai-node-state/v2/queries/getDirectoryContents/useGetListDirectoryContents';
import { useGetDownloadFile } from '@shinkai_network/shinkai-node-state/v2/queries/getDownloadFile/useGetDownloadFile';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
Button,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
ScrollArea,
Tabs,
Tooltip,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipTrigger,
} from '@shinkai_network/shinkai-ui';
import { cn } from '@shinkai_network/shinkai-ui/utils';
import { ChevronsRight } from 'lucide-react';
import { Tree } from 'primereact/tree';
import { TreeNode } from 'primereact/treenode';
import { PrismEditor } from 'prism-react-editor';
import { Fragment, useEffect, useRef, useState } from 'react';

import { treeOptions } from '../../lib/constants';
import { useAuth } from '../../store/auth';
import ToolCodeEditor from '../playground-tool/tool-code-editor';
import { useChatStore } from './context/chat-context';

const FileExplorer = () => {
const auth = useAuth((state) => state.auth);
const artifact = useChatStore((state) => state.selectedArtifact);
const [nodes, setNodes] = useState<TreeNode[]>([]);
const [selectedKey, setSelectedKey] = useState('');
const [fileContent, setFileContent] = useState<string>('');
const textFileContentRef = useRef<PrismEditor | null>(null);

const setFileExplorerOpen = useChatStore(
(state) => state.setFileExplorerOpen,
);

const { data: fileInfoArray, isSuccess: isVRFilesSuccess } =
useGetListDirectoryContents({
nodeAddress: auth?.node_address ?? '',
token: auth?.api_v2_key ?? '',
path: '/',
});

useEffect(() => {
if (isVRFilesSuccess) {
setNodes(transformDataToTreeNodes(fileInfoArray));
}
}, [fileInfoArray, isVRFilesSuccess]);

const { mutateAsync: downloadFile } = useGetDownloadFile({});

useEffect(() => {
const fetchFileContent = async () => {
if (selectedKey && auth) {
try {
const fileContentBase64 = await downloadFile({
nodeAddress: auth.node_address,
token: auth.api_v2_key,
path: selectedKey,
});
const binaryString = atob(fileContentBase64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const fileContent = new TextDecoder('utf-8').decode(bytes);
setFileContent(fileContent);
} catch (error) {
console.error('Error downloading file content:', error);
}
}
};

fetchFileContent();
}, [selectedKey, auth, downloadFile]);

return (
<TooltipProvider delayDuration={0}>
<Tabs
className="flex h-screen w-full flex-col overflow-hidden"
defaultValue="preview"
>
<div className={'flex h-screen flex-grow justify-stretch py-3'}>
<div className="flex size-full flex-col overflow-hidden">
<div className="flex items-center justify-between gap-2 border-b">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
className="text-gray-80 flex h-[30px] items-center gap-2 rounded-md text-xs"
onClick={() => {
setFileExplorerOpen(false);
}}
size="auto"
variant="tertiary"
>
<ChevronsRight className="h-4 w-4" />
<span className="">File Explorer</span>
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent className="flex flex-col items-center gap-1">
<p>Close File Explorer Panel</p>
</TooltipContent>
</TooltipPortal>
</Tooltip>
<h1 className="line-clamp-1 text-sm font-medium text-white">
{artifact?.title}
</h1>
</div>
</div>

<div className="h-full overflow-y-scroll whitespace-pre-line break-words">
<ResizablePanelGroup direction="horizontal">
<ResizablePanel
className="flex h-full flex-col"
defaultSize={32}
maxSize={50}
minSize={20}
>
<div className="flex h-8 items-center justify-between gap-3 px-2">
<h2 className="text-gray-80 text-xs">Files</h2>
</div>
<ScrollArea className="h-[calc(100vh-200px)] flex-1 px-2">
<Tree
onSelectionChange={(e) => {
const isFile = (e.value as string)?.includes('.');
if (isFile) {
setSelectedKey(e.value as string);
return;
}
}}
pt={treeOptions}
selectionKeys={selectedKey}
selectionMode="single"
value={nodes}
/>
</ScrollArea>
</ResizablePanel>
<ResizableHandle className="bg-gray-300" />
<ResizablePanel className="flex h-full flex-col">
{selectedKey && (
<Breadcrumb className="h-8 border-b p-2">
<BreadcrumbList className="text-xs">
{selectedKey?.split('/').map((item) => (
<Fragment key={item}>
<BreadcrumbItem>
<BreadcrumbLink
className={cn(
item === selectedKey.split('/').at(-1)
? 'text-gray-50'
: 'text-gray-80',
)}
>
{item}
</BreadcrumbLink>
</BreadcrumbItem>
{item === selectedKey.split('/').at(-1) ? null : (
<BreadcrumbSeparator />
)}
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
)}
<div className="flex flex-1 flex-col space-y-2 overflow-hidden">
<ToolCodeEditor
language="txt"
ref={textFileContentRef}
value={fileContent ?? ''}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</div>
</Tabs>
</TooltipProvider>
);
};
export default FileExplorer;
2 changes: 1 addition & 1 deletion apps/shinkai-desktop/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const treeOptions: TreePassThroughOptions = {
root: {
className: cn(
'',
'my-3 w-full rounded-md border border-gray-400 bg-transparent p-0 text-white',
'w-full rounded-md border border-gray-400 bg-transparent p-0 text-white',
),
},
label: { className: 'text-white text-sm line-clamp-1 break-all' },
Expand Down
24 changes: 24 additions & 0 deletions apps/shinkai-desktop/src/pages/chat/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { toast } from 'sonner';
import ArtifactPreview from '../../components/chat/artifact-preview';
import { useChatStore } from '../../components/chat/context/chat-context';
import { useSetJobScope } from '../../components/chat/context/set-job-scope-context';
import FileExplorer from '../../components/chat/file-explorer';
import { usePromptSelectionStore } from '../../components/prompt/context/prompt-selection-context';
import { handleSendNotification } from '../../lib/notifications';
import { useAuth } from '../../store/auth';
Expand Down Expand Up @@ -373,6 +374,7 @@ const ChatLayout = () => {
);

const selectedArtifact = useChatStore((state) => state.selectedArtifact);
const fileExplorerOpen = useChatStore((state) => state.fileExplorerOpen);
const showArtifactPanel = selectedArtifact != null;

return (
Expand Down Expand Up @@ -416,6 +418,28 @@ const ChatLayout = () => {
</AnimatePresence>
</ResizablePanel>
)}
{fileExplorerOpen && <ResizableHandle className="bg-gray-300" />}
{fileExplorerOpen && (
<ResizablePanel
collapsible
defaultSize={42}
maxSize={70}
minSize={40}
>
<AnimatePresence initial={false} mode="popLayout">
{fileExplorerOpen && (
<motion.div
animate={{ opacity: 1, filter: 'blur(0px)' }}
className="h-full"
initial={{ opacity: 0, filter: 'blur(5px)' }}
transition={{ duration: 0.2 }}
>
<FileExplorer />
</motion.div>
)}
</AnimatePresence>
</ResizablePanel>
)}
</ResizablePanelGroup>
</div>
);
Expand Down

0 comments on commit 671f3ac

Please sign in to comment.