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.

This commit is contained in:
Andrew Beal 2025-10-05 13:06:05 +01:00
parent d0b4d5b852
commit 74eb20c2a1
15 changed files with 422 additions and 100 deletions

View file

@ -185,7 +185,7 @@
{/each}
{#if messages.length === 0}
<div class="empty-state">
<div class="conversation-empty-state">
<div class="typing-in">{getGreetingByTime()}</div>
</div>
{/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;

View file

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

View file

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

View file

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

View file

@ -21,13 +21,21 @@ export function loadExternalCSS(href: string): Promise<void> {
});
}
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, '-');
}
}

View file

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

View file

@ -0,0 +1,173 @@
<script lang="ts">
import { setIcon } from "obsidian";
import { fade } from "svelte/transition";
export let items: Array<{id: string, date: string, title: string, selected: boolean}>;
export let onClose: () => void;
export let onDelete: (itemIds: string[]) => void;
let deleteButton: HTMLButtonElement;
let closeButton: HTMLButtonElement;
$: if (deleteButton) {
setIcon(deleteButton, 'trash-2');
}
$: if (closeButton) {
setIcon(closeButton, 'circle-x');
}
let selectedItems = new Set<string>();
function toggleSelection(itemId: string) {
if (selectedItems.has(itemId)) {
selectedItems.delete(itemId);
} else {
selectedItems.add(itemId);
}
selectedItems = selectedItems;
}
function handleDelete() {
if (selectedItems.size === 0) {
return;
}
onDelete(Array.from(selectedItems));
selectedItems.clear();
selectedItems = selectedItems;
}
</script>
<div class="conversation-history-modal-container">
<div class="conversation-history-modal-top-bar">
<div class="conversation-history-modal-top-bar-content">
<button
bind:this={closeButton}
id="close-button"
class="top-bar-button clickable-icon"
on:click={onClose}
aria-label="Close Conversation History"
></button>
{#if selectedItems.size > 0}
<button
bind:this={deleteButton}
in:fade={{ duration: 200 }} out:fade={{ duration: 200 }}
id="delete-button"
class="top-bar-button clickable-icon"
on:click={handleDelete}
aria-label="Delete Selected Conversations"
></button>
{/if}
</div>
</div>
<div class="conversation-history-modal-content">
{#if items.length === 0}
<p class="history-empty-state">
Conversation history is empty.
</p>
{:else}
{#each items as item (item.id)}
<div class="history-list-modal-content">
<span class="history-list-modal-date">{item.date}</span>
<span class="history-list-modal-separator">|</span>
<span class="history-list-modal-title">{item.title}</span>
<input
type="checkbox"
class="history-list-modal-checkbox"
checked={selectedItems.has(item.id)}
on:change={() => toggleSelection(item.id)}
/>
</div>
{/each}
{/if}
</div>
</div>
<style>
.conversation-history-modal-container {
display: grid;
grid-template-rows: var(--size-4-3) auto var(--size-4-3) 1fr var(--size-4-3);
grid-template-columns: var(--size-4-3) 1fr var(--size-4-3);
}
.conversation-history-modal-top-bar {
grid-row: 2;
grid-column: 2;
height: var(--size-4-16);
display: grid;
grid-template-rows: var(--size-4-3) 1fr var(--size-4-3);
grid-template-columns: 1fr;
}
.conversation-history-modal-top-bar-content {
grid-row: 2;
grid-column: 1;
display: grid;
grid-template-rows: auto;
grid-template-columns: var(--size-4-2) auto 1fr auto var(--size-4-2);
background-color: var(--color-base-30);
border-radius: var(--radius-m);
}
.conversation-history-modal-content {
grid-row: 4;
grid-column: 2;
min-height: 10vh;
overflow: scroll;
scroll-behavior: smooth;
}
.conversation-history-modal-content::-webkit-scrollbar {
display: none;
}
.history-empty-state {
margin: 3vh 0px;
text-align: center;
color: var(--text-muted);
}
.history-list-modal-content {
display: grid;
grid-template-rows: 1fr;
grid-template-columns: auto auto 1fr auto;
}
.history-list-modal-date {
grid-row: 1;
grid-column: 1;
margin: 0px var(--size-2-3) 0px var(--size-4-3)
}
.history-list-modal-separator {
grid-row: 1;
grid-column: 2;
color: var(--color-base-35);
margin: 0px var(--size-4-3);
}
.history-list-modal-title {
grid-row: 1;
grid-column: 3;
display: inline-block;
max-width: 80%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-list-modal-checkbox {
grid-row: 1;
grid-column: 4;
margin: 0px var(--size-4-3) 0px var(--size-2-3)
}
#delete-button {
grid-row: 1;
grid-column: 2;
}
#close-button {
grid-row: 1;
grid-column: 4;
}
</style>

View file

@ -1,3 +0,0 @@
export class Modals {
static SimpleModal = Symbol("SimpleModal");
}

View file

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

View file

@ -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<FileSystemService>(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<string> {
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<Conversation[]> {
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;
}
}

View file

@ -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<AIAgentPlugin>(Services.AIAgentPlugin).app.vault;
}
public async readObjectFromFile(filePath: string): Promise<object | null> {
public async readFile(filePath: string): Promise<string | null> {
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<boolean> {
public async writeFile(filePath: string, content: string): Promise<boolean> {
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<TFile[]> {
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<object | null> {
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<boolean> {
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 {
}
}
}
}

View file

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

View file

@ -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<StreamingMarkdownService>(Services.StreamingMarkdownService, () => new StreamingMarkdownService());
RegisterModals(plugin.app);
RegisterAiProvider(plugin);
}
@ -37,4 +38,8 @@ export function RegisterAiProvider(plugin: AIAgentPlugin) {
RegisterTransient<IActionDefinitions>(Services.IActionDefinitions, () => new GeminiActionDefinitions());
RegisterSingleton<IAIClass>(Services.IAIClass, new Gemini(plugin.settings.apiKey));
}
}
function RegisterModals(app: App) {
RegisterTransient<ConversationHistoryModal>(Services.ConversationHistoryModal, () => new ConversationHistoryModal(app));
}

View file

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

View file

@ -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 */
/* ============================== */