whole bunch of updates

This commit is contained in:
Shane Lamb 2025-01-21 19:40:34 +10:00
parent a57d74cf89
commit 9c712156b8
12 changed files with 3598 additions and 1605 deletions

View file

@ -17,6 +17,7 @@ const context = await esbuild.context({
}, },
entryPoints: ["src/main.ts"], entryPoints: ["src/main.ts"],
bundle: true, bundle: true,
platform: 'node',
external: [ external: [
"obsidian", "obsidian",
"electron", "electron",

7
jest.config.js Normal file
View file

@ -0,0 +1,7 @@
/** @type {import('ts-jest').JestConfigWithTsJest} **/
module.exports = {
testEnvironment: "node",
transform: {
"^.+.tsx?$": ["ts-jest",{}],
},
};

4689
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,15 +8,22 @@
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json" "version": "node version-bump.mjs && git add manifest.json versions.json"
}, },
"keywords": ["Obsidian", "LLM", "ChatGPT"], "keywords": [
"Obsidian",
"LLM",
"ChatGPT"
],
"author": "Shane Lamb", "author": "Shane Lamb",
"devDependencies": { "devDependencies": {
"@codemirror/language": "^6.10.8",
"@types/node": "18.15.0", "@types/node": "18.15.0",
"@typescript-eslint/eslint-plugin": "5.29.0", "@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0", "@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0", "builtin-modules": "3.3.0",
"esbuild": "0.17.3", "esbuild": "0.17.3",
"jest": "^29.7.0",
"obsidian": "latest", "obsidian": "latest",
"ts-jest": "^29.2.5",
"tslib": "2.4.0", "tslib": "2.4.0",
"typescript": "4.7.4" "typescript": "4.7.4"
} }

16
src/llm-doc-util.test.ts Normal file
View file

@ -0,0 +1,16 @@
import { describe, expect, it } from '@jest/globals'
import { messagesToText, textToMessages } from './llm-doc-util'
describe('text to messages (and back to text)', () => {
it('should work with all message types', () => {
const originalText = `# system\nmessage 1\n# user\nmessage 2\n# assistant\nmessage 3`
const messages = textToMessages(originalText)
expect(messages).toEqual([
{ role: 'system', content: 'message 1' },
{ role: 'user', content: 'message 2' },
{ role: 'assistant', content: 'message 3' },
])
const text = messagesToText(messages)
expect(text).toEqual(originalText)
})
})

39
src/llm-doc-util.ts Normal file
View file

@ -0,0 +1,39 @@
import { OpenaiMessage } from './open-ai'
export function textToMessages(text: string): OpenaiMessage[] {
const lines = text.split('\n')
let currentRole: 'system' | 'user' | 'assistant' | null = null
let currentLines: string[] = []
const messages: OpenaiMessage[] = []
for (const line of lines) {
let newRole: 'system' | 'user' | 'assistant' | null = null
if (line === '# system') {
newRole = 'system'
}
if (line === '# user') {
newRole = 'user'
}
if (line === '# assistant') {
newRole = 'assistant'
}
if (newRole) {
if (currentRole) {
messages.push({ role: currentRole, content: currentLines.join('\n') })
}
currentLines = []
currentRole = newRole
} else {
currentLines.push(line)
}
}
if (currentRole) {
messages.push({ role: currentRole, content: currentLines.join('\n') })
}
return messages
}
export function messagesToText(messages: OpenaiMessage[]): string {
const segments = messages.map((message) => `# ${message.role}\n${message.content}`)
return segments.join('\n')
}

96
src/llm-doc.ts Normal file
View file

@ -0,0 +1,96 @@
import { App, getFrontMatterInfo, TFile } from 'obsidian'
import { OpenaiChatCompletionStream, OpenaiDefaultModel, OpenaiMessage } from './open-ai'
import { messagesToText, textToMessages } from './llm-doc-util'
import { OpenaiSettings } from './settings'
export interface LlmDocProperties {
model: string
}
export class LlmDoc {
private constructor(
private app: App,
public file: TFile,
private messages: OpenaiMessage[],
private properties: LlmDocProperties,
) {
}
static async fromFile(app: App, file: TFile): Promise<LlmDoc> {
let properties = {} as LlmDocProperties
await app.fileManager.processFrontMatter(file, (frontmatter) => {
const defaults: LlmDocProperties = {
model: OpenaiDefaultModel,
}
properties = { ...defaults, ...frontmatter }
})
const text = await app.vault.read(file)
const {contentStart} = getFrontMatterInfo(text)
const withoutFrontmatter = text.slice(contentStart)
const messages = textToMessages(withoutFrontmatter)
return new LlmDoc(app, file, messages, properties)
}
static async create(app: App, path: string, properties: LlmDocProperties, messages: OpenaiMessage[]): Promise<LlmDoc> {
const text = messagesToText(messages)
const file = await app.vault.create(path, text)
await app.fileManager.processFrontMatter(file, (frontmatter) => {
frontmatter.model = properties.model
})
return new LlmDoc(app, file, messages, properties)
}
async write() {
const text = messagesToText(this.messages)
await this.app.vault.modify(this.file, text)
await this.app.fileManager.processFrontMatter(this.file, (frontmatter) => {
frontmatter.model = this.properties.model
})
}
async complete(openai: OpenaiSettings) {
if (this.messages.length === 0) {
return // todo
}
let lastMessage = this.messages[this.messages.length - 1]
lastMessage.content = lastMessage.content.trimEnd()
if (lastMessage.role !== 'assistant') {
this.messages.push({role: 'assistant', content: ''})
}
await this.write()
const stream = new OpenaiChatCompletionStream({
apiKey: openai.apiKey,
messages: this.messages,
})
await new Promise<void>((resolve, reject) => {
const onData = (data: string) => {
this.app.vault.append(this.file, data)
}
stream.on('data', onData)
const onError = (error: any) => {
}
stream.on('error', (error) => {
reject(error)
})
stream.on('end', (data) => {
stream.off('data', onData)
stream.off('end', onData)
stream.off('error', onError)
resolve()
})
stream.start()
})
lastMessage = this.messages[this.messages.length - 1]
lastMessage.content += stream.entireContent
this.messages.push({role: 'user', content: ''})
await this.write()
}
}

View file

@ -1,67 +1,123 @@
import { App, Editor, MarkdownView, Modal, Plugin, PluginSettingTab, Setting, request } from 'obsidian' import { App, Editor, MarkdownView, Plugin, PluginSettingTab, Setting, Notice, normalizePath } from 'obsidian'
import { OpenaiDefaultModel } from './open-ai'
import { LlmDoc } from './llm-doc'
interface Settings { import {
docsDir: string ViewUpdate,
PluginValue,
EditorView,
ViewPlugin,
DecorationSet, Decoration, PluginSpec,
} from '@codemirror/view'
import { RangeSetBuilder } from '@codemirror/state'
import { defaultPluginSettings, PluginSettings } from './settings'
class EmojiListPlugin implements PluginValue {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view)
console.log('constructed')
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view)
}
}
buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>()
let offset = 0
for (const text of view.state.doc.text) {
if (text.startsWith('# ')) {
if (text === '# system') {
builder.add(
offset,
offset + text.length,
Decoration.mark({
class: 'llmdocs-heading-system'
})
)
} else if (text === '# user') {
builder.add(
offset,
offset + text.length,
Decoration.mark({
class: 'llmdocs-heading-user'
})
)
} else if (text === '# assistant') {
builder.add(
offset,
offset + text.length,
Decoration.mark({
class: 'llmdocs-heading-assistant'
})
)
}
}
offset += text.length + 1
}
return builder.finish()
}
destroy() {
}
} }
const DEFAULT_SETTINGS: Settings = { const pluginSpec: PluginSpec<EmojiListPlugin> = {
docsDir: 'llm' decorations: (value: EmojiListPlugin) => value.decorations,
} }
export const emojiListPlugin = ViewPlugin.fromClass(
EmojiListPlugin,
pluginSpec
)
export default class LlmDocsPlugin extends Plugin { export default class LlmDocsPlugin extends Plugin {
settings: Settings settings: PluginSettings
async onload() { async onload() {
await this.loadSettings() await this.loadSettings()
// This adds a simple command that can be triggered anywhere
this.addCommand({ this.addCommand({
id: 'open-sample-modal-simple', id: 'create',
name: 'Open sample modal (simple)', name: 'Create new LLM document',
callback: () => { callback: async () => {
new SampleModal(this.app).open() // todo: handle directory not existing (not yet created)
} // todo: handle directory being empty
}) const dir = normalizePath(this.settings.docsDir)
// This adds an editor command that can perform some operation on the current editor instance const doc = await LlmDoc.create(
this.addCommand({ this.app,
id: 'sample-editor-command', `${dir}/new.md`,
name: 'Sample editor command', {model: OpenaiDefaultModel},
editorCallback: (editor: Editor, view: MarkdownView) => { [
console.log(editor.getSelection()) {role: 'system', content: 'system prompt here'},
editor.replaceSelection('Sample Editor Command') {role: 'user', content: ''}
} ]
}) )
// This adds a complex command that can check whether the current state of the app allows execution of the command const leaf = this.app.workspace.getLeaf(false) // false = open in the current tab
this.addCommand({ await leaf.openFile(doc.file)
id: 'open-sample-modal-complex',
name: 'Open sample modal (complex)',
checkCallback: (checking: boolean) => {
// Conditions to check
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView)
if (markdownView) {
// If checking is true, we're simply "checking" if the command can be run.
// If checking is false, then we want to actually perform the operation.
if (!checking) {
new SampleModal(this.app).open()
}
// This command will only show up in Command Palette when the check function returns true
return true
}
} }
}) })
// This adds a settings tab so the user can configure various aspects of the plugin this.addCommand({
this.addSettingTab(new SettingTab(this.app, this)) id: 'complete',
name: 'Complete LLM document',
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin) editorCallback: async (editor: Editor, view: MarkdownView) => {
// Using this function will automatically remove the event listener when this plugin is disabled. const doc = await LlmDoc.fromFile(this.app, view.file!)
this.registerDomEvent(document, 'click', (evt: MouseEvent) => { // todo handle situation where openai key empty or not valid
console.log('click', evt) await doc.complete(this.settings.openai)
editor.setCursor({line: editor.lastLine(), ch: 0})
}
}) })
// When registering intervals, this function will automatically clear the interval when the plugin is disabled. this.addSettingTab(new SettingsTab(this.app, this))
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000))
this.registerEditorExtension(emojiListPlugin)
} }
onunload() { onunload() {
@ -69,7 +125,7 @@ export default class LlmDocsPlugin extends Plugin {
} }
async loadSettings() { async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()) this.settings = Object.assign({}, defaultPluginSettings, await this.loadData())
} }
async saveSettings() { async saveSettings() {
@ -77,23 +133,7 @@ export default class LlmDocsPlugin extends Plugin {
} }
} }
class SampleModal extends Modal { class SettingsTab extends PluginSettingTab {
constructor(app: App) {
super(app)
}
onOpen() {
const {contentEl} = this
contentEl.setText('Woah!')
}
onClose() {
const {contentEl} = this
contentEl.empty()
}
}
class SettingTab extends PluginSettingTab {
plugin: LlmDocsPlugin plugin: LlmDocsPlugin
constructor(app: App, plugin: LlmDocsPlugin) { constructor(app: App, plugin: LlmDocsPlugin) {
@ -116,5 +156,27 @@ class SettingTab extends PluginSettingTab {
this.plugin.settings.docsDir = value this.plugin.settings.docsDir = value
await this.plugin.saveSettings() await this.plugin.saveSettings()
})) }))
new Setting(containerEl)
.setName('OpenAI API Key')
.setDesc('Your OpenAI API key for generating LLM responses')
.addText(text => text
.setPlaceholder('API key')
.setValue(this.plugin.settings.openai.apiKey)
.onChange(async (value) => {
this.plugin.settings.openai.apiKey = value
await this.plugin.saveSettings()
}))
new Setting(containerEl)
.setName('OpenAI Hostname')
.setDesc('Change this if you want to use an OpenAI proxy or other OpenAI compatible API, rather than the official public API')
.addText(text => text
.setPlaceholder('api.openai.com')
.setValue(this.plugin.settings.openai.host)
.onChange(async (value) => {
this.plugin.settings.openai.host = value
await this.plugin.saveSettings()
}))
} }
} }

100
src/open-ai.ts Normal file
View file

@ -0,0 +1,100 @@
import { request } from 'node:https'
import { EventEmitter } from 'node:events'
export const OpenaiDefaultModel = 'gpt-4o-mini'
export interface OpenaiMessage {
role: 'user' | 'assistant' | 'system'
content: string
}
export class OpenaiChatCompletionStream extends EventEmitter {
entireContent = ''
private currentRequest: ReturnType<typeof request>
private stopped = false
private readonly apiKey: string
private readonly messages: OpenaiMessage[]
private readonly model: string
constructor(settings: { apiKey: string, messages: OpenaiMessage[], model?: string }) {
super()
this.apiKey = settings.apiKey
this.messages = settings.messages
this.model = settings.model ?? OpenaiDefaultModel
}
start() {
const data = JSON.stringify({
model: this.model,
messages: this.messages,
stream: true,
})
const options = {
hostname: 'api.openai.com',
port: 443,
path: '/v1/chat/completions',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
}
const req = this.currentRequest = request(options, (res) => {
res.on('data', (chunk) => {
if (this.stopped) return
try {
const lines = chunk.toString().split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonData = line.slice(6)
if (jsonData === '[DONE]') {
this.emit('done')
} else {
try {
const parsed = JSON.parse(jsonData)
const content = parsed.choices[0]?.delta?.content
if (content) {
console.log(content)
this.entireContent += content
this.emit('data', content)
}
} catch (error) {
console.log(jsonData)
}
}
}
}
}
catch (error) {
this.emit('error', error)
this.stop()
}
})
res.on('end', () => {
console.log('end') // todo: remove
this.emit('end')
})
})
req.on('error', (error) => {
console.log('error', error) // todo: remove
this.emit('error', error)
})
req.write(data)
req.end()
}
stop() {
if (this.currentRequest && !this.stopped) {
this.stopped = true
this.currentRequest.destroy()
}
}
}

32
src/settings.ts Normal file
View file

@ -0,0 +1,32 @@
export interface PluginSettings {
docsDir: string
openai: OpenaiSettings
defaults: DefaultsSettings
}
export interface OpenaiSettings {
apiKey: string
host: string
}
export interface DefaultsSettings {
model: OpenaiModel
systemPrompt: string // todo: implement
}
export enum OpenaiModel {
gpt4o = 'gpt-4o',
gpt4oMini = 'gpt-4o-mini',
}
export const defaultPluginSettings: PluginSettings = {
docsDir: 'LLM',
openai: {
apiKey: '',
host: 'api.openai.com',
},
defaults: {
model: OpenaiModel.gpt4oMini,
systemPrompt: ''
}
}

9
src/utils.ts Normal file
View file

@ -0,0 +1,9 @@
import { App } from 'obsidian'
export async function getLinkText(app: App, link: string, sourcePath = ''): Promise<string | null> {
const file = app.metadataCache.getFirstLinkpathDest(link, sourcePath)
if (!file) {
return null
}
return app.vault.read(file)
}

View file

@ -6,3 +6,14 @@ available in the app when your plugin is enabled.
If your plugin does not need CSS, delete this file. If your plugin does not need CSS, delete this file.
*/ */
.llmdocs-heading-system {
color: cadetblue;
}
.llmdocs-heading-user {
color: green;
}
.llmdocs-heading-assistant {
color: darkorange;
}