From 74eb20c2a17de970d8bb9f42f23cf1f8a90839c6 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Sun, 5 Oct 2025 13:06:05 +0100 Subject: [PATCH] Implement conversation history modal with Svelte component, enhance conversation file system service for loading conversations, and update styles for modal presentation. Refactor file handling methods and remove unused modal service. --- Components/ChatArea.svelte | 24 +-- Components/TopBar.svelte | 14 +- Conversations/Conversation.ts | 16 +- Conversations/ConversationContent.ts | 13 ++ Helpers/Helpers.ts | 26 ++- Modals/ConversationHistoryModal.ts | 74 ++++++++ Modals/ConversationHistoryModalSvelte.svelte | 173 +++++++++++++++++++ Modals/Modals.ts | 3 - Modals/SimpleModal.ts | 17 -- Services/ConversationFileSystemService.ts | 33 +++- Services/FileSystemService.ts | 78 +++++++-- Services/ModalService.ts | 11 -- Services/ServiceRegistration.ts | 9 +- Services/Services.ts | 5 +- styles.css | 26 +++ 15 files changed, 422 insertions(+), 100 deletions(-) create mode 100644 Modals/ConversationHistoryModal.ts create mode 100644 Modals/ConversationHistoryModalSvelte.svelte delete mode 100644 Modals/Modals.ts delete mode 100644 Modals/SimpleModal.ts delete mode 100644 Services/ModalService.ts diff --git a/Components/ChatArea.svelte b/Components/ChatArea.svelte index d2b2acd..021a2f9 100644 --- a/Components/ChatArea.svelte +++ b/Components/ChatArea.svelte @@ -185,7 +185,7 @@ {/each} {#if messages.length === 0} -
+
{getGreetingByTime()}
{/if} @@ -241,9 +241,8 @@ margin: var(--size-4-2); } - .empty-state { - justify-content: center; - align-items: center; + .conversation-empty-state { + margin: auto; font-style: italic; color: var(--text-muted); pointer-events: none; @@ -273,7 +272,7 @@ .typing-in { overflow: hidden; white-space: nowrap; - animation: reveal-center 3s ease-in-out forwards; + animation: reveal-center 1.5s ease-in-out forwards; max-width: 0; margin: 0 auto; padding: 5px; @@ -285,21 +284,6 @@ opacity: 0; filter: blur(1px); } - 50% { - max-width: 50%; - opacity: 0.9; - filter: blur(0.5px) - } - 70% { - max-width: 60%; - opacity: 0.95; - filter: blur(0px) - } - 90% { - max-width: 80%; - opacity: 0.95; - filter: blur(0px) - } 100% { max-width: 100%; opacity: 1; diff --git a/Components/TopBar.svelte b/Components/TopBar.svelte index 674370d..00d52c1 100644 --- a/Components/TopBar.svelte +++ b/Components/TopBar.svelte @@ -5,6 +5,7 @@ import { setIcon, type WorkspaceLeaf } from 'obsidian'; import { ConversationFileSystemService } from '../Services/ConversationFileSystemService'; import { conversationStore } from '../Stores/conversationStore'; + import type { ConversationHistoryModal } from 'Modals/ConversationHistoryModal'; export let leaf: WorkspaceLeaf; @@ -22,7 +23,8 @@ } function openConversationHistory() { - + const modal = Resolve(Services.ConversationHistoryModal); + modal.open(); } function openSettings() { @@ -121,16 +123,6 @@ border-radius: var(--radius-m); } - .top-bar-button { - margin: var(--size-4-2) 0px var(--size-4-2) 0px; - padding: var(--size-4-1) var(--size-4-2) var(--size-4-1) var(--size-4-2); - color: var(--text-muted); - } - - .top-bar-button:hover { - background-color: var(--color-base-35); - } - .top-bar-divider { width: var(--divider-width); height: auto; diff --git a/Conversations/Conversation.ts b/Conversations/Conversation.ts index 5d733e8..7a8d1bf 100644 --- a/Conversations/Conversation.ts +++ b/Conversations/Conversation.ts @@ -1,5 +1,5 @@ import { dateToString } from "Helpers/Helpers"; -import type { ConversationContent } from "./ConversationContent"; +import { ConversationContent } from "./ConversationContent"; export class Conversation { @@ -8,6 +8,20 @@ export class Conversation { contents: ConversationContent[] = []; + public static isConversationData(data: unknown): data is { title: string; created: string; contents: ConversationContent[] } { + return ( + typeof data === 'object' && + data !== null && + 'title' in data && + 'created' in data && + 'contents' in data && + typeof data.title === 'string' && + typeof data.created === 'string' && + Array.isArray(data.contents) && + data.contents.every(ConversationContent.isConversationContentData) + ); + } + constructor() { this.created = new Date(); this.title = `${dateToString(this.created)}`; diff --git a/Conversations/ConversationContent.ts b/Conversations/ConversationContent.ts index 854bd14..5d97d5e 100644 --- a/Conversations/ConversationContent.ts +++ b/Conversations/ConversationContent.ts @@ -3,6 +3,19 @@ export class ConversationContent { content: string timestamp: Date; + public static isConversationContentData(data: unknown): data is { role: string; content: string; timestamp: string } { + return ( + typeof data === 'object' && + data !== null && + 'role' in data && + 'content' in data && + 'timestamp' in data && + typeof data.role === 'string' && + typeof data.content === 'string' && + typeof data.timestamp === 'string' + ); + } + constructor(role: string, content: string) { this.role = role; this.content = content; diff --git a/Helpers/Helpers.ts b/Helpers/Helpers.ts index b23bc71..562e5f0 100644 --- a/Helpers/Helpers.ts +++ b/Helpers/Helpers.ts @@ -21,13 +21,21 @@ export function loadExternalCSS(href: string): Promise { }); } -export function dateToString(date: Date): string { - return date.toLocaleString('sv-SE', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }).replace(/[:\s]/g, '-'); +export function dateToString(date: Date, includeTime: boolean = true): string { + if (includeTime) { + return date.toLocaleString('sv-SE', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }).replace(/[:\s]/g, '-'); + } else { + return date.toLocaleDateString('sv-SE', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).replace(/[:\s]/g, '-'); + } } \ No newline at end of file diff --git a/Modals/ConversationHistoryModal.ts b/Modals/ConversationHistoryModal.ts new file mode 100644 index 0000000..239480c --- /dev/null +++ b/Modals/ConversationHistoryModal.ts @@ -0,0 +1,74 @@ +import { App, Modal } from 'obsidian'; +import ConversationHistoryModalSvelte from './ConversationHistoryModalSvelte.svelte'; +import type { Conversation } from 'Conversations/Conversation'; +import { mount, unmount } from 'svelte'; +import { Resolve } from 'Services/DependencyService'; +import { Services } from 'Services/Services'; +import type { ConversationFileSystemService } from 'Services/ConversationFileSystemService'; +import { dateToString } from 'Helpers/Helpers'; + +interface ListItem { + id: string; + date: string; + title: string; + selected: boolean; +} + +export class ConversationHistoryModal extends Modal { + + private readonly conversationFileSystemService: ConversationFileSystemService = Resolve(Services.ConversationFileSystemService); + + private component: Record | null = null; + private items: ListItem[]; + + constructor(app: App) { + super(app); + } + + override async open() { + const conversations: Conversation[] = await this.conversationFileSystemService.getAllConversations(); + + this.items = conversations.map((conversation, index) => ({ + id: index.toString(), + date: dateToString(conversation.created, false), + title: conversation.title, + selected: false + })); + + super.open(); + } + + onOpen() { + const { contentEl, modalEl, containerEl } = this; + + containerEl.addClass('conversation-history-modal'); + modalEl.addClass('conversation-history-modal'); + + this.component = mount(ConversationHistoryModalSvelte, { + target: contentEl, + props: { + items: this.items, + onClose: () => this.close(), + onDelete: (itemIds: string[]) => this.handleDelete(itemIds) + } + }); + } + + handleDelete(itemIds: string[]) { + this.items = this.items.filter(item => !itemIds.includes(item.id)); + + if (this.component) { + this.component.items = this.items; + } + } + + onClose() { + if (this.component) { + unmount(this.component); + this.component = null; + } + + const { contentEl } = this; + contentEl.empty(); + } +} \ No newline at end of file diff --git a/Modals/ConversationHistoryModalSvelte.svelte b/Modals/ConversationHistoryModalSvelte.svelte new file mode 100644 index 0000000..16f44a5 --- /dev/null +++ b/Modals/ConversationHistoryModalSvelte.svelte @@ -0,0 +1,173 @@ + + +
+
+
+ + {#if selectedItems.size > 0} + + {/if} +
+
+
+ {#if items.length === 0} +

+ Conversation history is empty. +

+ {:else} + {#each items as item (item.id)} +
+ {item.date} + | + {item.title} + toggleSelection(item.id)} + /> +
+ {/each} + {/if} +
+
+ + \ No newline at end of file diff --git a/Modals/Modals.ts b/Modals/Modals.ts deleted file mode 100644 index 946e55c..0000000 --- a/Modals/Modals.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class Modals { - static SimpleModal = Symbol("SimpleModal"); -} \ No newline at end of file diff --git a/Modals/SimpleModal.ts b/Modals/SimpleModal.ts deleted file mode 100644 index 34f4053..0000000 --- a/Modals/SimpleModal.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Modal, App } from "obsidian"; - -class SimpleModal extends Modal { - constructor(app: App) { - super(app); - } - - onOpen() { - const { contentEl } = this; - contentEl.setText('Woah!'); - } - - onClose() { - const { contentEl } = this; - contentEl.empty(); - } -} \ No newline at end of file diff --git a/Services/ConversationFileSystemService.ts b/Services/ConversationFileSystemService.ts index ff85da9..bfa06f1 100644 --- a/Services/ConversationFileSystemService.ts +++ b/Services/ConversationFileSystemService.ts @@ -2,8 +2,8 @@ import { Path } from "Enums/Path"; import { Resolve } from "./DependencyService"; import { FileSystemService } from "./FileSystemService"; import { Services } from "./Services"; -import type { TFile } from "obsidian"; import { Conversation } from "Conversations/Conversation"; +import { ConversationContent } from "Conversations/ConversationContent"; export class ConversationFileSystemService { @@ -14,10 +14,8 @@ export class ConversationFileSystemService { this.fileSystemService = Resolve(Services.FileSystemService); } - public generateConversationFilename(): string { - const now = new Date(); - const timestamp = now.toISOString().replace(/:/g, '-').replace(/\..+/, '').replace('T', '-'); - return `${Path.Conversations}/conversation-${timestamp}.json`; + public generateConversationPath(conversation: Conversation): string { + return `${Path.Conversations}/${conversation.title}.json`; } // public async loadConversation(filePath: string): Promise<{ messages: @@ -27,7 +25,7 @@ export class ConversationFileSystemService { public async saveConversation(conversation: Conversation): Promise { if (!this.currentConversationPath) { - this.currentConversationPath = this.generateConversationFilename(); + this.currentConversationPath = this.generateConversationPath(conversation); } const conversationData = { @@ -41,7 +39,6 @@ export class ConversationFileSystemService { }; await this.fileSystemService.writeObjectToFile(this.currentConversationPath, conversationData); - console.log("Conversation saved to:", this.currentConversationPath); return this.currentConversationPath; } @@ -63,4 +60,26 @@ export class ConversationFileSystemService { return deleted; } + public async getAllConversations(): Promise { + const files = await this.fileSystemService.listFilesInDirectory(Path.Conversations, false); + const conversations: Conversation[] = []; + + for (const file of files) { + const data = await this.fileSystemService.readObjectFromFile(file.path); + if (Conversation.isConversationData(data)) { + const conversation: Conversation = new Conversation(); + conversation.title = data.title; + conversation.created = new Date(data.created); + conversation.contents = data.contents.map(content => { + const conversationContent = new ConversationContent(content.role, content.content); + conversationContent.timestamp = new Date(content.timestamp); + return conversationContent; + }); + conversations.push(conversation); + } + } + + return conversations; + } + } diff --git a/Services/FileSystemService.ts b/Services/FileSystemService.ts index d210e1b..1ce064e 100644 --- a/Services/FileSystemService.ts +++ b/Services/FileSystemService.ts @@ -1,5 +1,5 @@ import type AIAgentPlugin from "main"; -import { TAbstractFile, TFile, type Vault } from "obsidian"; +import { TAbstractFile, TFile, TFolder, type Vault } from "obsidian"; import { Resolve } from "./DependencyService"; import { Services } from "./Services"; import { isValidJson } from "Helpers/Helpers"; @@ -12,32 +12,27 @@ export class FileSystemService { this.vault = Resolve(Services.AIAgentPlugin).app.vault; } - public async readObjectFromFile(filePath: string): Promise { + public async readFile(filePath: string): Promise { const file: TAbstractFile | null = this.vault.getAbstractFileByPath(filePath); if (file && file instanceof TFile) { - const content = await this.vault.read(file); - if (isValidJson(content) === true) { - return JSON.parse(content); - } + return await this.vault.read(file); } return null; } - public async writeObjectToFile(filePath: string, data: object): Promise { + public async writeFile(filePath: string, content: string): Promise { try { let file: TAbstractFile | null = this.vault.getAbstractFileByPath(filePath); - - if (file && file instanceof TFile) { - await this.vault.modify(file, JSON.stringify(data, null, 4)); - } - else { + if (file == null || !(file instanceof TFile)) { await this.createDirectories(this.vault, filePath); - await this.vault.create(filePath, JSON.stringify(data, null, 4)); + await this.vault.create(filePath, content); + return true; } - + this.vault.modify(file as TFile, content); return true; - } catch (error) { - console.error("Error writing JSON file:", error); + } + catch (error) { + console.error("Error writing file:", error); return false; } } @@ -58,6 +53,56 @@ export class FileSystemService { } } + public async listFilesInDirectory(dirPath: string, recursive: boolean = true): Promise { + const dir: TAbstractFile | null = this.vault.getAbstractFileByPath(dirPath); + + if (dir == null || !(dir instanceof TFolder)) { + return []; + } + + let files: TFile[] = []; + for (let child of dir.children) { + if (child instanceof TFile) { + files.push(child); + } else if (child instanceof TFolder && recursive) { + const childFiles = await this.listFilesInDirectory(child.path, recursive); + files = files.concat(childFiles); + } + } + + return files; + } + + public async readObjectFromFile(filePath: string): Promise { + const file: TAbstractFile | null = this.vault.getAbstractFileByPath(filePath); + if (file && file instanceof TFile) { + const content = await this.vault.read(file); + if (isValidJson(content) === true) { + return JSON.parse(content); + } + } + return null; + } + + public async writeObjectToFile(filePath: string, data: object): Promise { + try { + let file: TAbstractFile | null = this.vault.getAbstractFileByPath(filePath); + + if (file && file instanceof TFile) { + await this.vault.modify(file, JSON.stringify(data, null, 4)); + } + else { + await this.createDirectories(this.vault, filePath); + await this.vault.create(filePath, JSON.stringify(data, null, 4)); + } + + return true; + } catch (error) { + console.error("Error writing JSON file:", error); + return false; + } + } + private async createDirectories(vault: Vault, filePath: string) { const dirPath: string = filePath.substring(0, filePath.lastIndexOf('/')); @@ -73,5 +118,4 @@ export class FileSystemService { } } } - } \ No newline at end of file diff --git a/Services/ModalService.ts b/Services/ModalService.ts deleted file mode 100644 index 0889926..0000000 --- a/Services/ModalService.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Modal } from "obsidian"; -import { Resolve } from "./DependencyService"; - -export class ModalService { - - showModal(modal: symbol): void { - let modalInstance: Modal = Resolve(modal); - modalInstance.open(); - } - -} \ No newline at end of file diff --git a/Services/ServiceRegistration.ts b/Services/ServiceRegistration.ts index bedb83f..f0cc92b 100644 --- a/Services/ServiceRegistration.ts +++ b/Services/ServiceRegistration.ts @@ -2,7 +2,6 @@ import { AIProvider } from "Enums/ApiProvider"; import type AIAgentPlugin from "main"; //import { OdbCache } from "ODB/Core/OdbCache"; import { RegisterSingleton, RegisterTransient } from "./DependencyService"; -import { ModalService } from "./ModalService"; import { Services } from "./Services"; import { AIPrompt, type IPrompt } from "AIClasses/IPrompt"; import { Actioner } from "Actioner/Actioner"; @@ -15,12 +14,13 @@ import { StreamingMarkdownService } from "./StreamingMarkdownService"; import { MessageService } from "./MessageService"; import { FileSystemService } from "./FileSystemService"; import { ConversationFileSystemService } from "./ConversationFileSystemService"; +import { ConversationHistoryModal } from "Modals/ConversationHistoryModal"; +import type { App } from "obsidian"; export function RegisterDependencies(plugin: AIAgentPlugin) { RegisterSingleton(Services.MessageService, new MessageService()); RegisterSingleton(Services.AIAgentPlugin, plugin); //RegisterSingleton(Services.OdbCache, new OdbCache()); - RegisterSingleton(Services.ModalService, new ModalService()) RegisterSingleton(Services.FileSystemService, new FileSystemService()); RegisterSingleton(Services.ConversationFileSystemService, new ConversationFileSystemService()); @@ -29,6 +29,7 @@ export function RegisterDependencies(plugin: AIAgentPlugin) { RegisterTransient(Services.StreamingMarkdownService, () => new StreamingMarkdownService()); + RegisterModals(plugin.app); RegisterAiProvider(plugin); } @@ -37,4 +38,8 @@ export function RegisterAiProvider(plugin: AIAgentPlugin) { RegisterTransient(Services.IActionDefinitions, () => new GeminiActionDefinitions()); RegisterSingleton(Services.IAIClass, new Gemini(plugin.settings.apiKey)); } +} + +function RegisterModals(app: App) { + RegisterTransient(Services.ConversationHistoryModal, () => new ConversationHistoryModal(app)); } \ No newline at end of file diff --git a/Services/Services.ts b/Services/Services.ts index c4c8dc2..df77e6b 100644 --- a/Services/Services.ts +++ b/Services/Services.ts @@ -2,17 +2,18 @@ export class Services { static MessageService = Symbol("MessageService"); static AIAgentPlugin = Symbol("AIAgentPlugin"); static OdbCache = Symbol("OdbCache"); - static ModalService = Symbol("ModalService"); static FileSystemService = Symbol("FileSystemService"); static ConversationFileSystemService = Symbol("ConversationFileSystemService"); static StreamingService = Symbol("StreamingService"); static MarkdownService = Symbol("MarkdownService"); static StreamingMarkdownService = Symbol("StreamingMarkdownService"); - // interfaces static IAIClass = Symbol("IAIClass"); static IPrompt = Symbol("IPrompt"); static IActioner = Symbol("IActioner"); static IActionDefinitions = Symbol("IActionDefinitions"); + + // modals + static ConversationHistoryModal = Symbol("ConversationHistoryModal"); } \ No newline at end of file diff --git a/styles.css b/styles.css index 8cb9fc7..6a6a325 100644 --- a/styles.css +++ b/styles.css @@ -29,6 +29,32 @@ padding-top: 0; } +/* ============================== */ +/* CSS Variables for Common Components */ +/* ============================== */ + +/* does this affect all modals? */ +.conversation-history-modal { + padding: 0px; + overflow: hidden; +} +.conversation-history-modal .modal-header { + display: none; +} +.conversation-history-modal .modal-close-button { + display: none; +} + +.top-bar-button { + margin: var(--size-4-2) 0px var(--size-4-2) 0px; + padding: var(--size-4-1) var(--size-4-2) var(--size-4-1) var(--size-4-2); + color: var(--text-muted); +} + +.top-bar-button:hover { + background-color: var(--color-base-35); +} + /* ============================== */ /* CSS Variables for Theming */ /* ============================== */