import {
LoomView,
LoomSiblingsView,
LoomEditorPlugin,
loomEditorPluginSpec,
MakePromptFromPassagesModal,
} from "./views";
import {
Provider,
ModelPreset,
LoomSettings,
SearchResultState,
Node,
NoteState,
getPreset,
} from "./common";
import {
App,
Editor,
MarkdownView,
Notice,
Plugin,
PluginSettingTab,
Setting,
TFile,
requestUrl,
setIcon,
} from "obsidian";
import { ViewPlugin } from "@codemirror/view";
import {
Configuration as AzureConfiguration,
OpenAIApi as AzureOpenAIApi,
} from "azure-openai";
import { Configuration, OpenAIApi } from "openai";
import * as cohere from "cohere-ai";
import Anthropic from "@anthropic-ai/sdk";
import cl100k from "gpt-tokenizer";
import p50k from "gpt-tokenizer/esm/model/text-davinci-003";
import r50k from "gpt-tokenizer/esm/model/davinci";
import * as fs from "fs";
import { toRoman } from "roman-numerals";
import { v4 as uuidv4 } from "uuid";
const untildify = require("untildify") as any;
type LoomSettingKey = keyof {
[K in keyof LoomSettings]: LoomSettings[K];
};
const DEFAULT_SETTINGS: LoomSettings = {
passageFolder: "",
defaultPassageSeparator: "\\n\\n---\\n\\n",
defaultPassageFrontmatter: "%r:\\n",
logApiCalls: false,
modelPresets: [],
modelPreset: -1,
visibility: {
visibility: true,
modelPreset: true,
maxTokens: true,
n: true,
bestOf: false,
temperature: true,
topP: false,
frequencyPenalty: false,
presencePenalty: false,
prepend: false,
systemPrompt: false,
userMessage: false,
},
maxTokens: 60,
temperature: 1,
topP: 1,
frequencyPenalty: 0,
presencePenalty: 0,
prepend: "<|endoftext|>",
bestOf: 0,
n: 5,
systemPrompt:
"The assistant is in CLI simulation mode, and responds to the user's CLI commands only with the output of the command.",
userMessage: "cat untitled.txt",
showSettings: false,
showSearchBar: false,
showNodeBorders: false,
showExport: false,
};
type CompletionResult =
| { ok: true; completions: string[] }
| { ok: false; status: number; message: string };
export default class LoomPlugin extends Plugin {
settings: LoomSettings;
state: Record;
editor: Editor;
statusBarItem: HTMLElement;
openai: OpenAIApi;
azure: AzureOpenAIApi;
anthropic: Anthropic;
anthropicApiKey: string;
rendering = false;
withFile(callback: (file: TFile) => T): T | null {
const file = this.app.workspace.getActiveFile();
if (!file) return null;
return callback(file);
}
thenSaveAndRender(callback: () => void) {
callback();
this.saveAndRender();
}
wftsar(callback: (file: TFile) => void) {
this.thenSaveAndRender(() => {
this.withFile(callback);
});
}
initializeProviders() {
const preset = getPreset(this.settings);
if (preset === undefined) return;
if (["openai", "openai-chat"].includes(preset.provider)) {
this.openai = new OpenAIApi(
new Configuration({
apiKey: preset.apiKey,
// @ts-expect-error TODO
organization: preset.organization,
})
);
} else if (preset.provider == "cohere") cohere.init(preset.apiKey);
else if (preset.provider == "azure") {
// @ts-expect-error TODO
const url = preset.url;
if (!preset.apiKey || !url) return;
this.azure = new AzureOpenAIApi(
new AzureConfiguration({
apiKey: preset.apiKey,
azure: {
apiKey: preset.apiKey,
endpoint: url,
},
})
);
} else if (preset.provider == "anthropic") {
//(property) ClientOptions.fetch?: Fetch | undefined
//Specify a custom fetch function implementation.
//If not provided, we use node-fetch on Node.js and otherwise expect that fetch is defined globally.
// expects Promise as return value
this.anthropicApiKey = preset.apiKey;
this.anthropic = new Anthropic({
apiKey: preset.apiKey,
// fetch:
defaultHeaders: {
"anthropic-version": "2023-06-01",
"anthropic-beta": "messages-2023-12-15",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "*",
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Credentials": "true",
},
});
}
}
apiKeySet() {
if (this.settings.modelPreset == -1) return false;
return this.settings.modelPresets[this.settings.modelPreset].apiKey != "";
}
newNode(
text: string,
parentId: string | null,
unread: boolean = false
): [string, Node] {
const id = uuidv4();
const node: Node = {
text,
parentId,
collapsed: false,
unread,
bookmarked: false,
searchResultState: null,
};
return [id, node];
}
initializeNoteState(file: TFile) {
const [rootId, root] = this.newNode(this.editor.getValue(), null);
this.state[file.path] = {
current: rootId,
hoisted: [] as string[],
searchTerm: "",
nodes: { [rootId]: root },
generating: null,
};
this.saveAndRender();
}
ancestors(file: TFile, id: string): string[] {
const state = this.state[file.path];
let ancestors = [];
let node: string | null = id;
while (node) {
node = state.nodes[node].parentId;
if (node) ancestors.push(node);
}
return ancestors.reverse();
}
family(file: TFile, id: string): string[] {
return [...this.ancestors(file, id), id];
}
fullText(file: TFile, id: string | null) {
const state = this.state[file.path];
let text = "";
let current = id;
while (current) {
text = state.nodes[current].text + text;
current = state.nodes[current].parentId;
}
return text;
}
breakAtPoint(file: TFile): (string | null)[] {
// split the current node into:
// - parent node with text before cursor
// - child node with text after cursor
const state = this.state[file.path];
const current = state.current;
// first, get the cursor's position in the full text
const cursor = this.editor.getCursor();
let cursorPos = 0;
for (let i = 0; i < cursor.line; i++)
cursorPos += this.editor.getLine(i).length + 1;
cursorPos += cursor.ch;
const family = this.family(file, current);
const familyTexts = family.map((id) => state.nodes[id].text);
// find the node that the cursor is in
let i = cursorPos;
let n = 0;
while (true) {
if (i < familyTexts[n].length) break;
// if the cursor is at the end of the last node, don't split, just return the current node
if (n === family.length - 1) return [current, null];
i -= familyTexts[n].length;
n++;
}
const parentNode = family[n];
const parentNodeText = familyTexts[n];
// then, get the text before and after the cursor
const before = parentNodeText.substring(0, i);
const after = parentNodeText.substring(i);
// then, set the in-range node's text to the text before the cursor
this.state[file.path].nodes[parentNode].text = before;
// get the in-range node's children, which will be moved later
const children = Object.values(state.nodes).filter(
(node) => node.parentId === parentNode
);
// then, create a new node with the text after the cursor
const [childId, childNode] = this.newNode(after, parentNode);
this.state[file.path].nodes[childId] = childNode;
// move the children to under the after node
children.forEach((child) => (child.parentId = childId));
return [parentNode, childId];
}
async onload() {
await this.loadSettings();
await this.loadState();
this.app.workspace.trigger("parse-style-settings");
this.addSettingTab(new LoomSettingTab(this.app, this));
this.initializeProviders();
this.statusBarItem = this.addStatusBarItem();
this.statusBarItem.setText("Generating...");
this.statusBarItem.style.display = "none";
const completeCallback = (
checking: boolean,
callback: (file: TFile) => Promise
) => {
const file = this.app.workspace.getActiveFile();
if (!file || file.extension !== "md") return;
if (!this.apiKeySet()) return false;
if (!checking) callback(file);
return true;
};
this.addCommand({
id: "complete",
name: "Complete from current point",
checkCallback: (checking: boolean) =>
completeCallback(checking, this.complete.bind(this)),
hotkeys: [{ modifiers: ["Ctrl"], key: " " }],
});
this.addCommand({
id: "generate-siblings",
name: "Generate siblings of the current node",
checkCallback: (checking: boolean) =>
completeCallback(checking, this.generateSiblings.bind(this)),
hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: " " }],
});
this.addCommand({
id: "bookmark",
name: "Bookmark current node",
checkCallback: (checking: boolean) =>
withState(checking, (state) => {
this.app.workspace.trigger("loom:toggle-bookmark", state.current);
}),
hotkeys: [{ modifiers: ["Ctrl"], key: "b" }],
});
const withState = (
checking: boolean,
callback: (state: NoteState) => void
) => {
const file = this.app.workspace.getActiveFile();
if (!file || file.extension !== "md") return false;
const state = this.state[file.path];
if (!state) this.initializeNoteState(file);
if (!checking) callback(state);
return true;
};
const withStateChecked = (
checking: boolean,
checkCallback: (state: NoteState) => boolean,
callback: (state: NoteState) => void
) => {
const file = this.app.workspace.getActiveFile();
if (!file || file.extension !== "md") return false;
const state = this.state[file.path];
if (!state) this.initializeNoteState(file);
if (!checkCallback(state)) return false;
if (!checking) callback(state);
return true;
};
const openPane = (type: string, focus: boolean) => {
const panes = this.app.workspace.getLeavesOfType(type);
try {
if (panes.length === 0)
this.app.workspace.getRightLeaf(false)?.setViewState({ type });
else if (focus) this.app.workspace.revealLeaf(panes[0]);
} catch (e) {} // expect "TypeError: Cannot read properties of null (reading 'children')"
};
const openLoomPane = (focus: boolean) => openPane("loom", focus);
const openLoomSiblingsPane = (focus: boolean) =>
openPane("loom-siblings", focus);
this.addCommand({
id: "create-child",
name: "Create child of current node",
checkCallback: (checking: boolean) =>
withState(checking, (state) => {
this.app.workspace.trigger("loom:create-child", state.current);
}),
});
this.addCommand({
id: "create-sibling",
name: "Create sibling of current node",
checkCallback: (checking: boolean) =>
withState(checking, (state) => {
this.app.workspace.trigger("loom:create-sibling", state.current);
}),
});
this.addCommand({
id: "clone-current-node",
name: "Clone current node",
checkCallback: (checking: boolean) =>
withState(checking, (state) => {
this.app.workspace.trigger("loom:clone", state.current);
}),
});
this.addCommand({
id: "break-at-point",
name: "Split at current point",
checkCallback: (checking: boolean) =>
withState(checking, (state) => {
this.app.workspace.trigger("loom:break-at-point", state.current);
}),
hotkeys: [{ modifiers: ["Alt"], key: "s" }],
});
this.addCommand({
id: "break-at-point-create-child",
name: "Split at current point and create child",
checkCallback: (checking: boolean) =>
withState(checking, (state) => {
this.app.workspace.trigger(
"loom:break-at-point-create-child",
state.current
);
}),
hotkeys: [{ modifiers: ["Alt"], key: "c" }],
});
const canMerge = (state: NoteState, id: string, checking: boolean) => {
const parentId = state.nodes[id].parentId;
if (!parentId) {
if (!checking) new Notice("Can't merge a root node with its parent");
return false;
}
const nSiblings = Object.values(state.nodes).filter(
(n) => n.parentId === parentId
).length;
if (nSiblings > 1) {
if (!checking)
new Notice("Can't merge this node with its parent; it has siblings");
return false;
}
return true;
};
this.addCommand({
id: "merge-with-parent",
name: "Merge current node with parent",
checkCallback: (checking: boolean) =>
withStateChecked(
checking,
(state) => canMerge(state, state.current, checking),
(state) => {
this.app.workspace.trigger("loom:merge-with-parent", state.current);
}
),
hotkeys: [{ modifiers: ["Alt"], key: "m" }],
});
const switchToSibling = (state: NoteState, delta: number) => {
const parentId = state.nodes[state.current].parentId;
const siblings = Object.entries(state.nodes)
.filter(([, node]) => node.parentId === parentId)
.map(([id]) => id);
if (siblings.length === 1) return;
const index =
(siblings.indexOf(state.current) + delta + siblings.length) %
siblings.length;
this.app.workspace.trigger("loom:switch-to", siblings[index]);
};
this.addCommand({
id: "switch-to-next-sibling",
name: "Switch to next sibling",
checkCallback: (checking: boolean) =>
withState(checking, (state) => switchToSibling(state, 1)),
hotkeys: [{ modifiers: ["Alt"], key: "ArrowDown" }],
});
this.addCommand({
id: "switch-to-previous-sibling",
name: "Switch to previous sibling",
checkCallback: (checking: boolean) =>
withState(checking, (state) => switchToSibling(state, -1)),
hotkeys: [{ modifiers: ["Alt"], key: "ArrowUp" }],
});
const switchToParent = (state: NoteState) =>
this.app.workspace.trigger(
"loom:switch-to",
state.nodes[state.current].parentId
);
this.addCommand({
id: "switch-to-parent",
name: "Switch to parent",
checkCallback: (checking: boolean) =>
withStateChecked(
checking,
(state) => state.nodes[state.current].parentId !== null,
switchToParent
),
hotkeys: [{ modifiers: ["Alt"], key: "ArrowLeft" }],
});
const switchToChild = (state: NoteState) => {
const children = Object.entries(state.nodes)
.filter(([, node]) => node.parentId === state.current)
.sort(
([, node1], [, node2]) =>
(node2.lastVisited || 0) - (node1.lastVisited || 0)
);
if (children.length > 0)
this.app.workspace.trigger("loom:switch-to", children[0][0]);
};
this.addCommand({
id: "switch-to-child",
name: "Switch to child",
checkCallback: (checking: boolean) => withState(checking, switchToChild),
hotkeys: [{ modifiers: ["Alt"], key: "ArrowRight" }],
});
const canDelete = (state: NoteState, id: string, checking: boolean) => {
const rootNodes = Object.entries(state.nodes)
.filter(([, node]) => node.parentId === null)
.map(([id]) => id);
if (rootNodes.length === 1 && rootNodes[0] === id) {
if (!checking) new Notice("Can't delete the last root node");
return false;
}
return true;
};
this.addCommand({
id: "delete-current-node",
name: "Delete current node",
checkCallback: (checking: boolean) =>
withStateChecked(
checking,
(state) => canDelete(state, state.current, checking),
(state) => {
this.app.workspace.trigger("loom:delete", [state.current]);
}
),
hotkeys: [{ modifiers: ["Alt"], key: "Backspace" }],
});
this.addCommand({
id: "clear-children",
name: "Delete current node's children",
checkCallback: (checking: boolean) =>
withState(checking, (state) => {
this.app.workspace.trigger("loom:clear-children", state.current);
}),
});
this.addCommand({
id: "clear-siblings",
name: "Delete current node's siblings",
checkCallback: (checking: boolean) =>
withState(checking, (state) => {
this.app.workspace.trigger("loom:clear-siblings", state.current);
}),
});
this.addCommand({
id: "toggle-collapse-current-node",
name: "Toggle whether current node is collapsed",
checkCallback: (checking: boolean) =>
withState(checking, (state) => {
this.app.workspace.trigger("loom:toggle-collapse", state.current);
}),
});
const getState = () => this.withFile((file) => this.state[file.path]);
const getSettings = () => this.settings;
this.addCommand({
id: "make-prompt-from-passages",
name: "Make prompt from passages",
callback: () => {
if (this.settings.passageFolder.trim() === "") {
new Notice("Please set the passage folder in settings");
return;
}
new MakePromptFromPassagesModal(this.app, getSettings).open();
},
});
this.addCommand({
id: "open-pane",
name: "Open Loom pane",
callback: () => openLoomPane(true),
});
this.addCommand({
id: "open-siblings-pane",
name: "Open Loom siblings pane",
callback: () => openLoomSiblingsPane(true),
});
this.addCommand({
id: "debug-reset-state",
name: "Debug: Reset state",
callback: () => this.thenSaveAndRender(() => (this.state = {})),
});
this.addCommand({
id: "debug-reset-hoist-stack",
name: "Debug: Reset hoist stack",
callback: () =>
this.wftsar((file) => (this.state[file.path].hoisted = [])),
});
this.registerView(
"loom",
(leaf) => new LoomView(leaf, getState, getSettings)
);
this.registerView(
"loom-siblings",
(leaf) => new LoomSiblingsView(leaf, getState)
);
openLoomPane(true);
openLoomSiblingsPane(false);
const loomEditorPlugin = ViewPlugin.fromClass(
LoomEditorPlugin,
loomEditorPluginSpec
);
this.registerEditorExtension([loomEditorPlugin]);
this.registerEvent(
this.app.workspace.on(
"editor-change",
(editor: Editor, view: MarkdownView) => {
// @ts-expect-error
const editorView = editor.cm;
const plugin = editorView.plugin(loomEditorPlugin);
// get cursor position, so it can be restored later
const cursor = editor.getCursor();
// if this note has no state, initialize it and return
// @ts-ignore `Object is possibly 'null'` only in github actions
if (!this.state[view.file.path]) {
const [current, node] = this.newNode(editor.getValue(), null);
// @ts-ignore
this.state[view.file.path] = {
current,
hoisted: [] as string[],
searchTerm: "",
nodes: { [current]: node },
generating: null,
};
return;
}
// @ts-ignore
const current = this.state[view.file.path].current;
// `ancestors`: starts with the root node, ends with the parent of the current node
let ancestors: string[] = [];
let node: string | null = current;
while (node) {
// @ts-ignore
node = this.state[view.file.path].nodes[node].parentId;
if (node) ancestors.push(node);
}
ancestors = ancestors.reverse();
// `ancestorTexts`: the text of each node in `ancestors`
const text = editor.getValue();
const ancestorTexts = ancestors.map(
// @ts-ignore
(id) => this.state[view.file.path].nodes[id].text
);
// `familyTexts`: `ancestorTexts` + the current node's text
const familyTexts = ancestorTexts.concat(
// @ts-ignore
this.state[view.file.path].nodes[current].text
);
// for each ancestor, check if the editor's text starts with the ancestor's full text
// if not, edit the ancestor's text to match the in-range section of the editor's text
const editNode = (i: number) => {
const prefix = familyTexts.slice(0, i).join("");
const suffix = familyTexts.slice(i + 1).join("");
let newText = text.substring(prefix.length);
newText = newText.substring(0, newText.length - suffix.length);
// @ts-ignore
this.state[view.file.path].nodes[ancestors[i]].text = newText;
};
const updateDecorations = () => {
const ancestorLengths = ancestors.map((id) => [
id,
// @ts-ignore
this.state[view.file.path].nodes[id].text.length,
]);
plugin.state = { ...plugin.state, ancestorLengths };
plugin.update();
};
for (let i = 0; i < ancestors.length; i++) {
const textBefore = ancestorTexts.slice(0, i + 1).join("");
if (!text.startsWith(textBefore)) {
editNode(i);
updateDecorations();
return;
}
}
// @ts-ignore
this.state[view.file.path].nodes[current].text = text.slice(
ancestorTexts.join("").length
);
updateDecorations();
setTimeout(() => {
this.saveAndRender();
}, 0);
// restore cursor position
editor.setCursor(cursor);
}
)
);
this.registerEvent(
// ignore ts2769; the obsidian-api declarations don't account for custom events
// @ts-expect-error
this.app.workspace.on("loom:switch-to", (id: string) =>
this.wftsar((file) => {
this.state[file.path].current = id;
this.state[file.path].nodes[id].unread = false;
this.state[file.path].nodes[id].lastVisited = Date.now();
// uncollapse the node's ancestors
const ancestors = this.family(file, id).slice(0, -1);
ancestors.forEach(
(id) => (this.state[file.path].nodes[id].collapsed = false)
);
// update the editor's text
// const cursor = this.editor.getCursor();
// const linesBefore = this.editor.getValue().split("\n");
this.editor.setValue(this.fullText(file, id));
// always move cursor to the end of the editor
const line = this.editor.lineCount() - 1;
const ch = this.editor.getLine(line).length;
this.editor.setCursor({ line, ch });
// return;
// // if the cursor is at the beginning of the editor, move it to the end
// if(cursor.line === 0 && cursor.ch === 0) {
// const line = this.editor.lineCount() - 1;
// const ch = this.editor.getLine(line).length;
// this.editor.setCursor({ line, ch });
// return;
// }
// // if the text preceding the cursor has changed, move the cursor to the end of the text
// // otherwise, restore the cursor position
// const linesAfter = this.editor
// .getValue()
// .split("\n")
// .slice(0, cursor.line + 1);
// for (let i = 0; i < cursor.line; i++)
// if (linesBefore[i] !== linesAfter[i]) {
// const line = this.editor.lineCount() - 1;
// const ch = this.editor.getLine(line).length;
// this.editor.setCursor({ line, ch });
// return;
// }
// this.editor.setCursor(cursor);
this.saveAndRender(); // Add explicit view refresh
})
)
);
this.registerEvent(
// @ts-expect-error
this.app.workspace.on("loom:toggle-collapse", (id: string) =>
this.wftsar(
(file) =>
(this.state[file.path].nodes[id].collapsed =
!this.state[file.path].nodes[id].collapsed)
)
)
);
this.registerEvent(
// @ts-expect-error
this.app.workspace.on("loom:hoist", (id: string) =>
this.wftsar((file) => this.state[file.path].hoisted.push(id))
)
);
this.registerEvent(
// @ts-expect-error
this.app.workspace.on("loom:unhoist", () =>
this.wftsar((file) => this.state[file.path].hoisted.pop())
)
);
this.registerEvent(
// @ts-expect-error
this.app.workspace.on("loom:toggle-bookmark", (id: string) =>
this.wftsar(
(file) =>
(this.state[file.path].nodes[id].bookmarked =
!this.state[file.path].nodes[id].bookmarked)
)
)
);
this.registerEvent(
// @ts-expect-error
this.app.workspace.on("loom:create-child", (id: string) =>
this.withFile((file) => {
const [newId, newNode] = this.newNode("", id);
this.state[file.path].nodes[newId] = newNode;
this.app.workspace.trigger("loom:switch-to", newId);
})
)
);
this.registerEvent(
// @ts-expect-error
this.app.workspace.on("loom:create-sibling", (id: string) =>
this.withFile((file) => {
const [newId, newNode] = this.newNode(
"",
this.state[file.path].nodes[id].parentId
);
this.state[file.path].nodes[newId] = newNode;
this.app.workspace.trigger("loom:switch-to", newId);
})
)
);
this.registerEvent(
// @ts-expect-error
this.app.workspace.on("loom:clone", (id: string) =>
this.withFile((file) => {
const node = this.state[file.path].nodes[id];
const [newId, newNode] = this.newNode(node.text, node.parentId);
this.state[file.path].nodes[newId] = newNode;
this.app.workspace.trigger("loom:switch-to", newId);
})
)
);
this.registerEvent(
// @ts-expect-error
this.app.workspace.on("loom:break-at-point", () =>
this.withFile((file) => {
const [, childId] = this.breakAtPoint(file);
if (childId) this.app.workspace.trigger("loom:switch-to", childId);
})
)
);
this.registerEvent(
// @ts-expect-error
this.app.workspace.on("loom:break-at-point-create-child", () =>
this.withFile((file) => {
const [parentId] = this.breakAtPoint(file);
if (parentId !== undefined) {
const [newId, newNode] = this.newNode("", parentId);
this.state[file.path].nodes[newId] = newNode;
this.app.workspace.trigger("loom:switch-to", newId);
}
})
)
);
this.registerEvent(
// @ts-expect-error
this.app.workspace.on("loom:merge-with-parent", (id: string) =>
this.wftsar((file) => {
const state = this.state[file.path];
if (!canMerge(state, id, false)) return;
const parentId = state.nodes[id].parentId!;
// update the merged node's text
state.nodes[parentId].text += state.nodes[id].text;
// move the children to the merged node
const children = Object.entries(state.nodes).filter(
([, node]) => node.parentId === id
);
for (const [childId] of children)
this.state[file.path].nodes[childId].parentId = parentId;
// switch to the merged node and delete the child node
this.app.workspace.trigger("loom:switch-to", parentId);
this.app.workspace.trigger("loom:delete", [id]);
})
)
);
this.registerEvent(
// @ts-expect-error
this.app.workspace.on("loom:delete", (ids: string[]) =>
this.wftsar((file) => {
const state = this.state[file.path];
ids = ids.filter((id) => canDelete(state, id, false));
if (ids.length === 0) return;
// remove the nodes from the hoist stack
this.state[file.path].hoisted = state.hoisted.filter(
(id) => !ids.includes(id)
);
// add the nodes and their descendants to a list of nodes to delete
let deleted = [...ids];
const addChildren = (id: string) => {
const children = Object.entries(state.nodes)
.filter(([, node]) => node.parentId === id)
.map(([id]) => id);
deleted = deleted.concat(children);
children.forEach(addChildren);
};
ids.forEach(addChildren);
// if the current node will be deleted, switch to its next sibling or its closest ancestor
if (deleted.includes(state.current)) {
const parentId = state.nodes[state.current].parentId;
const siblings = Object.entries(state.nodes)
.filter(([, node]) => node.parentId === parentId)
.map(([id]) => id);
(() => {
// try to switch to the next sibling
if (siblings.some((id) => !deleted.includes(id))) {
const index = siblings.indexOf(state.current);
const nextSibling = siblings[(index + 1) % siblings.length];
this.app.workspace.trigger("loom:switch-to", nextSibling);
return;
}
// try to switch to the closest ancestor
let ancestorId = parentId;
while (ancestorId !== null) {
if (!deleted.includes(ancestorId)) {
this.app.workspace.trigger("loom:switch-to", ancestorId);
return;
}
ancestorId = state.nodes[ancestorId].parentId;
}
// if all else fails, switch to a root node
const rootNodes = Object.entries(state.nodes)
.filter(([, node]) => node.parentId === null)
.map(([id]) => id);
for (const id of rootNodes)
if (!deleted.includes(id)) {
this.app.workspace.trigger("loom:switch-to", id);
return;
}
})();
}
// delete the nodes in the list
for (const id of deleted) delete this.state[file.path].nodes[id];
})
)
);
this.registerEvent(
// @ts-expect-error
this.app.workspace.on("loom:clear-children", (id: string) =>
this.wftsar((file) => {
const children = Object.entries(this.state[file.path].nodes)
.filter(([, node]) => node.parentId === id)
.map(([id]) => id);
this.app.workspace.trigger("loom:delete", children);
})
)
);
this.registerEvent(
// @ts-expect-error
this.app.workspace.on("loom:clear-siblings", (id: string) =>
this.wftsar((file) => {
const parentId = this.state[file.path].nodes[id].parentId;
const siblings = Object.entries(this.state[file.path].nodes)
.filter(([id_, node]) => node.parentId === parentId && id_ !== id)
.map(([id]) => id);
this.app.workspace.trigger("loom:delete", siblings);
})
)
);
this.registerEvent(
this.app.workspace.on(
// @ts-expect-error
"loom:set-setting",
(setting: string, value: any) => {
this.settings = { ...this.settings, [setting]: value };
this.saveAndRender();
// if changing showNodeBorders, update the editor
if (setting === "showNodeBorders") {
// @ts-expect-error
const editor = this.editor.cm;
const plugin = editor.plugin(loomEditorPlugin);
plugin.state.showNodeBorders = this.settings.showNodeBorders;
plugin.update();
editor.focus();
}
}
)
);
this.registerEvent(
this.app.workspace.on(
// @ts-expect-error
"loom:set-visibility-setting",
(setting: string, value: boolean) => {
this.settings.visibility[setting] = value;
this.saveAndRender();
}
)
);
this.registerEvent(
// @ts-expect-error
this.app.workspace.on("loom:search", (term: string) =>
this.withFile((file) => {
const state = this.state[file.path];
this.state[file.path].searchTerm = term;
if (!term) {
Object.keys(state.nodes).forEach((id) => {
this.state[file.path].nodes[id].searchResultState = null;
});
this.save(); // don't re-render
return;
}
const matches = Object.entries(state.nodes)
.filter(([, node]) =>
node.text.toLowerCase().includes(term.toLowerCase())
)
.map(([id]) => id);
let ancestors: string[] = [];
for (const id of matches) {
let parentId = state.nodes[id].parentId;
while (parentId !== null) {
ancestors.push(parentId);
parentId = state.nodes[parentId].parentId;
}
}
Object.keys(state.nodes).forEach((id) => {
let searchResultState: SearchResultState;
if (matches.includes(id)) searchResultState = "result";
else if (ancestors.includes(id)) searchResultState = "ancestor";
else searchResultState = "none";
this.state[file.path].nodes[id].searchResultState =
searchResultState;
});
this.save();
})
)
);
this.registerEvent(
// @ts-expect-error
this.app.workspace.on("loom:import", (path: string) =>
this.wftsar((file) => {
const fullPath = untildify(path);
const data = JSON.parse(fs.readFileSync(fullPath, "utf8"));
this.state[file.path] = data;
this.app.workspace.trigger("loom:switch-to", data.current);
new Notice("Imported from " + fullPath);
})
)
);
this.registerEvent(
// @ts-expect-error
this.app.workspace.on("loom:export", (path: string) =>
this.wftsar((file) => {
const fullPath = untildify(path);
const json = JSON.stringify(this.state[file.path], null, 2);
fs.writeFileSync(fullPath, json);
new Notice("Exported to " + fullPath);
})
)
);
this.registerEvent(
this.app.workspace.on(
// @ts-expect-error
"loom:make-prompt-from-passages",
(passages: string[], rawSeparator: string, rawFrontmatter: string) =>
this.wftsar((file) => {
const separator = rawSeparator.replace(/\\n/g, "\n");
const frontmatter = (index: number) =>
rawFrontmatter
.replace(/%n/g, (index + 1).toString())
.replace(/%r/g, toRoman(index + 1))
.replace(/\\n/g, "\n");
const passageTexts = passages.map((passage, index) => {
return Object.entries(this.state[passage].nodes)
.filter(([, node]) => node.parentId === null)
.map(([, node]) => frontmatter(index) + node.text);
});
const text = `${passageTexts.join(
separator
)}${separator}${frontmatter(passages.length)}`;
const state = this.state[file.path];
const currentNode = state.nodes[state.current];
let id;
if (currentNode.text === "" && currentNode.parentId === null) {
this.state[file.path].nodes[state.current].text = text;
id = state.current;
} else {
const [newId, newNode] = this.newNode(text, null);
this.state[file.path].nodes[newId] = newNode;
id = newId;
}
this.app.workspace.trigger("loom:switch-to", id);
})
)
);
const onFileOpen = (file: TFile) => {
if (file.extension !== "md") return;
// if this file is new, initialize its state
if (!this.state[file.path]) this.initializeNoteState(file);
const state = this.state[file.path];
// find this file's `MarkdownView`, then set `this.editor` to its editor
this.app.workspace.iterateRootLeaves((leaf) => {
if (
leaf.view instanceof MarkdownView &&
// @ts-ignore
leaf.view.file.path === file.path
)
this.editor = leaf.view.editor;
});
// get the length of each ancestor's text,
// which will be passed to `LoomEditorPlugin` to mark ancestor nodes in the editor
const ancestors = this.ancestors(file, state.current);
const ancestorLengths = ancestors.map((id) => [
id,
state.nodes[id].text.length,
]);
// set `LoomEditorPlugin`'s state, then refresh it
// @ts-expect-error
const plugin = this.editor.cm.plugin(loomEditorPlugin);
plugin.state = {
ancestorLengths,
showNodeBorders: this.settings.showNodeBorders,
};
plugin.update();
this.refreshViews();
};
this.registerEvent(
this.app.workspace.on("file-open", (file) => file && onFileOpen(file))
);
this.registerEvent(
this.app.workspace.on("active-leaf-change", (leaf) => {
if (!leaf) return;
const view = leaf.view;
if (view instanceof MarkdownView) this.editor = view.editor;
})
);
this.registerEvent(
this.app.workspace.on("resize", () => {
this.refreshViews();
})
);
this.registerEvent(
this.app.vault.on("rename", (file, oldPath) => {
this.state[file.path] = this.state[oldPath];
delete this.state[oldPath];
this.save();
})
);
this.registerEvent(
this.app.vault.on("delete", (file) => {
delete this.state[file.path];
this.save();
})
);
this.withFile((file) =>
this.app.workspace.iterateRootLeaves((leaf) => {
if (
leaf.view instanceof MarkdownView &&
// @ts-ignore
leaf.view.file.path === file.path
)
this.editor = leaf.view.editor;
onFileOpen(file);
})
);
}
async complete(file: TFile) {
const state = this.state[file.path];
const [parentNode] = this.breakAtPoint(file);
// switch to the parent node
this.app.workspace.trigger("loom:switch-to", parentNode);
this.saveAndRender();
await this.generate(file, state.current);
}
async generateSiblings(file: TFile) {
const state = this.state[file.path];
await this.generate(file, state.nodes[state.current].parentId);
}
async generate(file: TFile, rootNode: string | null) {
// show the "Generating..." indicator in the status bar
this.statusBarItem.style.display = "inline-flex";
const state = this.state[file.path];
this.state[file.path].generating = rootNode;
// show the "Generating..." indicator in the loom view
this.refreshViews();
let prompt = `${this.settings.prepend}${this.fullText(file, rootNode)}`;
// remove a trailing space if there is one
// store whether there was, so it can be added back post-completion
const trailingSpace = prompt.match(/\s+$/);
prompt = prompt.replace(/\s+$/, "");
// replace "\<" with "<", because obsidian tries to render html tags
// and "\[" with "["
prompt = prompt.replace(/\\ Promise
> = {
cohere: this.completeCohere,
textsynth: this.completeTextSynth,
"openai-compat": this.completeOpenAICompat,
openai: this.completeOpenAI,
"openai-chat": this.completeOpenAIChat,
azure: this.completeAzure,
"azure-chat": this.completeAzureChat,
anthropic: this.completeAnthropic,
openrouter: this.completeOpenRouter,
};
let result;
try {
result = await completionMethods[getPreset(this.settings).provider].bind(
this
)(prompt);
} catch (e) {
new Notice(`Error: ${e}`);
this.state[file.path].generating = null;
this.saveAndRender();
this.statusBarItem.style.display = "none";
return;
}
if (!result.ok) {
new Notice(`Error ${result.status}: ${result.message}`);
this.state[file.path].generating = null;
this.saveAndRender();
this.statusBarItem.style.display = "none";
return;
}
const rawCompletions = result.completions;
// console.log("rawCompletions", rawCompletions);
// escape and clean up the completions
const completions = rawCompletions.map((completion: string) => {
if (!completion) completion = ""; // empty completions are null, apparently
completion = completion.replace(/ generation.text
),
}
: {
ok: false,
status: response.statusCode!,
message: "",
};
return result;
}
async completeTextSynth(prompt: string) {
const body = {
prompt,
max_tokens: this.settings.maxTokens,
best_of: this.settings.bestOf,
n: this.settings.n,
temperature: this.settings.temperature,
top_p: this.settings.topP,
frequency_penalty: this.settings.frequencyPenalty,
presence_penalty: this.settings.presencePenalty,
};
if (this.settings.logApiCalls) {
console.log("TextSynth request:", body);
}
const response = await requestUrl({
url: `https://api.textsynth.com/v1/engines/${
getPreset(this.settings).model
}/completions`,
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${getPreset(this.settings).apiKey}`,
},
throw: false,
body: JSON.stringify(body),
});
if (this.settings.logApiCalls) {
console.log("TextSynth response:", response);
}
let result: CompletionResult;
if (response.status === 200) {
const completions =
this.settings.n === 1 ? [response.json.text] : response.json.text;
result = { ok: true, completions };
} else {
result = {
ok: false,
status: response.status,
message: response.json.error || "Unknown error",
};
}
return result;
}
trimOpenAIPrompt(prompt: string) {
const cl100kModels = [
"gpt-4-32k",
"gpt-4-0314",
"gpt-4-32k-0314",
"gpt-3.5-turbo",
"gpt-3.5-turbo-0301",
"gpt-4-base",
// TODO: llama 3.1 has "28k additional multilingual tokens", so cl100k is not exactly right
"meta-llama/llama-3.1-8b",
"meta-llama/llama-3.1-70b",
"meta-llama/llama-3.1-405b",
"meta-llama/llama-3.1-8b-instruct",
"meta-llama/llama-3.1-70b-instruct",
"meta-llama/llama-3.1-405b-instruct",
"meta-llama/Meta-Llama-3.1-405B",
"meta-llama/Meta-Llama-3.1-405B-FP8",
];
const p50kModels = [
"text-davinci-003",
"text-davinci-002",
"code-davinci-002",
"code-davinci-001",
"code-cushman-002",
"code-cushman-001",
"davinci-codex",
"cushman-codex",
];
// const r50kModels = ["text-davinci-001", "text-curie-001", "text-babbage-001", "text-ada-001", "davinci", "curie", "babbage", "ada"];
let tokenizer;
if (cl100kModels.includes(getPreset(this.settings).model))
tokenizer = cl100k;
else if (p50kModels.includes(getPreset(this.settings).model))
tokenizer = p50k;
else tokenizer = r50k; // i expect that an unknown model will most likely be r50k
return tokenizer.decode(
tokenizer
.encode(prompt, { disallowedSpecial: new Set() })
.slice(
-(getPreset(this.settings).contextLength - this.settings.maxTokens)
)
);
}
async completeOpenAICompat(prompt: string) {
prompt = this.trimOpenAIPrompt(prompt);
// @ts-expect-error TODO
let url = getPreset(this.settings).url;
if (!(url.startsWith("http://") || url.startsWith("https://")))
url = "https://" + url;
if (!url.endsWith("/")) url += "/";
url = url.replace(/v1\//, "");
url += "v1/completions";
let body: any = {
prompt,
model: getPreset(this.settings).model,
max_tokens: this.settings.maxTokens,
n: this.settings.n,
temperature: this.settings.temperature,
top_p: this.settings.topP,
};
if (this.settings.bestOf > this.settings.n) {
body.best_of = this.settings.bestOf;
}
if (this.settings.frequencyPenalty !== 0)
body.frequency_penalty = this.settings.frequencyPenalty;
if (this.settings.presencePenalty !== 0)
body.presence_penalty = this.settings.presencePenalty;
if (this.settings.logApiCalls) {
console.log("OpenAI-compatible API request:", {
url,
body
});
}
const response = await requestUrl({
url,
method: "POST",
headers: {
Authorization: `Bearer ${getPreset(this.settings).apiKey}`,
"Content-Type": "application/json",
},
throw: false,
body: JSON.stringify(body),
});
if (this.settings.logApiCalls) {
console.log("OpenAI-compatible API response:", response);
}
const result: CompletionResult =
response.status === 200
? {
ok: true,
completions: response.json.choices.map(
(choice: any) => choice.text
),
}
: {
ok: false,
status: response.status,
message: response.json?.error?.message || "Unknown error"
};
if (!result.ok && this.settings.logApiCalls) {
console.error("OpenAI-compatible error:", response);
}
return result;
}
async completeOpenRouter(prompt: string) {
prompt = this.trimOpenAIPrompt(prompt);
let body: any = {
prompt,
model: getPreset(this.settings).model,
max_tokens: this.settings.maxTokens,
n: this.settings.n,
temperature: this.settings.temperature,
top_p: this.settings.topP,
best_of: this.settings.bestOf,
provider: {
// @ts-expect-error
quantizations: [getPreset(this.settings).quantization]
}
};
if (this.settings.frequencyPenalty !== 0)
body.frequency_penalty = this.settings.frequencyPenalty;
if (this.settings.presencePenalty !== 0)
body.presence_penalty = this.settings.presencePenalty;
if (this.settings.logApiCalls) {
console.log("OpenRouter request:", body);
}
const requests = Array(this.settings.n).fill(null).map(() =>
requestUrl({
url: "https://openrouter.ai/api/v1/completions",
method: "POST",
headers: {
Authorization: `Bearer ${getPreset(this.settings).apiKey}`,
"HTTP-Referer": "https://github.com/cosmicoptima/loom",
"X-Title": "Loomsidian",
"Content-Type": "application/json",
},
throw: false,
body: JSON.stringify(body),
})
);
const responses = await Promise.all(requests);
if (this.settings.logApiCalls) {
console.log("OpenRouter responses:", responses);
}
const result: CompletionResult = responses.every(response => !response.json.hasOwnProperty('error'))
? {
ok: true,
completions: responses.map(response => response.json.choices[0].text),
}
: {
ok: false,
status: responses[0].json.error.code,
message: responses[0].json.error.message,
};
return result;
}
async completeOpenAI(prompt: string) {
prompt = this.trimOpenAIPrompt(prompt);
const body = {
model: getPreset(this.settings).model,
prompt,
max_tokens: this.settings.maxTokens,
n: this.settings.n,
temperature: this.settings.temperature,
top_p: this.settings.topP,
frequency_penalty: this.settings.frequencyPenalty,
presence_penalty: this.settings.presencePenalty,
};
if (this.settings.logApiCalls) {
console.log("OpenAI request:", body);
}
let result: CompletionResult;
try {
const response = await this.openai.createCompletion(body);
if (this.settings.logApiCalls) {
console.log("OpenAI response:", response);
}
result = {
ok: true,
completions: response.data.choices.map((choice) => choice.text || ""),
};
} catch (e) {
if (this.settings.logApiCalls) {
console.error("OpenAI error:", e);
}
result = {
ok: false,
status: e.response.status,
message: e.response.data.error.message || "Unknown error",
};
}
return result;
}
async completeOpenAIChat(prompt: string) {
prompt = this.trimOpenAIPrompt(prompt);
const body = {
model: getPreset(this.settings).model,
messages: [{ role: "assistant" as const, content: prompt }],
max_tokens: this.settings.maxTokens,
n: this.settings.n,
temperature: this.settings.temperature,
top_p: this.settings.topP,
frequency_penalty: this.settings.frequencyPenalty,
presence_penalty: this.settings.presencePenalty,
};
if (this.settings.logApiCalls) {
console.log("OpenAI Chat request:", body);
}
let result: CompletionResult;
try {
const response = await this.openai.createChatCompletion(body);
if (this.settings.logApiCalls) {
console.log("OpenAI Chat response:", response);
}
result = {
ok: true,
completions: response.data.choices.map(
(choice) => choice.message?.content || ""
),
};
} catch (e) {
if (this.settings.logApiCalls) {
console.error("OpenAI Chat error:", e);
}
result = {
ok: false,
status: e.response.status,
message: e.response.data.error.message || "Unknown error",
};
}
return result;
}
async completeAzure(prompt: string) {
prompt = this.trimOpenAIPrompt(prompt);
const body = {
model: getPreset(this.settings).model,
prompt,
max_tokens: this.settings.maxTokens,
n: this.settings.n,
temperature: this.settings.temperature,
top_p: this.settings.topP,
frequency_penalty: this.settings.frequencyPenalty,
presence_penalty: this.settings.presencePenalty,
};
if (this.settings.logApiCalls) {
console.log("Azure request:", body);
}
let result: CompletionResult;
try {
const response = await this.azure.createCompletion(body);
if (this.settings.logApiCalls) {
console.log("Azure response:", response);
}
result = {
ok: true,
completions: response.data.choices.map((choice) => choice.text || ""),
};
} catch (e) {
if (this.settings.logApiCalls) {
console.error("Azure error:", e);
}
result = {
ok: false,
status: e.response.status,
message: e.response.data.error.message || "Unknown error",
};
}
return result;
}
async completeAzureChat(prompt: string) {
prompt = this.trimOpenAIPrompt(prompt);
const body = {
model: getPreset(this.settings).model,
messages: [{ role: "assistant" as const, content: prompt }],
max_tokens: this.settings.maxTokens,
n: this.settings.n,
temperature: this.settings.temperature,
top_p: this.settings.topP,
frequency_penalty: this.settings.frequencyPenalty,
presence_penalty: this.settings.presencePenalty,
};
if (this.settings.logApiCalls) {
console.log("Azure Chat request:", body);
}
let result: CompletionResult;
try {
const response = await this.azure.createChatCompletion(body);
if (this.settings.logApiCalls) {
console.log("Azure Chat response:", response);
}
result = {
ok: true,
completions: response.data.choices.map(
(choice) => choice.message?.content || ""
),
};
} catch (e) {
if (this.settings.logApiCalls) {
console.error("Azure Chat error:", e);
}
result = {
ok: false,
status: e.response.status,
message: e.response.data.error.message || "Unknown error",
};
}
return result;
}
async completeAnthropic(prompt: string) {
const completions = await Promise.all(
[...Array(this.settings.n).keys()].map(async () => {
return await this.getAnthropicResponse(prompt);
})
);
const result: CompletionResult = { ok: true, completions };
return result;
}
async getAnthropicResponse(prompt: string) {
prompt = this.trimOpenAIPrompt(prompt);
// let result: CompletionResult;
const body = JSON.stringify(
{
model: getPreset(this.settings).model,
max_tokens: this.settings.maxTokens,
temperature: this.settings.temperature,
system: this.settings.systemPrompt,
messages: [
{ role: "user", content: `${this.settings.userMessage}` },
{ role: "assistant", content: `${prompt}` },
],
},
null,
2
);
if (this.settings.logApiCalls) {
console.log(`request body: ${body}`);
}
try {
const response = await requestUrl({
url: "https://api.anthropic.com/v1/messages",
method: "POST",
headers: {
"content-type": "application/json",
"anthropic-version": "2023-06-01",
"x-api-key": this.anthropicApiKey,
},
body,
});
if (response.status !== 200) {
console.error("response", response);
return null;
}
const result = response.json.content[0]?.text || "";
// ? { ok: true, completions: [response.json.content[0]?.text || ""] }
// : { ok: false, status: response.status, message: "" };
if (this.settings.logApiCalls) {
console.log(result);
}
return result;
} catch (e) {
console.error(e);
return null;
}
}
async loadSettings() {
const settings = (await this.loadData())?.settings || {};
this.settings = Object.assign({}, DEFAULT_SETTINGS, settings);
}
async loadState() {
this.state = (await this.loadData())?.state || {};
}
async save() {
await this.saveData({ settings: this.settings, state: this.state });
this.initializeProviders();
}
// Add this new method to properly refresh all views
private refreshViews() {
// Refresh Loom views
this.app.workspace.getLeavesOfType("loom").forEach((leaf) => {
if (leaf.view instanceof LoomView) {
leaf.view.render();
}
});
// Refresh Loom siblings views
this.app.workspace.getLeavesOfType("loom-siblings").forEach((leaf) => {
if (leaf.view instanceof LoomSiblingsView) {
leaf.view.render();
}
});
}
// Update saveAndRender to use the new refresh method
async saveAndRender() {
await this.save();
if (this.rendering) return;
this.rendering = true;
this.refreshViews();
this.rendering = false;
}
}
// this relies on `LoomPlugin`, so it's here, not in `views.ts`
class LoomSettingTab extends PluginSettingTab {
plugin: LoomPlugin;
constructor(app: App, plugin: LoomPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
const disclaimerHeader = containerEl.createEl("p");
disclaimerHeader.createEl("strong", { text: "To those new to Obsidian:" });
disclaimerHeader.createEl("span", {
text: " the Loom UI is not open by default. You can open it via one of the following methods:",
});
const methods = containerEl.createEl("ul");
methods.createEl("li", {
text: "Open the right sidebar and click the Loom icon.",
});
const method2 = methods.createEl("li");
method2.createEl("span", {
text: "Open the command palette, then search for and run the ",
});
method2.createEl("kbd", { text: "Loom: Open Loom pane" });
method2.createEl("span", { text: " command." });
const presetHeader = containerEl.createDiv({
cls: "setting-item setting-item-heading",
});
presetHeader.createDiv({ cls: "setting-item-name", text: "Presets" });
const presetEditor = containerEl.createDiv({
cls: "loom__preset-editor setting-item",
});
const presetList = presetEditor.createDiv({ cls: "loom__preset-list" });
const selectPreset = (index: number) => {
this.plugin.settings.modelPreset = index;
this.plugin.save();
updatePresetFields();
updatePresetList();
};
const deletePreset = (index: number) => {
this.plugin.settings.modelPresets.splice(index, 1);
this.plugin.save();
if (index === this.plugin.settings.modelPreset) {
if (this.plugin.settings.modelPresets.length === 0) selectPreset(-1);
else if (index === this.plugin.settings.modelPresets.length)
selectPreset(index - 1);
else selectPreset(index);
}
};
const createPreset = (preset: ModelPreset) => {
this.plugin.settings.modelPresets.push(preset);
this.plugin.save();
selectPreset(this.plugin.settings.modelPresets.length - 1);
};
const newPresetButtons = presetEditor.createDiv({
cls: "loom__new-preset-buttons",
});
const newPresetButton = newPresetButtons.createEl("button", {
text: "New preset",
});
newPresetButton.addEventListener("click", () => {
const newPreset: ModelPreset<"openai"> = {
name: "New preset",
provider: "openai",
model: "davinci-002",
contextLength: 16384,
apiKey: "",
organization: "",
};
createPreset(newPreset);
});
const fillInModelDropdown = newPresetButtons.createEl("select", {
cls: "loom__new-preset-button dropdown",
});
fillInModelDropdown.createEl("option", {
text: "Fill in model details...",
attr: { value: "none", selected: "", disabled: "" },
});
fillInModelDropdown.createEl("option", {
text: "Llama 3.1 405B (Hyperbolic)",
attr: { value: "llama-3.1-405b-hyperbolic" },
});
fillInModelDropdown.createEl("option", {
text: "Llama 3.1 405B (OpenRouter)",
attr: { value: "llama-3.1-405b-openrouter" },
});
fillInModelDropdown.createEl("option", {
text: "Claude 3 Opus",
attr: { value: "claude-3-opus" },
});
fillInModelDropdown.createEl("option", {
text: "Claude 3.5 Sonnet",
attr: { value: "claude-3.5-sonnet" },
});
fillInModelDropdown.createEl("option", {
text: "GPT-4 base",
attr: { value: "gpt-4-base" },
});
fillInModelDropdown.createEl("option", {
text: "davinci-002",
attr: { value: "davinci-002" },
});
fillInModelDropdown.addEventListener("change", (event) => {
const value = (event.target as HTMLSelectElement).value;
switch (value) {
case "llama-3.1-405b-hyperbolic": {
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].provider = "openai-compat";
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
// @ts-expect-error
].url = "https://api.hyperbolic.xyz";
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].model = "meta-llama/Meta-Llama-3.1-405B";
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].contextLength = 32768;
break;
}
case "llama-3.1-405b-openrouter": {
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].provider = "openrouter";
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
// @ts-expect-error
].quantization = "bf16";
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].model = "meta-llama/llama-3.1-405b";
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].contextLength = 32768;
break;
}
case "claude-3-opus": {
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].provider = "anthropic";
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].model = "claude-3-opus-20240229";
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].contextLength = 50000;
break;
}
case "claude-3.5-sonnet": {
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].provider = "anthropic";
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].model = "claude-3-5-sonnet-20240620";
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].contextLength = 50000;
break;
}
case "gpt-4-base": {
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].provider = "openai";
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].model = "gpt-4-base";
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].contextLength = 8192;
break;
}
case "davinci-002": {
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].provider = "openai";
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].model = "davinci-002";
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].contextLength = 16384;
break;
}
}
this.plugin.save();
updatePresetFields();
fillInModelDropdown.value = "none";
});
const restoreApiKeyDropdown = newPresetButtons.createEl("select", {
cls: "loom__new-preset-button dropdown",
});
restoreApiKeyDropdown.createEl("option", {
text: "Restore API key from pre-1.19...",
attr: { value: "none", selected: "", disabled: "" },
});
restoreApiKeyDropdown.createEl("option", {
text: "OpenAI-compatible API",
attr: { value: "openai-compat" },
});
restoreApiKeyDropdown.createEl("option", {
text: "Anthropic",
attr: { value: "anthropic" },
});
restoreApiKeyDropdown.createEl("option", {
text: "OpenAI",
attr: { value: "openai" },
});
restoreApiKeyDropdown.createEl("option", {
text: "Azure",
attr: { value: "azure" },
});
restoreApiKeyDropdown.createEl("option", {
text: "Cohere",
attr: { value: "cohere" },
});
restoreApiKeyDropdown.createEl("option", {
text: "TextSynth",
attr: { value: "textsynth" },
});
restoreApiKeyDropdown.addEventListener("change", (event) => {
const provider = (event.target as HTMLSelectElement).value as Provider;
let preset = {
name: "New preset",
provider,
model: "",
contextLength: "",
};
switch (provider) {
case "openai": {
preset = {
...preset,
// @ts-expect-error
apiKey: this.plugin.settings.openaiApiKey || "",
// @ts-expect-error
organization: this.plugin.settings.openaiOrganization || "",
};
break;
}
case "openai-compat": {
preset = {
...preset,
// @ts-expect-error
apiKey: this.plugin.settings.ocpApiKey || "",
// @ts-expect-error
url: this.plugin.settings.ocpUrl || "",
};
break;
}
case "cohere": {
preset = {
...preset,
// @ts-expect-error
apiKey: this.plugin.settings.cohereApiKey || "",
};
break;
}
case "textsynth": {
preset = {
...preset,
// @ts-expect-error
apiKey: this.plugin.settings.textsynthApiKey || "",
};
break;
}
case "azure": {
preset = {
...preset,
// @ts-expect-error
apiKey: this.plugin.settings.azureApiKey || "",
// @ts-expect-error
endpoint: this.plugin.settings.azureEndpoint || "",
};
break;
}
case "anthropic": {
preset = {
...preset,
// @ts-expect-error
apiKey: this.plugin.settings.anthropicApiKey || "",
// // @ts-expect-error
// systemPrompt: this.plugin.settings.anthropicSystemPrompt || "",
// // @ts-expect-error
// userMessage: this.plugin.settings.anthropicUserMessage || "",
};
break;
}
default: {
throw new Error(`Unknown provider: ${provider}`);
}
}
// @ts-expect-error TODO
createPreset(preset);
restoreApiKeyDropdown.value = "none";
});
// edit preset fields
const presetFields = containerEl.createDiv();
const updatePresetFields = () => {
presetFields.empty();
if (this.plugin.settings.modelPreset === -1) {
presetFields.createEl("p", {
cls: "loom__no-preset-selected",
text: "No preset selected.",
});
return;
}
new Setting(presetFields).setName("Name").addText((text) =>
text
.setValue(
this.plugin.settings.modelPresets[this.plugin.settings.modelPreset]
.name
)
.onChange((value) => {
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].name = value;
this.plugin.saveAndRender();
updatePresetList();
})
);
new Setting(presetFields).setName("Provider").addDropdown((dropdown) => {
const options: Record = {
"openai-compat": "OpenAI-compatible API",
"openrouter": "OpenRouter",
anthropic: "Anthropic",
openai: "OpenAI",
"openai-chat": "OpenAI (Chat)",
azure: "Azure",
"azure-chat": "Azure (Chat)",
cohere: "Cohere",
textsynth: "TextSynth",
};
dropdown.addOptions(options);
dropdown.setValue(
this.plugin.settings.modelPresets[this.plugin.settings.modelPreset]
.provider
);
dropdown.onChange(async (value) => {
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].provider = value;
await this.plugin.save();
updatePresetFields();
});
});
new Setting(presetFields).setName("Model").addText((text) =>
text
.setValue(
this.plugin.settings.modelPresets[this.plugin.settings.modelPreset]
.model
)
.onChange(async (value) => {
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].model = value;
await this.plugin.save();
})
);
new Setting(presetFields).setName("Context length").addText((text) =>
text
.setValue(
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].contextLength.toString()
)
.onChange(async (value) => {
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].contextLength = parseInt(value);
await this.plugin.save();
})
);
new Setting(presetFields).setName("API key").addText((text) =>
text
.setValue(
this.plugin.settings.modelPresets[this.plugin.settings.modelPreset]
.apiKey
)
.onChange(async (value) => {
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
].apiKey = value;
await this.plugin.save();
})
);
if (
["openai", "openai-chat"].includes(
this.plugin.settings.modelPresets[this.plugin.settings.modelPreset]
.provider
)
) {
new Setting(presetFields).setName("Organization").addText((text) =>
text
.setValue(
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
// @ts-expect-error TODO
].organization
)
.onChange(async (value) => {
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
// @ts-expect-error TODO
].organization = value;
await this.plugin.save();
})
);
}
if (
["openai-compat", "azure", "azure-chat"].includes(
this.plugin.settings.modelPresets[this.plugin.settings.modelPreset]
.provider
)
) {
new Setting(presetFields).setName("URL").addText((text) =>
text
.setValue(
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
// @ts-expect-error TODO
].url
)
.onChange(async (value) => {
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
// @ts-expect-error TODO
].url = value;
await this.plugin.save();
})
);
}
if (this.plugin.settings.modelPresets[this.plugin.settings.modelPreset].provider === "openrouter") {
new Setting(presetFields).setName("Quantization").addDropdown((dropdown) =>
dropdown
.addOptions({
bf16: "bf16",
fp16: "fp16",
fp8: "fp8",
int8: "int8",
int4: "int4"
})
.setValue(
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
// @ts-expect-error TODO
].quantization
)
.onChange(async (value) => {
this.plugin.settings.modelPresets[
this.plugin.settings.modelPreset
// @ts-expect-error TODO
].quantization = value;
await this.plugin.save();
})
);
}
};
const updatePresetList = () => {
presetList.empty();
for (const i in this.plugin.settings.modelPresets) {
const preset = this.plugin.settings.modelPresets[i];
const isActive = this.plugin.settings.modelPreset === parseInt(i);
const presetContainer = presetList.createDiv({
cls: `loom__preset is-clickable outgoing-link-item tree-item-self${
isActive ? " is-active" : ""
}`,
});
presetContainer.addEventListener("click", () =>
selectPreset(parseInt(i))
);
presetContainer.createSpan({
cls: "loom__preset-name tree-item-inner",
text: preset.name,
});
const deletePresetOuter = presetContainer.createDiv({
cls: "loom__preset-buttons",
});
const deletePresetInner = deletePresetOuter.createDiv({
cls: "loom__preset-button",
attr: { "aria-label": "Delete" },
});
setIcon(deletePresetInner, "trash-2");
deletePresetInner.addEventListener("click", (event) => {
event.stopPropagation();
deletePreset(parseInt(i));
});
}
};
updatePresetFields();
updatePresetList();
// TODO simplify below?
const passagesHeader = containerEl.createDiv({
cls: "setting-item setting-item-heading",
});
passagesHeader.createDiv({ cls: "setting-item-name", text: "Passages" });
const setting = (
name: string,
key: LoomSettingKey,
toText: (value: any) => string,
fromText: (text: string) => any
) => {
new Setting(containerEl).setName(name).addText((text) =>
text
.setValue(toText(this.plugin.settings[key]))
.onChange(async (value) => {
// @ts-expect-error
this.plugin.settings[key] = fromText(value);
await this.plugin.save();
})
);
};
const idSetting = (name: string, key: LoomSettingKey) =>
setting(
name,
key,
(value) => value,
(text) => text
);
new Setting(containerEl)
.setName("Passage folder location")
.setDesc("Passages can be quickly combined into a multipart prompt")
.addText((text) =>
text
.setValue(this.plugin.settings.passageFolder)
.onChange(async (value) => {
this.plugin.settings.passageFolder = value;
await this.plugin.save();
})
);
idSetting("Default passage separator", "defaultPassageSeparator");
idSetting("Default passage frontmatter", "defaultPassageFrontmatter");
const debugHeader = containerEl.createDiv({
cls: "setting-item setting-item-heading",
});
debugHeader.createDiv({ cls: "setting-item-name", text: "Debug" });
new Setting(containerEl)
.setName("Log API calls")
.setDesc("Log API calls to the console")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.logApiCalls)
.onChange(async (value) => {
this.plugin.settings.logApiCalls = value;
await this.plugin.save();
})
);
}
}