From 3e15f0b12f9109119fa0a2a0a8b51a7e45ac0a25 Mon Sep 17 00:00:00 2001 From: Jose Date: Sun, 15 Sep 2024 21:16:14 +0100 Subject: [PATCH] Chat improv (#401) * Allow full files to be passed to context. * feat: tools and chat history/metadata clarify. * fix: loading state enum * error handling * fix: generation cleanup * rename: update->savechat * v0.5: tool calling * refactor: separate message components. * feat: tool call rendering * feat: tool call execution. * fix:saving chat. * fix:tool calls. * fix:tool calls. * rm vector field * c * results rendering * custom tool result render. * Add shadcn. * Button. * Markdown comp. * a * Respond to tool result. --- components.json | 20 ++ electron/main/electron-store/ipcHandlers.ts | 73 +----- .../electron-store/storeSchemaMigrator.ts | 28 ++- electron/main/filesystem/filesystem.ts | 6 +- electron/main/filesystem/ipcHandlers.ts | 17 +- electron/main/filesystem/types.ts | 4 + .../main/vector-database/lanceTableWrapper.ts | 10 +- electron/main/vector-database/schema.ts | 1 - electron/preload/index.ts | 33 +-- package-lock.json | 91 ++++++- package.json | 6 + screenshots.md | 4 - shared/utils.ts | 21 ++ src/App.tsx | 5 + .../Chat/AddContextFiltersModal.tsx | 24 +- src/components/Chat/ChatInput.tsx | 7 +- src/components/Chat/ChatMessages.tsx | 114 ++++----- src/components/Chat/ChatSidebar.tsx | 29 +-- .../MessageComponents/AssistantMessage.tsx | 169 +++++++++++++ .../Chat/MessageComponents/InChatContext.tsx | 43 ++++ .../Chat/MessageComponents/SystemMessage.tsx | 23 ++ .../Chat/MessageComponents/ToolCalls.tsx | 150 ++++++++++++ .../Chat/MessageComponents/UserMessage.tsx | 27 +++ src/components/Chat/StartChat.tsx | 10 +- src/components/Chat/index.tsx | 224 ++++++------------ src/components/Chat/tools.ts | 129 ++++++++++ src/components/Chat/types.ts | 39 ++- src/components/Chat/utils.ts | 144 ++++++----- .../{Menu => Common}/CustomContextMenu.tsx | 7 +- src/components/File/DBResultPreview.tsx | 56 +++-- src/components/MainPage.tsx | 6 +- .../Sidebars/SimilarFilesSidebar.tsx | 11 +- src/components/Tabs/TabBar.tsx | 184 -------------- src/components/ui/button.tsx | 48 ++++ src/components/ui/card.tsx | 45 ++++ src/contexts/ChatContext.tsx | 129 +++------- src/contexts/WindowContentContext.tsx | 31 +-- src/lib/utils.ts | 7 + src/styles/global.css | 71 +++++- src/styles/tab.css | 28 --- src/utils/animations.tsx | 2 +- src/utils/db.ts | 37 +++ tailwind.config.js | 171 ++++++++----- tsconfig.json | 11 +- vite.config.ts | 30 ++- 45 files changed, 1431 insertions(+), 894 deletions(-) create mode 100644 components.json delete mode 100644 screenshots.md create mode 100644 shared/utils.ts create mode 100644 src/components/Chat/MessageComponents/AssistantMessage.tsx create mode 100644 src/components/Chat/MessageComponents/InChatContext.tsx create mode 100644 src/components/Chat/MessageComponents/SystemMessage.tsx create mode 100644 src/components/Chat/MessageComponents/ToolCalls.tsx create mode 100644 src/components/Chat/MessageComponents/UserMessage.tsx create mode 100644 src/components/Chat/tools.ts rename src/components/{Menu => Common}/CustomContextMenu.tsx (95%) delete mode 100644 src/components/Tabs/TabBar.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/lib/utils.ts delete mode 100644 src/styles/tab.css create mode 100644 src/utils/db.ts diff --git a/components.json b/components.json new file mode 100644 index 00000000..5db6c61f --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/styles/global.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/electron/main/electron-store/ipcHandlers.ts b/electron/main/electron-store/ipcHandlers.ts index 69a27ae7..5cf95e32 100644 --- a/electron/main/electron-store/ipcHandlers.ts +++ b/electron/main/electron-store/ipcHandlers.ts @@ -3,7 +3,6 @@ import path from 'path' import { ipcMain } from 'electron' import Store from 'electron-store' import { - Tab, EmbeddingModelConfig, EmbeddingModelWithLocalPath, EmbeddingModelWithRepo, @@ -14,7 +13,7 @@ import { import WindowsManager from '../common/windowManager' import { initializeAndMaybeMigrateStore } from './storeSchemaMigrator' -import { Chat } from '@/components/Chat/types' +import { Chat, ChatMetadata } from '@/components/Chat/types' export const registerStoreHandlers = (store: Store, windowsManager: WindowsManager) => { initializeAndMaybeMigrateStore(store) @@ -131,7 +130,7 @@ export const registerStoreHandlers = (store: Store, windowsManager: store.set(StoreKeys.hasUserOpenedAppBefore, true) }) - ipcMain.handle('get-all-chats', (event) => { + ipcMain.handle('get-all-chats-metadata', (event) => { const vaultDir = windowsManager.getVaultDirectoryForWinContents(event.sender) if (!vaultDir) { @@ -140,33 +139,34 @@ export const registerStoreHandlers = (store: Store, windowsManager: const allHistories = store.get(StoreKeys.ChatHistories) const chatHistoriesCorrespondingToVault = allHistories?.[vaultDir] ?? [] - return chatHistoriesCorrespondingToVault + return chatHistoriesCorrespondingToVault.map(({ messages, ...rest }) => rest) as ChatMetadata[] }) - ipcMain.handle('update-chat', (event, newChat: Chat) => { + ipcMain.handle('save-chat', (event, newChat: Chat) => { const vaultDir = windowsManager.getVaultDirectoryForWinContents(event.sender) - const allChatHistories = store.get(StoreKeys.ChatHistories) if (!vaultDir) { return } + + const allChatHistories = store.get(StoreKeys.ChatHistories) const chatHistoriesCorrespondingToVault = allChatHistories?.[vaultDir] ?? [] - // check if chat history already exists. if it does, update it. if it doesn't append it + const existingChatIndex = chatHistoriesCorrespondingToVault.findIndex((chat) => chat.id === newChat.id) if (existingChatIndex !== -1) { chatHistoriesCorrespondingToVault[existingChatIndex] = newChat } else { chatHistoriesCorrespondingToVault.push(newChat) } - // store.set(StoreKeys.ChatHistories, allChatHistories); store.set(StoreKeys.ChatHistories, { ...allChatHistories, [vaultDir]: chatHistoriesCorrespondingToVault, }) - - event.sender.send('update-chat-histories', chatHistoriesCorrespondingToVault) }) - ipcMain.handle('get-chat', (event, chatId: string) => { + ipcMain.handle('get-chat', (event, chatId: string | undefined) => { + if (!chatId) { + return undefined + } const vaultDir = windowsManager.getVaultDirectoryForWinContents(event.sender) if (!vaultDir) { return null @@ -176,7 +176,7 @@ export const registerStoreHandlers = (store: Store, windowsManager: return vaultChatHistories.find((chat) => chat.id === chatId) }) - ipcMain.handle('delete-chat-at-id', (event, chatID: string | undefined) => { + ipcMain.handle('delete-chat', (event, chatID: string | undefined) => { if (!chatID) return const vaultDir = windowsManager.getVaultDirectoryForWinContents(event.sender) @@ -188,56 +188,9 @@ export const registerStoreHandlers = (store: Store, windowsManager: const allChatHistories = chatHistoriesMap[vaultDir] || [] const filteredChatHistories = allChatHistories.filter((item) => item.id !== chatID) - chatHistoriesMap[vaultDir] = filteredChatHistories.reverse() + chatHistoriesMap[vaultDir] = filteredChatHistories store.set(StoreKeys.ChatHistories, chatHistoriesMap) }) - - ipcMain.handle('get-current-open-files', () => store.get(StoreKeys.OpenTabs) || []) - - ipcMain.handle('add-current-open-files', (event, tab: Tab) => { - if (tab === null) return - const openTabs: Tab[] = store.get(StoreKeys.OpenTabs) || [] - const existingTab = openTabs.findIndex((item) => item.path === tab.path) - - /* If tab is already open, do not do anything */ - if (existingTab !== -1) return - openTabs.push(tab) - store.set(StoreKeys.OpenTabs, openTabs) - }) - - ipcMain.handle('remove-current-open-files', (event, tabId: string, idx: number, newIndex: number) => { - // Ensure indices are within range - const openTabs: Tab[] = store.get(StoreKeys.OpenTabs) || [] - if (idx < 0 || idx >= openTabs.length || newIndex < 0 || newIndex >= openTabs.length) return - openTabs[idx].lastAccessed = false - openTabs[newIndex].lastAccessed = true - const updatedTabs = openTabs.filter((tab) => tab.id !== tabId) - store.set(StoreKeys.OpenTabs, updatedTabs) - }) - - ipcMain.handle('clear-current-open-files', () => { - store.set(StoreKeys.OpenTabs, []) - }) - - ipcMain.handle('update-current-open-files', (event, draggedIndex: number, targetIndex: number) => { - const openTabs: Tab[] = store.get(StoreKeys.OpenTabs) || [] - if (draggedIndex < 0 || draggedIndex >= openTabs.length || targetIndex < 0 || targetIndex >= openTabs.length) return - ;[openTabs[draggedIndex], openTabs[targetIndex]] = [openTabs[targetIndex], openTabs[draggedIndex]] - store.set(StoreKeys.OpenTabs, openTabs) - }) - - ipcMain.handle('set-current-open-files', (event, tabs: Tab[]) => { - if (tabs) store.set(StoreKeys.OpenTabs, tabs) - }) - - ipcMain.handle('remove-current-open-files-by-path', (event, filePath: string) => { - if (!filePath) return - const openTabs: Tab[] = store.get(StoreKeys.OpenTabs) || [] - // Filter out selected tab - const updatedTabs = openTabs.filter((tab) => tab.path !== filePath) - store.set(StoreKeys.OpenTabs, updatedTabs) - event.sender.send('remove-tab-after-deletion', updatedTabs) - }) } export function getDefaultEmbeddingModelConfig(store: Store): EmbeddingModelConfig { diff --git a/electron/main/electron-store/storeSchemaMigrator.ts b/electron/main/electron-store/storeSchemaMigrator.ts index a017f663..6aebff24 100644 --- a/electron/main/electron-store/storeSchemaMigrator.ts +++ b/electron/main/electron-store/storeSchemaMigrator.ts @@ -1,5 +1,6 @@ import Store from 'electron-store' +import getDisplayableChatName from '../../../shared/utils' import { StoreKeys, StoreSchema } from './storeConfig' import { defaultEmbeddingModelRepos } from '../vector-database/embeddings' import { defaultOllamaAPI } from '../llm/models/ollama' @@ -50,7 +51,7 @@ export function setupDefaultStoreValues(store: Store) { } if (!store.get(StoreKeys.ChunkSize)) { - store.set(StoreKeys.ChunkSize, 500) + store.set(StoreKeys.ChunkSize, 1000) } setupDefaultAnalyticsValue(store) @@ -84,6 +85,29 @@ function ensureChatHistoryIsCorrectProperty(store: Store) { store.set(StoreKeys.ChatHistories, chatHistories) } +function ensureChatHistoryHasDisplayNameAndTimestamp(store: Store) { + const chatHistories = store.get(StoreKeys.ChatHistories) + if (!chatHistories) { + return + } + + Object.keys(chatHistories).forEach((vaultDir) => { + const chats = chatHistories[vaultDir] + chats.map((chat) => { + const outputChat = chat + if (!outputChat.displayName || outputChat.displayName === chat.id) { + outputChat.displayName = getDisplayableChatName(chat.messages) + } + if (!outputChat.timeOfLastMessage) { + outputChat.timeOfLastMessage = Date.now() + } + return outputChat + }) + }) + + store.set(StoreKeys.ChatHistories, chatHistories) +} + export const initializeAndMaybeMigrateStore = (store: Store) => { const storeSchemaVersion = store.get(StoreKeys.SchemaVersion) if (storeSchemaVersion !== currentSchemaVersion) { @@ -93,6 +117,6 @@ export const initializeAndMaybeMigrateStore = (store: Store) => { } ensureChatHistoryIsCorrectProperty(store) - + ensureChatHistoryHasDisplayNameAndTimestamp(store) setupDefaultStoreValues(store) } diff --git a/electron/main/filesystem/filesystem.ts b/electron/main/filesystem/filesystem.ts index 2e82e0bc..ff6a825b 100644 --- a/electron/main/filesystem/filesystem.ts +++ b/electron/main/filesystem/filesystem.ts @@ -6,6 +6,7 @@ import chokidar from 'chokidar' import { BrowserWindow } from 'electron' import { FileInfo, FileInfoTree, isFileNodeDirectory } from './types' +import addExtensionToFilenameIfNoExtensionPresent from '../path/path' export const markdownExtensions = ['.md', '.markdown', '.mdown', '.mkdn', '.mkd'] @@ -97,11 +98,9 @@ export function GetFilesInfoList(directory: string): FileInfo[] { } export function GetFilesInfoListForListOfPaths(paths: string[]): FileInfo[] { - // so perhaps for this function, all we maybe need to do is remove const fileInfoTree = paths.map((_path) => GetFilesInfoTree(_path)).flat() const fileInfoList = flattenFileInfoTree(fileInfoTree) - // remove duplicates: const uniquePaths = new Set() const fileInfoListWithoutDuplicates = fileInfoList.filter((fileInfo) => { if (uniquePaths.has(fileInfo.path)) { @@ -123,8 +122,9 @@ export function createFileRecursive(filePath: string, content: string, charset?: if (fs.existsSync(filePath)) { return } + const filePathWithExtension = addExtensionToFilenameIfNoExtensionPresent(filePath, markdownExtensions, '.md') - fs.writeFileSync(filePath, content, charset) + fs.writeFileSync(filePathWithExtension, content, charset) } export function updateFileListForRenderer(win: BrowserWindow, directory: string): void { diff --git a/electron/main/filesystem/ipcHandlers.ts b/electron/main/filesystem/ipcHandlers.ts index 64ce3852..d28092d7 100644 --- a/electron/main/filesystem/ipcHandlers.ts +++ b/electron/main/filesystem/ipcHandlers.ts @@ -6,12 +6,7 @@ import Store from 'electron-store' import WindowsManager from '../common/windowManager' import { StoreSchema } from '../electron-store/storeConfig' -import { DBEntry } from '../vector-database/schema' -import { - convertFileInfoListToDBItems, - orchestrateEntryMove, - updateFileInTable, -} from '../vector-database/tableHelperFunctions' +import { orchestrateEntryMove, updateFileInTable } from '../vector-database/tableHelperFunctions' import { GetFilesInfoTree, @@ -21,7 +16,7 @@ import { startWatchingDirectory, updateFileListForRenderer, } from './filesystem' -import { FileInfoTree, WriteFileProps, RenameFileProps } from './types' +import { FileInfoTree, WriteFileProps, RenameFileProps, FileInfoWithContent } from './types' const registerFileHandlers = (store: Store, _windowsManager: WindowsManager) => { const windowsManager = _windowsManager @@ -164,12 +159,10 @@ const registerFileHandlers = (store: Store, _windowsManager: Window orchestrateEntryMove(windowInfo.dbTableClient, sourcePath, destinationPath) }) - ipcMain.handle('get-filesystem-paths-as-db-items', async (_event, filePaths: string[]): Promise => { + ipcMain.handle('get-files', async (_event, filePaths: string[]): Promise => { const fileItems = GetFilesInfoListForListOfPaths(filePaths) - - const dbItems = await convertFileInfoListToDBItems(fileItems) - - return dbItems.flat() + const fileContents = fileItems.map((fileItem) => fs.readFileSync(fileItem.path, 'utf-8')) + return fileItems.map((fileItem, index) => ({ ...fileItem, content: fileContents[index] })) }) ipcMain.handle('get-files-in-directory', (event, dirName: string) => { diff --git a/electron/main/filesystem/types.ts b/electron/main/filesystem/types.ts index 0aea13ca..5a63e4df 100644 --- a/electron/main/filesystem/types.ts +++ b/electron/main/filesystem/types.ts @@ -6,6 +6,10 @@ export type FileInfo = { dateCreated: Date } +export type FileInfoWithContent = FileInfo & { + content: string +} + export type FileInfoNode = FileInfo & { children?: FileInfoNode[] } diff --git a/electron/main/vector-database/lanceTableWrapper.ts b/electron/main/vector-database/lanceTableWrapper.ts index 5b710c4e..b57d17e2 100644 --- a/electron/main/vector-database/lanceTableWrapper.ts +++ b/electron/main/vector-database/lanceTableWrapper.ts @@ -11,7 +11,8 @@ export function unsanitizePathForFileSystem(dbPath: string): string { } export function convertRecordToDBType(record: Record): T | null { - const recordWithType = record as T + const { vector, ...recordWithoutVector } = record + const recordWithType = recordWithoutVector as unknown as T recordWithType.notepath = unsanitizePathForFileSystem(recordWithType.notepath) return recordWithType } @@ -100,12 +101,7 @@ class LanceDBTableWrapper { } } - async search( - query: string, - // metricType: string, - limit: number, - filter?: string, - ): Promise { + async search(query: string, limit: number, filter?: string): Promise { const lanceQuery = await this.lanceTable.search(query).metricType(MetricType.Cosine).limit(limit) if (filter) { diff --git a/electron/main/vector-database/schema.ts b/electron/main/vector-database/schema.ts index 07eb0593..c1d5e5f8 100644 --- a/electron/main/vector-database/schema.ts +++ b/electron/main/vector-database/schema.ts @@ -2,7 +2,6 @@ import { Schema, Field, Utf8, FixedSizeList, Float32, Float64, DateUnit, Date_ a export interface DBEntry { notepath: string - vector?: Float32Array content: string subnoteindex: number timeadded: Date diff --git a/electron/preload/index.ts b/electron/preload/index.ts index a466f1c8..7d3326c6 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -6,12 +6,11 @@ import { LLMConfig, LLMAPIConfig, LLMGenerationParameters, - Tab, } from 'electron/main/electron-store/storeConfig' -import { FileInfoTree, RenameFileProps, WriteFileProps } from 'electron/main/filesystem/types' -import { DBEntry, DBQueryResult } from 'electron/main/vector-database/schema' +import { FileInfoTree, FileInfoWithContent, RenameFileProps, WriteFileProps } from 'electron/main/filesystem/types' +import { DBQueryResult } from 'electron/main/vector-database/schema' -import { Chat } from '@/components/Chat/types' +import { Chat, ChatMetadata } from '@/components/Chat/types' // eslint-disable-next-line @typescript-eslint/no-explicit-any type IPCHandler any> = (...args: Parameters) => Promise> @@ -23,10 +22,6 @@ function createIPCHandler any>(channel: string): I const database = { search: createIPCHandler<(query: string, limit: number, filter?: string) => Promise>('search'), - searchWithReranking: - createIPCHandler<(query: string, limit: number, filter?: string) => Promise>( - 'search-with-reranking', - ), deleteLanceDBEntriesByFilePath: createIPCHandler<(filePath: string) => Promise>( 'delete-lance-db-entries-by-filepath', ), @@ -68,24 +63,14 @@ const electronStore = { setSpellCheckMode: createIPCHandler<(isSpellCheck: boolean) => Promise>('set-spellcheck-mode'), getHasUserOpenedAppBefore: createIPCHandler<() => Promise>('has-user-opened-app-before'), setHasUserOpenedAppBefore: createIPCHandler<() => Promise>('set-user-has-opened-app-before'), - getAllChats: createIPCHandler<() => Promise>('get-all-chats'), - updateChat: createIPCHandler<(chat: Chat) => Promise>('update-chat'), - deleteChatAtID: createIPCHandler<(chatID: string) => Promise>('delete-chat-at-id'), - getChat: createIPCHandler<(chatID: string) => Promise>('get-chat'), + getAllChatsMetadata: createIPCHandler<() => Promise>('get-all-chats-metadata'), + saveChat: createIPCHandler<(chat: Chat) => Promise>('save-chat'), + deleteChat: createIPCHandler<(chatID: string) => Promise>('delete-chat'), + getChat: createIPCHandler<(chatID: string | undefined) => Promise>('get-chat'), getSBCompact: createIPCHandler<() => Promise>('get-sb-compact'), setSBCompact: createIPCHandler<(isSBCompact: boolean) => Promise>('set-sb-compact'), getEditorFlexCenter: createIPCHandler<() => Promise>('get-editor-flex-center'), setEditorFlexCenter: createIPCHandler<(editorFlexCenter: boolean) => Promise>('set-editor-flex-center'), - getCurrentOpenTabs: createIPCHandler<() => Promise>('get-current-open-files'), - setCurrentOpenTabs: createIPCHandler<(action: string, args: any) => Promise>('set-current-open-files'), - addOpenTabs: createIPCHandler<(tab: Tab) => Promise>('add-current-open-files'), - removeOpenTabs: - createIPCHandler<(tabId: string, idx: number, newIndex: number) => Promise>('remove-current-open-files'), - clearOpenTabs: createIPCHandler<() => Promise>('clear-current-open-files'), - updateOpenTabs: - createIPCHandler<(draggedIndex: number, targetIndex: number) => Promise>('update-current-open-files'), - selectOpenTabs: createIPCHandler<(tabs: Tab[]) => Promise>('set-current-open-files'), - removeOpenTabsByPath: createIPCHandler<(path: string) => Promise>('remove-current-open-files-by-path'), } const fileSystem = { @@ -102,10 +87,8 @@ const fileSystem = { checkFileExists: createIPCHandler<(filePath: string) => Promise>('check-file-exists'), deleteFile: createIPCHandler<(filePath: string) => Promise>('delete-file'), moveFileOrDir: createIPCHandler<(sourcePath: string, destinationPath: string) => Promise>('move-file-or-dir'), - getFilesystemPathsAsDBItems: createIPCHandler<(paths: string[]) => Promise>( - 'get-filesystem-paths-as-db-items', - ), getAllFilenamesInDirectory: createIPCHandler<(dirName: string) => Promise>('get-files-in-directory'), + getFiles: createIPCHandler<(filePaths: string[]) => Promise>('get-files'), } const path = { diff --git a/package-lock.json b/package-lock.json index 79a5a56a..9f85f6af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,8 @@ "@mui/joy": "^5.0.0-beta.23", "@mui/material": "^5.15.11", "@radix-ui/colors": "^3.0.0", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-slot": "^1.1.0", "@sentry/electron": "^5.3.0", "@tailwindcss/typography": "^0.5.10", "@tiptap/core": "^2.5.0", @@ -46,6 +48,8 @@ "ai": "^3.3.17", "apache-arrow": "^14.0.2", "chokidar": "^3.5.3", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", "cm6-theme-basic-dark": "^0.2.0", "date-fns": "^3.3.1", "dotenv": "^16.4.5", @@ -78,7 +82,9 @@ "react-window": "^1.8.10", "rehype-raw": "^7.0.0", "remove-markdown": "^0.5.0", + "tailwind-merge": "^2.5.2", "tailwind-scrollbar": "^3.1.0", + "tailwindcss-animate": "^1.0.7", "tiptap-markdown": "^0.8.10", "turndown": "^7.1.2", "use-debounce": "^10.0.1", @@ -2952,6 +2958,11 @@ "react-dom": "^16 || ^17 || ^18" } }, + "node_modules/@material-tailwind/react/node_modules/tailwind-merge": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.8.1.tgz", + "integrity": "sha512-+fflfPxvHFr81hTJpQ3MIwtqgvefHZFUHFiIHpVIRXvG/nX9+gu2P7JNlFu2bfDMJ+uHhi/pUgzaYacMoXv+Ww==" + }, "node_modules/@motionone/animation": { "version": "10.17.0", "license": "MIT", @@ -3889,6 +3900,45 @@ "version": "3.0.0", "license": "MIT" }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.0.tgz", + "integrity": "sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x" + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@remirror/core-constants": { "version": "2.0.2", "license": "MIT" @@ -6770,6 +6820,25 @@ "version": "1.2.3", "license": "MIT" }, + "node_modules/class-variance-authority": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.0.tgz", + "integrity": "sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==", + "dependencies": { + "clsx": "2.0.0" + }, + "funding": { + "url": "https://joebell.co.uk" + } + }, + "node_modules/class-variance-authority/node_modules/clsx": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/classnames": { "version": "2.3.2", "license": "MIT" @@ -6846,8 +6915,9 @@ } }, "node_modules/clsx": { - "version": "2.1.0", - "license": "MIT", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "engines": { "node": ">=6" } @@ -20273,8 +20343,13 @@ } }, "node_modules/tailwind-merge": { - "version": "1.8.1", - "license": "MIT" + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.2.tgz", + "integrity": "sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } }, "node_modules/tailwind-scrollbar": { "version": "3.1.0", @@ -20323,6 +20398,14 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, "node_modules/tailwindcss/node_modules/glob-parent": { "version": "6.0.2", "dev": true, diff --git a/package.json b/package.json index 42ee6f34..79457f0b 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "@mui/joy": "^5.0.0-beta.23", "@mui/material": "^5.15.11", "@radix-ui/colors": "^3.0.0", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-slot": "^1.1.0", "@sentry/electron": "^5.3.0", "@tailwindcss/typography": "^0.5.10", "@tiptap/core": "^2.5.0", @@ -65,6 +67,8 @@ "ai": "^3.3.17", "apache-arrow": "^14.0.2", "chokidar": "^3.5.3", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", "cm6-theme-basic-dark": "^0.2.0", "date-fns": "^3.3.1", "dotenv": "^16.4.5", @@ -97,7 +101,9 @@ "react-window": "^1.8.10", "rehype-raw": "^7.0.0", "remove-markdown": "^0.5.0", + "tailwind-merge": "^2.5.2", "tailwind-scrollbar": "^3.1.0", + "tailwindcss-animate": "^1.0.7", "tiptap-markdown": "^0.8.10", "turndown": "^7.1.2", "use-debounce": "^10.0.1", diff --git a/screenshots.md b/screenshots.md deleted file mode 100644 index 39262c25..00000000 --- a/screenshots.md +++ /dev/null @@ -1,4 +0,0 @@ -Screenshot 2024-02-10 at 18 56 23 -Screenshot 2024-02-10 at 18 56 40 -Screenshot 2024-02-10 at 18 57 02 -Screenshot 2024-02-10 at 18 57 13 diff --git a/shared/utils.ts b/shared/utils.ts new file mode 100644 index 00000000..a77f5276 --- /dev/null +++ b/shared/utils.ts @@ -0,0 +1,21 @@ +import { ReorChatMessage } from '@/components/Chat/types' + +const getDisplayableChatName = (messages: ReorChatMessage[]): string => { + if (!messages || messages.length === 0 || !messages[0].content) { + return 'Empty Chat' + } + + const firstMsg = messages[0] + + if (firstMsg.visibleContent) { + return firstMsg.visibleContent.slice(0, 30) + } + + const firstMessage = firstMsg.content + if (!firstMessage || firstMessage === '' || typeof firstMessage !== 'string') { + return 'Empty Chat' + } + return firstMessage.slice(0, 30) +} + +export default getDisplayableChatName diff --git a/src/App.tsx b/src/App.tsx index 71d85fea..d258eacc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,11 @@ const App: React.FC = () => { window.ipcRenderer.receive('indexing-progress', handleProgressUpdate) }, []) + useEffect(() => { + const root = window.document.documentElement + root.classList.add('dark') + }, []) + useEffect(() => { const initialisePosthog = async () => { if (await window.electronStore.getAnalyticsMode()) { diff --git a/src/components/Chat/AddContextFiltersModal.tsx b/src/components/Chat/AddContextFiltersModal.tsx index 5e6249b3..63930bb1 100644 --- a/src/components/Chat/AddContextFiltersModal.tsx +++ b/src/components/Chat/AddContextFiltersModal.tsx @@ -17,8 +17,8 @@ import { ChatFilters } from './types' interface Props { isOpen: boolean onClose: () => void - setChatFilters: (chatFilters: ChatFilters) => void chatFilters: ChatFilters + setChatFilters: React.Dispatch> } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -26,7 +26,7 @@ const AddContextFiltersModal: React.FC = ({ isOpen, onClose, chatFilters, const [internalFilesSelected, setInternalFilesSelected] = useState(chatFilters?.files || []) const [searchText, setSearchText] = useState('') const [suggestionsState, setSuggestionsState] = useState(null) - const [numberOfChunksToFetch, setNumberOfChunksToFetch] = useState(chatFilters.numberOfChunksToFetch || 15) + const [numberOfChunksToFetch, setNumberOfChunksToFetch] = useState(chatFilters.limit || 15) const [minDate, setMinDate] = useState(chatFilters.minDate) const [maxDate, setMaxDate] = useState(chatFilters.maxDate) const [showAdvanced, setShowAdvanced] = useState(false) @@ -41,16 +41,16 @@ const AddContextFiltersModal: React.FC = ({ isOpen, onClose, chatFilters, { label: 'Past year', value: 'lastYear' }, ] - // useEffect(() => { - // const updatedChatFilters: ChatFilters = { - // ...chatFilters, - // files: [...new Set([...chatFilters.files, ...internalFilesSelected])], - // numberOfChunksToFetch, - // minDate: minDate || undefined, - // maxDate: maxDate || undefined, - // } - // setChatFilters(updatedChatFilters) - // }, [internalFilesSelected, numberOfChunksToFetch, minDate, maxDate, setChatFilters]) + useEffect(() => { + setChatFilters((currentFilters) => ({ + ...currentFilters, + files: [...new Set([...currentFilters.files, ...internalFilesSelected])], + limit: numberOfChunksToFetch, + minDate, + maxDate, + passFullNoteIntoContext: true, + })) + }, [internalFilesSelected, numberOfChunksToFetch, minDate, maxDate, setChatFilters]) const handleNumberOfChunksChange = (event: Event, value: number | number[]) => { const newValue = Array.isArray(value) ? value[0] : value diff --git a/src/components/Chat/ChatInput.tsx b/src/components/Chat/ChatInput.tsx index 6bde0bbb..8ee5bdc3 100644 --- a/src/components/Chat/ChatInput.tsx +++ b/src/components/Chat/ChatInput.tsx @@ -1,19 +1,20 @@ import React from 'react' import { PiPaperPlaneRight } from 'react-icons/pi' +import { LoadingState } from './types' interface ChatInputProps { userTextFieldInput: string setUserTextFieldInput: (value: string) => void handleSubmitNewMessage: () => void - loadingResponse: boolean + loadingState: LoadingState } const ChatInput: React.FC = ({ userTextFieldInput, setUserTextFieldInput, handleSubmitNewMessage, - loadingResponse, + loadingState, }) => (
@@ -45,7 +46,7 @@ const ChatInput: React.FC = ({ />
diff --git a/src/components/Chat/ChatMessages.tsx b/src/components/Chat/ChatMessages.tsx index 57afab11..70dba045 100644 --- a/src/components/Chat/ChatMessages.tsx +++ b/src/components/Chat/ChatMessages.tsx @@ -1,92 +1,59 @@ -import React, { MutableRefObject, useState } from 'react' -import { HiOutlineClipboardCopy, HiOutlinePencilAlt } from 'react-icons/hi' -import { toast } from 'react-toastify' -import ReactMarkdown from 'react-markdown' -import rehypeRaw from 'rehype-raw' -import { FaRegUserCircle } from 'react-icons/fa' +import React, { useState } from 'react' import '../../styles/chat.css' -import { Chat, ChatFilters, ReorChatMessage } from './types' -import { useWindowContentContext } from '@/contexts/WindowContentContext' +import { Chat, ChatFilters, LoadingState, ReorChatMessage } from './types' import ChatInput from './ChatInput' -import { getClassNameBasedOnMessageRole, getDisplayMessage } from './utils' import LoadingDots from '@/utils/animations' +import UserMessage from './MessageComponents/UserMessage' +import AssistantMessage from './MessageComponents/AssistantMessage' +import SystemMessage from './MessageComponents/SystemMessage' interface ChatMessagesProps { - currentChatHistory: Chat | undefined - chatContainerRef: MutableRefObject - loadAnimation: boolean - handleNewChatMessage: (userTextFieldInput: string | undefined, chatFilters?: ChatFilters) => void - loadingResponse: boolean + currentChat: Chat + setCurrentChat: React.Dispatch> + loadingState: LoadingState + handleNewChatMessage: (userTextFieldInput?: string, chatFilters?: ChatFilters) => void } const ChatMessages: React.FC = ({ - currentChatHistory, - chatContainerRef, + currentChat, + setCurrentChat, handleNewChatMessage, - loadAnimation, - loadingResponse, + loadingState, }) => { - const { openContent: openTabContent } = useWindowContentContext() const [userTextFieldInput, setUserTextFieldInput] = useState() - const copyToClipboard = (message: ReorChatMessage) => { - navigator.clipboard.writeText(getDisplayMessage(message) ?? '') - toast.success('Copied to clipboard!') - } - - const createNewNoteFromMessage = async (message: ReorChatMessage) => { - const title = `${(getDisplayMessage(message) ?? `${new Date().toDateString()}`).substring(0, 20)}...` - openTabContent(title, getDisplayMessage(message)) + const renderMessage = (message: ReorChatMessage, index: number) => { + switch (message.role) { + case 'user': + return + case 'assistant': + return ( + + ) + case 'system': + return + default: + return null + } } return (
-
+
- {currentChatHistory && - currentChatHistory.messages && - currentChatHistory.messages.length > 0 && - currentChatHistory.messages - .filter((msg) => msg.role !== 'system') - .map((message, index) => ( - // eslint-disable-next-line react/no-array-index-key -
-
- {message.role === 'user' ? ( - - ) : ( - ReorImage - )} -
-
-
- - {getDisplayMessage(message)} - - {message.role === 'assistant' && ( -
-
copyToClipboard(message)} - > - -
-
createNewNoteFromMessage(message)} - > - -
-
- )} -
-
-
- ))} + {currentChat?.messages?.length > 0 && + currentChat.messages.map((message, index) => renderMessage(message, index))}
- {loadAnimation && ( + {loadingState === 'waiting-for-first-token' && (
ReorImage @@ -95,15 +62,18 @@ const ChatMessages: React.FC = ({
- {currentChatHistory && ( + {currentChat && (
{ - handleNewChatMessage(userTextFieldInput) + if (userTextFieldInput) { + handleNewChatMessage(userTextFieldInput) + setUserTextFieldInput('') + } }} - loadingResponse={loadingResponse} + loadingState={loadingState} />
)} diff --git a/src/components/Chat/ChatSidebar.tsx b/src/components/Chat/ChatSidebar.tsx index 79df1d0e..fa0fb9ad 100644 --- a/src/components/Chat/ChatSidebar.tsx +++ b/src/components/Chat/ChatSidebar.tsx @@ -1,23 +1,24 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useState } from 'react' import { RiChatNewFill, RiArrowDownSLine } from 'react-icons/ri' import { IoChatbubbles } from 'react-icons/io5' import posthog from 'posthog-js' -import { ChatMetadata, useChatContext } from '@/contexts/ChatContext' +import { useChatContext } from '@/contexts/ChatContext' import { useWindowContentContext } from '@/contexts/WindowContentContext' +import { ChatMetadata } from './types' export interface ChatItemProps { chatMetadata: ChatMetadata } export const ChatItem: React.FC = ({ chatMetadata }) => { - const { currentOpenChat } = useChatContext() + const { currentOpenChatID } = useChatContext() const { openContent, showContextMenu } = useWindowContentContext() const itemClasses = ` flex items-center cursor-pointer py-2 px-3 rounded-md transition-colors duration-150 ease-in-out - ${chatMetadata.id === currentOpenChat?.id ? 'bg-neutral-700 text-white' : 'text-gray-300 hover:bg-neutral-800'} + ${chatMetadata.id === currentOpenChatID ? 'bg-neutral-700 text-white' : 'text-gray-300 hover:bg-neutral-800'} ` return (
@@ -42,23 +43,9 @@ export const ChatSidebar: React.FC = () => { const [isRecentsOpen, setIsRecentsOpen] = useState(true) const dropdownAnimationDelay = 0.02 - const { setShowChatbot, allChatsMetadata, setCurrentOpenChat } = useChatContext() + const { setShowChatbot, allChatsMetadata, setCurrentOpenChatID } = useChatContext() const toggleRecents = () => setIsRecentsOpen((prev) => !prev) - const currentSelectedChatID = useRef() - - useEffect(() => { - const deleteChatRow = window.ipcRenderer.receive('delete-chat-at-id', (chatID) => { - if (chatID === currentSelectedChatID.current) { - setShowChatbot(false) - } - window.electronStore.deleteChatAtID(chatID) - }) - - return () => { - deleteChatRow() - } - }, [allChatsMetadata, setShowChatbot]) return (
@@ -74,7 +61,7 @@ export const ChatSidebar: React.FC = () => { onClick={() => { posthog.capture('create_new_chat') setShowChatbot(true) - setCurrentOpenChat(undefined) + setCurrentOpenChatID(undefined) }} > @@ -94,7 +81,7 @@ export const ChatSidebar: React.FC = () => {
    {allChatsMetadata .slice() - .reverse() + .sort((a, b) => b.timeOfLastMessage - a.timeOfLastMessage) .map((chatMetadata, index) => (
  • > + currentChat: Chat + messageIndex: number + handleNewChatMessage: (userTextFieldInput?: string, chatFilters?: ChatFilters) => void +} + +const AssistantMessage: React.FC = ({ + message, + setCurrentChat, + currentChat, + messageIndex, + handleNewChatMessage, +}) => { + const { openContent } = useWindowContentContext() + const { saveChat } = useChatContext() + + const { textParts, toolCalls } = useMemo(() => { + const outputTextParts: string[] = [] + const outputToolCalls: ToolCallPart[] = [] + + if (typeof message.content === 'string') { + outputTextParts.push(getDisplayMessage(message) || '') + } else if (Array.isArray(message.content)) { + message.content.forEach((part) => { + if ('text' in part) { + outputTextParts.push(part.text) + } else if (part.type === 'tool-call') { + outputToolCalls.push(part) + } + }) + } + + return { textParts: outputTextParts, toolCalls: outputToolCalls } + }, [message]) + + const copyToClipboard = () => { + const content = typeof message.content === 'string' ? message.content : JSON.stringify(message.content, null, 2) + navigator.clipboard.writeText(content) + toast.success('Copied to clipboard!') + } + + const createNewNoteFromMessage = async () => { + const content = typeof message.content === 'string' ? message.content : JSON.stringify(message.content, null, 2) + const title = `${content.substring(0, 20)}...` + openContent(title, content) + } + const executeToolCall = useCallback( + async (toolCallPart: ToolCallPart) => { + const existingToolResult = findToolResultMatchingToolCall(toolCallPart.toolCallId, currentChat) + if (existingToolResult) { + toast.error('Tool call id already exists') + return + } + const toolResult = await createToolResult( + toolCallPart.toolName, + toolCallPart.args as any, + toolCallPart.toolCallId, + ) + const toolMessage: CoreToolMessage = { + role: 'tool', + content: [toolResult], + } + + setCurrentChat((prevChat) => { + if (!prevChat) return prevChat + const updatedChat = { + ...prevChat, + messages: [...prevChat.messages, toolMessage], + } + saveChat(updatedChat) + return updatedChat + }) + }, + [currentChat, setCurrentChat, saveChat], + ) + + const isLatestAssistantMessage = (index: number, messages: ReorChatMessage[]) => { + return messages.slice(index + 1).every((msg) => msg.role !== 'assistant') + } + + useEffect(() => { + if (!isLatestAssistantMessage(messageIndex, currentChat.messages)) return + toolCalls.forEach((toolCall) => { + // TODO: Add condition to check this is the latest message. + const existingToolCall = findToolResultMatchingToolCall(toolCall.toolCallId, currentChat) + const toolDefinition = currentChat.toolDefinitions.find((definition) => definition.name === toolCall.toolName) + if (toolDefinition && toolDefinition.autoExecute && !existingToolCall) { + executeToolCall(toolCall) + } + }) + }, [currentChat, currentChat.toolDefinitions, executeToolCall, toolCalls, messageIndex]) + + useEffect(() => { + if (!isLatestAssistantMessage(messageIndex, currentChat.messages)) return + + const shouldLLMRespondToToolResults = + toolCalls.length > 0 && + toolCalls.every((toolCall) => { + const existingToolResult = findToolResultMatchingToolCall(toolCall.toolCallId, currentChat) + const toolDefinition = currentChat.toolDefinitions.find((definition) => definition.name === toolCall.toolName) + return existingToolResult && toolDefinition?.autoExecute + }) + + if (shouldLLMRespondToToolResults) { + handleNewChatMessage() + } + }, [currentChat, currentChat.toolDefinitions, executeToolCall, toolCalls, messageIndex, handleNewChatMessage]) + + const renderContent = () => { + return ( + <> + {textParts.map((text, index) => ( + + ))} + {toolCalls.map((toolCall, index) => ( + + ))} + + ) + } + + return ( +
    +
    + ReorImage +
    +
    +
    + {renderContent()} +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + ) +} +export default AssistantMessage diff --git a/src/components/Chat/MessageComponents/InChatContext.tsx b/src/components/Chat/MessageComponents/InChatContext.tsx new file mode 100644 index 00000000..681d367f --- /dev/null +++ b/src/components/Chat/MessageComponents/InChatContext.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import { Card, CardContent, CardFooter } from '@/components/ui/card' +import { MarkdownContent } from '@/components/File/DBResultPreview' +import { useWindowContentContext } from '@/contexts/WindowContentContext' + +interface RenderableContextType { + content: string + name: string + path: string +} + +interface InChatContextComponentProps { + contextList: RenderableContextType[] +} + +const InChatContextComponent: React.FC = ({ contextList }) => { + const { openContent } = useWindowContentContext() + const truncateContent = (content: string, maxLength: number) => { + if (content.length <= maxLength) return content + return `${content.slice(0, maxLength)}...` + } + + return ( +
    + {contextList.map((contextItem) => ( + openContent(contextItem.path)} + > + + + + +

    {contextItem.name}

    +
    +
    + ))} +
    + ) +} + +export default InChatContextComponent diff --git a/src/components/Chat/MessageComponents/SystemMessage.tsx b/src/components/Chat/MessageComponents/SystemMessage.tsx new file mode 100644 index 00000000..159ef98f --- /dev/null +++ b/src/components/Chat/MessageComponents/SystemMessage.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import ReactMarkdown from 'react-markdown' +import rehypeRaw from 'rehype-raw' +import { ReorChatMessage } from '../types' +import { getClassNameBasedOnMessageRole, getDisplayMessage } from '../utils' + +interface SystemMessageProps { + message: ReorChatMessage +} + +const SystemMessage: React.FC = ({ message }) => ( +
    +
    +
    + + {getDisplayMessage(message)} + +
    +
    +
    +) + +export default SystemMessage diff --git a/src/components/Chat/MessageComponents/ToolCalls.tsx b/src/components/Chat/MessageComponents/ToolCalls.tsx new file mode 100644 index 00000000..fa85df87 --- /dev/null +++ b/src/components/Chat/MessageComponents/ToolCalls.tsx @@ -0,0 +1,150 @@ +import { CoreToolMessage, ToolCallPart } from 'ai' +import React from 'react' +import ReactMarkdown from 'react-markdown' +import rehypeRaw from 'rehype-raw' +import { FileInfoWithContent } from 'electron/main/filesystem/types' +import { Chat } from '../types' +import InChatContextComponent from './InChatContext' +import { findToolResultMatchingToolCall } from '../utils' + +interface TextPartProps { + text: string +} + +export const TextPart: React.FC = ({ text }) => ( + + {text} + +) + +interface ToolCallComponentProps { + toolCallPart: ToolCallPart + currentChat: Chat + executeToolCall: (toolCall: ToolCallPart) => Promise +} + +interface ToolRendererProps { + // eslint-disable-next-line react/no-unused-prop-types + toolCallPart: ToolCallPart + existingToolResult: CoreToolMessage | undefined + // eslint-disable-next-line react/no-unused-prop-types + executeToolCall: (toolCall: ToolCallPart) => Promise +} + +const SearchToolRenderer: React.FC = ({ existingToolResult }) => { + const parseResult = (): FileInfoWithContent[] | null => { + if (!existingToolResult || !existingToolResult.content[0].result) return null + + try { + const result = existingToolResult.content[0].result as FileInfoWithContent[] + // Optional: Add a simple check to ensure it's an array + if (!Array.isArray(result)) throw new Error('Result is not an array') + return result + } catch (error) { + return null + } + } + + const parsedResult = parseResult() + + return ( +
    + {/*
    +        {JSON.stringify(toolCallPart.args, null, 2)}
    +      
    */} + {existingToolResult && ( +
    + {parsedResult ? ( + + ) : ( +
    +              {JSON.stringify(existingToolResult.content[0].result, null, 2)}
    +            
    + )} +
    + )} +
    + ) +} + +const CreateFileToolRenderer: React.FC = ({ toolCallPart, existingToolResult, executeToolCall }) => ( +
    +

    Create File Tool Call

    +
    +      {JSON.stringify(toolCallPart.args, null, 2)}
    +    
    + {existingToolResult ? ( +
    +
    File Creation Result:
    +
    +          {JSON.stringify(existingToolResult.content[0].result, null, 2)}
    +        
    +
    + ) : ( + + )} +
    +) + +const DefaultToolRenderer: React.FC = ({ toolCallPart, existingToolResult, executeToolCall }) => ( +
    +

    Tool Call: {toolCallPart.toolName}

    +
    +      {JSON.stringify(toolCallPart.args, null, 2)}
    +    
    + {existingToolResult ? ( +
    +
    Tool Result:
    +
    +          {JSON.stringify(existingToolResult.content[0].result, null, 2)}
    +        
    +
    + ) : ( + + )} +
    +) + +export const ToolCallComponent: React.FC = ({ toolCallPart, currentChat, executeToolCall }) => { + const existingToolResult = findToolResultMatchingToolCall(toolCallPart.toolCallId, currentChat) + + return ( + <> + {toolCallPart.toolName === 'search' && ( + + )} + {toolCallPart.toolName === 'create-file' && ( + + )} + {toolCallPart.toolName !== 'search' && toolCallPart.toolName !== 'create-file' && ( + + )} + + ) +} + +export default ToolCallComponent diff --git a/src/components/Chat/MessageComponents/UserMessage.tsx b/src/components/Chat/MessageComponents/UserMessage.tsx new file mode 100644 index 00000000..63de8a25 --- /dev/null +++ b/src/components/Chat/MessageComponents/UserMessage.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { FaRegUserCircle } from 'react-icons/fa' +import ReactMarkdown from 'react-markdown' +import rehypeRaw from 'rehype-raw' +import { ReorChatMessage } from '../types' +import { getClassNameBasedOnMessageRole, getDisplayMessage } from '../utils' + +interface UserMessageProps { + message: ReorChatMessage +} + +const UserMessage: React.FC = ({ message }) => ( +
    +
    + +
    +
    +
    + + {getDisplayMessage(message)} + +
    +
    +
    +) + +export default UserMessage diff --git a/src/components/Chat/StartChat.tsx b/src/components/Chat/StartChat.tsx index a30b288d..9af89922 100644 --- a/src/components/Chat/StartChat.tsx +++ b/src/components/Chat/StartChat.tsx @@ -14,17 +14,16 @@ const EXAMPLE_PROMPT_OPTIONS = [ interface StartChatProps { defaultModelName: string - handleNewChatMessage: (userTextFieldInput: string | undefined, chatFilters?: ChatFilters) => void + handleNewChatMessage: (userTextFieldInput?: string, chatFilters?: ChatFilters) => void } const StartChat: React.FC = ({ defaultModelName, handleNewChatMessage }) => { const [llmConfigs, setLLMConfigs] = useState([]) const [selectedLLM, setSelectedLLM] = useState(defaultModelName) - // text input state: - const [userTextFieldInput, setUserTextFieldInput] = useState() + const [userTextFieldInput, setUserTextFieldInput] = useState('') const [chatFilters, setChatFilters] = useState({ files: [], - numberOfChunksToFetch: 15, + limit: 15, minDate: new Date(0), maxDate: new Date(), }) @@ -36,14 +35,13 @@ const StartChat: React.FC = ({ defaultModelName, handleNewChatMe setLLMConfigs(LLMConfigs) const defaultLLM = await window.llm.getDefaultLLMName() setSelectedLLM(defaultLLM) - console.log('fetched defaultLLM', defaultLLM) } fetchLLMModels() }, []) const sendMessageHandler = async () => { - handleNewChatMessage(userTextFieldInput, chatFilters) await window.llm.setDefaultLLM(selectedLLM) + handleNewChatMessage(userTextFieldInput, chatFilters) } return ( diff --git a/src/components/Chat/index.tsx b/src/components/Chat/index.tsx index ab03178b..ec530f08 100644 --- a/src/components/Chat/index.tsx +++ b/src/components/Chat/index.tsx @@ -1,24 +1,27 @@ -import React, { useCallback, useEffect, useState, useRef } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import posthog from 'posthog-js' import { streamText } from 'ai' -import { anonymizeChatFiltersForPosthog, resolveLLMClient, resolveRAGContext } from './utils' +import { + anonymizeChatFiltersForPosthog, + resolveLLMClient, + appendNewMessageToChat, + appendTextContentToMessages, +} from './utils' import '../../styles/chat.css' import ChatMessages from './ChatMessages' -import { Chat, ChatFilters } from './types' +import { Chat, ChatFilters, LoadingState } from './types' import { useChatContext } from '@/contexts/ChatContext' import StartChat from './StartChat' +import { convertToolConfigToZodSchema } from './tools' const ChatComponent: React.FC = () => { - const [loadingResponse, setLoadingResponse] = useState(false) - const [loadAnimation, setLoadAnimation] = useState(false) - const [readyToSave, setReadyToSave] = useState(false) + const [loadingState, setLoadingState] = useState('idle') const [defaultModelName, setDefaultLLMName] = useState('') - const chatContainerRef = useRef(null) - - const { setCurrentOpenChat, currentOpenChat } = useChatContext() + const [currentChat, setCurrentChat] = useState(undefined) + const { saveChat, currentOpenChatID, setCurrentOpenChatID } = useChatContext() useEffect(() => { const fetchDefaultLLM = async () => { @@ -28,174 +31,89 @@ const ChatComponent: React.FC = () => { fetchDefaultLLM() }, []) - // useEffect(() => { - // const setContextOnFileAdded = async () => { - // if (chatFilters.files.length > 0) { - // const results = await window.fileSystem.getFilesystemPathsAsDBItems(chatFilters.files) - // setCurrentContext(results as DBQueryResult[]) - // } else if (!currentChatHistory?.id) { - // // if there is no prior history, set current context to empty - // setCurrentContext([]) - // } - // } - // setContextOnFileAdded() - // }, [chatFilters.files, currentChatHistory?.id]) - useEffect(() => { - if (readyToSave && currentOpenChat) { - window.electronStore.updateChat(currentOpenChat) - setReadyToSave(false) - } - }, [readyToSave, currentOpenChat]) - - const appendNewContentToMessageHistory = useCallback( - (chatID: string, newContent: string) => { - setCurrentOpenChat((prev) => { - if (chatID !== prev?.id) return prev - const newDisplayableHistory = prev?.messages || [] - if (newDisplayableHistory.length > 0) { - const lastMessage = newDisplayableHistory[newDisplayableHistory.length - 1] - if (lastMessage.role === 'assistant') { - lastMessage.content += newContent - } else { - newDisplayableHistory.push({ - role: 'assistant', - content: newContent, - context: [], - }) - } - } else { - newDisplayableHistory.push({ - role: 'assistant', - content: newContent, - context: [], - }) - } - return { - id: prev!.id, - messages: newDisplayableHistory, + const fetchChat = async () => { + const chat = await window.electronStore.getChat(currentOpenChatID) + setCurrentChat((oldChat) => { + if (oldChat) { + saveChat(oldChat) } + return chat }) - }, - [setCurrentOpenChat], - ) - - const handleSubmitNewMessage = useCallback( - async (currentChat: Chat | undefined, userTextFieldInput: string | undefined, chatFilters?: ChatFilters) => { - posthog.capture('chat_message_submitted', { - chatId: currentChat?.id, - chatLength: currentChat?.messages.length, - chatFilters: anonymizeChatFiltersForPosthog(chatFilters), - }) - let outputChat = currentChat - - if (loadingResponse || !userTextFieldInput?.trim()) return + setLoadingState('idle') + } + fetchChat() + }, [currentOpenChatID, saveChat]) - setLoadingResponse(true) - setLoadAnimation(true) + const handleNewChatMessage = useCallback( + async (userTextFieldInput?: string, chatFilters?: ChatFilters) => { + try { + const defaultLLMName = await window.llm.getDefaultLLMName() - const defaultLLMName = await window.llm.getDefaultLLMName() - if (!outputChat || !outputChat.id) { - outputChat = { - id: Date.now().toString(), - messages: [], + if (!userTextFieldInput?.trim() && (!currentChat || currentChat.messages.length === 0)) { + return } - } - if (outputChat.messages.length === 0 && chatFilters) { - outputChat.messages.push(await resolveRAGContext(userTextFieldInput ?? '', chatFilters)) - } else { - outputChat.messages.push({ - role: 'user', - content: userTextFieldInput, - context: [], - }) - } - // setUserTextFieldInput('') - setCurrentOpenChat(outputChat) + let outputChat = userTextFieldInput?.trim() + ? await appendNewMessageToChat(currentChat, userTextFieldInput, chatFilters) + : currentChat - if (!outputChat) return + if (!outputChat) { + return + } - await window.electronStore.updateChat(outputChat) + setCurrentChat(outputChat) + setCurrentOpenChatID(outputChat.id) + await saveChat(outputChat) - const client = await resolveLLMClient(defaultLLMName) - const { textStream } = await streamText({ - model: client, - messages: outputChat.messages, - }) + const client = await resolveLLMClient(defaultLLMName) - // eslint-disable-next-line no-restricted-syntax - for await (const textPart of textStream) { - setLoadAnimation(false) - appendNewContentToMessageHistory(outputChat.id, textPart) + const { textStream, toolCalls } = await streamText({ + model: client, + messages: outputChat.messages, + tools: Object.assign({}, ...outputChat.toolDefinitions.map(convertToolConfigToZodSchema)), + }) + // eslint-disable-next-line no-restricted-syntax + for await (const text of textStream) { + outputChat = { + ...outputChat, + messages: appendTextContentToMessages(outputChat.messages || [], text), + } + setCurrentChat(outputChat) + setLoadingState('generating') + } + outputChat.messages = appendTextContentToMessages(outputChat.messages, await toolCalls) + setCurrentChat(outputChat) + await saveChat(outputChat) + + setLoadingState('idle') + posthog.capture('chat_message_submitted', { + chatId: outputChat?.id, + chatLength: outputChat?.messages.length, + chatFilters: anonymizeChatFiltersForPosthog(chatFilters), + }) + } catch (error) { + setLoadingState('idle') + throw error } - setLoadingResponse(false) - setReadyToSave(true) - }, - [loadingResponse, setCurrentOpenChat, appendNewContentToMessageHistory], - ) - - // useEffect(() => { - // // Update context when the chat history changes - // const context = getChatHistoryContext(currentChatHistory) - // setCurrentContext(context) - - // if (!promptSelected) { - // setLoadAnimation(false) - // setLoadingResponse(false) - // } else { - // setPromptSelected(false) - // } - // }, [currentChatHistory, currentChatHistory?.id, promptSelected]) - - // useEffect(() => { - // // Handle prompt selection and message submission separately - // if (promptSelected) { - // handleSubmitNewMessage(undefined) - // } - // /* eslint-disable-next-line react-hooks/exhaustive-deps */ - // }, [promptSelected]) - - // const handleNewChatMessage = useCallback( - // (prompt: string | undefined) => { - // setUserTextFieldInput(prompt) - // }, - // [setUserTextFieldInput], - // ) - - const handleNewChatMessage = useCallback( - (userTextFieldInput: string | undefined, chatFilters?: ChatFilters) => { - handleSubmitNewMessage(currentOpenChat, userTextFieldInput, chatFilters) }, - [currentOpenChat, handleSubmitNewMessage], + [setCurrentOpenChatID, saveChat, currentChat], ) return (
    -
    - {currentOpenChat && currentOpenChat.messages && currentOpenChat.messages.length > 0 ? ( +
    + {currentChat && currentChat.messages && currentChat.messages.length > 0 ? ( ) : ( )}
    - {/* {showSimilarFiles && ( - { - openTabContent(path) - posthog.capture('open_file_from_chat_context') - }} - isLoadingSimilarEntries={false} - /> - )} */}
    ) } diff --git a/src/components/Chat/tools.ts b/src/components/Chat/tools.ts new file mode 100644 index 00000000..e62292e0 --- /dev/null +++ b/src/components/Chat/tools.ts @@ -0,0 +1,129 @@ +import { ToolResultPart } from 'ai' +import { z } from 'zod' +import { ToolDefinition } from './types' +import { retreiveFromVectorDB } from '@/utils/db' + +export const searchTool: ToolDefinition = { + name: 'search', + description: "Semantically search the user's personal knowledge base", + parameters: [ + { + name: 'query', + type: 'string', + description: 'The query to search for', + }, + { + name: 'limit', + type: 'number', + defaultValue: 10, + description: 'The number of results to return', + }, + ], + autoExecute: true, +} + +export const createNoteTool: ToolDefinition = { + name: 'createNote', + description: "Create a new note in the user's personal knowledge base", + parameters: [ + { + name: 'filename', + type: 'string', + description: 'The filename of the note', + }, + { + name: 'content', + type: 'string', + description: 'The content of the note', + }, + ], +} + +type ToolFunction = (...args: any[]) => Promise + +type ToolFunctionMap = { + [key: string]: ToolFunction +} + +export const toolNamesToFunctions: ToolFunctionMap = { + search: async (query: string, limit: number): Promise => { + const results = await retreiveFromVectorDB(query, { limit, passFullNoteIntoContext: true }) + return results + }, + createNote: async (filename: string, content: string): Promise => { + const vault = await window.electronStore.getVaultDirectoryForWindow() + const path = await window.path.join(vault, filename) + await window.fileSystem.createFile(path, content) + return `Note ${filename} created successfully` + }, +} + +type ToolName = keyof typeof toolNamesToFunctions + +export async function executeTool(toolName: ToolName, args: unknown[]): Promise { + const tool = toolNamesToFunctions[toolName] + if (!tool) { + throw new Error(`Unknown tool: ${toolName}`) + } + const out = await tool(...Object.values(args)) // TODO: make this cleaner quizas. + return out +} + +export async function createToolResult(toolName: string, args: unknown[], toolCallId: string): Promise { + try { + const result = await executeTool(toolName, args) + return { + type: 'tool-result', + toolCallId, + toolName, + result, + } + } catch (error) { + return { + type: 'tool-result', + toolCallId, + toolName, + result: error, + isError: true, + } + } +} + +export function convertToolConfigToZodSchema(tool: ToolDefinition) { + const parameterSchema = z.object( + tool.parameters.reduce((acc, param) => { + let zodType: z.ZodType + + switch (param.type) { + case 'string': + zodType = z.string() + break + case 'number': + zodType = z.number() + break + case 'boolean': + zodType = z.boolean() + break + default: + throw new Error(`Unsupported parameter type: ${param.type}`) + } + + // Apply default value if it exists + if (param.defaultValue !== undefined) { + zodType = zodType.default(param.defaultValue) + } + + // Apply description + zodType = zodType.describe(param.description) + + return { ...acc, [param.name]: zodType } + }, {}), + ) + + return { + [tool.name]: { + description: tool.description, + parameters: parameterSchema, + }, + } +} diff --git a/src/components/Chat/types.ts b/src/components/Chat/types.ts index 4e4b12e9..65c7fe93 100644 --- a/src/components/Chat/types.ts +++ b/src/components/Chat/types.ts @@ -1,22 +1,49 @@ import { CoreMessage } from 'ai' +import { FileInfoWithContent } from 'electron/main/filesystem/types' import { DBEntry } from 'electron/main/vector-database/schema' export type ReorChatMessage = CoreMessage & { - context?: DBEntry[] - visibleContent?: string + context?: DBEntry[] | FileInfoWithContent[] + visibleContent?: string // what to display in the chat bubble +} + +type ParameterType = 'string' | 'number' | 'boolean' + +type ToolParameter = { + name: string + type: ParameterType + defaultValue?: string | number | boolean + description: string +} + +export type ToolDefinition = { + name: string + description: string + parameters: ToolParameter[] + autoExecute?: boolean } export type Chat = { [x: string]: any // used to delete legacy properties in store migrator. id: string messages: ReorChatMessage[] + displayName: string + timeOfLastMessage: number + toolDefinitions: ToolDefinition[] } -export interface ChatFilters { - numberOfChunksToFetch: number - files: string[] +export type ChatMetadata = Omit + +export interface SearchFilters { + limit: number minDate?: Date maxDate?: Date + passFullNoteIntoContext?: boolean +} + +export type ChatFilters = SearchFilters & { + files: string[] + propertiesToIncludeInContext?: string[] } export interface AnonymizedChatFilters { @@ -25,3 +52,5 @@ export interface AnonymizedChatFilters { minDate?: Date maxDate?: Date } + +export type LoadingState = 'idle' | 'generating' | 'waiting-for-first-token' diff --git a/src/components/Chat/utils.ts b/src/components/Chat/utils.ts index 04f3fbc7..dedee39e 100644 --- a/src/components/Chat/utils.ts +++ b/src/components/Chat/utils.ts @@ -1,20 +1,42 @@ -import { DBEntry, DBQueryResult } from 'electron/main/vector-database/schema' +import { DBEntry } from 'electron/main/vector-database/schema' import { createOpenAI } from '@ai-sdk/openai' import { createAnthropic } from '@ai-sdk/anthropic' +import { FileInfoWithContent } from 'electron/main/filesystem/types' +import getDisplayableChatName from '@shared/utils' +import { AssistantContent, CoreToolMessage, ToolCallPart } from 'ai' import { AnonymizedChatFilters, Chat, ChatFilters, ReorChatMessage } from './types' - -export const appendTextContentToMessages = (messages: ReorChatMessage[], text: string): ReorChatMessage[] => { - if (text === '') { +import { createNoteTool, searchTool } from './tools' +import { retreiveFromVectorDB } from '@/utils/db' + +export const appendTextContentToMessages = ( + messages: ReorChatMessage[], + content: string | ToolCallPart[], +): ReorChatMessage[] => { + if (content === '' || (Array.isArray(content) && content.length === 0)) { return messages } + + const appendContent = (existingContent: AssistantContent, newContent: string | ToolCallPart[]): AssistantContent => { + if (typeof existingContent === 'string') { + return typeof newContent === 'string' + ? existingContent + newContent + : [{ type: 'text' as const, text: existingContent }, ...newContent] + } + return [ + ...existingContent, + ...(typeof newContent === 'string' ? [{ type: 'text' as const, text: newContent }] : newContent), + ] + } + if (messages.length === 0) { return [ { role: 'assistant', - content: text, + content: typeof content === 'string' ? content : content, }, ] } + const lastMessage = messages[messages.length - 1] if (lastMessage.role === 'assistant') { @@ -22,15 +44,16 @@ export const appendTextContentToMessages = (messages: ReorChatMessage[], text: s ...messages.slice(0, -1), { ...lastMessage, - content: lastMessage.content + text, + content: appendContent(lastMessage.content, content), }, ] } + return [ ...messages, { role: 'assistant', - content: text, + content: typeof content === 'string' ? content : content, }, ] } @@ -48,74 +71,75 @@ export const convertMessageToString = (message: ReorChatMessage | undefined): st return '' } -export const generateTimeStampFilter = (minDate?: Date, maxDate?: Date): string => { - let filter = '' - - if (minDate) { - const minDateStr = minDate.toISOString().slice(0, 19).replace('T', ' ') - filter += `filemodified > timestamp '${minDateStr}'` - } - - if (maxDate) { - const maxDateStr = maxDate.toISOString().slice(0, 19).replace('T', ' ') - if (filter) { - filter += ' AND ' - } - filter += `filemodified < timestamp '${maxDateStr}'` - } - - return filter +const generateStringOfContextItemsForPrompt = (contextItems: DBEntry[] | FileInfoWithContent[]): string => { + return contextItems.map((item) => item.content).join('\n\n') } -export const resolveRAGContext = async (query: string, chatFilters: ChatFilters): Promise => { - let results: DBEntry[] = [] +export const generateRAGMessages = async (query: string, chatFilters: ChatFilters): Promise => { + let results: DBEntry[] | FileInfoWithContent[] = [] if (chatFilters.files.length > 0) { - results = await window.fileSystem.getFilesystemPathsAsDBItems(chatFilters.files) - } else if (chatFilters.numberOfChunksToFetch > 0) { - const timeStampFilter = generateTimeStampFilter(chatFilters.minDate, chatFilters.maxDate) - results = await window.database.search(query, chatFilters.numberOfChunksToFetch, timeStampFilter) + results = await window.fileSystem.getFiles(chatFilters.files) + } else { + results = await retreiveFromVectorDB(query, chatFilters) } - return { - role: 'user', - context: results, - content: `Based on the following context answer the question down below. \n\n\nContext: \n${results - .map((dbItem) => dbItem.content) - .join('\n\n')}\n\n\nQuery:\n${query}`, - visibleContent: query, - } -} - -export const getChatHistoryContext = (chatHistory: Chat | undefined): DBQueryResult[] => { - if (!chatHistory || !chatHistory.messages) return [] - const contextForChat = chatHistory.messages.map((message) => message.context).flat() - return contextForChat as DBQueryResult[] + const contextString = generateStringOfContextItemsForPrompt(results) + return [ + { + role: 'user', + context: results, + content: `Based on the following context answer the question down below. \n\n\nContext: \n${contextString}\n\n\nQuery:\n${query}`, + visibleContent: query, + }, + ] } -export const getDisplayableChatName = (chat: Chat): string => { - if (!chat.messages || chat.messages.length === 0 || !chat.messages[chat.messages.length - 1].content) { - return 'Empty Chat' +export const appendNewMessageToChat = async ( + currentChat: Chat | undefined, + userTextFieldInput: string, + chatFilters?: ChatFilters, +): Promise => { + let outputChat = currentChat + + if (!outputChat || !outputChat.id) { + const newID = Date.now().toString() + outputChat = { + id: newID, + messages: [], + displayName: '', + timeOfLastMessage: Date.now(), + toolDefinitions: [], + } } - const lastMsg = chat.messages[0] - - if (lastMsg.visibleContent) { - return lastMsg.visibleContent.slice(0, 30) + if (outputChat.messages.length === 0 && chatFilters) { + const ragMessages = await generateRAGMessages(userTextFieldInput ?? '', chatFilters) + outputChat.messages.push(...ragMessages) + outputChat.displayName = getDisplayableChatName(outputChat.messages) + outputChat.toolDefinitions = [searchTool, createNoteTool] + } else { + outputChat.messages.push({ + role: 'user', + content: userTextFieldInput, + context: [], + }) } - const lastMessage = lastMsg.content - if (!lastMessage || lastMessage === '' || typeof lastMessage !== 'string') { - return 'Empty Chat' - } - return lastMessage.slice(0, 30) + return outputChat } +// export const getChatHistoryContext = (chatHistory: Chat | undefined): DBQueryResult[] => { +// if (!chatHistory || !chatHistory.messages) return [] +// const contextForChat = chatHistory.messages.map((message) => message.context).flat() +// return contextForChat as DBQueryResult[] +// } + export function anonymizeChatFiltersForPosthog( chatFilters: ChatFilters | undefined, ): AnonymizedChatFilters | undefined { if (!chatFilters) { return undefined } - const { numberOfChunksToFetch, files, minDate, maxDate } = chatFilters + const { limit: numberOfChunksToFetch, files, minDate, maxDate } = chatFilters return { numberOfChunksToFetch, filesLength: files.length, @@ -167,3 +191,9 @@ export const getClassNameBasedOnMessageRole = (message: ReorChatMessage): string export const getDisplayMessage = (message: ReorChatMessage): string | undefined => { return message.visibleContent || typeof message.content !== 'string' ? message.visibleContent : message.content } + +export const findToolResultMatchingToolCall = (toolCallId: string, currentChat: Chat): CoreToolMessage | undefined => { + return currentChat.messages.find( + (message) => message.role === 'tool' && message.content.some((content) => content.toolCallId === toolCallId), + ) as CoreToolMessage | undefined +} diff --git a/src/components/Menu/CustomContextMenu.tsx b/src/components/Common/CustomContextMenu.tsx similarity index 95% rename from src/components/Menu/CustomContextMenu.tsx rename to src/components/Common/CustomContextMenu.tsx index 8187e4d5..5f370813 100644 --- a/src/components/Menu/CustomContextMenu.tsx +++ b/src/components/Common/CustomContextMenu.tsx @@ -1,9 +1,10 @@ import { FileInfoNode } from 'electron/main/filesystem/types' import React, { useEffect, useRef } from 'react' import { useFileContext } from '@/contexts/FileContext' -import { ChatMetadata, useChatContext } from '@/contexts/ChatContext' +import { useChatContext } from '@/contexts/ChatContext' import { useModalOpeners } from '@/contexts/ModalContext' import { useWindowContentContext } from '@/contexts/WindowContentContext' +import { ChatMetadata } from '../Chat/types' export type ContextMenuLocations = 'FileSidebar' | 'FileItem' | 'ChatItem' | 'DirectoryItem' | 'None' @@ -43,7 +44,7 @@ const CustomContextMenu: React.FC = () => { useModalOpeners() const { deleteFile, setNoteToBeRenamed } = useFileContext() - const { handleDeleteChat } = useChatContext() + const { deleteChat } = useChatContext() useEffect(() => { const handleOutsideClick = (event: MouseEvent) => { @@ -106,7 +107,7 @@ const CustomContextMenu: React.FC = () => { break } case 'ChatItem': { - displayList = [{ title: 'Delete Chat', onSelect: () => handleDeleteChat(chatMetadata?.id), icon: '' }] + displayList = [{ title: 'Delete Chat', onSelect: () => deleteChat(chatMetadata?.id), icon: '' }] break } case 'DirectoryItem': { diff --git a/src/components/File/DBResultPreview.tsx b/src/components/File/DBResultPreview.tsx index a8e2dde0..81c0b401 100644 --- a/src/components/File/DBResultPreview.tsx +++ b/src/components/File/DBResultPreview.tsx @@ -1,11 +1,10 @@ -/* eslint-disable react/jsx-props-no-spreading */ import React from 'react' - -import { formatDistanceToNow } from 'date-fns' // for human-readable time format +import { formatDistanceToNow } from 'date-fns' import { DBQueryResult } from 'electron/main/vector-database/schema' import ReactMarkdown from 'react-markdown' const cosineDistanceToPercentage = (similarity: number) => ((1 - similarity) * 100).toFixed(2) + export function getFileName(filePath: string): string { const parts = filePath.split(/[/\\]/) return parts.pop() || '' @@ -17,17 +16,39 @@ const formatModifiedDate = (date: Date) => { } return formatDistanceToNow(new Date(date), { addSuffix: true }) } -// eslint-disable-next-line jsx-a11y/heading-has-content + +// eslint-disable-next-line jsx-a11y/heading-has-content, react/jsx-props-no-spreading const CustomH1 = (props: React.ComponentPropsWithoutRef<'h1'>) =>

    const CustomPre = (props: React.ComponentPropsWithoutRef<'pre'>) => ( + // eslint-disable-next-line react/jsx-props-no-spreading
     )
     
     const CustomCode = (props: React.ComponentPropsWithoutRef<'code'>) => (
    +  // eslint-disable-next-line react/jsx-props-no-spreading
       
     )
     
    +interface MarkdownContentProps {
    +  content: string
    +}
    +
    +export const MarkdownContent: React.FC = ({ content }) => {
    +  return (
    +    
    +      {content}
    +    
    +  )
    +}
    +
     interface DBResultPreviewProps {
       dbResult: DBQueryResult
       onSelect: (path: string) => void
    @@ -42,17 +63,8 @@ export const DBResultPreview: React.FC = ({ dbResult: entr
           className="mt-0 max-w-full cursor-pointer overflow-hidden rounded border-[0.1px] border-solid border-gray-600 bg-neutral-800 px-2 py-1 text-slate-300 shadow-md transition-transform duration-300 hover:bg-neutral-700 hover:shadow-lg"
           onClick={() => onSelect(entry.notepath)}
         >
    -      
    - - {entry.content} - +
    +
    {fileName && {fileName} } | Similarity:{' '} @@ -63,6 +75,7 @@ export const DBResultPreview: React.FC = ({ dbResult: entr
    ) } + interface DBSearchPreviewProps { dbResult: DBQueryResult onSelect: (path: string) => void @@ -77,17 +90,8 @@ export const DBSearchPreview: React.FC = ({ dbResult: entr className="mb-4 mt-0 max-w-full cursor-pointer overflow-hidden rounded border border-gray-600 bg-neutral-800 p-2 shadow-md transition-transform duration-300 hover:bg-neutral-500 hover:shadow-lg" onClick={() => onSelect(entry.notepath)} > -
    - - {entry.content} - +
    +
    {fileName && {fileName} } | Similarity:{' '} diff --git a/src/components/MainPage.tsx b/src/components/MainPage.tsx index 913c2338..80222a52 100644 --- a/src/components/MainPage.tsx +++ b/src/components/MainPage.tsx @@ -14,11 +14,11 @@ import WritingAssistant from './WritingAssistant/WritingAssistant' import { ChatProvider, useChatContext } from '@/contexts/ChatContext' import { FileProvider, useFileContext } from '@/contexts/FileContext' import ModalProvider from '@/contexts/ModalContext' -import CustomContextMenu from './Menu/CustomContextMenu' +import CustomContextMenu from './Common/CustomContextMenu' import CommonModals from './Common/CommonModals' const MainPageContent: React.FC = () => { - const [showSimilarFiles, setShowSimilarFiles] = useState(true) + const [showSimilarFiles, setShowSimilarFiles] = useState(false) const { currentlyOpenFilePath } = useFileContext() @@ -26,7 +26,6 @@ const MainPageContent: React.FC = () => { return (
    -
    { @@ -34,7 +33,6 @@ const MainPageContent: React.FC = () => { }} /> -
    diff --git a/src/components/Sidebars/SimilarFilesSidebar.tsx b/src/components/Sidebars/SimilarFilesSidebar.tsx index 1f844478..f0f32f85 100644 --- a/src/components/Sidebars/SimilarFilesSidebar.tsx +++ b/src/components/Sidebars/SimilarFilesSidebar.tsx @@ -35,16 +35,13 @@ const SimilarFilesSidebarComponent: React.FC = () => { const performSearchOnChunk = async ( sanitizedText: string, fileToBeExcluded: string | null, - withReranking = false, ): Promise => { try { const databaseFields = await window.database.getDatabaseFields() const filterString = `${databaseFields.NOTE_PATH} != '${fileToBeExcluded}'` setIsLoadingSimilarEntries(true) - const searchResults: DBQueryResult[] = withReranking - ? await window.database.searchWithReranking(sanitizedText, 20, filterString) - : await window.database.search(sanitizedText, 20, filterString) + const searchResults: DBQueryResult[] = await window.database.search(sanitizedText, 20, filterString) setIsLoadingSimilarEntries(false) return searchResults @@ -65,7 +62,7 @@ const SimilarFilesSidebarComponent: React.FC = () => { if (!sanitizedText) { return } - const searchResults = await performSearchOnChunk(sanitizedText, path, false) + const searchResults = await performSearchOnChunk(sanitizedText, path) if (searchResults.length > 0) { setSimilarEntries(searchResults) @@ -78,7 +75,7 @@ const SimilarFilesSidebarComponent: React.FC = () => { } }, [currentlyOpenFilePath]) - const updateSimilarEntries = async (isRefined?: boolean) => { + const updateSimilarEntries = async () => { const sanitizedText = await getChunkForInitialSearchFromFile(currentlyOpenFilePath) if (!sanitizedText) { @@ -86,7 +83,7 @@ const SimilarFilesSidebarComponent: React.FC = () => { return } - const searchResults = await performSearchOnChunk(sanitizedText, currentlyOpenFilePath, isRefined) + const searchResults = await performSearchOnChunk(sanitizedText, currentlyOpenFilePath) setSimilarEntries(searchResults) } diff --git a/src/components/Tabs/TabBar.tsx b/src/components/Tabs/TabBar.tsx deleted file mode 100644 index 8c555139..00000000 --- a/src/components/Tabs/TabBar.tsx +++ /dev/null @@ -1,184 +0,0 @@ -// import React, { useState, DragEventHandler, useRef, useEffect } from 'react' -// import { FaPlus } from 'react-icons/fa6' -// import { createPortal } from 'react-dom' -// import { Tab } from 'electron/main/electron-store/storeConfig' -// import { removeFileExtension } from '@/utils/strings' -// import '../../styles/tab.css' -// import { useTabsContext } from '../../contexts/TabContext' -// import NewNoteComponent from '../File/NewNote' -// import { useModalOpeners } from '../../contexts/ModalContext' - -// interface TooltipProps { -// filepath: string -// position: { x: number; y: number } -// } - -// /* Displays the filepath when hovering on a tab */ -// const Tooltip: React.FC = ({ filepath, position }) => { -// const [style, setStyle] = useState({ top: 0, left: 0, maxWidth: '300px' }) - -// useEffect(() => { -// const viewportWidth = window.innerWidth -// let maxWidth = '300px' - -// if (position.x && viewportWidth) { -// const availableWidth = viewportWidth - position.x - 10 -// maxWidth = `${availableWidth}px` -// } - -// setStyle({ -// top: position.y, -// left: position.x, -// maxWidth, -// }) -// }, [position]) - -// return createPortal( -//
    -// {filepath} -//
    , -// document.getElementById('tooltip-container') as HTMLElement, -// ) -// } - -// const DraggableTabs: React.FC = () => { -// const { currentTabID, openTabContent, openTabs, addTab, selectTab, removeTabByID, updateTabOrder } = useTabsContext() -// const [isLastTabAccessed, setIsLastTabAccessed] = useState(false) - -// const [hoveredTab, setHoveredTab] = useState(null) -// const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }) -// const containerRef = useRef(null) - -// const { isNewNoteModalOpen, setIsNewNoteModalOpen } = useModalOpeners() - -// // Note: Do not put dependency on addTab or else removeTabByID does not work properly. -// // Typically you would define addTab inside the useEffect and then call it but since -// // we are using it inside a useContext we can remove it -// /* eslint-disable */ -// // useEffect(() => { -// // if (!currentTabID) return -// // addTab(currentTabID) -// // }, [currentTabID]) -// /* eslint-enable */ - -// /* -// * Deals with setting which file to open on launch. -// */ -// useEffect(() => { -// const selectLastTabAccessed = () => { -// if (!isLastTabAccessed) { -// openTabs.forEach((tab: Tab) => { -// if (tab.lastAccessed) { -// setIsLastTabAccessed(true) -// openTabContent(tab.path) -// return true -// } -// return false -// }) -// } -// } - -// selectLastTabAccessed() -// }, [openTabs, openTabContent, isLastTabAccessed]) - -// const onDragStart = (event: any, tabId: string) => { -// event.dataTransfer.setData('tabId', tabId) -// } - -// const onDrop: DragEventHandler = (event) => { -// event.preventDefault() -// const draggedTabId = event.dataTransfer.getData('tabId') - -// let target = event.target as HTMLElement | null -// // Iterate each child until we find the one we moved to -// while (target && !target.getAttribute('data-tabid')) { -// target = target.parentElement as HTMLElement | null -// } - -// const targetTabId = target ? target.getAttribute('data-tabid') : null - -// if (draggedTabId && targetTabId) { -// const draggedIndex = openTabs.findIndex((tab) => tab.id === draggedTabId) -// const targetIndex = openTabs.findIndex((tab) => tab.id === targetTabId) -// updateTabOrder(draggedIndex, targetIndex) -// } -// } - -// const onDragOver: DragEventHandler = (event) => { -// event.preventDefault() -// } - -// const handleMouseEnter = (e: React.MouseEvent, tab: Tab) => { -// const rect = e.currentTarget.getBoundingClientRect() || null -// setHoveredTab(tab.path) -// setTooltipPosition({ -// x: rect.left - 75, -// y: rect.bottom - 5, -// }) -// } - -// const handleMouseLevel = () => { -// setHoveredTab(null) -// } - -// const handleTabSelect = (tab: Tab) => { -// selectTab(tab) -// } - -// const handleTabClose = (event: React.MouseEvent, tabId: string) => { -// event.stopPropagation() -// removeTabByID(tabId) -// } - -// return ( -//
    -//
    -// {openTabs && -// openTabs.map((tab) => ( -//
    handleMouseEnter(e, tab)} -// onMouseLeave={handleMouseLevel} -// > -//
    onDragStart(event, tab.id)} -// onDrop={onDrop} -// onDragOver={onDragOver} -// className={`relative flex w-full cursor-pointer items-center justify-between gap-1 rounded-md p-2 text-sm text-white -// ${currentTabID === tab.path ? 'bg-dark-gray-c-eleven' : ''}`} -// onClick={() => handleTabSelect(tab)} -// > -// {removeFileExtension(tab.title)} -// { -// e.stopPropagation() // Prevent triggering onClick of parent div -// handleTabClose(e, tab.id) -// }} -// > -// × -// -// {hoveredTab === tab.path && } -//
    -//
    -// ))} -//
    -// {openTabs.length > 0 && ( -//
    setIsNewNoteModalOpen(true)} -// > -// -//
    -// )} -// setIsNewNoteModalOpen(false)} /> -//
    -// ) -// } - -// export default DraggableTabs diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 00000000..24c8fb59 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,48 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import cn from '@/lib/utils' + +const buttonVariants = cva( + 'inline-flex cursor-pointer items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'border border-input bg-primary text-primary-foreground hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'size-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + // eslint-disable-next-line react/jsx-props-no-spreading + return + }, +) +Button.displayName = 'Button' + +export { Button, buttonVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 00000000..2264e972 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,45 @@ +/* eslint-disable jsx-a11y/heading-has-content */ +/* eslint-disable react/jsx-props-no-spreading */ +import * as React from 'react' + +import cn from '@/lib/utils' + +const Card = React.forwardRef>(({ className, ...props }, ref) => ( +
    +)) +Card.displayName = 'Card' + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
    + ), +) +CardHeader.displayName = 'CardHeader' + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

    + ), +) +CardTitle.displayName = 'CardTitle' + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +

    + ), +) +CardDescription.displayName = 'CardDescription' + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) =>

    , +) +CardContent.displayName = 'CardContent' + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
    + ), +) +CardFooter.displayName = 'CardFooter' + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/contexts/ChatContext.tsx b/src/contexts/ChatContext.tsx index 75f88d7b..4b6d2093 100644 --- a/src/contexts/ChatContext.tsx +++ b/src/contexts/ChatContext.tsx @@ -1,25 +1,19 @@ import React, { createContext, useContext, useState, useCallback, useEffect } from 'react' -import { Chat } from '@/components/Chat/types' +import { Chat, ChatMetadata } from '@/components/Chat/types' import { SidebarAbleToShow } from '@/components/Sidebars/MainSidebar' -import { getDisplayableChatName } from '@/components/Chat/utils' export const UNINITIALIZED_STATE = 'UNINITIALIZED_STATE' -export interface ChatMetadata { - id: string - displayName: string -} interface ChatContextType { sidebarShowing: SidebarAbleToShow setSidebarShowing: (option: SidebarAbleToShow) => void - getChatIdFromPath: (path: string) => string showChatbot: boolean setShowChatbot: (show: boolean) => void - currentOpenChat: Chat | undefined - setCurrentOpenChat: React.Dispatch> + currentOpenChatID: string | undefined + setCurrentOpenChatID: (chatID: string | undefined) => void allChatsMetadata: ChatMetadata[] - openChatSidebarAndChat: (chat: Chat | undefined) => void - handleDeleteChat: (chatID: string | undefined) => Promise + deleteChat: (chatID: string | undefined) => Promise + saveChat: (updatedChat: Chat) => Promise } const ChatContext = createContext(undefined) @@ -27,115 +21,60 @@ const ChatContext = createContext(undefined) export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [showChatbot, setShowChatbot] = useState(false) const [sidebarShowing, setSidebarShowing] = useState('files') - - const [currentOpenChat, setCurrentOpenChat] = useState() + const [currentOpenChatID, setCurrentOpenChatID] = useState(undefined) const [allChatsMetadata, setAllChatsMetadata] = useState([]) - const fetchChatHistories = async () => { - let allChats = await window.electronStore.getAllChats() - if (!allChats) { - allChats = [] - } - setAllChatsMetadata( - allChats.map((chat) => ({ - id: chat.id, - displayName: getDisplayableChatName(chat), - })), - ) - - setCurrentOpenChat(undefined) - } - useEffect(() => { - const updateChatHistoriesMetadata = window.ipcRenderer.receive( - 'update-chat-histories', - (retrievedChatHistoriesMetadata: Chat[]) => { - setAllChatsMetadata( - retrievedChatHistoriesMetadata.map((chat: Chat) => ({ - id: chat.id, - displayName: getDisplayableChatName(chat), - })), - ) - }, - ) - - return () => { - updateChatHistoriesMetadata() + const fetchChatHistories = async () => { + const allChatsMetadataResponse = await window.electronStore.getAllChatsMetadata() + setAllChatsMetadata(allChatsMetadataResponse) } - }, []) - - useEffect(() => { fetchChatHistories() }, []) - const openChatSidebarAndChat = useCallback( - (chat: Chat | undefined) => { - setShowChatbot(true) - setCurrentOpenChat(chat) - }, - [setCurrentOpenChat], - ) - - const getChatIdFromPath = useCallback( - (path: string) => { - if (allChatsMetadata.length === 0) return UNINITIALIZED_STATE - const metadata = allChatsMetadata.find((chat) => chat.displayName === path) - if (metadata) return metadata.id - return '' - }, - [allChatsMetadata], - ) + const saveChat = useCallback(async (updatedChat: Chat) => { + await window.electronStore.saveChat(updatedChat) - useEffect(() => { - const handleAddFileToChatFilters = () => { - // setSidebarShowing('chats') - // setShowChatbot(true) - // setCurrentChatHistory(undefined) - // setChatFilters((prevChatFilters) => ({ - // ...prevChatFilters, - // files: [...prevChatFilters.files, file], - // })) - // TODO: CALL START CONVERSATION FUNCTION - } - const removeAddChatToFileListener = window.ipcRenderer.receive('add-file-to-chat-listener', () => { - handleAddFileToChatFilters() - }) - - return () => { - removeAddChatToFileListener() - } + const retrievedChatsMetadata = await window.electronStore.getAllChatsMetadata() + setAllChatsMetadata(retrievedChatsMetadata) }, []) - const handleDeleteChat = useCallback(async (chatID: string | undefined) => { - if (!chatID) return false - await window.electronStore.deleteChatAtID(chatID) - return true - }, []) + const deleteChat = useCallback( + async (chatID: string | undefined) => { + if (!chatID) return false + await window.electronStore.deleteChat(chatID) + const retrievedChatsMetadata = await window.electronStore.getAllChatsMetadata() + setAllChatsMetadata(retrievedChatsMetadata) + if (currentOpenChatID === chatID) { + setCurrentOpenChatID(undefined) + } + return true + }, + [currentOpenChatID], + ) const value = React.useMemo( () => ({ - currentOpenChat, - setCurrentOpenChat, allChatsMetadata, sidebarShowing, setSidebarShowing, - getChatIdFromPath, showChatbot, setShowChatbot, - openChatSidebarAndChat, - handleDeleteChat, + currentOpenChatID, + setCurrentOpenChatID, + deleteChat, + saveChat, }), [ allChatsMetadata, sidebarShowing, setSidebarShowing, - getChatIdFromPath, showChatbot, setShowChatbot, - currentOpenChat, - setCurrentOpenChat, - openChatSidebarAndChat, - handleDeleteChat, + currentOpenChatID, + setCurrentOpenChatID, + deleteChat, + saveChat, ], ) diff --git a/src/contexts/WindowContentContext.tsx b/src/contexts/WindowContentContext.tsx index d032ff17..22e5c2bd 100644 --- a/src/contexts/WindowContentContext.tsx +++ b/src/contexts/WindowContentContext.tsx @@ -1,8 +1,8 @@ -import React, { createContext, useContext, useEffect, useMemo, ReactNode, useState } from 'react' +import React, { createContext, useContext, useMemo, ReactNode, useState } from 'react' -import { UNINITIALIZED_STATE, useChatContext } from './ChatContext' +import { useChatContext } from './ChatContext' import { useFileContext } from './FileContext' -import { OnShowContextMenuData, ShowContextMenuInputType } from '@/components/Menu/CustomContextMenu' +import { OnShowContextMenuData, ShowContextMenuInputType } from '@/components/Common/CustomContextMenu' interface WindowContentContextType { openContent: (pathOrChatID: string, optionalContentToWriteOnCreate?: string) => void @@ -31,29 +31,23 @@ export const WindowContentProvider: React.FC = ({ ch position: { x: 0, y: 0 }, }) - const { currentOpenChat, setCurrentOpenChat, allChatsMetadata, setShowChatbot, setSidebarShowing } = useChatContext() - const { currentlyOpenFilePath, openOrCreateFile } = useFileContext() - - const filePathRef = React.useRef('') - const chatIDRef = React.useRef('') + const { setCurrentOpenChatID, allChatsMetadata, setShowChatbot, setSidebarShowing } = useChatContext() + const { openOrCreateFile } = useFileContext() const openContent = React.useCallback( async (pathOrChatID: string, optionalContentToWriteOnCreate?: string) => { if (!pathOrChatID) return const chatMetadata = allChatsMetadata.find((chat) => chat.id === pathOrChatID) if (chatMetadata) { - const chatID = chatMetadata.id - if (chatID === UNINITIALIZED_STATE) return - const chat = await window.electronStore.getChat(chatID) setShowChatbot(true) - setCurrentOpenChat(chat) + setCurrentOpenChatID(pathOrChatID) } else { setShowChatbot(false) setSidebarShowing('files') openOrCreateFile(pathOrChatID, optionalContentToWriteOnCreate) } }, - [allChatsMetadata, setShowChatbot, setCurrentOpenChat, setSidebarShowing, openOrCreateFile], + [allChatsMetadata, setShowChatbot, setCurrentOpenChatID, setSidebarShowing, openOrCreateFile], ) const showContextMenu: ShowContextMenuInputType = React.useCallback( @@ -77,17 +71,6 @@ export const WindowContentProvider: React.FC = ({ ch })) }, [setFocusedItem]) - useEffect(() => { - if (currentlyOpenFilePath != null && filePathRef.current !== currentlyOpenFilePath) { - filePathRef.current = currentlyOpenFilePath - } - - const currentChatHistoryId = currentOpenChat?.id ?? '' - if (chatIDRef.current !== currentChatHistoryId) { - chatIDRef.current = currentChatHistoryId - } - }, [currentOpenChat, allChatsMetadata, currentlyOpenFilePath]) - const WindowContentContextMemo = useMemo( () => ({ openContent, diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 00000000..90436e20 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,7 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} +export default cn diff --git a/src/styles/global.css b/src/styles/global.css index 0e88879c..db2e7975 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,6 +1,6 @@ -@import "tailwindcss/base"; -@import "tailwindcss/components"; -@import "tailwindcss/utilities"; +@tailwind base; +@tailwind components; +@tailwind utilities; :root { --bg-000: 60 1.8% 22%; @@ -194,4 +194,69 @@ button { .my-day-picker .rdp-day { @apply w-6 h-6 leading-6; /* Adjust day cell size */ +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } } \ No newline at end of file diff --git a/src/styles/tab.css b/src/styles/tab.css deleted file mode 100644 index af722dcd..00000000 --- a/src/styles/tab.css +++ /dev/null @@ -1,28 +0,0 @@ -/* CSS for dropdown animation onMouseEnter */ -.tab-tooltip { - position: absolute; - background-color: black; - color: white; - padding: 5px 10px; - border-radius: 8px; - white-space: no-wrap; - z-index: 1010; - animation: fadeIn 0.3s ease-in-out; - height: 30px; - max-width: 100%; - font-size: 14px; - text-align: center; - white-space: nowrap; - - overflow: hidden; - text-overflow: ellipsis; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} diff --git a/src/utils/animations.tsx b/src/utils/animations.tsx index 0c58ad44..abfe68f2 100644 --- a/src/utils/animations.tsx +++ b/src/utils/animations.tsx @@ -5,7 +5,7 @@ const LoadingDots = () => {
    . . - . + .
    ) } diff --git a/src/utils/db.ts b/src/utils/db.ts new file mode 100644 index 00000000..6d386f68 --- /dev/null +++ b/src/utils/db.ts @@ -0,0 +1,37 @@ +import { FileInfoWithContent } from 'electron/main/filesystem/types' +import { DBEntry } from 'electron/main/vector-database/schema' +import { SearchFilters } from '@/components/Chat/types' + +export const generateTimeStampFilter = (minDate?: Date, maxDate?: Date): string => { + let filter = '' + + if (minDate) { + const minDateStr = minDate.toISOString().slice(0, 19).replace('T', ' ') + filter += `filemodified > timestamp '${minDateStr}'` + } + + if (maxDate) { + const maxDateStr = maxDate.toISOString().slice(0, 19).replace('T', ' ') + if (filter) { + filter += ' AND ' + } + filter += `filemodified < timestamp '${maxDateStr}'` + } + + return filter +} + +export const retreiveFromVectorDB = async ( + query: string, + searchFilters: SearchFilters, +): Promise => { + if (searchFilters.limit > 0) { + const timeStampFilter = generateTimeStampFilter(searchFilters.minDate, searchFilters.maxDate) + const dbSearchResults = await window.database.search(query, searchFilters.limit, timeStampFilter) + if (searchFilters.passFullNoteIntoContext) { + return window.fileSystem.getFiles(dbSearchResults.map((result) => result.notepath)) + } + return dbSearchResults + } + return [] +} diff --git a/tailwind.config.js b/tailwind.config.js index 8f25d5c1..15387d62 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,68 +1,123 @@ /** @type {import('tailwindcss').Config} */ export default { - content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + darkMode: ["class"], + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], theme: { - extend: { - colors: { - 'deep-blue': '#002b36', - - 'dark-gray-c-one': '#121212', - 'dark-gray-c-two': '#1e1e1e', - 'dark-gray-c-three': '#222222', - 'dark-gray-c-four': '#242424', - 'dark-gray-c-five': '#272727', - 'dark-gray-c-six': '#2c2c2c', - 'dark-gray-c-seven': '#2e2e2e', - 'dark-gray-c-eight': '#333333', - 'dark-gray-c-nine': '#343434', - 'dark-gray-c-ten': '#383838', - 'dark-gray-c-eleven': '#191919', - "dark-slate-gray": "#2F4F4F", - "light-arsenic": "#182c44", - "distinct-dark-purple": "#3a395e", - "moodly-blue": "#7f7dcb", - 'bg-000': 'hsl(var(--bg-000) / 1.0)', - - 'text-gen-100': 'hsl(var(--gen-100) / 1.0)', - }, - height: { - titlebar: "30px", - "below-titlebar": "calc(100vh - 30px)", - }, - minHeight: { - "below-titlebar-min": "calc(100vh - 30px)", - }, - transitionProperty: { - 'height': 'height', - 'spacing': 'margin, padding', - 'transform': 'transform' - }, - transitionDuration: { - '400': '400ms' - }, - fontSize: { - '2lg': '1.0rem', - }, - keyframes: { - slideIn: { - '0%': { transform: 'translateX(100%)', opacity: '0'}, - '100%': { transform: 'translateX(0)', opacity: '1'}, - }, - bounce: { - '0%, 20%, 50%, 80%, 100%': { opacity: '1' }, - '40%, 60%': { opacity: '0' }, - } - }, - animation: { - 'slide-in': 'slideIn 0.3s ease-out', - 'bounce': 'bounce 1.4s infinite both', - }, - }, + extend: { + colors: { + 'deep-blue': '#002b36', + 'dark-gray-c-one': '#121212', + 'dark-gray-c-two': '#1e1e1e', + 'dark-gray-c-three': '#222222', + 'dark-gray-c-four': '#242424', + 'dark-gray-c-five': '#272727', + 'dark-gray-c-six': '#2c2c2c', + 'dark-gray-c-seven': '#2e2e2e', + 'dark-gray-c-eight': '#333333', + 'dark-gray-c-nine': '#343434', + 'dark-gray-c-ten': '#383838', + 'dark-gray-c-eleven': '#191919', + 'dark-slate-gray': '#2F4F4F', + 'light-arsenic': '#182c44', + 'distinct-dark-purple': '#3a395e', + 'moodly-blue': '#7f7dcb', + 'bg-000': 'hsl(var(--bg-000) / 1.0)', + 'text-gen-100': 'hsl(var(--gen-100) / 1.0)', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))' + } + }, + height: { + titlebar: '30px', + 'below-titlebar': 'calc(100vh - 30px)' + }, + minHeight: { + 'below-titlebar-min': 'calc(100vh - 30px)' + }, + transitionProperty: { + height: 'height', + spacing: 'margin, padding', + transform: 'transform' + }, + transitionDuration: { + '400': '400ms' + }, + fontSize: { + '2lg': '1.0rem' + }, + keyframes: { + slideIn: { + '0%': { + transform: 'translateX(100%)', + opacity: '0' + }, + '100%': { + transform: 'translateX(0)', + opacity: '1' + } + }, + bounce: { + '0%, 20%, 50%, 80%, 100%': { + opacity: '1' + }, + '40%, 60%': { + opacity: '0' + } + } + }, + animation: { + 'slide-in': 'slideIn 0.3s ease-out', + bounce: 'bounce 1.4s infinite both' + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + } + } }, corePlugins: { preflight: false, }, plugins: [ require('tailwind-scrollbar'), - ], + require("tailwindcss-animate") +], }; diff --git a/tsconfig.json b/tsconfig.json index c9c47659..7ecbc484 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,13 +15,12 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - "baseUrl": "./", + "baseUrl": ".", "paths": { - "@/*": [ - "src/*" - ] + "@/*": ["./src/*"], + "@shared/*": ["./shared/*"] }, }, - "include": ["src", "electron"], + "include": ["src", "electron", "shared"], "references": [{ "path": "./tsconfig.node.json" }] -} +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 9714a517..07228bb0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,7 +19,8 @@ export default defineConfig(({ command }) => { return { resolve: { alias: { - '@': path.join(__dirname, 'src'), + '@': path.join(__dirname, './src'), + '@shared': path.join(__dirname, './shared'), }, }, plugins: [ @@ -41,7 +42,15 @@ export default defineConfig(({ command }) => { minify: isBuild, outDir: 'dist-electron/main', rollupOptions: { - external: Object.keys('dependencies' in pkg ? pkg.dependencies : {}), + external: [ + ...Object.keys('dependencies' in pkg ? pkg.dependencies : {}), + '@shared/utils', + ], + }, + }, + resolve: { + alias: { + '@shared': path.join(__dirname, 'shared'), }, }, }, @@ -49,23 +58,28 @@ export default defineConfig(({ command }) => { { entry: 'electron/preload/index.ts', onstart(options) { - // Notify the Renderer-Process to reload the page when the Preload-Scripts build is complete, - // instead of restarting the entire Electron App. options.reload() }, vite: { build: { - sourcemap: sourcemap ? 'inline' : undefined, // #332 + sourcemap: sourcemap ? 'inline' : undefined, minify: isBuild, outDir: 'dist-electron/preload', rollupOptions: { - external: Object.keys('dependencies' in pkg ? pkg.dependencies : {}), + external: [ + ...Object.keys('dependencies' in pkg ? pkg.dependencies : {}), + '@shared/utils', + ], + }, + }, + resolve: { + alias: { + '@shared': path.join(__dirname, 'shared'), }, }, }, }, ]), - // Use Node.js API in the Renderer-process renderer(), ], css: { @@ -84,4 +98,4 @@ export default defineConfig(({ command }) => { })(), clearScreen: false, } -}) +}) \ No newline at end of file