Compare commits

..

No commits in common. "main" and "0.0.2" have entirely different histories.
main ... 0.0.2

9 changed files with 474 additions and 406 deletions

21
LICENSE
View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2023 vorotamoroz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -24,6 +24,3 @@ Note: We need the [API key of ChatGPT](https://platform.openai.com/account/api-k
- The dialogue is saved as a code block. We can resume the conversation anytime we want.
- Also, they are searchable.
- Long consultation consumes many tokens. If the dialogue gets longer, starting a brand-new one is recommended.
## License
- MIT License

View file

@ -15,7 +15,7 @@ const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
entryPoints: ["main.ts"],
bundle: true,
external: [
"obsidian",

249
main.ts Normal file
View file

@ -0,0 +1,249 @@
import { App, Editor, MarkdownRenderer, MarkdownView, Notice, Plugin, PluginSettingTab, Setting, TFile } from 'obsidian';
import { Configuration, OpenAIApi } from "openai"
import axios from "axios";
interface RingASecretarySettings {
token: string;
defaultSystem: string;
model: string;
showConsumedTokens: boolean;
}
const DEFAULT_SETTINGS: RingASecretarySettings = {
token: "",
defaultSystem: "",
model: 'gpt-3.5-turbo-0301',
showConsumedTokens: false,
}
const roleSystem = "**SYSTEM**";
const roleUser = "**USER**";
const roleAssistant = "**ASSISTANT**";
type DIALOGUE_ROLE = "**SYSTEM**" | "**USER**" | "**ASSISTANT**";
type API_ROLE = "system" | "user" | "assistant";
const MarkToRole = {
"**SYSTEM**": "system",
"**USER**": "user",
"**ASSISTANT**": "assistant",
} as Record<DIALOGUE_ROLE, API_ROLE>;
const WAIT_MARK = " <span class='ofx-thinking'></span>Please, bear with me.";
export default class RingASecretaryPlugin extends Plugin {
settings: RingASecretarySettings;
configuration: Configuration;
processingFile?: TFile;
async askToAI(dialogue: string) {
const messages = [] as { role: "system" | "user" | "assistant", content: string }[];
let currentRole = "user" as "user" | "system" | "assistant";
let buffer = "";
const dialogLines = dialogue.split("\n");
for (const line of dialogLines) {
let lineBuf = line.trim();
if (lineBuf.startsWith("#")) continue;
let newRole = "" as "" | API_ROLE;
for (const [dialogueRole, APIRole] of Object.entries(MarkToRole)) {
if (lineBuf.startsWith(dialogueRole)) {
newRole = APIRole;
lineBuf = lineBuf.substring(dialogueRole.length + 1);
}
}
if (newRole != "" && buffer.trim() != "") {
messages.push({ role: currentRole, content: buffer.trim() });
currentRole = newRole;
buffer = "";
}
buffer += lineBuf + "\n";
}
if (buffer != "") {
messages.push({ role: currentRole, content: buffer });
}
const openai = new OpenAIApi(this.configuration);
const response = await openai.createChatCompletion({
model: this.settings.model,
messages
// temperature: 0,
// max_tokens: 7,
});
const responseContent = response.data.choices[0].message?.content;
this.writeResponseToFile(responseContent ?? "");
if (this.settings.showConsumedTokens && response.data.usage) {
new Notice(`Consumed: ${response.data.usage.total_tokens} tokens`);
}
}
/** Write response to processing file by replacing placeholder. */
async writeResponseToFile(response: string) {
if (!this.processingFile) return;
const file = this.processingFile;
await app.vault.process(file, (data) => {
return data.replace(WAIT_MARK, response);
});
app.vault.trigger("modify", file);
this.processingFile = undefined;
}
async onload() {
await this.loadSettings();
axios.defaults.adapter = "http";
this.configuration = new Configuration({ apiKey: this.settings.token });
this.registerMarkdownCodeBlockProcessor("aichat", (source, el, ctx) => {
const sourcePath = ctx.sourcePath;
const fx = el.createDiv({ text: "", cls: ["obsidian-fx"] });
MarkdownRenderer.renderMarkdown(source, fx, sourcePath, this)
const ops = el.createDiv({ text: "", cls: ["obsidian-fx-buttons"] });
const span = ops.createEl("span", { text: "USER:", cls: "label" });
const input = ops.createEl("textarea");
const submit = ops.createEl("button", { text: "🤵" });
const secInfo = ctx.getSectionInfo(el);
ops.appendChild(span);
ops.appendChild(input)
ops.appendChild(submit);
fx.appendChild(ops)
el.replaceWith(fx);
// ctx.
const c = new AbortController();
const submitFunc = async () => {
if (source.contains(WAIT_MARK)) {
new Notice("Some question is already in progress... If not, please modify the code block directly.", 5000);
return
}
const f = app.vault.getAbstractFileByPath(sourcePath);
if (!f) {
new Notice("Could not find the file", 3000);
return;
}
if (!(f instanceof TFile)) {
new Notice("Could not find the file", 3000);
return;
}
const dataMain = source;
const text = input.value;
if (text.trim() == "") {
new Notice("Request is empty");
return;
}
//Note: ESCAPE MARKDOWN?
const newBody = `${dataMain}\n${roleUser}:${text} \n${roleAssistant}: ${WAIT_MARK}`;
await app.vault.process(f, (data) => {
const dataList = data.split("\n");
const dataBefore = dataList.slice(0, (secInfo?.lineStart ?? 0) + 1);
const dataAfter = dataList.slice(secInfo?.lineEnd)
return dataBefore.join("\n") + `\n${newBody}\n` + dataAfter.join("\n");
});
this.processingFile = f;
setTimeout(() => {
this.askToAI(newBody).catch(err => {
this.writeResponseToFile(`Something has been occurred: ${err?.message}`);
new Notice(`Something has been occurred: ${err?.message}`);
});
}, 20);
app.vault.trigger("modify", f);
}
submit.addEventListener("click", submitFunc, { signal: c.signal });
input.addEventListener("keydown", e => {
setTimeout(() => {
if (input.clientHeight < input.scrollHeight) {
input.style.height = `${input.scrollHeight + 4}px`;
}
}, 10);
if (e.key == "Enter" && (e.shiftKey) && !e.isComposing) {
e.preventDefault();
submitFunc();
input.value = "";
}
}, { signal: c.signal, capture: true })
});
this.addCommand({
id: 'obs-chat-new-dialogue',
name: 'New dialogue',
editorCallback: async (editor: Editor, view: MarkdownView) => {
editor.replaceSelection(
`${"```aichat"}
${this.settings.defaultSystem ? `${roleSystem}:${this.settings.defaultSystem}` : `#${roleSystem}:`}
${"```"}
`
)
}
});
this.addSettingTab(new RingASecretarySettingTab(this.app, this));
}
onunload() {
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class RingASecretarySettingTab extends PluginSettingTab {
plugin: RingASecretaryPlugin;
constructor(app: App, plugin: RingASecretaryPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Settings for Ring a secretary' });
new Setting(containerEl)
.setName('Token')
.setDesc('The token of ChatGPT')
.addText(text => text
.setPlaceholder('sk-TrbCVkcuvcshu7b....')
.setValue(this.plugin.settings.token)
.onChange(async (value) => {
this.plugin.settings.token = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Model')
.setDesc('Model')
.addText(text => text
.setPlaceholder('gpt-3.5-turbo-0301')
.setValue(this.plugin.settings.model)
.onChange(async (value) => {
this.plugin.settings.model = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Initial prompt')
.setDesc('Initial prompt; i.e., instructions and prerequisites presented to the AI.')
.addText(text => text
.setPlaceholder('Behave as a British English speaker and answer my questions')
.setValue(this.plugin.settings.defaultSystem)
.onChange(async (value) => {
this.plugin.settings.defaultSystem = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Show consumed tokens')
.setDesc('When enabled, consumed tokens will be notified after each turn.')
.addToggle(toggle => toggle.setValue(this.plugin.settings.showConsumedTokens)
.onChange(async (value) => {
this.plugin.settings.showConsumedTokens = value;
await this.plugin.saveSettings();
}));
}
}

View file

@ -1,7 +1,7 @@
{
"id": "ring-a-secretary",
"name": "Ring a secretary",
"version": "0.0.8",
"version": "0.0.2",
"minAppVersion": "0.15.0",
"description": "Yet another ChatGPT-powered digital secretary",
"author": "vorotamoroz",

224
package-lock.json generated
View file

@ -1,21 +1,22 @@
{
"name": "ring-a-secretary",
"version": "0.0.8",
"version": "0.0.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "ring-a-secretary",
"version": "0.0.8",
"version": "0.0.2",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"axios": "^1.3.4",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"openai-ext": "^1.2.6",
"openai": "^3.2.1",
"tslib": "2.4.0",
"typescript": "4.7.4"
}
@ -824,6 +825,23 @@
"node": ">=8"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
"node_modules/axios": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.3.4.tgz",
"integrity": "sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==",
"dev": true,
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -913,6 +931,18 @@
"dev": true,
"peer": true
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -959,6 +989,15 @@
"dev": true,
"peer": true
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@ -1367,6 +1406,40 @@
"dev": true,
"peer": true
},
"node_modules/follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -1677,6 +1750,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -1736,11 +1830,24 @@
"wrappy": "1"
}
},
"node_modules/openai-ext": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/openai-ext/-/openai-ext-1.2.6.tgz",
"integrity": "sha512-bva4Ql5G1lwBGeIBu0vI3mI9FeTyW/l2b7cq5kdEYasujzSAQxxqN9rPi4lSvjNuKofp3C0ravUMdTZkUtKlxw==",
"dev": true
"node_modules/openai": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/openai/-/openai-3.2.1.tgz",
"integrity": "sha512-762C9BNlJPbjjlWZi4WYK9iM2tAVAv0uUp1UmI34vb0CN5T2mjB/qM6RYBmNKMh/dN9fC+bxqPwWJZUTWW052A==",
"dev": true,
"dependencies": {
"axios": "^0.26.0",
"form-data": "^4.0.0"
}
},
"node_modules/openai/node_modules/axios": {
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
"integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
"dev": true,
"dependencies": {
"follow-redirects": "^1.14.8"
}
},
"node_modules/optionator": {
"version": "0.9.1",
@ -1866,6 +1973,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true
},
"node_modules/punycode": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",
@ -2672,6 +2785,23 @@
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
"dev": true
},
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
"axios": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.3.4.tgz",
"integrity": "sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==",
"dev": true,
"requires": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -2740,6 +2870,15 @@
"dev": true,
"peer": true
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"requires": {
"delayed-stream": "~1.0.0"
}
},
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -2775,6 +2914,12 @@
"dev": true,
"peer": true
},
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true
},
"dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@ -3096,6 +3241,23 @@
"dev": true,
"peer": true
},
"follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
"dev": true
},
"form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dev": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
},
"fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -3333,6 +3495,21 @@
"picomatch": "^2.3.1"
}
},
"mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true
},
"mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"requires": {
"mime-db": "1.52.0"
}
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -3382,11 +3559,26 @@
"wrappy": "1"
}
},
"openai-ext": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/openai-ext/-/openai-ext-1.2.6.tgz",
"integrity": "sha512-bva4Ql5G1lwBGeIBu0vI3mI9FeTyW/l2b7cq5kdEYasujzSAQxxqN9rPi4lSvjNuKofp3C0ravUMdTZkUtKlxw==",
"dev": true
"openai": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/openai/-/openai-3.2.1.tgz",
"integrity": "sha512-762C9BNlJPbjjlWZi4WYK9iM2tAVAv0uUp1UmI34vb0CN5T2mjB/qM6RYBmNKMh/dN9fC+bxqPwWJZUTWW052A==",
"dev": true,
"requires": {
"axios": "^0.26.0",
"form-data": "^4.0.0"
},
"dependencies": {
"axios": {
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
"integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
"dev": true,
"requires": {
"follow-redirects": "^1.14.8"
}
}
}
},
"optionator": {
"version": "0.9.1",
@ -3473,6 +3665,12 @@
"dev": true,
"peer": true
},
"proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true
},
"punycode": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "ring-a-secretary",
"version": "0.0.8",
"version": "0.0.2",
"description": "Yet another ChatGPT-powered digital secretary",
"main": "main.js",
"scripts": {
@ -15,10 +15,11 @@
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"axios": "^1.3.4",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"openai-ext": "^1.2.6",
"openai": "^3.2.1",
"tslib": "2.4.0",
"typescript": "4.7.4"
},

View file

@ -1,353 +0,0 @@
import { App, Editor, MarkdownRenderer, MarkdownView, Notice, Plugin, PluginSettingTab, Setting, TFile } from 'obsidian';
// import { Configuration, OpenAIApi } from "openai"
import { ClientStreamChatCompletionConfig, OpenAIExt } from "openai-ext";
interface RingASecretarySettings {
token: string;
defaultSystem: string;
model: string;
showConsumedTokens: boolean;
template: string;
}
const DEFAULT_SETTINGS: RingASecretarySettings = {
token: "",
defaultSystem: "",
model: 'gpt-3.5-turbo-0301',
showConsumedTokens: false,
template: "##temperature\n##top_p\n##max_tokens\n##presence_penalty"
}
const roleSystem = "**SYSTEM**";
const roleUser = "**USER**";
const roleAssistant = "**ASSISTANT**";
type DIALOGUE_ROLE = "**SYSTEM**" | "**USER**" | "**ASSISTANT**";
type API_ROLE = "system" | "user" | "assistant";
const MarkToRole = {
"**SYSTEM**": "system",
"**USER**": "user",
"**ASSISTANT**": "assistant",
} as Record<DIALOGUE_ROLE, API_ROLE>;
const RESPONSE_START_MARK = "<span class='ofx-response-start'></span>";
const RESPONSE_END_MARK = "<span class='ofx-thinking'></span>";
const WAIT_MARK_INITIAL = `${RESPONSE_START_MARK}Please, bear with me.`;
function replaceStringBetweenMarks(source: string, newPiece: string, fromMark: string, toMark: string) {
const start = source.indexOf(fromMark);
const end = source.indexOf(toMark);
if (start == -1 || end == -1) return source;
return source.substring(0, start) + newPiece + source.substring(end + toMark.length);
}
// const WAIT_MARK = "<span class='ofx-response-start'></span><span class='ofx-thinking'></span>Please, bear with me.";
export default class RingASecretaryPlugin extends Plugin {
settings: RingASecretarySettings;
configuration: ClientStreamChatCompletionConfig;
request?: XMLHttpRequest;
async askToAI(dialogue: string, targetFile: TFile, targetKey: string) {
if (this.request) {
new Notice("Some question is already in progress...", 5000);
return
}
const messages = [] as { role: "system" | "user" | "assistant", content: string }[];
let currentRole = "" as "" | "user" | "system" | "assistant";
let buffer = "";
// let temperature: number | undefine = 0; /* 0 -> 1[def] -> 2 */
// let top_p: number | undefine = undefined; /* 1 def */
// let max_tokens: number | undefined = undefined; /* inf def */
// let presence_penalty: number | undefined = undefined; /* -2 -> 2[def] -> 2*/
const options = {} as {
temperature?: number,
top_p?: number,
max_tokens?: number,
presence_penalty?: number
}
const dialogLines = dialogue.split("\n");
const params = ["temperature", "top_p", "max_tokens", "presence_penalty"] as ("temperature" | "top_p" | "max_tokens" | "presence_penalty")[];
for (const line of dialogLines) {
let lineBuf = line.trim();
if (lineBuf.startsWith("#")) {
for (const param of params) {
const paramHead = `##${param}`;
if (lineBuf.startsWith(paramHead)) {
const val = lineBuf.substring(paramHead.length).trim();
if (val != "") options[param] = Number.parseFloat(val);
}
continue;
}
continue;
}
let newRole = "" as "" | API_ROLE;
for (const [dialogueRole, APIRole] of Object.entries(MarkToRole)) {
if (lineBuf.startsWith(dialogueRole)) {
newRole = APIRole;
lineBuf = lineBuf.substring(dialogueRole.length + 1);
}
}
if (newRole != "") {
if (buffer.trim() != "") {
messages.push({ role: currentRole == "" ? "user" : currentRole, content: buffer.trim() });
buffer = "";
}
currentRole = newRole;
}
buffer += lineBuf + "\n";
}
if (buffer != "" && !buffer.contains(RESPONSE_START_MARK)) {
messages.push({ role: currentRole == "" ? "user" : currentRole, content: buffer });
}
// eslint-disable-next-line @typescript-eslint/no-this-alias
const _this = this;
this.request = OpenAIExt.streamClientChatCompletion({
...options,
model: this.settings.model,
messages,
}, {
apiKey: this.settings.token,
allEnvsAllowed: true,
handler: {
// Content contains the string draft, which may be partial. When isFinal is true, the completion is done.
onContent(content, isFinal, xhr) {
_this.applyResponse(content, isFinal, targetKey, targetFile, dialogue);
},
onDone(xhr) {
//TODO
_this.request = undefined;
},
onError(error, status, xhr) {
_this.applyResponse(error.message, true, targetKey, targetFile, dialogue);
console.error(error);
_this.request = undefined;
},
},
});
}
/** Apply the response to the note while streaming, and write response to the file when it has been done. */
async applyResponse(response: string, isDone: boolean, targetKey: string, targetFile: TFile, basedDialogue: string) {
if (!isDone) {
const data = basedDialogue;
const out = replaceStringBetweenMarks(data, `${RESPONSE_START_MARK}${response}<span id='${targetKey}'></span>`, RESPONSE_START_MARK, RESPONSE_END_MARK);
const els = document.querySelectorAll(`span#${targetKey}`);
const el = els.length > 0 ? els[0] as HTMLSpanElement : undefined;
if (el) {
const p = el.matchParent("div.obsidian-fx") as HTMLDivElement;
this.renderMarkdownToEl(p, out, targetFile.path)
}
return;
}
await this.app.vault.process(targetFile, (data) => {
const out = replaceStringBetweenMarks(data, `${response}\n`, RESPONSE_START_MARK, RESPONSE_END_MARK);
return out;
});
this.app.vault.trigger("modify", targetFile);
}
renderMarkdownToEl(fx: HTMLDivElement, source: string, sourcePath: string) {
// All pragmatic comments will be hidden, but just simple comments are shown as with strikethrough.
const renderSource1 = `${source.replace(/^(##.*)$/mg, "<!-- $1 -->").replace(/^#(.*?)\s*$/mg, "~~$1~~")}`;
// All lines can be deleted or cancelled
const renderSource2 = renderSource1.split("\n").map((e, i) =>
e.contains(RESPONSE_START_MARK) ? e.split(roleAssistant).join(`<a href="#" class="fx-toggle-comment" data-line="${i}" data-cancel="1">✋</a>${roleAssistant}`) : e.split(roleAssistant).join(`<a href="#" class="fx-toggle-comment" data-line="${i}">🗑️</a>${roleAssistant}`).split(roleUser).join(`<a href="#" class="fx-toggle-comment" data-line="${i}">🗑️</a>${roleUser}`))
.join("\n")
fx.replaceChildren("");
const renderSource = `> [!consult]+\n${renderSource2.replace(/^/mg, "> ")}`;
MarkdownRenderer.renderMarkdown(renderSource, fx, sourcePath, this).then(() => {
document.querySelectorAll(".fx-toggle-comment").forEach((e) => {
e.addEventListener("click", (evt) => {
evt.preventDefault();
const el = e.matchParent("div.obsidian-fx") as HTMLDivElement;
if (!el) return;
const filename = el.getAttribute("data-source-path");
const line = Number.parseInt(el.getAttribute("data-context-line") as string);
const commentLine = Number.parseInt(e.getAttribute("data-line") as string);
if (!filename) return;
const f = this.app.vault.getAbstractFileByPath(filename);
if (!(f instanceof TFile)) {
new Notice("Could not find the file", 3000);
return;
}
const cancel = e.getAttribute("data-cancel");
if (cancel == "1") {
if (this.request) {
this.request.abort();
this.request = undefined;
new Notice("Cancelled!", 5000);
}
}
this.app.vault.process(f, (data) => {
const f = data.split("\n");
// Toggle commenting out, or remove it if it has been cancelled.
f[line + commentLine + 1] = cancel == "1" ? "" : f[line + commentLine + 1].startsWith("#") ? f[line + commentLine + 1].substring(1) : `#${f[line + commentLine + 1]}`;
return f.join("\n");
});
})
});
})
}
async onload() {
await this.loadSettings();
this.registerMarkdownCodeBlockProcessor("aichat", (source, el, ctx) => {
const uniq = `obsidian-fx-${Date.now()}-${~~(Math.random() * 10000)}`;
const fx = el.createDiv({ text: "", cls: ["obsidian-fx", uniq] });
// Store the path and the line to modify later.
const sourcePath = ctx.sourcePath;
const secInfo = ctx.getSectionInfo(el);
fx.setAttribute("data-source-path", sourcePath);
fx.setAttribute("data-context-line", `${secInfo?.lineStart}`);
this.renderMarkdownToEl(fx, source, sourcePath);
const ops = el.createDiv({ text: "", cls: ["obsidian-fx-buttons"] });
const span = ops.createEl("span", { text: "USER:", cls: "label" });
const input = ops.createEl("textarea", { cls: "ai-dialogue-input" });
const submit = ops.createEl("button", { text: "🤵" });
ops.appendChild(span);
ops.appendChild(input)
ops.appendChild(submit);
el.appendChild(fx);
el.appendChild(ops)
const c = new AbortController();
const submitFunc = async () => {
if (source.contains(RESPONSE_START_MARK)) {
new Notice("Some question is already in progress... If not, please modify the code block directly.", 5000);
return
}
const f = this.app.vault.getAbstractFileByPath(sourcePath);
if (!f) {
new Notice("Could not find the file", 3000);
return;
}
if (!(f instanceof TFile)) {
new Notice("Could not find the file", 3000);
return;
}
const dataMain = source;
const text = input.value;
if (text.trim() == "") {
new Notice("Request is empty");
return;
}
//Note: ESCAPE MARKDOWN?
const newBody = `${dataMain}\n${roleUser}: ${text}\n \n${roleAssistant}: ${WAIT_MARK_INITIAL}<span id='${uniq}'></span>${RESPONSE_END_MARK}`;
await this.app.vault.process(f, (data) => {
const dataList = data.split("\n");
const dataBefore = dataList.slice(0, (secInfo?.lineStart ?? 0) + 1);
const dataAfter = dataList.slice(secInfo?.lineEnd)
return dataBefore.join("\n") + `\n${newBody}\n` + dataAfter.join("\n");
});
setTimeout(() => {
this.askToAI(newBody, f, uniq).catch(err => {
this.applyResponse(`Something has been occurred: ${err?.message}`, true, uniq, f, newBody);
new Notice(`Something has been occurred: ${err?.message}`);
});
}, 20);
this.app.vault.trigger("modify", f);
}
submit.addEventListener("click", submitFunc, { signal: c.signal });
input.addEventListener("keydown", e => {
setTimeout(() => {
if (input.clientHeight < input.scrollHeight) {
input.style.height = `${input.scrollHeight + 4}px`;
}
}, 10);
if (e.key == "Enter" && (e.shiftKey) && !e.isComposing) {
e.preventDefault();
submitFunc();
input.value = "";
}
}, { signal: c.signal, capture: true })
});
this.addCommand({
id: 'new-dialogue',
name: 'New dialogue',
editorCallback: async (editor: Editor, view: MarkdownView) => {
editor.replaceSelection(
`${"````aichat"}
${this.settings.template ? `${this.settings.template}\n` : ""}${this.settings.defaultSystem ? `${roleSystem}: ${this.settings.defaultSystem}` : `#${roleSystem}: `}
${"````"}
`
)
}
});
this.addSettingTab(new RingASecretarySettingTab(this.app, this));
}
onunload() {
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class RingASecretarySettingTab extends PluginSettingTab {
plugin: RingASecretaryPlugin;
constructor(app: App, plugin: RingASecretaryPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Settings for Ring a secretary' });
new Setting(containerEl)
.setName('Token')
.setDesc('The token of ChatGPT')
.addText(text => text
.setPlaceholder('sk-TrbCVkcuvcshu7b....')
.setValue(this.plugin.settings.token)
.onChange(async (value) => {
this.plugin.settings.token = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Model')
.setDesc('Model')
.addText(text => text
.setPlaceholder('gpt-3.5-turbo-0301')
.setValue(this.plugin.settings.model)
.onChange(async (value) => {
this.plugin.settings.model = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Initial prompt')
.setDesc('Initial prompt; i.e., instructions and prerequisites presented to the AI.')
.addText(text => text
.setPlaceholder('Behave as a British English speaker and answer my questions')
.setValue(this.plugin.settings.defaultSystem)
.onChange(async (value) => {
this.plugin.settings.defaultSystem = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Conversation customising template')
.setDesc('')
.addTextArea(text => text
.setPlaceholder("")
.setValue(this.plugin.settings.template)
.onChange(async (value) => {
this.plugin.settings.template = value;
await this.plugin.saveSettings();
}));
}
}

View file

@ -6,7 +6,8 @@ available in the app when your plugin is enabled.
If your plugin does not need CSS, delete this file.
*/
.markdown-reading-view .obsidian-fx .obsidian-fx-buttons {
.markdown-reading-view .obsidian-fx input,
.markdown-reading-view .obsidian-fx button {
display: none;
}
@ -33,7 +34,11 @@ If your plugin does not need CSS, delete this file.
}
.obsidian-fx-buttons textarea {
.obsidian-fx * {
margin: auto auto;
}
.obsidian-fx textarea {
flex-grow: 1;
min-height: 3ex;
resize: vertical;
@ -50,21 +55,17 @@ If your plugin does not need CSS, delete this file.
font-family: var(--font-editor);
}
.obsidian-fx-buttons textarea:focus {
.obsidian-fx textarea:focus {
-webkit-appearance: none;
outline: none;
outline-style: none;
}
.obsidian-fx:has(.callout.is-collapsed)+.obsidian-fx-buttons {
display: none;
}
.ofx-thinking {
animation: ofx-thinking-animation 1s infinite;
}
.ofx-thinking::before {
.ofx-thinking::after {
content: " ......";
animation: ofx-thinking-animation 1s infinite;
}
@ -94,7 +95,3 @@ If your plugin does not need CSS, delete this file.
content: " ...... ";
}
}
.callout[data-callout="consult"] {
--callout-icon: concierge-bell;
}