Implement spike work for plugin.

This commit is contained in:
Andrew Beal 2025-09-08 15:50:06 +01:00
parent 64831546f2
commit 0fb3e4632f
38 changed files with 5095 additions and 40 deletions

15
AIClasses/AIMemory.ts Normal file
View file

@ -0,0 +1,15 @@
export class AIMemory {
// private chatHistory: Part[][] = [];
// public addChat(userText: string, aiText: Part[]) {
// this.chatHistory.push([
// { text: userText },
// ...aiText
// ]);
// }
// public getChatHistory(): Part[][] {
// return this.chatHistory;
// }
}

View file

@ -0,0 +1,76 @@
import { AIProviderURL } from "Enums/ApiProvider";
import { isValidJson } from "Helpers";
import { request, type RequestUrlParam } from "obsidian";
import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
import type { IActioner } from "Actioner/IActioner";
import type { GeminiActionDefinitions } from "Actioner/Gemini/GeminiActionDefinitions";
import { create_file } from "Actioner/Actions";
import type { IAIClass } from "AIClasses/IAIClass";
import type { IPrompt } from "AIClasses/IPrompt";
export class Gemini implements IAIClass {
private readonly apiKey: string;
private readonly aiPrompt: IPrompt;
private readonly actionDefinitions: GeminiActionDefinitions;
public constructor(apiKey: string) {
this.apiKey = apiKey;
this.aiPrompt = Resolve(Services.IPrompt);
this.actionDefinitions = Resolve(Services.IActionDefinitions);
}
public async apiRequest(userInput: string, actioner: IActioner): Promise<Part[] | null> { //AIResponse
let prompt: string = "The users prompt is: " + userInput;
let requestBody = JSON.stringify({
contents: [
{
role: "user",
parts: [
{
text: this.aiPrompt.instructions() + "\n" +
this.aiPrompt.responseFormat() + "\n" +
this.aiPrompt.getDirectories() + "\n" +
prompt + "\n" +
this.aiPrompt.instructionsReminder()
},
],
},
],
tools: [{
functionDeclarations: [this.actionDefinitions[create_file]]
}]
});
let reqParam: RequestUrlParam = {
url: AIProviderURL.Gemini,
method: "POST",
headers: {
"X-goog-api-key": this.apiKey,
"Content-Type": "application/json"
},
body: requestBody
};
let response: GeminiApiResponse = JSON.parse(await request(reqParam));
console.log(response);
//TODO: tidy up this
let ai_response: Part[] = response.candidates[0]?.content.parts ?? "{}";
// if (isValidJson(ai_response)) {
// return JSON.parse(ai_response);
// }
console.log(ai_response);
return ai_response;
return null;
}
}

5
AIClasses/IAIClass.ts Normal file
View file

@ -0,0 +1,5 @@
import type { IActioner } from "Actioner/IActioner";
export interface IAIClass {
apiRequest(req: string, actioner: IActioner): Promise<Part[] | null>;
}

52
AIClasses/IPrompt.ts Normal file
View file

@ -0,0 +1,52 @@
import type DmsAssistantPlugin from "main";
import type { Vault } from "obsidian";
import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
export interface IPrompt {
getDirectories(): string;
instructions(): string;
instructionsReminder(): string;
responseFormat(): string;
}
export class AIPrompt implements IPrompt {
private vault: Vault;
public constructor() {
this.vault = Resolve<DmsAssistantPlugin>(Services.DmsAssistantPlugin).app.vault;
}
public getDirectories(): string {
let directories: string[] = this.vault.getAllFolders(true).map(folder => folder.path);
return "Available user directories:" + "\n" + directories.join("\n");
}
public readonly instructionsArr: string[] = [
"You are an AI assistant for the Obsidian note taking app.",
//"In addition to answering questions, you can execute helpful functions which are defined below.",
"The user has provided extra context to your responsibilities:",
"You are a DND expert and can provide detailed information about DND rules, character creation, and gameplay mechanics. Please give concise responses."
];
public instructions(): string {
return this.instructionsArr.join("\n");
}
public readonly instructionsReminderArr: string[] = [
"Ensure your response is valid JSON following the format defined above."
]
public instructionsReminder(): string {
return this.instructionsReminderArr.join("\n");
}
public readonly responseFormatArr: string[] = [
"All responses that are not function calls should be in JSON parsable format. The response should not be wrapped in ```json```. The following fields should be used:",
"user_response - the response to be delivered to the user.",
//"function_name - a string name for the desired function or null.",
//"function_object - an object containing the function arguments or null."
]
public responseFormat(): string {
return this.responseFormatArr.join("\n");
}
}

View file

@ -0,0 +1,3 @@
export class AIRequest {
}

View file

@ -0,0 +1,16 @@
/**
* Interfaces for requested custom API responses.
*/
interface AIResponse {
function_calls: FunctionCall[];
// function_name: string | null;
// function_object: object | null;
// user_response: string;
}
interface CreateFileRequest {
file_path: string;
file_content: string;
}

View file

@ -0,0 +1,80 @@
/**
* Interface for the top-level Gemini API response.
*/
interface GeminiApiResponse {
candidates: Candidate[];
usageMetadata: UsageMetadata;
modelVersion: string;
responseId: string;
}
/**
* Interface for a single candidate in the response.
*/
interface Candidate {
content: Content;
finishReason: string;
index: number;
citationMetadata: CitationMetadata;
}
/**
* Interface for the content of a candidate.
*/
interface Content {
parts: Part[];
role: string;
}
/**
* Interface for a single part of the content.
* The `text` property contains a JSON string, which needs to be parsed.
*/
interface Part {
text: string;
functionCall: FunctionCall;
}
/**
* Interface for a single function call.
*/
interface FunctionCall {
name: string;
args: object;
}
/**
* Interface for the metadata about citations.
*/
interface CitationMetadata {
citationSources: CitationSource[];
}
/**
* Interface for a single citation source.
*/
interface CitationSource {
startIndex: number;
endIndex: number;
uri: string;
license: string;
}
/**
* Interface for the usage metadata of the API call.
*/
interface UsageMetadata {
promptTokenCount: number;
candidatesTokenCount: number;
totalTokenCount: number;
promptTokensDetails: PromptTokensDetails[];
thoughtsTokenCount: number;
}
/**
* Interface for the details of prompt tokens.
*/
interface PromptTokensDetails {
modality: string;
tokenCount: number;
}

21
Actioner/Actioner.ts Normal file
View file

@ -0,0 +1,21 @@
import { createDirectories } from "Helpers";
import { create_file } from "./Actions";
import type { IActioner } from "./IActioner";
import type { Vault } from "obsidian";
import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
import type DmsAssistantPlugin from "main";
export class Actioner implements IActioner {
private vault: Vault;
public constructor() {
this.vault = Resolve<DmsAssistantPlugin>(Services.DmsAssistantPlugin).app.vault;
}
public async [create_file](action: CreateFileRequest) {
await createDirectories(this.vault, action.file_path);
await this.vault.create(action.file_path, JSON.stringify(action.file_content, null, 4));
}
}

9
Actioner/Actions.ts Normal file
View file

@ -0,0 +1,9 @@
const request_directories: symbol = Symbol.for("request_directories");
const request_contents: symbol = Symbol.for("request_contents");
const create_schema: symbol = Symbol.for("create_schema");
const create_file: symbol = Symbol.for("create_file");
const delete_file: symbol = Symbol.for("delete_file");
const edit_file: symbol = Symbol.for("edit_file");
const rename_file: symbol = Symbol.for("rename_file");
export { request_directories, request_contents, create_schema, create_file, delete_file, edit_file, rename_file };

View file

@ -0,0 +1,131 @@
import { Type } from "@google/genai";
import { create_file, create_schema, delete_file, edit_file, rename_file, request_contents, request_directories } from "Actioner/Actions";
import type { IActionDefinitions } from "Actioner/IActionDefinitions";
export class GeminiActionDefinitions implements IActionDefinitions {
public [request_directories](): object {
return {
name: "request_directories",
description: "Request the available user directories. Call this for further available functions",
parameters: {}
};
}
public [request_contents](): object {
return {
name: "request_contents",
description: "Request the contents of a file. The contents will be added using the Files API",
parameters: {
type: Type.OBJECT,
properties: {
file_path: {
type: Type.STRING,
description: "The file path of the file to be requested"
}
},
requited: ["file_path"]
}
};
}
public [create_schema](): object {
return {
name: "create_schema",
description: "Create a new data definition schema for the user",
parameters: {
type: Type.OBJECT,
properties: {
file_path: {
type: Type.STRING,
description: "The file path of the schema to be created"
},
schema_content: {
type: Type.OBJECT,
description: "The schema definition where each field is the property name and each value is the default property value. Nested properties are accepted"
}
},
required: ["file_path", "file_content"]
}
};
}
public [create_file](): object {
return {
name: "create_file",
description: "Create a new file for the user",
parameters: {
type: Type.OBJECT,
properties: {
file_path: {
type: Type.STRING,
description: "The file path of the file to be created"
},
file_content: {
type: Type.STRING,
description: "The content of the file to be created"
}
},
required: ["file_path", "file_content"]
}
};
}
public [delete_file](): object {
return {
name: "delete_file",
description: "Request a file to be deleted",
parameters: {
type: Type.OBJECT,
properties: {
file_path: {
type: Type.STRING,
description: "The file path of the file to be deleted"
}
},
requited: ["file_path"]
}
};
}
public [edit_file](): object {
return {
name: "edit_file",
description: "Edit the contents of an existing file",
parameters: {
type: Type.OBJECT,
properties: {
file_path: {
type: Type.STRING,
description: "The file path of the file to be edited"
},
file_content: {
type: Type.STRING,
description: "The new content of the file to be written"
}
},
requited: ["file_path, file_content"]
}
};
}
public [rename_file](): object {
return {
name: "rename_file",
description: "Rename or move a file",
parameters: {
type: Type.OBJECT,
properties: {
old_file_path: {
type: Type.STRING,
description: "The old file path of the file"
},
new_file_path: {
type: Type.STRING,
description: "The new file path of the file"
}
},
requited: ["old_file_path", "new_file_path"]
}
};
}
}

View file

@ -0,0 +1,11 @@
import type { create_file, create_schema, delete_file, edit_file, rename_file, request_contents, request_directories } from "./Actions";
export interface IActionDefinitions {
[request_directories](): object;
[request_contents](): object;
[create_schema](): object;
[create_file](): object;
[delete_file](): object;
[edit_file](): object;
[rename_file](): object;
}

3
Actioner/IActioner.ts Normal file
View file

@ -0,0 +1,3 @@
export interface IActioner {
[key: symbol]: (...args: any[]) => any;
}

View file

77
Components/Input.svelte Normal file
View file

@ -0,0 +1,77 @@
<script lang="ts">
import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
import type { IActioner } from "Actioner/IActioner";
import type { IAIClass } from "AIClasses/IAIClass";
interface Props {
input: string;
}
let {
input
}: Props = $props();
let output: string = $state("");
async function submit() {
let aiClass: IAIClass = Resolve(Services.IAIClass)
let actioner: IActioner = Resolve(Services.IActioner);
let aiResponse: Part[] | null = await aiClass.apiRequest(input, actioner);
if (aiResponse == null) {
throw "Response was invalid JSON";
}
for (let part of aiResponse) {
if (part.functionCall) {
let functionName: string = part.functionCall.name;
let functionObject: object = part.functionCall.args;
await actioner[Symbol.for(functionName)](functionObject);
};
}
output = `Done: ${aiResponse}`
}
</script>
<div class="container">
<input
type="string"
bind:value={input}
placeholder="Enter a prompt"
aria-label="Enter a prompt"
/>
<button onclick={submit}>Submit</button>
<p>{output}</p>
</div>
<style>
.container {
display: flex;
background-color: hotpink;
flex-direction: column;
gap: 0.5rem;
max-width: 200px;
}
input {
padding: 0.4rem;
font-size: 1rem;
}
button {
background: #0066ff;
color: white;
border: none;
padding: 0.4rem;
cursor: pointer;
}
button:hover {
background: #0055dd;
}
</style>

8
Enums/ApiProvider.ts Normal file
View file

@ -0,0 +1,8 @@
export enum AIProvider {
Gemini = "Gemini",
OpenAI = "OpenAI"
};
export enum AIProviderURL {
Gemini = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent"
}

4
Enums/DynamicRecord.ts Normal file
View file

@ -0,0 +1,4 @@
export enum DynamicRecordProp {
Type = "type",
ObjectId = "object_id"
}

6
Enums/FileAction.ts Normal file
View file

@ -0,0 +1,6 @@
export enum FileAction {
Create = "create",
Modify = "modify",
Delete = "delete",
Rename = "rename"
}

5
Enums/Path.ts Normal file
View file

@ -0,0 +1,5 @@
export enum Path {
Root = "DM's Assistant",
Schemas = "DM's Assistant/Schemas",
Records = "DM's Assistant/Records"
};

26
Helpers.ts Normal file
View file

@ -0,0 +1,26 @@
import { TFile, TFolder, type Vault } from "obsidian";
export function isValidJson(str: string): boolean {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
}
export async function createDirectories(vault: Vault, filePath: string) {
const dirPath: string = filePath.substring(0, filePath.lastIndexOf('/'));
const dirs: string[] = dirPath.split('/');
let currentPath = "";
for (const dir of dirs) {
if (dir) {
currentPath = currentPath ? `${currentPath}/${dir}` : dir;
if (vault.getAbstractFileByPath(currentPath) == null) {
await vault.createFolder(currentPath);
}
}
}
}

3
Modals/Modals.ts Normal file
View file

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

17
Modals/SimpleModal.ts Normal file
View file

@ -0,0 +1,17 @@
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();
}
}

157
ODB/Core/DynamicRecord.ts Normal file
View file

@ -0,0 +1,157 @@
import { DynamicRecordProp } from 'Enums/DynamicRecord';
import { version as uuidVersion } from 'uuid';
import { validate as uuidValidate } from 'uuid';
import type { OdbCache } from './OdbCache';
import { Resolve } from 'Services/DependencyService';
import { Services } from 'Services/Services';
import type DmsAssistantPlugin from 'main';
import { TAbstractFile, TFile, type Vault } from 'obsidian';
interface DynamicProps {
[key: string]: any;
}
export class DynamicRecord {
public readonly type: string;
public readonly schema: object;
public readonly objectId: string;
public readonly recordPath: string;
public props: DynamicProps;
private vault: Vault;
private odbCache: OdbCache;
public constructor(recordPath: string, schema: object, record: { [key: string]: any }) {
this.odbCache = Resolve<OdbCache>(Services.OdbCache);
this.vault = Resolve<DmsAssistantPlugin>(Services.DmsAssistantPlugin).app.vault;
this.schema = schema;
this.recordPath = recordPath;
if (record[DynamicRecordProp.Type] === undefined || record[DynamicRecordProp.ObjectId] === undefined) {
throw new Error(`Invalid record format: missing ${DynamicRecordProp.Type} or ${DynamicRecordProp.ObjectId}`);
}
this.type = record[DynamicRecordProp.Type];
this.objectId = record[DynamicRecordProp.ObjectId];
let parsedRecord: Record<string,any> = {};
for (let key in schema) {
if (!(key in record) || key === DynamicRecordProp.Type || key === DynamicRecordProp.ObjectId) {
continue;
}
parsedRecord[key] = this.parse(record[key]);
}
this.props = this.createProxy(parsedRecord);
}
public save() {
let file: TAbstractFile | null = this.vault.getAbstractFileByPath(this.recordPath);
if (file == null || !(file instanceof TAbstractFile)) {
this.vault.create(this.recordPath, this.toFileContent());
}
this.vault.modify(file as TFile, this.toFileContent());
}
public delete() {
let file: TAbstractFile | null = this.vault.getAbstractFileByPath(this.recordPath);
if (file == null || !(file instanceof TAbstractFile)) {
return;
}
this.vault.delete(file as TFile);
}
public move(newPath: string) {
let file: TAbstractFile | null = this.vault.getAbstractFileByPath(this.recordPath);
if (file == null || !(file instanceof TAbstractFile)) {
return;
}
this.vault.rename(file as TFile, newPath);
}
private parse(data: any): any {
if (typeof data === null) {
return data;
}
if (Array.isArray(data)) {
let arr: any[] = [];
for (let item of data) {
arr.push(this.parse(item));
}
return arr;
}
if (typeof data === "object") {
let obj: Record<string,any> = {};
for (let key in data) {
obj[key] = this.parse(data[key]);
}
return this.createProxy(obj);
}
return data;
}
private createProxy(data: object): DynamicProps {
return new Proxy(data, {
get: (target, prop, receiver) => {
if (this.isObjectId(String(prop))) {
return this.odbCache.getRecord(String(prop));
}
return Reflect.get(target, prop, receiver);
},
set: (target, prop, value, receiver) => {
return Reflect.set(target, prop, value, receiver);
}
});
}
private isObjectId(key: string): boolean {
return uuidValidate(key) && uuidVersion(key) === 4;
}
private toFileContent(): string {
let content: Record<string,any> = {};
content[DynamicRecordProp.Type] = this.type;
content[DynamicRecordProp.ObjectId] = this.objectId;
// use the schema when saving to file in case the AI did something funky
for (let key in this.schema) {
if (!(key in this.props) || key === DynamicRecordProp.Type || key === DynamicRecordProp.ObjectId) {
continue;
}
content[key] = this.serialise(this.props[key]);
}
return JSON.stringify(content, null, 4);
}
private serialise(value: any): any {
if (value instanceof DynamicRecord) {
return value.objectId;
}
if (Array.isArray(value)) {
let arr: any[] = [];
for (let item of value) {
arr.push(this.serialise(item));
}
return arr;
}
if (typeof value === "object") {
let obj: Record<string,any> = {};
for (let key in value) {
obj[key] = this.serialise(value[key]);
}
return obj;
}
return value;
}
}

131
ODB/Core/OdbCache.ts Normal file
View file

@ -0,0 +1,131 @@
import { TAbstractFile, TFile, TFolder, Vault } from "obsidian";
import { DynamicRecord } from "./DynamicRecord";
import { Resolve } from "Services/DependencyService";
import type DmsAssistantPlugin from "main";
import { Services } from "Services/Services";
import { Path } from "Enums/Path";
import { DynamicRecordProp } from "Enums/DynamicRecord";
import { FileAction } from "Enums/FileAction";
import { isValidJson } from "Helpers";
export class OdbCache {
private vault: Vault;
private schemas: Map<string, object> = new Map<string, object>();
private cache: Map<string, DynamicRecord> = new Map<string, DynamicRecord>();
public constructor() {
this.vault = Resolve<DmsAssistantPlugin>(Services.DmsAssistantPlugin).app.vault;
}
public getSchemas(): Map<string, object> {
return this.schemas;
}
public getRecord(objectId: string): DynamicRecord | null {
return this.cache.get(objectId) ?? null;
}
public async buildCache() {
await this.loadSchemas();
let recordDir: TAbstractFile | null = this.vault.getAbstractFileByPath(Path.Records);
if (!(recordDir instanceof TFolder)) {
return;
}
this.recursiveBuild(recordDir);
}
public async onFileChanged(file: TAbstractFile, fileAction: FileAction) {
if (file instanceof TFolder) {
for (let child of file.children) {
this.onFileChanged(child, fileAction);
}
return;
}
for (let [_, record] of this.cache) {
if (record.recordPath === file.path) {
switch (fileAction) {
case FileAction.Create:
this.addToCache(file as TFile);
break;
case FileAction.Modify, FileAction.Rename:
this.removeFromCache(record.objectId);
this.addToCache(file as TFile);
break;
case FileAction.Delete:
this.removeFromCache(record.objectId);
break;
}
}
}
}
private async recursiveBuild(child : TAbstractFile) {
if (child instanceof TFolder) {
for (let subChild of child.children) {
await this.recursiveBuild(subChild);
}
} else if (child instanceof TFile && child.extension === "json") {
await this.addToCache(child);
}
}
private async createDynamicRecord(file: TFile): Promise<DynamicRecord> {
let contents: { [key: string]: any } = JSON.parse(await this.vault.read(file));
if (contents[DynamicRecordProp.Type] === undefined || contents[DynamicRecordProp.ObjectId] === undefined) {
throw new Error(`Invalid record format in file ${file.path}: missing ${DynamicRecordProp.Type} or ${DynamicRecordProp.ObjectId}`);
}
let schema: object | undefined = this.schemas.get(contents[DynamicRecordProp.Type]);
if (schema === undefined) {
throw new Error(`No schema found for record type ${contents[DynamicRecordProp.Type]} in file ${file.path}`);
}
return new DynamicRecord(file.path, schema, contents);
}
private async loadSchemas() {
let schemaDir: TAbstractFile | null = this.vault.getAbstractFileByPath(Path.Schemas);
if (!(schemaDir instanceof TFolder)) {
return;
}
for (let child of schemaDir.children) {
if (!(child instanceof TFile) || child.extension !== "json") {
continue;
}
let contents: string = await this.vault.read(child);
if (!isValidJson(contents)) {
console.warn(`Invalid schema format in file ${child.path}`);
continue;
}
let schema: Record<string,any> = JSON.parse(contents);
if (schema[DynamicRecordProp.Type] === undefined || schema[DynamicRecordProp.ObjectId] === undefined) {
throw new Error(`Invalid schema format in file ${child.path}: missing ${DynamicRecordProp.Type} or ${DynamicRecordProp.ObjectId}`);
}
this.schemas.set(schema[DynamicRecordProp.Type], schema);
}
}
private async addToCache(file: TFile) {
let record: DynamicRecord = await this.createDynamicRecord(file);
this.cache.set(record.objectId, record);
}
private async removeFromCache(objectId: string) {
this.cache.delete(objectId);
}
}

View file

@ -0,0 +1,24 @@
const services = new Map<symbol, any>();
export function RegisterSingleton<T>(type: symbol, instance: T): void {
services.set(type, instance);
}
export function RegisterTransient<T>(type: symbol, factory: () => T): void {
services.set(type, factory);
}
export function Resolve<T>(type: symbol): T {
const service = services.get(type);
if (!service) {
throw new Error(`Service not found for type: ${type.description}`);
}
if (typeof service === 'function') {
// It's a transient factory, return a new instance
return service();
}
// It's a singleton, return the existing instance
return service as T;
}

14
Services/ModalService.ts Normal file
View file

@ -0,0 +1,14 @@
import { Modal } from "obsidian";
import { Resolve } from "./DependencyService";
interface IModalService {
showModal(modal: symbol): void;
}
export class ModalService implements IModalService {
showModal(modal: symbol): void {
let modalInstance: Modal = Resolve(modal);
modalInstance.open();
}
}

View file

@ -0,0 +1,31 @@
import { AIProvider } from "Enums/ApiProvider";
import type DmsAssistantPlugin 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";
import type { IActioner } from "Actioner/IActioner";
import type { IAIClass } from "AIClasses/IAIClass";
import { GeminiActionDefinitions } from "Actioner/Gemini/GeminiActionDefinitions";
import type { IActionDefinitions } from "Actioner/IActionDefinitions";
import { Gemini } from "AIClasses/Gemini/Gemini";
export function RegisterDependencies(plugin: DmsAssistantPlugin) {
RegisterSingleton(Services.DmsAssistantPlugin, plugin);
RegisterSingleton(Services.OdbCache, new OdbCache());
RegisterSingleton(Services.ModalService, new ModalService())
RegisterSingleton<IPrompt>(Services.IPrompt, new AIPrompt());
RegisterSingleton<IActioner>(Services.IActioner, new Actioner())
RegisterAiProvider(plugin);
}
export function RegisterAiProvider(plugin: DmsAssistantPlugin) {
if (plugin.settings.apiProvider == AIProvider.Gemini && plugin.settings.apiKey != "") {
RegisterTransient<IActionDefinitions>(Services.IActionDefinitions, () => new GeminiActionDefinitions());
RegisterSingleton<IAIClass>(Services.IAIClass, new Gemini(plugin.settings.apiKey));
}
}

11
Services/Services.ts Normal file
View file

@ -0,0 +1,11 @@
export class Services {
static DmsAssistantPlugin = Symbol("DmsAssistantPlugin");
static OdbCache = Symbol("OdbCache");
static ModalService = Symbol("ModalService");
// interfaces
static IAIClass = Symbol("IAIClass");
static IPrompt = Symbol("IPrompt");
static IActioner = Symbol("IActioner");
static IActionDefinitions = Symbol("IActionDefinitions");
}

0
Views/App.svelte Normal file
View file

0
Views/ChatHistory.svelte Normal file
View file

0
Views/Input.svelte Normal file
View file

40
Views/MainView.ts Normal file
View file

@ -0,0 +1,40 @@
import { ItemView, WorkspaceLeaf } from 'obsidian';
import Input from '../Components/Input.svelte';
import { mount, unmount } from 'svelte';
export const VIEW_TYPE_MAIN = 'main-view';
export class MainView extends ItemView {
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
input: ReturnType<typeof Input> | undefined;
getViewType() {
return VIEW_TYPE_MAIN;
}
getDisplayText() {
return 'Main View';
}
async onOpen() {
const container = this.contentEl;
container.empty();
this.input = mount(Input, {
target: container,
props: {
input: "",
}
});
}
async onClose() {
if (this.input) {
unmount(this.input);
}
}
}

0
Views/viewStore.ts Normal file
View file

View file

@ -2,6 +2,9 @@ import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import esbuildSvelte from 'esbuild-svelte';
import { sveltePreprocess } from 'svelte-preprocess';
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
@ -12,6 +15,12 @@ if you want to view the source, please visit the github repository of this plugi
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
plugins: [
esbuildSvelte({
compilerOptions: { css: 'injected' },
preprocess: sveltePreprocess(),
}),
],
banner: {
js: banner,
},

138
main.ts
View file

@ -1,25 +1,44 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
import { App, Editor, MarkdownView, WorkspaceLeaf, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
import { Resolve } from './Services/DependencyService';
import { AIProvider } from './Enums/ApiProvider';
// Remember to rename these classes and interfaces!
import { MainView, VIEW_TYPE_MAIN } from 'Views/MainView';
import { Services } from 'Services/Services';
import { OdbCache } from 'ODB/Core/OdbCache';
import { FileAction } from 'Enums/FileAction';
import { Path } from 'Enums/Path';
import { RegisterAiProvider, RegisterDependencies } from 'Services/ServiceRegistration';
interface MyPluginSettings {
mySetting: string;
interface DmsAssistantSettings {
apiProvider: string;
apiKey: string;
}
const DEFAULT_SETTINGS: MyPluginSettings = {
mySetting: 'default'
const DEFAULT_SETTINGS: DmsAssistantSettings = {
apiProvider: AIProvider.Gemini,
apiKey: ""
}
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
export default class DmsAssistantPlugin extends Plugin {
settings: DmsAssistantSettings;
async onload() {
await this.loadSettings();
RegisterDependencies(this);
CreateDirectories(this);
this.registerView(
VIEW_TYPE_MAIN,
(leaf) => new MainView(leaf)
);
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => {
// Called when the user clicks the icon.
new Notice('This is a notice!');
this.activateView();
});
// Perform additional things with the ribbon
ribbonIconEl.addClass('my-plugin-ribbon-class');
@ -28,14 +47,6 @@ export default class MyPlugin extends Plugin {
const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText('Status Bar Text');
// This adds a simple command that can be triggered anywhere
this.addCommand({
id: 'open-sample-modal-simple',
name: 'Open sample modal (simple)',
callback: () => {
new SampleModal(this.app).open();
}
});
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: 'sample-editor-command',
@ -66,7 +77,7 @@ export default class MyPlugin extends Plugin {
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
this.addSettingTab(new DmsAssistantSettingTab(this.app, this));
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
@ -76,10 +87,48 @@ export default class MyPlugin extends Plugin {
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
let odbCache: OdbCache = Resolve<OdbCache>(Services.OdbCache)
await odbCache.buildCache();
this.registerEvent(
this.app.vault.on(FileAction.Create, (file) => odbCache.onFileChanged(file, FileAction.Create))
);
this.registerEvent(
this.app.vault.on(FileAction.Modify, (file) => odbCache.onFileChanged(file, FileAction.Modify))
);
this.registerEvent(
this.app.vault.on(FileAction.Delete, (file) => odbCache.onFileChanged(file, FileAction.Delete))
);
this.registerEvent(
this.app.vault.on(FileAction.Rename, (file) => odbCache.onFileChanged(file, FileAction.Rename))
);
}
onunload() {
async onunload() {
}
async activateView() {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(VIEW_TYPE_MAIN);
if (leaves.length > 0) {
// A leaf with our view already exists, use that
leaf = leaves[0];
} else {
// Our view could not be found in the workspace, create a new leaf
// in the right sidebar for it
leaf = workspace.getRightLeaf(false);
await leaf?.setViewState({ type: VIEW_TYPE_MAIN, active: true });
}
// "Reveal" the leaf in case it is in a collapsed sidebar
if (leaf != null) {
workspace.revealLeaf(leaf);
}
}
async loadSettings() {
@ -88,6 +137,7 @@ export default class MyPlugin extends Plugin {
async saveSettings() {
await this.saveData(this.settings);
RegisterAiProvider(this);
}
}
@ -97,38 +147,68 @@ class SampleModal extends Modal {
}
onOpen() {
const {contentEl} = this;
const { contentEl } = this;
contentEl.setText('Woah!');
}
onClose() {
const {contentEl} = this;
const { contentEl } = this;
contentEl.empty();
}
}
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
class DmsAssistantSettingTab extends PluginSettingTab {
plugin: DmsAssistantPlugin;
constructor(app: App, plugin: MyPlugin) {
constructor(app: App, plugin: DmsAssistantPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Setting #1')
.setDesc('It\'s a secret')
.setName("API Provider")
.setDesc("Select the API provider to use.")
.addDropdown((dropdown) =>
dropdown
.addOption("1", AIProvider.Gemini)
.addOption("2", AIProvider.OpenAI)
.setValue(this.plugin.settings.apiProvider)
.onChange(async (value) => {
this.plugin.settings.apiProvider = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("API Key")
.setDesc("Enter your API key here.")
.addText(text => text
.setPlaceholder('Enter your secret')
.setValue(this.plugin.settings.mySetting)
.setPlaceholder("Enter your API key")
.setValue(this.plugin.settings.apiKey)
.onChange(async (value) => {
this.plugin.settings.mySetting = value;
this.plugin.settings.apiKey = value;
await this.plugin.saveSettings();
}));
}
}
function CreateDirectories(plugin: DmsAssistantPlugin) {
this.app.workspace.onLayoutReady(async () => {
const vault = plugin.app.vault;
if (vault.getAbstractFileByPath(Path.Root) == null) {
vault.createFolder(Path.Root);
}
if (vault.getAbstractFileByPath(Path.Schemas) == null) {
vault.createFolder(Path.Schemas);
}
if (vault.getAbstractFileByPath(Path.Records) == null) {
vault.createFolder(Path.Records);
}
});
}

View file

@ -1,9 +1,9 @@
{
"id": "sample-plugin",
"name": "Sample Plugin",
"id": "dms_assistant",
"name": "DM's Assistant",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Demonstrates some of the capabilities of the Obsidian API.",
"description": "AI powered Dungeon Master's assistant.",
"author": "Obsidian",
"authorUrl": "https://obsidian.md",
"fundingUrl": "https://obsidian.md/pricing",

3976
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,24 +1,35 @@
{
"name": "obsidian-sample-plugin",
"name": "dms_assistant",
"version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"description": "AI powered Dungeon Master's assistant.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
"version": "node version-bump.mjs && git add manifest.json versions.json",
"svelte-check": "svelte-check --tsconfig tsconfig.json"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@types/express": "^5.0.3",
"@types/node": "^16.18.126",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"esbuild": "^0.25.9",
"esbuild-svelte": "^0.9.3",
"obsidian": "latest",
"svelte": "^5.38.3",
"svelte-check": "^4.3.1",
"svelte-preprocess": "^6.0.3",
"tslib": "2.4.0",
"typescript": "4.7.4"
"typescript": "~5.0.0"
},
"dependencies": {
"@google/genai": "^1.17.0",
"express": "^5.1.0",
"uuid": "^11.1.0"
}
}

View file

@ -1,5 +1,7 @@
{
"compilerOptions": {
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
@ -19,6 +21,7 @@
]
},
"include": [
"**/*.ts"
"**/*.ts",
"**/*.svelte"
]
}
}