mirror of
https://github.com/fbarrca/obsidian-inlineAI.git
synced 2026-07-22 11:50:24 +00:00
Rework the whole codebase
This commit is contained in:
parent
71f2db3669
commit
c1a1176469
13 changed files with 1026 additions and 950 deletions
164
package-lock.json
generated
164
package-lock.json
generated
|
|
@ -9,6 +9,8 @@
|
|||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.7.1",
|
||||
"@codemirror/merge": "^6.7.5",
|
||||
"@codemirror/state": "^6.4.1",
|
||||
"@codemirror/view": "^6.34.1",
|
||||
"@langchain/core": "^0.3.16",
|
||||
|
|
@ -16,7 +18,8 @@
|
|||
"@langchain/openai": "^0.3.11",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"ollama": "^0.5.9"
|
||||
"jest-diff": "^29.7.0",
|
||||
"simple-text-diff": "^1.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/codemirror": "^5.60.15",
|
||||
|
|
@ -30,6 +33,42 @@
|
|||
"typescript": "^5.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.7.1.tgz",
|
||||
"integrity": "sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.4.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.10.8",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.8.tgz",
|
||||
"integrity": "sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw==",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.1.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/merge": {
|
||||
"version": "6.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.7.5.tgz",
|
||||
"integrity": "sha512-cLmL8sJR4+4d4/E7gJpDFfWfYp8DRN4uVsqb2JkufPANv5Y9rgkFZvbnFtcPMhc6VFlpGA+bBFpS/uOaCwy95w==",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"style-mod": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz",
|
||||
|
|
@ -573,6 +612,17 @@
|
|||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@jest/schemas": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
|
||||
"integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.27.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/core": {
|
||||
"version": "0.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.16.tgz",
|
||||
|
|
@ -641,6 +691,27 @@
|
|||
"@langchain/core": ">=0.2.26 <0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
|
||||
"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA=="
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
|
||||
"integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
|
||||
"integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
|
@ -676,6 +747,11 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.27.8",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
|
||||
"integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="
|
||||
},
|
||||
"node_modules/@types/codemirror": {
|
||||
"version": "5.60.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.15.tgz",
|
||||
|
|
@ -1003,8 +1079,6 @@
|
|||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
|
|
@ -1113,8 +1187,6 @@
|
|||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
|
|
@ -1130,8 +1202,6 @@
|
|||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
|
|
@ -1142,9 +1212,7 @@
|
|||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
|
|
@ -1239,6 +1307,14 @@
|
|||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff-sequences": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
|
||||
"integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||
|
|
@ -1532,6 +1608,11 @@
|
|||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
||||
|
|
@ -1772,8 +1853,6 @@
|
|||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
|
|
@ -1889,6 +1968,28 @@
|
|||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/jest-diff": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz",
|
||||
"integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==",
|
||||
"dependencies": {
|
||||
"chalk": "^4.0.0",
|
||||
"diff-sequences": "^29.6.3",
|
||||
"jest-get-type": "^29.6.3",
|
||||
"pretty-format": "^29.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-get-type": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
|
||||
"integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==",
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tiktoken": {
|
||||
"version": "1.0.15",
|
||||
"resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.15.tgz",
|
||||
|
|
@ -2374,6 +2475,30 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
|
||||
"integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
|
||||
"dependencies": {
|
||||
"@jest/schemas": "^29.6.3",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^18.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
|
@ -2404,6 +2529,11 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
|
|
@ -2507,6 +2637,14 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-text-diff": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-text-diff/-/simple-text-diff-1.7.0.tgz",
|
||||
"integrity": "sha512-IQzeFnG66XPGWS2UjeVFGDAcUk2LFJEbTv52Vb4/YzNzWrBVwzHcNUdNxF0y7QK8NkdW43LX6mljroDyBfPK2g==",
|
||||
"dependencies": {
|
||||
"fast-diff": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
|
|
@ -2542,8 +2680,6 @@
|
|||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@
|
|||
"typescript": "^5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.7.1",
|
||||
"@codemirror/merge": "^6.7.5",
|
||||
"@codemirror/state": "^6.4.1",
|
||||
"@codemirror/view": "^6.34.1",
|
||||
"@langchain/core": "^0.3.16",
|
||||
|
|
@ -30,6 +32,7 @@
|
|||
"@langchain/openai": "^0.3.11",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"ollama": "^0.5.9"
|
||||
"jest-diff": "^29.7.0",
|
||||
"simple-text-diff": "^1.7.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
203
src/api.ts
203
src/api.ts
|
|
@ -1,71 +1,148 @@
|
|||
// api.ts
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
import { ChatOllama } from "@langchain/ollama"; // Replace with actual Ollama import
|
||||
|
||||
import {
|
||||
generateFineGrainedConflictWithinLine,
|
||||
generateFineGrainedConflictWithinLineString,
|
||||
} from "./helpers/conflictMarkers";
|
||||
import { ChatOllama } from "@langchain/ollama";
|
||||
import { SystemMessage, HumanMessage, AIMessage } from "@langchain/core/messages";
|
||||
import { MyPluginSettings } from "./settings";
|
||||
import { App, MarkdownView } from "obsidian";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { setAIResponseEffect } from "./modules/AIExtension";
|
||||
|
||||
// Function to initialize the appropriate chat client based on provider
|
||||
function initializeChatClient(settings: MyPluginSettings) {
|
||||
if (settings.provider === "openai") {
|
||||
if (!settings.apiKey) {
|
||||
throw new Error("OpenAI API key is required when using OpenAI as the provider.");
|
||||
/**
|
||||
* Class to manage interactions with different chat APIs.
|
||||
*/
|
||||
export class ChatApiManager {
|
||||
private chatClient: ChatOpenAI | ChatOllama;
|
||||
private app: App;
|
||||
|
||||
/**
|
||||
* Initializes the ChatApiManager with the given settings.
|
||||
* @param settings - Configuration settings for the chat API.
|
||||
*/
|
||||
constructor(private settings: MyPluginSettings, app: App) {
|
||||
this.app = app;
|
||||
this.chatClient = this.initializeChatClient(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the appropriate chat client based on the provider specified in settings.
|
||||
* @param settings - Configuration settings for the chat API.
|
||||
* @returns An instance of ChatOpenAI or ChatOllama.
|
||||
* @throws Error if the provider is unsupported or required settings are missing.
|
||||
*/
|
||||
private initializeChatClient(settings: MyPluginSettings): ChatOpenAI | ChatOllama {
|
||||
switch (settings.provider) {
|
||||
case "openai":
|
||||
if (!settings.apiKey) {
|
||||
throw new Error("OpenAI API key is required when using OpenAI as the provider.");
|
||||
}
|
||||
return new ChatOpenAI({
|
||||
modelName: settings.model,
|
||||
temperature: 0, // Set temperature to 0 for deterministic outputs
|
||||
apiKey: settings.apiKey,
|
||||
});
|
||||
|
||||
case "ollama":
|
||||
return new ChatOllama({
|
||||
model: settings.model,
|
||||
// Add other necessary configurations for Ollama if needed
|
||||
});
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${settings.provider}`);
|
||||
}
|
||||
return new ChatOpenAI({
|
||||
modelName: settings.model,
|
||||
temperature: 0, // Set temperature to 0 for deterministic outputs
|
||||
apiKey: settings.apiKey,
|
||||
});
|
||||
} else if (settings.provider === "ollama") {
|
||||
// Initialize Ollama client. Replace with actual initialization code.
|
||||
return new ChatOllama({
|
||||
model: settings.model,
|
||||
// Add other necessary configurations for Ollama if needed
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported provider: ${settings.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function callApi(
|
||||
content: string,
|
||||
context: string,
|
||||
settings: MyPluginSettings
|
||||
): Promise<{ generated: string; diff: string }> {
|
||||
// Initialize the appropriate chat client based on settings
|
||||
const chat = initializeChatClient(settings);
|
||||
|
||||
// Prepare the messages array using LangChain message classes
|
||||
const messages = [
|
||||
new SystemMessage(
|
||||
"Reply to the user request, do not add any intro messages or any alternative outputs, just do one example of what you are told"
|
||||
),
|
||||
new HumanMessage(`\`\`\`\n${context}\n${content}\`\`\``),
|
||||
];
|
||||
|
||||
try {
|
||||
// Call the chat model with the prepared messages
|
||||
const aiMessage: AIMessage = await chat.call(messages);
|
||||
const generatedContentString = aiMessage.content.toString();
|
||||
|
||||
// Log the fine-grained conflict for debugging or analysis
|
||||
console.log(
|
||||
generateFineGrainedConflictWithinLine(context, generatedContentString)
|
||||
);
|
||||
|
||||
return {
|
||||
generated: generatedContentString,
|
||||
diff: generateFineGrainedConflictWithinLineString(
|
||||
context,
|
||||
generatedContentString
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error calling the chat model:", error);
|
||||
throw new Error("Failed to generate response from the chat model.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the chat API with the provided content and context.
|
||||
* @param systemMessage - The system message to send to the chat API.
|
||||
* @param message - The user's message to send to the chat API.
|
||||
* @returns A promise that resolves with the generated content.
|
||||
* @throws Error if the API call fails.
|
||||
*/
|
||||
public async callApi(systemMessage: string, message: string): Promise<string> {
|
||||
const messages = [
|
||||
new SystemMessage(systemMessage),
|
||||
new HumanMessage(message),
|
||||
];
|
||||
|
||||
try {
|
||||
const aiMessage: AIMessage = await this.chatClient.invoke(messages);
|
||||
return aiMessage.content.toString();
|
||||
} catch (error) {
|
||||
console.error("Error calling the chat model:", error);
|
||||
throw new Error("Failed to generate response from the chat model.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles user input and updates the editor with the response.
|
||||
* @param systemPrompt - The system prompt to send to the chat API.
|
||||
* @param userRequest - The user's request to process.
|
||||
* @returns The AI-generated response.
|
||||
*/
|
||||
private async handleEditorUpdate(systemPrompt: string, userRequest: string): Promise<string> {
|
||||
try {
|
||||
const response = await this.callApi(systemPrompt, userRequest);
|
||||
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
|
||||
if (!markdownView) return "";
|
||||
|
||||
const mainEditorView = (markdownView.editor as any).cm as EditorView;
|
||||
mainEditorView?.dispatch({
|
||||
effects: setAIResponseEffect.of({ airesponse: response, prompt: userRequest }),
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error processing request:", error);
|
||||
throw new Error("Failed to process request.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles user input and generates a response using the cursor API.
|
||||
* @param userRequest - The user's request to process.
|
||||
* @returns The AI-generated response.
|
||||
*/
|
||||
public async callCursor(userRequest: string): Promise<string> {
|
||||
const systemPrompt = "You are a helpful assistant. Please help the user with the following request:";
|
||||
return this.handleEditorUpdate(systemPrompt, userRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes selected text using the specified prompt and transformation.
|
||||
* @param prompt - The transformation prompt (e.g., "Add Emojis").
|
||||
* @param selectedText - The selected text to transform.
|
||||
* @returns The transformed text.
|
||||
*/
|
||||
public async callSelection(prompt: string, selectedText: string): Promise<string> {
|
||||
const systemPrompt = `
|
||||
**System Prompt:**
|
||||
You are an advanced language model that performs text transformations based on specific instructions. Your task is to process input text to produce the desired output based on a given transformation type. You can handle tasks like adding emojis, making text longer or shorter, and converting text into tables among many others. Follow the examples provided to guide your responses.
|
||||
It is **very important** that you follow the examples. Do not add anything at the start of the output like "Output:" or "Here's arephrased version of the input text:" or anything similar. Just provide the transformed text.
|
||||
|
||||
**Examples:**
|
||||
|
||||
---
|
||||
|
||||
**Task:** Add Emojis.
|
||||
**Prompt:** Add relevant emojis to make the text more engaging.
|
||||
|
||||
**Input:**
|
||||
"Let's celebrate the success of our project."
|
||||
|
||||
**Output:**
|
||||
"🎉 Let's celebrate the success of our project! 🚀👏"
|
||||
|
||||
---
|
||||
`;
|
||||
|
||||
const userPrompt = `
|
||||
**Task:** ${prompt}
|
||||
**Input:**
|
||||
${selectedText}
|
||||
|
||||
**Output:**`
|
||||
return this.handleEditorUpdate(systemPrompt, userPrompt);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,331 +0,0 @@
|
|||
import { EditorView, placeholder, keymap } from "@codemirror/view";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import {
|
||||
ButtonComponent,
|
||||
setIcon,
|
||||
Notice,
|
||||
App,
|
||||
MarkdownView,
|
||||
EditorPosition,
|
||||
} from "obsidian";
|
||||
import { callApi } from "../api";
|
||||
|
||||
export class TooltipWidget {
|
||||
dom: HTMLDivElement;
|
||||
editorView: EditorView;
|
||||
submitButton: ButtonComponent;
|
||||
acceptButton: ButtonComponent;
|
||||
discardButton: ButtonComponent;
|
||||
reloadButton: ButtonComponent;
|
||||
loader: HTMLDivElement;
|
||||
selectedText: string;
|
||||
promptText: string;
|
||||
generatedText: string;
|
||||
diff: string;
|
||||
app: App;
|
||||
selectionRange: { from: EditorPosition; to: EditorPosition } | null;
|
||||
generatedTextRange: { from: EditorPosition; to: EditorPosition } | null;
|
||||
|
||||
constructor(app: App, selectedText: string) {
|
||||
this.app = app;
|
||||
this.selectedText = selectedText;
|
||||
this.promptText = "";
|
||||
this.generatedText = "";
|
||||
this.diff = "";
|
||||
this.selectionRange = null;
|
||||
this.generatedTextRange = null;
|
||||
|
||||
this.dom = document.createElement("div");
|
||||
this.dom.className = "cm-tooltip";
|
||||
setIcon(this.dom, "pencil");
|
||||
|
||||
// First Stage: Prompt Editor
|
||||
const editorDom = document.createElement("div");
|
||||
editorDom.className = "cm-tooltip-editor";
|
||||
this.dom.appendChild(editorDom);
|
||||
|
||||
this.editorView = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: "",
|
||||
extensions: [
|
||||
placeholder("Ask copilot"),
|
||||
keymap.of([
|
||||
{
|
||||
key: "Enter",
|
||||
run: () => {
|
||||
this.submitAction();
|
||||
return true;
|
||||
},
|
||||
preventDefault: true,
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
parent: editorDom,
|
||||
});
|
||||
|
||||
// Buttons
|
||||
this.submitButton = this.createButton(
|
||||
"",
|
||||
"submit-button tooltip-button",
|
||||
() => this.submitAction()
|
||||
);
|
||||
setIcon(this.submitButton.buttonEl, "send-horizontal");
|
||||
|
||||
this.loader = document.createElement("div");
|
||||
this.loader.className = "loader";
|
||||
this.loader.style.display = "none";
|
||||
this.dom.appendChild(this.loader);
|
||||
|
||||
this.acceptButton = this.createButton(
|
||||
"Accept",
|
||||
"accept-button tooltip-button",
|
||||
() => this.acceptAction()
|
||||
);
|
||||
this.acceptButton.buttonEl.style.display = "none";
|
||||
|
||||
this.discardButton = this.createButton(
|
||||
"Discard",
|
||||
"discard-button tooltip-button",
|
||||
() => this.discardAction()
|
||||
);
|
||||
this.discardButton.buttonEl.style.display = "none";
|
||||
|
||||
this.reloadButton = this.createButton(
|
||||
"",
|
||||
"submit-button reload-button tooltip-button",
|
||||
() => this.reloadAction()
|
||||
);
|
||||
setIcon(this.reloadButton.buttonEl, "rotate-cw");
|
||||
this.reloadButton.buttonEl.style.display = "none";
|
||||
|
||||
this.editorView.requestMeasure({
|
||||
read: () => {
|
||||
this.editorView.dom.style.height = "100%";
|
||||
this.editorView.dom.style.width = "100%";
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create a button with specified properties.
|
||||
* @param text - The text to display on the button.
|
||||
* @param classNames - Space-separated list of CSS class names.
|
||||
* @param onClick - The callback function to execute on button click.
|
||||
* @returns A ButtonComponent instance.
|
||||
*/
|
||||
private createButton(
|
||||
text: string,
|
||||
classNames: string,
|
||||
onClick: () => void
|
||||
): ButtonComponent {
|
||||
const button = new ButtonComponent(this.dom);
|
||||
classNames
|
||||
.split(" ")
|
||||
.forEach((cls) => button.buttonEl.classList.add(cls));
|
||||
if (text) {
|
||||
button.setButtonText(text);
|
||||
}
|
||||
button.setTooltip(text || "Reload");
|
||||
button.onClick(onClick);
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the submit action when the user submits their prompt.
|
||||
*/
|
||||
private async submitAction(): Promise<void> {
|
||||
const userInput = this.editorView.state.doc.toString();
|
||||
this.promptText = userInput;
|
||||
console.log("Selected Text:", this.selectedText);
|
||||
|
||||
this.submitButton.setDisabled(true);
|
||||
this.submitButton.buttonEl.style.display = "none";
|
||||
this.loader.style.display = "block";
|
||||
|
||||
try {
|
||||
const response = await callApi(userInput, this.selectedText, (this.app as any).plugins.getPlugin("inlineAI").settings);
|
||||
this.generatedText = response.generated;
|
||||
this.diff = response.diff;
|
||||
|
||||
// Automatically paste the diff into the editor
|
||||
const markdownView =
|
||||
this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (markdownView) {
|
||||
const editor = markdownView.editor;
|
||||
// Save the current selection range
|
||||
const from = editor.getCursor("from");
|
||||
const to = editor.getCursor("to");
|
||||
this.selectionRange = { from: { ...from }, to: { ...to } };
|
||||
|
||||
// Replace the selection with the diff
|
||||
editor.replaceSelection(this.diff);
|
||||
|
||||
// After replacement, get the new cursor position
|
||||
const endPos = editor.getCursor();
|
||||
this.generatedTextRange = {
|
||||
from: { ...from },
|
||||
to: { ...endPos },
|
||||
};
|
||||
} else {
|
||||
new Notice("Failed to find the active Markdown editor.");
|
||||
}
|
||||
|
||||
// Transition to second stage
|
||||
this.editorView.dom.style.display = "none";
|
||||
this.loader.style.display = "none";
|
||||
this.acceptButton.buttonEl.style.display = "block";
|
||||
this.discardButton.buttonEl.style.display = "block";
|
||||
this.reloadButton.buttonEl.style.display = "block";
|
||||
} catch (error) {
|
||||
new Notice("Error: " + (error as Error).message);
|
||||
this.submitButton.setDisabled(false);
|
||||
this.submitButton.buttonEl.style.display = "block";
|
||||
this.loader.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the accept action, allowing the user to accept the generated text.
|
||||
*/
|
||||
private acceptAction(): void {
|
||||
const markdownView =
|
||||
this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (markdownView && this.generatedTextRange) {
|
||||
const editor = markdownView.editor;
|
||||
// Replace the diff with the generated text
|
||||
editor.replaceRange(
|
||||
this.generatedText,
|
||||
this.generatedTextRange.from,
|
||||
this.generatedTextRange.to
|
||||
);
|
||||
} else {
|
||||
new Notice("Failed to find the active Markdown editor.");
|
||||
}
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the discard action, allowing the user to discard the generated text.
|
||||
*/
|
||||
private discardAction(): void {
|
||||
const markdownView =
|
||||
this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (markdownView && this.generatedTextRange) {
|
||||
const editor = markdownView.editor;
|
||||
// Replace the diff with the original selected text
|
||||
editor.replaceRange(
|
||||
this.selectedText,
|
||||
this.generatedTextRange.from,
|
||||
this.generatedTextRange.to
|
||||
);
|
||||
} else {
|
||||
new Notice("Failed to find the active Markdown editor.");
|
||||
}
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the reload action, re-fetching the generated text and updating the editor.
|
||||
*/
|
||||
private async reloadAction(): Promise<void> {
|
||||
// Step 1: Disable the reload button and show the loader
|
||||
this.reloadButton.setDisabled(true);
|
||||
this.loader.style.display = "block";
|
||||
this.reloadButton.buttonEl.style.display = "none";
|
||||
|
||||
// Step 2: Hide the accept and discard buttons
|
||||
this.acceptButton.buttonEl.style.display = "none";
|
||||
this.discardButton.buttonEl.style.display = "none";
|
||||
|
||||
const markdownView =
|
||||
this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!markdownView || !this.generatedTextRange) {
|
||||
new Notice("Failed to find the active Markdown editor.");
|
||||
// Re-enable the reload button and hide the loader
|
||||
this.reloadButton.setDisabled(false);
|
||||
this.loader.style.display = "none";
|
||||
this.reloadButton.buttonEl.style.display = "block";
|
||||
// Re-show the accept and discard buttons in case of failure
|
||||
this.acceptButton.buttonEl.style.display = "block";
|
||||
this.discardButton.buttonEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = markdownView.editor;
|
||||
|
||||
// Step 3: Restore the original selected text
|
||||
editor.replaceRange(
|
||||
this.selectedText,
|
||||
this.generatedTextRange.from,
|
||||
this.generatedTextRange.to
|
||||
);
|
||||
|
||||
// Step 4: Update the generatedTextRange to the original selection range
|
||||
if (this.selectionRange) {
|
||||
this.generatedTextRange = {
|
||||
from: { ...this.selectionRange.from },
|
||||
to: { ...this.selectionRange.to },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const userInput = this.editorView.state.doc.toString();
|
||||
this.promptText = userInput;
|
||||
// Step 5: Call the API to get the new generated text
|
||||
const response = await callApi(this.promptText, this.selectedText, (this.app as any).plugins.getPlugin("inlineAI").settings);
|
||||
this.generatedText = response.generated;
|
||||
this.diff = response.diff;
|
||||
|
||||
// Step 6: Replace the original text with the new diff
|
||||
editor.replaceRange(
|
||||
this.diff,
|
||||
this.generatedTextRange.from,
|
||||
this.generatedTextRange.to
|
||||
);
|
||||
|
||||
// Step 7: Update the generatedTextRange to the new diff's range
|
||||
const newLines = this.diff.split("\n").length;
|
||||
const lastLineLength = this.diff.split("\n").pop()!.length;
|
||||
|
||||
this.generatedTextRange.to = {
|
||||
line: this.generatedTextRange.from.line + newLines - 1,
|
||||
ch:
|
||||
newLines === 1
|
||||
? this.generatedTextRange.from.ch + this.diff.length
|
||||
: lastLineLength,
|
||||
};
|
||||
} catch (error) {
|
||||
// Step 8: Handle any errors that occur during the API call
|
||||
new Notice("Error: " + (error as Error).message);
|
||||
// Optionally, you might want to restore the original text if desired
|
||||
// editor.replaceRange(this.selectedText, this.generatedTextRange.from, this.generatedTextRange.to);
|
||||
} finally {
|
||||
// Step 9: Re-enable the reload button and hide the loader
|
||||
this.reloadButton.setDisabled(false);
|
||||
this.loader.style.display = "none";
|
||||
this.reloadButton.buttonEl.style.display = "block";
|
||||
|
||||
// Step 10: Re-show the accept and discard buttons after reload completes
|
||||
this.acceptButton.buttonEl.style.display = "block";
|
||||
this.discardButton.buttonEl.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the tooltip widget, cleaning up the DOM and editor view.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.editorView.destroy();
|
||||
this.dom.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the tooltip widget, focusing the editor.
|
||||
*/
|
||||
mount(): void {
|
||||
setTimeout(() => {
|
||||
this.editorView.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
import { diffLines, diffWords, Change } from "diff";
|
||||
|
||||
/**
|
||||
* Configuration for custom conflict markers.
|
||||
*/
|
||||
const CONFLICT_MARKERS = {
|
||||
currentStart: "[-",
|
||||
currentEnd: "-]",
|
||||
incomingStart: "{+",
|
||||
incomingEnd: "+}",
|
||||
separator: " | ",
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates fine-grained custom conflict markers between two strings.
|
||||
*
|
||||
* @param base - The original string (optional).
|
||||
* @param current - The string from the current branch.
|
||||
* @param incoming - The string from the incoming branch.
|
||||
* @returns The merged string with custom conflict markers.
|
||||
*/
|
||||
export function generateCustomConflictMarkers(
|
||||
base: string | null,
|
||||
current: string,
|
||||
incoming: string
|
||||
): string {
|
||||
// If base is provided, perform a three-way merge.
|
||||
// Otherwise, perform a two-way comparison.
|
||||
if (base) {
|
||||
return threeWayMerge(base, current, incoming);
|
||||
} else {
|
||||
return twoWayConflict(current, incoming);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a two-way comparison and adds fine-grained custom conflict markers where differences are found.
|
||||
*
|
||||
* @param current - The string from the current branch.
|
||||
* @param incoming - The string from the incoming branch.
|
||||
* @returns The merged string with fine-grained custom conflict markers.
|
||||
*/
|
||||
function twoWayConflict(current: string, incoming: string): string {
|
||||
const currentLines: string[] = current.split("\n");
|
||||
const incomingLines: string[] = incoming.split("\n");
|
||||
const maxLength: number = Math.max(
|
||||
currentLines.length,
|
||||
incomingLines.length
|
||||
);
|
||||
let merged: string = "";
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const currentLine: string | undefined = currentLines[i];
|
||||
const incomingLine: string | undefined = incomingLines[i];
|
||||
|
||||
if (currentLine === incomingLine) {
|
||||
merged += currentLine !== undefined ? currentLine + "\n" : "";
|
||||
} else {
|
||||
const fineGrainedConflict = generateFineGrainedConflictWithinLine(
|
||||
currentLine || "",
|
||||
incomingLine || ""
|
||||
);
|
||||
merged += `${CONFLICT_MARKERS.currentStart} CURRENT ${CONFLICT_MARKERS.currentEnd}\n${fineGrainedConflict.current}\n${CONFLICT_MARKERS.incomingStart} INCOMING ${CONFLICT_MARKERS.incomingEnd}\n${fineGrainedConflict.incoming}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a three-way merge using the base, current, and incoming strings with fine-grained custom conflict markers.
|
||||
*
|
||||
* @param base - The original string.
|
||||
* @param current - The string from the current branch.
|
||||
* @param incoming - The string from the incoming branch.
|
||||
* @returns The merged string with fine-grained custom conflict markers.
|
||||
*/
|
||||
function threeWayMerge(
|
||||
base: string,
|
||||
current: string,
|
||||
incoming: string
|
||||
): string {
|
||||
const diffsCurrent: Change[] = diffLines(base, current);
|
||||
const diffsIncoming: Change[] = diffLines(base, incoming);
|
||||
|
||||
let merged: string = "";
|
||||
let i: number = 0,
|
||||
j: number = 0;
|
||||
|
||||
while (i < diffsCurrent.length && j < diffsIncoming.length) {
|
||||
const partCurrent: Change = diffsCurrent[i];
|
||||
const partIncoming: Change = diffsIncoming[j];
|
||||
|
||||
if (partCurrent.value === partIncoming.value) {
|
||||
merged += partCurrent.value;
|
||||
i++;
|
||||
j++;
|
||||
} else {
|
||||
// For simplicity, treat as conflict and generate fine-grained markers
|
||||
const fineGrainedConflict = generateFineGrainedConflictWithinLine(
|
||||
partCurrent.value,
|
||||
partIncoming.value
|
||||
);
|
||||
merged += `${CONFLICT_MARKERS.currentStart} CURRENT ${CONFLICT_MARKERS.currentEnd}\n${fineGrainedConflict.current}\n${CONFLICT_MARKERS.incomingStart} INCOMING ${CONFLICT_MARKERS.incomingEnd}\n${fineGrainedConflict.incoming}\n`;
|
||||
i++;
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
// Append any remaining parts
|
||||
while (i < diffsCurrent.length) {
|
||||
merged += diffsCurrent[i].value;
|
||||
i++;
|
||||
}
|
||||
|
||||
while (j < diffsIncoming.length) {
|
||||
merged += diffsIncoming[j].value;
|
||||
j++;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates fine-grained custom conflict markers within a single line by performing a word-level diff.
|
||||
*
|
||||
* @param currentLine - The current branch's line.
|
||||
* @param incomingLine - The incoming branch's line.
|
||||
* @returns An object containing the current and incoming lines with inline conflict markers.
|
||||
*/
|
||||
export function generateFineGrainedConflictWithinLine(
|
||||
currentLine: string,
|
||||
incomingLine: string
|
||||
): { current: string; incoming: string } {
|
||||
const diff = diffWords(currentLine, incomingLine);
|
||||
|
||||
let currentConflict: string = "";
|
||||
let incomingConflict: string = "";
|
||||
|
||||
diff.forEach((part) => {
|
||||
if (part.added) {
|
||||
// Text only in incoming
|
||||
incomingConflict += `${CONFLICT_MARKERS.incomingStart}${part.value}${CONFLICT_MARKERS.incomingEnd}`;
|
||||
} else if (part.removed) {
|
||||
// Text only in current
|
||||
currentConflict += `${CONFLICT_MARKERS.currentStart}${part.value}${CONFLICT_MARKERS.currentEnd}`;
|
||||
} else {
|
||||
// Text present in both
|
||||
currentConflict += part.value;
|
||||
incomingConflict += part.value;
|
||||
}
|
||||
});
|
||||
|
||||
return { current: currentConflict, incoming: incomingConflict };
|
||||
}
|
||||
/**
|
||||
* Generates fine-grained custom conflict markers within a single line by performing a word-level diff.
|
||||
*
|
||||
* @param currentLine - The current branch's line.
|
||||
* @param incomingLine - The incoming branch's line.
|
||||
* @returns A single string with inline conflict markers.
|
||||
*/
|
||||
export function generateFineGrainedConflictWithinLineString(
|
||||
currentLine: string,
|
||||
incomingLine: string
|
||||
): string {
|
||||
const diff = diffWords(currentLine, incomingLine);
|
||||
|
||||
let conflict: string = "";
|
||||
|
||||
diff.forEach((part) => {
|
||||
if (part.added) {
|
||||
// Text only in incoming
|
||||
conflict += `{+${part.value}+}`;
|
||||
} else if (part.removed) {
|
||||
// Text only in current
|
||||
conflict += `[-${part.value}-]`;
|
||||
} else {
|
||||
// Text present in both
|
||||
conflict += part.value;
|
||||
}
|
||||
});
|
||||
|
||||
return conflict;
|
||||
}
|
||||
53
src/main.ts
53
src/main.ts
|
|
@ -1,21 +1,27 @@
|
|||
// main.ts
|
||||
import { Plugin, MarkdownView, App } from "obsidian";
|
||||
import {
|
||||
cursorTooltipExtension,
|
||||
showTooltipEffect,
|
||||
} from "./modules/tooltipExtension";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { conflictMarkers } from "./modules/diffExtension";
|
||||
import { MyPluginSettings, DEFAULT_SETTINGS, MyPluginSettingTab } from "./settings";
|
||||
import { commandEffect, selectionOverlayWidget } from "./modules/WidgetExtension";
|
||||
import { ChatApiManager } from "./api";
|
||||
import { AIResponseField } from "./modules/AIExtension";
|
||||
import { selectionHighlightField, selectionInfoField, setSelectionInfoEffect } from "./modules/SelectionSate";
|
||||
import { diffField } from "./modules/diffExtension";
|
||||
|
||||
export default class MyPlugin extends Plugin {
|
||||
settings: MyPluginSettings = DEFAULT_SETTINGS;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
const chatapi = new ChatApiManager(this.settings, this.app);
|
||||
|
||||
// Register the cursor tooltip extension
|
||||
this.registerEditorExtension(cursorTooltipExtension(this.app));
|
||||
this.registerEditorExtension(conflictMarkers());
|
||||
this.registerEditorExtension([
|
||||
selectionOverlayWidget(chatapi),
|
||||
AIResponseField,
|
||||
selectionInfoField,
|
||||
selectionHighlightField,
|
||||
diffField,
|
||||
]);
|
||||
|
||||
// Add command to show tooltip
|
||||
this.addCommand({
|
||||
|
|
@ -24,20 +30,39 @@ export default class MyPlugin extends Plugin {
|
|||
callback: () => {
|
||||
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (markdownView) {
|
||||
// Dispatch the effect to show the tooltip
|
||||
((markdownView.editor as any).cm as EditorView).dispatch({
|
||||
effects: showTooltipEffect.of(null),
|
||||
});
|
||||
const cmEditor = (markdownView.editor as any).cm as EditorView;
|
||||
|
||||
// Grab the main selection range
|
||||
const { from, to } = cmEditor.state.selection.main;
|
||||
const effects = [];
|
||||
|
||||
if (from !== to) {
|
||||
// If there is a real selection, store it
|
||||
const selectedText = cmEditor.state.doc.sliceString(from, to);
|
||||
effects.push(
|
||||
setSelectionInfoEffect.of({ from, to, text: selectedText })
|
||||
);
|
||||
} else {
|
||||
// If no selection, you could clear it or do nothing
|
||||
effects.push(setSelectionInfoEffect.of(null));
|
||||
}
|
||||
|
||||
// Also trigger the overlay
|
||||
effects.push(commandEffect.of(null));
|
||||
|
||||
// Dispatch all effects in one go
|
||||
cmEditor.dispatch({ effects });
|
||||
}
|
||||
},
|
||||
hotkeys: [
|
||||
{
|
||||
modifiers: ["Mod"], // "Mod" stands for "Ctrl" on Windows/Linux and "Cmd" on macOS
|
||||
key: "a", // You can change this key to your preference
|
||||
modifiers: ["Mod"],
|
||||
key: "a",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
// Add settings tab
|
||||
this.addSettingTab(new MyPluginSettingTab(this.app, this));
|
||||
}
|
||||
|
|
|
|||
38
src/modules/AIExtension.ts
Normal file
38
src/modules/AIExtension.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import {
|
||||
StateEffect,
|
||||
StateField,
|
||||
} from "@codemirror/state";
|
||||
|
||||
import { dismmisTooltipEffect } from "./WidgetExtension";
|
||||
|
||||
// Custom structure that has a airesponse and context fields
|
||||
export interface AIResponse {
|
||||
airesponse: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* State Effect to set the AI response.
|
||||
*/
|
||||
export const setAIResponseEffect = StateEffect.define<AIResponse | null>();
|
||||
|
||||
// State field of type text to store the response from the API
|
||||
export const AIResponseField = StateField.define<AIResponse | null>({
|
||||
create() {
|
||||
return null;
|
||||
},
|
||||
update(value, tr) {
|
||||
// Check if the transaction contains an effect to set the response
|
||||
if (tr.effects.some((e) => e.is(setAIResponseEffect))) {
|
||||
const effect = tr.effects.find((e) => e.is(setAIResponseEffect));
|
||||
return effect ? effect.value : value;
|
||||
}
|
||||
// if we geta dismmisTooltipEffect we should clear the response
|
||||
if (tr.effects.some((e) => e.is(dismmisTooltipEffect))) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
68
src/modules/SelectionSate.ts
Normal file
68
src/modules/SelectionSate.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// modules/SelectionState.ts
|
||||
import { StateEffect, StateField } from "@codemirror/state";
|
||||
import { Decoration, DecorationSet } from "@codemirror/view";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { dismmisTooltipEffect } from "./WidgetExtension";
|
||||
|
||||
/**
|
||||
* Interface describing the selection range and text.
|
||||
*/
|
||||
export interface SelectionInfo {
|
||||
from: number;
|
||||
to: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effect used to set or clear the selection info.
|
||||
*/
|
||||
export const setSelectionInfoEffect = StateEffect.define<SelectionInfo | null>();
|
||||
|
||||
/**
|
||||
* Field that holds the most recently preserved selection info.
|
||||
*/
|
||||
export const selectionInfoField = StateField.define<SelectionInfo | null>({
|
||||
create() {
|
||||
return null;
|
||||
},
|
||||
update(value, tr) {
|
||||
// Look for a setSelectionInfoEffect in this transaction
|
||||
const effect = tr.effects.find(e => e.is(setSelectionInfoEffect));
|
||||
if (effect) {
|
||||
return effect.value;
|
||||
} else if (tr.effects.some(e => e.is(dismmisTooltipEffect))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Decoration to highlight the selected text.
|
||||
*/
|
||||
const highlightDecoration = Decoration.mark({
|
||||
class: "cm-selectionBackground", // CSS class for highlighting
|
||||
});
|
||||
|
||||
/**
|
||||
* StateField that manages the decoration set for highlighting.
|
||||
*/
|
||||
export const selectionHighlightField = StateField.define<DecorationSet>({
|
||||
create(state) {
|
||||
const info = state.field(selectionInfoField);
|
||||
if (info) {
|
||||
return Decoration.set([highlightDecoration.range(info.from, info.to)]);
|
||||
}
|
||||
return Decoration.none;
|
||||
},
|
||||
update(decos, tr) {
|
||||
// Check if selectionInfoField has changed
|
||||
const info = tr.state.field(selectionInfoField);
|
||||
if (info) {
|
||||
return Decoration.set([highlightDecoration.range(info.from, info.to)]);
|
||||
}
|
||||
return Decoration.none;
|
||||
},
|
||||
provide: f => EditorView.decorations.from(f),
|
||||
});
|
||||
259
src/modules/WidgetExtension.ts
Normal file
259
src/modules/WidgetExtension.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
// modules/WidgetExtension.ts
|
||||
import {
|
||||
EditorState,
|
||||
StateEffect,
|
||||
StateField,
|
||||
} from "@codemirror/state";
|
||||
|
||||
import {
|
||||
EditorView,
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
WidgetType,
|
||||
placeholder,
|
||||
keymap,
|
||||
} from "@codemirror/view";
|
||||
import { setIcon } from "obsidian";
|
||||
import { ChatApiManager } from "../api";
|
||||
|
||||
import { selectionInfoField, SelectionInfo } from "./SelectionSate";
|
||||
|
||||
// Trigger the effect with a keybinding or command
|
||||
export const commandEffect = StateEffect.define<null>();
|
||||
export const dismmisTooltipEffect = StateEffect.define<null>();
|
||||
export const acceptTooltipEffect = StateEffect.define<null>();
|
||||
export const discardTooltipEffect = StateEffect.define<null>();
|
||||
export const reloadTooltipEffect = StateEffect.define<null>();
|
||||
|
||||
/**
|
||||
* Custom widget to display an overlay near the cursor.
|
||||
*/
|
||||
class CursorOverlayWidget extends WidgetType {
|
||||
private chatApiManager: ChatApiManager;
|
||||
private selectionInfo: SelectionInfo | null;
|
||||
dom = document.createElement("div");
|
||||
innerDom = document.createElement("div");
|
||||
editorView?: EditorView;
|
||||
private submitButton!: HTMLButtonElement;
|
||||
private loaderElement!: HTMLElement;
|
||||
|
||||
constructor(chatApiManager: ChatApiManager, selectionInfo: SelectionInfo | null) {
|
||||
super();
|
||||
this.chatApiManager = chatApiManager;
|
||||
this.selectionInfo = selectionInfo;
|
||||
this.dom.className = "cm-cursor-overlay";
|
||||
this.dom.style.userSelect = "none";
|
||||
this.innerDom.className = "cm-cursor-overlay-inner";
|
||||
this.dom.appendChild(this.innerDom);
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
this.createPencilIcon();
|
||||
this.createInputField();
|
||||
this.createSubmitButton();
|
||||
this.createLoader();
|
||||
|
||||
setTimeout(() => {
|
||||
this.editorView?.focus();
|
||||
}, 0);
|
||||
|
||||
// Add event listeners for dismissal
|
||||
const onClickOutside = (event: MouseEvent) => {
|
||||
if (!this.dom.contains(event.target as Node)) {
|
||||
this.dismissTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
const onEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
this.dismissTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", onClickOutside);
|
||||
document.addEventListener("keydown", onEscape);
|
||||
|
||||
// Cleanup listeners in destroy
|
||||
this.dom.addEventListener("destroy", () => {
|
||||
document.removeEventListener("mousedown", onClickOutside);
|
||||
document.removeEventListener("keydown", onEscape);
|
||||
});
|
||||
|
||||
return this.dom;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
console.log("CursorOverlayWidget destroyed");
|
||||
this.editorView?.destroy();
|
||||
this.innerDom.innerHTML = "";
|
||||
this.editorView = undefined;
|
||||
}
|
||||
|
||||
private dismissTooltip() {
|
||||
const view = this.editorView;
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
effects: dismmisTooltipEffect.of(null),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private createPencilIcon() {
|
||||
if (!this.innerDom.querySelector(".cm-pencil-icon")) {
|
||||
const icon = document.createElement("div");
|
||||
icon.className = "cm-pencil-icon";
|
||||
this.innerDom.appendChild(icon);
|
||||
setIcon(icon, "pencil");
|
||||
}
|
||||
}
|
||||
|
||||
private createInputField() {
|
||||
const editorDom = document.createElement("div");
|
||||
editorDom.className = "cm-tooltip-editor";
|
||||
editorDom.style.userSelect = "text";
|
||||
this.innerDom.appendChild(editorDom);
|
||||
|
||||
this.editorView = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: "",
|
||||
extensions: [
|
||||
placeholder("Ask copilot"),
|
||||
keymap.of([
|
||||
{
|
||||
key: "Enter",
|
||||
run: () => {
|
||||
this.submitAction();
|
||||
return true;
|
||||
},
|
||||
preventDefault: true,
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
parent: editorDom,
|
||||
});
|
||||
}
|
||||
|
||||
private createSubmitButton() {
|
||||
this.submitButton = document.createElement("button");
|
||||
this.submitButton.textContent = "Submit";
|
||||
this.submitButton.className = "submit-button tooltip-button";
|
||||
setIcon(this.submitButton, "send-horizontal");
|
||||
|
||||
this.submitButton.onclick = () => {
|
||||
this.submitAction();
|
||||
};
|
||||
this.innerDom.appendChild(this.submitButton);
|
||||
}
|
||||
|
||||
private createLoader() {
|
||||
this.loaderElement = document.createElement("div");
|
||||
this.loaderElement.className = "loader";
|
||||
this.loaderElement.style.display = "none";
|
||||
this.innerDom.appendChild(this.loaderElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the submit action by calling the AI with the user input and the selected text.
|
||||
*/
|
||||
private submitAction() {
|
||||
const userPrompt = this.editorView?.state.doc.toString() ?? "";
|
||||
console.log("User Input:", userPrompt);
|
||||
|
||||
if (!userPrompt.trim()) {
|
||||
console.warn("Empty input. Submission aborted.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Grab the selected text from the stored selection info
|
||||
const selectedText = this.selectionInfo?.text ?? "";
|
||||
|
||||
// Show loader
|
||||
this.toggleLoading(true);
|
||||
|
||||
this.chatApiManager
|
||||
.callSelection(userPrompt, selectedText)
|
||||
.then((aiResponse) => {
|
||||
// Optionally handle or display aiResponse
|
||||
// we dont use it here as we are dispatching the effect from the method
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error calling AI:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
// Hide loader
|
||||
this.toggleLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the visibility of the submit button and loader.
|
||||
* @param isLoading - Whether to show the loader.
|
||||
*/
|
||||
private toggleLoading(isLoading: boolean) {
|
||||
if (isLoading) {
|
||||
this.submitButton.style.display = "none";
|
||||
this.loaderElement.style.display = "flex";
|
||||
} else {
|
||||
this.submitButton.style.display = "inline-block";
|
||||
this.loaderElement.style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build decorations for the first non-empty selection range.
|
||||
* Also obtains the entire selection info from the state
|
||||
* and passes it to the CursorOverlayWidget constructor.
|
||||
*/
|
||||
function getSelectionOverlayDecorations(
|
||||
state: EditorState,
|
||||
chatApiManager: ChatApiManager
|
||||
): DecorationSet {
|
||||
// If there's no actual selection, bail out
|
||||
const firstSelectedRange = state.selection.ranges.find((range) => !range.empty);
|
||||
if (!firstSelectedRange) return Decoration.none;
|
||||
|
||||
// Get the entire SelectionInfo (which includes the text)
|
||||
const selectionInfo = state.field(selectionInfoField, false) ?? null;
|
||||
|
||||
// Create the widget decoration at the selection start (or end)
|
||||
const deco = Decoration.widget({
|
||||
widget: new CursorOverlayWidget(chatApiManager, selectionInfo),
|
||||
above: true,
|
||||
inline: false,
|
||||
}).range(firstSelectedRange.from);
|
||||
|
||||
return Decoration.set([deco]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the selection overlay field with access to ChatApiManager.
|
||||
*/
|
||||
function TooltipFiled(chatApiManager: ChatApiManager) {
|
||||
return StateField.define<DecorationSet>({
|
||||
create(state) {
|
||||
return getSelectionOverlayDecorations(state, chatApiManager);
|
||||
},
|
||||
update(decorations, tr) {
|
||||
// Recompute if the user triggers the command
|
||||
if (tr.effects.some((e) => e.is(commandEffect))) {
|
||||
return getSelectionOverlayDecorations(tr.state, chatApiManager);
|
||||
}
|
||||
// Or dismiss it
|
||||
if (tr.effects.some((e) => e.is(dismmisTooltipEffect))) {
|
||||
return Decoration.none;
|
||||
}
|
||||
// Otherwise, return the existing overlay
|
||||
return decorations;
|
||||
},
|
||||
provide: (field) => EditorView.decorations.from(field),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension enabling selection overlay widgets.
|
||||
*/
|
||||
export function selectionOverlayWidget(chatApiManager: ChatApiManager) {
|
||||
return [TooltipFiled(chatApiManager)];
|
||||
}
|
||||
|
|
@ -1,160 +1,185 @@
|
|||
// src/conflictMarkers.ts
|
||||
|
||||
// modules/diffExtension.ts
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
EditorState,
|
||||
StateEffect,
|
||||
StateField,
|
||||
RangeSetBuilder,
|
||||
} from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
WidgetType,
|
||||
} from "@codemirror/view";
|
||||
import { RangeSetBuilder } from "@codemirror/state";
|
||||
import { diffWords, Change } from "diff";
|
||||
|
||||
import { dismmisTooltipEffect } from "./WidgetExtension";
|
||||
import { AIResponseField, setAIResponseEffect } from "./AIExtension";
|
||||
import { selectionInfoField } from "./SelectionSate";
|
||||
|
||||
// Configuration for chaff removal
|
||||
const MIN_UNCHANGED_LENGTH = 5; // Minimum length of unchanged text to keep
|
||||
|
||||
/**
|
||||
* Regular expressions to match conflict markers.
|
||||
*
|
||||
* - Current Conflict: [- ... -]
|
||||
* - Incoming Conflict: {+ ... +}
|
||||
* - Selected for InlineAI: <<InlineAI<< ... >>InlineAI>>
|
||||
* Widget to display added or removed content.
|
||||
* Improves accessibility by using appropriate ARIA attributes.
|
||||
*/
|
||||
const CURRENT_CONFLICT_REGEX = /\[-([\s\S]*?)-\]/g;
|
||||
const INCOMING_CONFLICT_REGEX = /\{\+([\s\S]*?)\+\}/g;
|
||||
const INLINE_AI_CONFLICT_REGEX = /<<InlineAI<<([\s\S]*?)>>InlineAI>>/g; // Added regex for InlineAI
|
||||
class ChangeContentWidget extends WidgetType {
|
||||
constructor(
|
||||
private readonly content: string,
|
||||
private readonly type: 'added' | 'removed'
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define decorations with corresponding CSS classes.
|
||||
*/
|
||||
const currentConflictDecoration = Decoration.mark({
|
||||
class: "cm-conflict-current",
|
||||
});
|
||||
toDOM(): HTMLElement {
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.className = `cm-change-widget cm-change-${this.type}`;
|
||||
wrapper.textContent = this.content;
|
||||
|
||||
const incomingConflictDecoration = Decoration.mark({
|
||||
class: "cm-conflict-incoming",
|
||||
});
|
||||
// Accessibility: Provide ARIA label
|
||||
wrapper.setAttribute(
|
||||
"aria-label",
|
||||
this.type === 'added' ? "Added content" : "Removed content"
|
||||
);
|
||||
|
||||
const selectedForInlineAIDecoration = Decoration.mark({
|
||||
// Renamed for clarity
|
||||
class: "cm-selected-inline",
|
||||
});
|
||||
// Optional: Add tooltip or additional styling here
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// Decoration to hide conflict markers
|
||||
const hideDecoration = Decoration.replace({
|
||||
// This decoration replaces the matched text with nothing, effectively hiding it
|
||||
// The `inclusive: false` ensures it doesn't affect surrounding text
|
||||
inclusive: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* ViewPlugin to detect and decorate conflict markers, including InlineAI markers.
|
||||
*/
|
||||
const conflictMarkersPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = this.buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
buildDecorations(view: EditorView): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
const text = view.state.doc.toString();
|
||||
|
||||
// Array to hold all decorations to be added
|
||||
type DecorationEntry = {
|
||||
from: number;
|
||||
to: number;
|
||||
decoration: Decoration;
|
||||
};
|
||||
|
||||
const decorationsToAdd: DecorationEntry[] = [];
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Helper function to collect decorations
|
||||
const collectConflict = (
|
||||
match: RegExpExecArray,
|
||||
prefixLength: number,
|
||||
suffixLength: number,
|
||||
decoration: Decoration
|
||||
) => {
|
||||
const start = match.index;
|
||||
const end = match.index + match[0].length;
|
||||
const innerStart = start + prefixLength;
|
||||
const innerEnd = end - suffixLength;
|
||||
|
||||
// Add decoration to highlight the inner content
|
||||
decorationsToAdd.push({
|
||||
from: innerStart,
|
||||
to: innerEnd,
|
||||
decoration,
|
||||
});
|
||||
|
||||
// Add decorations to hide the prefix and suffix
|
||||
decorationsToAdd.push({
|
||||
from: start,
|
||||
to: start + prefixLength,
|
||||
decoration: hideDecoration,
|
||||
});
|
||||
decorationsToAdd.push({
|
||||
from: end - suffixLength,
|
||||
to: end,
|
||||
decoration: hideDecoration,
|
||||
});
|
||||
};
|
||||
|
||||
// Match Current Conflicts
|
||||
while ((match = CURRENT_CONFLICT_REGEX.exec(text)) !== null) {
|
||||
collectConflict(match, 0, 0, currentConflictDecoration); // '[-' and '-]'
|
||||
}
|
||||
|
||||
// Match Incoming Conflicts
|
||||
while ((match = INCOMING_CONFLICT_REGEX.exec(text)) !== null) {
|
||||
collectConflict(match, 0, 0, incomingConflictDecoration); // '{+' and '+}'
|
||||
}
|
||||
|
||||
// Match InlineAI Conflicts
|
||||
while ((match = INLINE_AI_CONFLICT_REGEX.exec(text)) !== null) {
|
||||
// collectConflict(match, 12, 12, selectedForInlineAIDecoration); // '<<InlineAI<<' and '>>InlineAI>>'
|
||||
collectConflict(match, 0, 0, selectedForInlineAIDecoration); // '<<InlineAI<<' and '>>InlineAI>>'
|
||||
}
|
||||
|
||||
// Sort all decorations by 'from' position and 'startSide'
|
||||
decorationsToAdd.sort((a, b) => {
|
||||
if (a.from !== b.from) {
|
||||
return a.from - b.from;
|
||||
}
|
||||
// Optional: Define startSide if necessary. Codemirror's RangeSetBuilder handles this internally.
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Check for overlapping decorations and handle accordingly
|
||||
let lastAddedTo = -1;
|
||||
for (const deco of decorationsToAdd) {
|
||||
if (deco.from < lastAddedTo) {
|
||||
continue; // Skip overlapping decorations to prevent errors
|
||||
}
|
||||
builder.add(deco.from, deco.to, deco.decoration);
|
||||
if (deco.to > lastAddedTo) {
|
||||
lastAddedTo = deco.to;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Export the conflictMarkers extension.
|
||||
*/
|
||||
export function conflictMarkers() {
|
||||
return conflictMarkersPlugin;
|
||||
ignoreEvent(): boolean {
|
||||
// Decide whether to ignore events on the widget
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a DecorationSet representing the diff between AI response and context.
|
||||
* @param state - The current editor state.
|
||||
* @returns A DecorationSet with the appropriate widgets.
|
||||
*/
|
||||
function generateDiffView(state: EditorState): DecorationSet {
|
||||
try {
|
||||
// Retrieve the AI response and the current context text from the state
|
||||
const response = state.field(AIResponseField);
|
||||
const context = state.field(selectionInfoField);
|
||||
|
||||
const aiText: string = response?.airesponse ?? "";
|
||||
const contextText: string = context?.text ?? "";
|
||||
|
||||
// Debugging: Remove or conditionally enable in production
|
||||
console.debug("Generating diff view", {
|
||||
aiResponse: response,
|
||||
contextText: context,
|
||||
});
|
||||
|
||||
console.log(
|
||||
"AI Response:",
|
||||
response,
|
||||
"Context Text:",
|
||||
context
|
||||
)
|
||||
// Compute the word-level differences using the 'diff' package
|
||||
const diffResult: Change[] = diffWords(contextText, aiText);
|
||||
|
||||
// Initialize RangeSetBuilder for efficient decoration construction
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
let currentPos = 0;
|
||||
|
||||
diffResult.forEach((part) => {
|
||||
const { added, removed, value } = part;
|
||||
const length = value.length;
|
||||
|
||||
if (added) {
|
||||
// Highlight added text (AI response)
|
||||
const widget = new ChangeContentWidget(value, 'added');
|
||||
builder.add(
|
||||
currentPos,
|
||||
currentPos,
|
||||
Decoration.widget({ widget, side: 1 })
|
||||
);
|
||||
} else if (removed) {
|
||||
// Highlight removed text (from context)
|
||||
const widget = new ChangeContentWidget(value, 'removed');
|
||||
builder.add(
|
||||
currentPos,
|
||||
currentPos + length,
|
||||
Decoration.widget({ widget, side: -1 })
|
||||
);
|
||||
currentPos += length;
|
||||
} else {
|
||||
// Unchanged text
|
||||
if (length >= MIN_UNCHANGED_LENGTH) {
|
||||
// Retain unchanged text if it meets the minimum length
|
||||
currentPos += length;
|
||||
} else {
|
||||
// Skip small unchanged text to remove chaff
|
||||
// Optionally, add a thin separator or annotation if desired
|
||||
currentPos += length;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Debugging: Remove or conditionally enable in production
|
||||
console.debug("Diff result:", diffResult);
|
||||
|
||||
return builder.finish();
|
||||
} catch (error) {
|
||||
console.error("Error generating diff view:", error);
|
||||
return Decoration.none;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a StateField to manage the diff decorations.
|
||||
*/
|
||||
export const diffField = StateField.define<DecorationSet>({
|
||||
create(): DecorationSet {
|
||||
return Decoration.none;
|
||||
},
|
||||
update(decorations: DecorationSet, tr): DecorationSet {
|
||||
let shouldRecompute = false;
|
||||
|
||||
// Check if the AI response effect is present
|
||||
const hasAIResponseEffect = tr.effects.some(e => e.is(setAIResponseEffect));
|
||||
if (hasAIResponseEffect) {
|
||||
const effect = tr.effects.find(e => e.is(setAIResponseEffect));
|
||||
if (effect && effect !== null) {
|
||||
console.debug("Received AI response");
|
||||
console.log("AI Response:", effect);
|
||||
shouldRecompute = true;
|
||||
} else {
|
||||
console.error("Error in AI response effect");
|
||||
return Decoration.none;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// Check if the dismiss tooltip effect is present
|
||||
const hasDismissEffect = tr.effects.some(e => e.is(dismmisTooltipEffect));
|
||||
if (hasDismissEffect) {
|
||||
return Decoration.none;
|
||||
}
|
||||
|
||||
// Check if the document or selection has changed
|
||||
if (tr.docChanged || tr.selection) {
|
||||
shouldRecompute = true;
|
||||
}
|
||||
|
||||
if (shouldRecompute) {
|
||||
return generateDiffView(tr.state);
|
||||
}
|
||||
|
||||
// Retain the existing decorations if no relevant changes
|
||||
return decorations;
|
||||
},
|
||||
provide: (field) => EditorView.decorations.from(field),
|
||||
});
|
||||
|
||||
/**
|
||||
* Exported extension to be included in the EditorView.
|
||||
*/
|
||||
export const diffExtension = [
|
||||
diffField,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,142 +0,0 @@
|
|||
import {
|
||||
StateField,
|
||||
EditorState,
|
||||
StateEffect,
|
||||
Transaction,
|
||||
} from "@codemirror/state";
|
||||
import { EditorView, showTooltip, type Tooltip } from "@codemirror/view";
|
||||
import { TooltipWidget } from "../components/tooltipWidget";
|
||||
import { App, MarkdownView } from "obsidian";
|
||||
|
||||
export const showTooltipEffect = StateEffect.define<null>();
|
||||
export const dismissTooltipEffect = StateEffect.define<null>();
|
||||
|
||||
export function cursorTooltipExtension(app: App) {
|
||||
return StateField.define<Tooltip | null>({
|
||||
create() {
|
||||
return null;
|
||||
},
|
||||
update(tooltip, tr) {
|
||||
// Check if the transaction contains an effect to show the tooltip
|
||||
if (tr.effects.some((e) => e.is(showTooltipEffect))) {
|
||||
return getCursorTooltip(tr.state, app);
|
||||
}
|
||||
// Check if the transaction contains an effect to dismiss the tooltip
|
||||
if (tr.effects.some((e) => e.is(dismissTooltipEffect))) {
|
||||
return null;
|
||||
}
|
||||
// Keep the tooltip if it's already visible
|
||||
if (tooltip && !shouldDismissTooltip(tr)) {
|
||||
return tooltip;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
provide: (field) => showTooltip.from(field),
|
||||
});
|
||||
}
|
||||
|
||||
function shouldDismissTooltip(tr: Transaction): boolean {
|
||||
// Dismiss the tooltip if the user presses Escape
|
||||
if (tr.selection) {
|
||||
const { main } = tr.selection;
|
||||
if (main.empty && tr.docChanged) {
|
||||
return false; // Keep the tooltip open on document changes
|
||||
}
|
||||
}
|
||||
// Add any additional conditions for dismissing the tooltip here
|
||||
return false;
|
||||
}
|
||||
|
||||
function getCursorTooltip(state: EditorState, app: App): Tooltip | null {
|
||||
const { selection } = state;
|
||||
const mainSelection = selection.main;
|
||||
const { from, to, head, anchor } = mainSelection;
|
||||
const currentLine = state.doc.lineAt(from);
|
||||
const isSelectionActive = !mainSelection.empty;
|
||||
|
||||
let posAt = currentLine.from;
|
||||
let above = true;
|
||||
let selectedText = "";
|
||||
|
||||
if (isSelectionActive) {
|
||||
selectedText = state.doc.sliceString(from, to);
|
||||
|
||||
if (head > anchor) {
|
||||
console.log("Putting tooltip below");
|
||||
const endLine = state.doc.lineAt(to);
|
||||
|
||||
if (endLine.number < state.doc.lines) {
|
||||
const nextLine = state.doc.line(endLine.number + 1);
|
||||
posAt = nextLine.from;
|
||||
} else {
|
||||
posAt = state.doc.length;
|
||||
}
|
||||
above = false;
|
||||
} else if (head < anchor) {
|
||||
console.log("Putting tooltip above");
|
||||
const startLine = state.doc.lineAt(from);
|
||||
posAt = startLine.from;
|
||||
above = true;
|
||||
} else {
|
||||
posAt = currentLine.from;
|
||||
above = true;
|
||||
}
|
||||
} else {
|
||||
posAt = currentLine.from;
|
||||
above = true;
|
||||
}
|
||||
|
||||
const tooltipWidget = new TooltipWidget(app, selectedText);
|
||||
|
||||
return {
|
||||
pos: posAt,
|
||||
above,
|
||||
arrow: true,
|
||||
create: (view: EditorView) => {
|
||||
const dom = tooltipWidget.dom;
|
||||
|
||||
// Add the event listener
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (!dom.contains(event.target as Node)) {
|
||||
// Clicked outside the tooltip
|
||||
view.dispatch({
|
||||
effects: dismissTooltipEffect.of(null),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
dom.addEventListener("mousedown", (event: MouseEvent) => {
|
||||
event.stopPropagation(); // Prevent the event from propagating to the document
|
||||
});
|
||||
|
||||
document.addEventListener("mousedown", (event: MouseEvent) => {
|
||||
if (!dom.contains(event.target as Node)) {
|
||||
// Clicked outside the tooltip
|
||||
view.dispatch({
|
||||
effects: dismissTooltipEffect.of(null),
|
||||
});
|
||||
}
|
||||
});
|
||||
const onEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
view.dispatch({
|
||||
effects: dismissTooltipEffect.of(null),
|
||||
});
|
||||
}
|
||||
};
|
||||
// listen also for escape key
|
||||
document.addEventListener("keydown", onEscape);
|
||||
|
||||
return {
|
||||
dom: tooltipWidget.dom,
|
||||
destroy: () => {
|
||||
// Remove the event listener when the tooltip is destroyed
|
||||
document.removeEventListener("mousedown", onClick);
|
||||
document.removeEventListener("keydown", onEscape);
|
||||
tooltipWidget.destroy();
|
||||
},
|
||||
mount: () => tooltipWidget.mount(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
103
styles copy.css
Normal file
103
styles copy.css
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/* Tooltip Styles */
|
||||
.cm-tooltip {
|
||||
padding: 4px 8px;
|
||||
background: var(--background-secondary) !important;
|
||||
z-index: var(--layer-tooltip);
|
||||
color: var(--text-normal);
|
||||
border-radius: 4px;
|
||||
font-size: var(--font-interface-theme);
|
||||
width: 90%;
|
||||
max-width: var(--file-line-width);
|
||||
max-height: 200px;
|
||||
border: 1px solid var(--background-modifier-border) !important;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@media (min-width: 1024px) {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.cm-tooltip-editor {
|
||||
display: flex;
|
||||
padding: 1px 0 1px 10px;
|
||||
height: var(--line-height);
|
||||
width: auto;
|
||||
max-height: 100%;
|
||||
align-items: center;
|
||||
background-color: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border-focus);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-left: 8px;
|
||||
flex: 1 0 0;
|
||||
transition: flex-basis 0.3s ease;
|
||||
}
|
||||
|
||||
/* Loader Animation */
|
||||
.loader {
|
||||
width: 5px;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
color: var(--text-normal);
|
||||
background: var(--background-primary);
|
||||
animation: l5 1s infinite linear alternate;
|
||||
margin: 0 20px 0 26px;
|
||||
}
|
||||
|
||||
@keyframes l5 {
|
||||
0%, 33% {
|
||||
box-shadow: 10px 0 var(--text-accent), -10px 0 var(--text-faint);
|
||||
background: var(--text-accent);
|
||||
}
|
||||
66%, 100% {
|
||||
box-shadow: 10px 0 var(--text-faint), -10px 0 var(--text-accent);
|
||||
background: var(--text-faint);
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.submit-button {
|
||||
height: var(--line-height);
|
||||
}
|
||||
|
||||
.reload-button {
|
||||
padding-top: 3px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.accept-button {
|
||||
background-color: var(--interactive-accent) !important;
|
||||
color: var(--text-on-accent) !important;
|
||||
}
|
||||
|
||||
.tooltip-button {
|
||||
height: 100%;
|
||||
margin-left: 8px;
|
||||
border: 1px solid var(--background-modifier-border-focus);
|
||||
}
|
||||
|
||||
|
||||
.cm-selected-inline {
|
||||
background-color: var(--text-highlight-bg);
|
||||
}
|
||||
|
||||
/* Tooltip Arrow */
|
||||
.cm-tooltip-arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 7px solid transparent;
|
||||
border-right: 7px solid transparent;
|
||||
border-top: 7px solid var(--background-modifier-border) !important;
|
||||
}
|
||||
|
||||
|
||||
.cm-diff-added {
|
||||
background-color: var(--color-green);
|
||||
}
|
||||
|
||||
.cm-diff-removed {
|
||||
background-color: var(--color-red);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
100
styles.css
100
styles.css
|
|
@ -1,24 +1,30 @@
|
|||
/* Tooltip Styles */
|
||||
.cm-tooltip {
|
||||
padding: 4px 8px;
|
||||
.cm-cursor-overlay {
|
||||
left: -10px;
|
||||
top: -40px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
align-items: center;
|
||||
background: var(--background-secondary) !important;
|
||||
z-index: var(--layer-tooltip);
|
||||
color: var(--text-normal);
|
||||
border-radius: 4px;
|
||||
font-size: var(--font-interface-theme);
|
||||
width: 90%;
|
||||
max-width: var(--file-line-width);
|
||||
max-height: 200px;
|
||||
border: 1px solid var(--background-modifier-border) !important;
|
||||
user-select: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1000;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.cm-cursor-overlay-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@media (min-width: 1024px) {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.cm-tooltip-editor {
|
||||
display: flex;
|
||||
padding: 1px 0 1px 10px;
|
||||
|
|
@ -35,6 +41,22 @@
|
|||
transition: flex-basis 0.3s ease;
|
||||
}
|
||||
|
||||
.cm-cursor-overlay .cm-editor {
|
||||
user-select: text; /* allow text interaction inside the mini-editor */
|
||||
}
|
||||
|
||||
|
||||
/* Button Styles */
|
||||
.submit-button {
|
||||
height: var(--line-height);
|
||||
}
|
||||
|
||||
.tooltip-button {
|
||||
height: 100%;
|
||||
margin-left: 8px;
|
||||
border: 1px solid var(--background-modifier-border-focus);
|
||||
}
|
||||
|
||||
/* Loader Animation */
|
||||
.loader {
|
||||
width: 5px;
|
||||
|
|
@ -57,47 +79,25 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.submit-button {
|
||||
height: var(--line-height);
|
||||
|
||||
.cm-change-widget {
|
||||
font-size: 0.9em;
|
||||
margin-left: 4px;
|
||||
color: #555;
|
||||
background-color: #f0f0f0;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.reload-button {
|
||||
padding-top: 3px;
|
||||
padding-bottom: 1px;
|
||||
.cm-change-added {
|
||||
background-color: rgba(144, 238, 144, 0.5); /* Slightly darker green for added content */
|
||||
}
|
||||
|
||||
.accept-button {
|
||||
background-color: var(--interactive-accent) !important;
|
||||
color: var(--text-on-accent) !important;
|
||||
.cm-change-removed {
|
||||
background-color: rgba(255, 99, 71, 0.5); /* Slightly darker red for removed content */
|
||||
}
|
||||
|
||||
.tooltip-button {
|
||||
height: 100%;
|
||||
margin-left: 8px;
|
||||
border: 1px solid var(--background-modifier-border-focus);
|
||||
}
|
||||
|
||||
/* Conflict Styles */
|
||||
.cm-conflict-current {
|
||||
background-color: rgba(255, 0, 0, 0.2);
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.cm-conflict-incoming {
|
||||
background-color: rgba(0, 255, 0, 0.2);
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.cm-selected-inline {
|
||||
background-color: var(--text-highlight-bg);
|
||||
}
|
||||
|
||||
/* Tooltip Arrow */
|
||||
.cm-tooltip-arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 7px solid transparent;
|
||||
border-right: 7px solid transparent;
|
||||
border-top: 7px solid var(--background-modifier-border) !important;
|
||||
}
|
||||
.cm-selection-highlight {
|
||||
background-color: rgba(255, 255, 0, 0.4); /* Semi-transparent yellow */
|
||||
/* You can add more styles as needed */
|
||||
}
|
||||
Loading…
Reference in a new issue