mirror of
https://github.com/shane-lamb/obsidian-llm-docs.git
synced 2026-07-22 05:44:55 +00:00
whole bunch of updates
This commit is contained in:
parent
a57d74cf89
commit
9c712156b8
12 changed files with 3598 additions and 1605 deletions
|
|
@ -17,6 +17,7 @@ const context = await esbuild.context({
|
|||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
|
|
|
|||
7
jest.config.js
Normal file
7
jest.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/** @type {import('ts-jest').JestConfigWithTsJest} **/
|
||||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+.tsx?$": ["ts-jest",{}],
|
||||
},
|
||||
};
|
||||
4689
package-lock.json
generated
4689
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,15 +8,22 @@
|
|||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": ["Obsidian", "LLM", "ChatGPT"],
|
||||
"keywords": [
|
||||
"Obsidian",
|
||||
"LLM",
|
||||
"ChatGPT"
|
||||
],
|
||||
"author": "Shane Lamb",
|
||||
"devDependencies": {
|
||||
"@codemirror/language": "^6.10.8",
|
||||
"@types/node": "18.15.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"jest": "^29.7.0",
|
||||
"obsidian": "latest",
|
||||
"ts-jest": "^29.2.5",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
|
|
|
|||
16
src/llm-doc-util.test.ts
Normal file
16
src/llm-doc-util.test.ts
Normal 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
39
src/llm-doc-util.ts
Normal 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
96
src/llm-doc.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
194
src/main.ts
194
src/main.ts
|
|
@ -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 {
|
||||
docsDir: string
|
||||
import {
|
||||
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 = {
|
||||
docsDir: 'llm'
|
||||
const pluginSpec: PluginSpec<EmojiListPlugin> = {
|
||||
decorations: (value: EmojiListPlugin) => value.decorations,
|
||||
}
|
||||
|
||||
export const emojiListPlugin = ViewPlugin.fromClass(
|
||||
EmojiListPlugin,
|
||||
pluginSpec
|
||||
)
|
||||
|
||||
export default class LlmDocsPlugin extends Plugin {
|
||||
settings: Settings
|
||||
settings: PluginSettings
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings()
|
||||
|
||||
// This adds a simple command that can be triggered anywhere
|
||||
this.addCommand({
|
||||
id: 'open-sample-modal-simple',
|
||||
name: 'Open sample modal (simple)',
|
||||
callback: () => {
|
||||
new SampleModal(this.app).open()
|
||||
}
|
||||
})
|
||||
// This adds an editor command that can perform some operation on the current editor instance
|
||||
this.addCommand({
|
||||
id: 'sample-editor-command',
|
||||
name: 'Sample editor command',
|
||||
editorCallback: (editor: Editor, view: MarkdownView) => {
|
||||
console.log(editor.getSelection())
|
||||
editor.replaceSelection('Sample Editor Command')
|
||||
}
|
||||
})
|
||||
// This adds a complex command that can check whether the current state of the app allows execution of the command
|
||||
this.addCommand({
|
||||
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
|
||||
}
|
||||
id: 'create',
|
||||
name: 'Create new LLM document',
|
||||
callback: async () => {
|
||||
// todo: handle directory not existing (not yet created)
|
||||
// todo: handle directory being empty
|
||||
const dir = normalizePath(this.settings.docsDir)
|
||||
const doc = await LlmDoc.create(
|
||||
this.app,
|
||||
`${dir}/new.md`,
|
||||
{model: OpenaiDefaultModel},
|
||||
[
|
||||
{role: 'system', content: 'system prompt here'},
|
||||
{role: 'user', content: ''}
|
||||
]
|
||||
)
|
||||
const leaf = this.app.workspace.getLeaf(false) // false = open in the current tab
|
||||
await leaf.openFile(doc.file)
|
||||
}
|
||||
})
|
||||
|
||||
// This adds a settings tab so the user can configure various aspects of the plugin
|
||||
this.addSettingTab(new SettingTab(this.app, this))
|
||||
|
||||
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
|
||||
// Using this function will automatically remove the event listener when this plugin is disabled.
|
||||
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
|
||||
console.log('click', evt)
|
||||
this.addCommand({
|
||||
id: 'complete',
|
||||
name: 'Complete LLM document',
|
||||
editorCallback: async (editor: Editor, view: MarkdownView) => {
|
||||
const doc = await LlmDoc.fromFile(this.app, view.file!)
|
||||
// todo handle situation where openai key empty or not valid
|
||||
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.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000))
|
||||
this.addSettingTab(new SettingsTab(this.app, this))
|
||||
|
||||
this.registerEditorExtension(emojiListPlugin)
|
||||
}
|
||||
|
||||
onunload() {
|
||||
|
|
@ -69,7 +125,7 @@ export default class LlmDocsPlugin extends Plugin {
|
|||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData())
|
||||
this.settings = Object.assign({}, defaultPluginSettings, await this.loadData())
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
|
|
@ -77,23 +133,7 @@ export default class LlmDocsPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
class SampleModal extends Modal {
|
||||
constructor(app: App) {
|
||||
super(app)
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const {contentEl} = this
|
||||
contentEl.setText('Woah!')
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const {contentEl} = this
|
||||
contentEl.empty()
|
||||
}
|
||||
}
|
||||
|
||||
class SettingTab extends PluginSettingTab {
|
||||
class SettingsTab extends PluginSettingTab {
|
||||
plugin: LlmDocsPlugin
|
||||
|
||||
constructor(app: App, plugin: LlmDocsPlugin) {
|
||||
|
|
@ -116,5 +156,27 @@ class SettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.docsDir = value
|
||||
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
100
src/open-ai.ts
Normal 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
32
src/settings.ts
Normal 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
9
src/utils.ts
Normal 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)
|
||||
}
|
||||
11
styles.css
11
styles.css
|
|
@ -6,3 +6,14 @@ available in the app when your plugin is enabled.
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue