mirror of
https://github.com/taichimaeda/markpilot.git
synced 2026-07-22 10:00:25 +00:00
Re-implement API logic using OpenAI API
This commit is contained in:
parent
ce759f3afa
commit
bfdd5cf044
26 changed files with 3422 additions and 3059 deletions
|
|
@ -1,23 +1,25 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"env": { "node": true },
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off"
|
||||
}
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"env": { "node": true },
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"no-constant-condition": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off"
|
||||
}
|
||||
}
|
||||
24
.gitignore
vendored
24
.gitignore
vendored
|
|
@ -1,10 +1,22 @@
|
|||
# Pnpm
|
||||
node_modules/
|
||||
# vscode
|
||||
.vscode
|
||||
|
||||
# Intellij
|
||||
*.iml
|
||||
.idea
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Don't include the compiled main.js file in the repo.
|
||||
# They should be uploaded to GitHub releases instead.
|
||||
main.js
|
||||
main.js.map
|
||||
|
||||
# Parcel
|
||||
.parcel-cache/
|
||||
# Exclude sourcemaps
|
||||
*.map
|
||||
|
||||
# Obsidian
|
||||
# obsidian
|
||||
data.json
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
|
|
|
|||
49
esbuild.config.mjs
Normal file
49
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import builtins from "builtin-modules";
|
||||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
|
||||
const banner = `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins,
|
||||
],
|
||||
platform: "node",
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
{
|
||||
"id": "GitHub Copilot (Unofficial)",
|
||||
"name": "GitHub Copilot (Unofficial)",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "GitHub Copilot is an AI pair programmer that helps you write code faster. This plugin adds support for Copilot in Obsidian.",
|
||||
"author": "Taichi Maeda",
|
||||
"isDesktopOnly": true
|
||||
"id": "obsidian-copilot",
|
||||
"name": "Obsidian Copilot",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "",
|
||||
"author": "Taichi Maeda",
|
||||
"authorUrl": "https://obsidian.md",
|
||||
"fundingUrl": "https://obsidian.md/pricing",
|
||||
"isDesktopOnly": true
|
||||
}
|
||||
|
|
|
|||
35
package.json
35
package.json
|
|
@ -1,32 +1,45 @@
|
|||
{
|
||||
"name": "obsidian-copilot",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "main.js",
|
||||
"source": "src/main.ts",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier src --check",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"watch": "parcel watch --dist-dir .",
|
||||
"build": "parcel build --dist-dir .",
|
||||
"test": "echo \"Error: no test specified\"",
|
||||
"version": "node scripts/version.js"
|
||||
"test": "echo \"Error: no test specified\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.30",
|
||||
"@types/react": "^18.2.73",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"eslint": "^8.57.0",
|
||||
"parcel": "^2.12.0",
|
||||
"prettier": "^3.2.5",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"obsidian": "latest",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.10.1",
|
||||
"@codemirror/state": "^6.4.1",
|
||||
"@codemirror/view": "^6.26.0"
|
||||
"@codemirror/view": "^6.26.0",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"lucide-react": "^0.363.0",
|
||||
"obsidian": "latest",
|
||||
"openai": "^4.30.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"rehype-katex": "^7.0.0",
|
||||
"remark-math": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2806
pnpm-lock.yaml
2806
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 4,
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 4,
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
let manifest = JSON.parse(readFileSync("manifest.json"));
|
||||
let versions = JSON.parse(readFileSync("versions.json"));
|
||||
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest));
|
||||
|
||||
versions[targetVersion] = manifest.minAppVersion;
|
||||
writeFileSync("versions.json", JSON.stringify(versions));
|
||||
73
src/api/client.ts
Normal file
73
src/api/client.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import OpenAI from "openai";
|
||||
import ObsidianCopilot from "src/main";
|
||||
|
||||
export type Role = "system" | "assistant" | "user";
|
||||
|
||||
export interface Message {
|
||||
role: Role;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export class OpenAIClient {
|
||||
private openai: OpenAI;
|
||||
|
||||
constructor(private plugin: ObsidianCopilot) {
|
||||
const apiKey = this.plugin.settings.apiKey ?? "";
|
||||
this.openai = new OpenAI({ apiKey, dangerouslyAllowBrowser: true });
|
||||
}
|
||||
|
||||
protected get client() {
|
||||
const apiKey = this.plugin.settings.apiKey;
|
||||
if (apiKey === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
this.openai.apiKey = apiKey;
|
||||
return this.openai;
|
||||
}
|
||||
|
||||
async *fetchChat(messages: Message[]) {
|
||||
if (this.client === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
messages: [
|
||||
// {
|
||||
// role: "system",
|
||||
// content: defaultPrompt,
|
||||
// },
|
||||
...messages,
|
||||
],
|
||||
model: "gpt-3.5-turbo",
|
||||
stream: true,
|
||||
max_tokens: 4096,
|
||||
temperature: 0.1,
|
||||
top_p: 1,
|
||||
n: 1,
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const content = chunk.choices[0].delta.content ?? "";
|
||||
yield content;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchCompletions(language: string, prefix: string, suffix: string) {
|
||||
if (this.client === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const completions = await this.client.completions.create({
|
||||
prompt: `Continue the following ${language} code:\n\n${prefix}`,
|
||||
suffix,
|
||||
model: "gpt-3.5-turbo-instruct",
|
||||
max_tokens: 100,
|
||||
temperature: 0,
|
||||
top_p: 1,
|
||||
n: 1,
|
||||
stop: ["\n\n\n"],
|
||||
});
|
||||
|
||||
return completions.choices[0].text;
|
||||
}
|
||||
}
|
||||
94
src/chat/App.tsx
Normal file
94
src/chat/App.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { css } from "@emotion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Message, Role } from "src/api/client";
|
||||
import ObsidianCopilot from "src/main";
|
||||
import { ChatInput } from "./components/ChatBox";
|
||||
import { ChatItem } from "./components/ChatItem";
|
||||
|
||||
const systemPrompt = `
|
||||
Welcome, I'm your Copilot and I'm here to help you get things done faster. You can also start an inline chat session.
|
||||
|
||||
I'm powered by AI, so surprises and mistakes are possible. Make sure to verify any generated code or suggestions, and share feedback so that we can learn and improve. Check out the Copilot documentation to learn more.
|
||||
`;
|
||||
|
||||
interface History {
|
||||
messages: Message[];
|
||||
response: string;
|
||||
}
|
||||
|
||||
export function App({ plugin }: { plugin: ObsidianCopilot }) {
|
||||
const [turn, setTurn] = useState<Role>("user");
|
||||
const [history, setHistory] = useState<History>({
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
},
|
||||
],
|
||||
response: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (turn === "assistant") {
|
||||
(async () => {
|
||||
for await (const chunk of plugin.client.fetchChat(history.messages)) {
|
||||
setHistory((history) => ({
|
||||
...history,
|
||||
response: history.response + chunk,
|
||||
}));
|
||||
}
|
||||
setHistory((history) => ({
|
||||
messages: [
|
||||
...history.messages,
|
||||
{ role: "assistant", content: history.response },
|
||||
],
|
||||
response: "",
|
||||
}));
|
||||
setTurn("user");
|
||||
})();
|
||||
}
|
||||
}, [turn]);
|
||||
|
||||
function submit(content: string) {
|
||||
setHistory({
|
||||
...history,
|
||||
messages: [...history.messages, { role: "user", content }],
|
||||
});
|
||||
setTurn("assistant");
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
`}
|
||||
>
|
||||
<div
|
||||
css={css`
|
||||
margin: 15px 0;
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
`}
|
||||
>
|
||||
{history.messages.map((message, index) => (
|
||||
<ChatItem key={index} message={message} />
|
||||
))}
|
||||
{turn === "assistant" && (
|
||||
<ChatItem
|
||||
message={{ role: "assistant", content: history.response }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
css={css`
|
||||
margin: 15px;
|
||||
margin-top: 0;
|
||||
`}
|
||||
>
|
||||
<ChatInput disabled={turn === "assistant"} submit={submit} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/chat/components/ChatBox.tsx
Normal file
77
src/chat/components/ChatBox.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { css } from "@emotion/react";
|
||||
import { SendHorizontal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function ChatInput({
|
||||
disabled,
|
||||
submit,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
submit: (text: string) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const numLines = value.split("\n").length;
|
||||
const numRows = Math.min(10, numLines);
|
||||
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`}
|
||||
>
|
||||
<textarea
|
||||
disabled={disabled}
|
||||
placeholder="Type a message..."
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
// Press Enter to submit, Shift+Enter for newline
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
setValue("");
|
||||
submit(value);
|
||||
}
|
||||
}}
|
||||
css={css`
|
||||
width: 100%;
|
||||
height: ${numRows + 1.5}rem;
|
||||
resize: none;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
`}
|
||||
/>
|
||||
<div
|
||||
css={css`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
height: ${numRows + 1.5}rem;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`}
|
||||
>
|
||||
<button
|
||||
css={css`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-shadow: none !important;
|
||||
background-color: rgba(255, 255, 255, 0) !important;
|
||||
&:hover {
|
||||
box-shadow: none !important;
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
border-radius: 50%;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<SendHorizontal size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
src/chat/components/ChatItem.tsx
Normal file
109
src/chat/components/ChatItem.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { css } from "@emotion/react";
|
||||
import { Bot, Copy, User } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import remarkMath from "remark-math";
|
||||
import { Message } from "src/api/client";
|
||||
|
||||
export function ChatItem({ message }: { message: Message }) {
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
padding: 15px 20px;
|
||||
background-color: rgba(
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
${message.role === "user" ? 0.05 : 0}
|
||||
);
|
||||
border-bottom: 1px solid gray;
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<ChatItemHeader message={message} />
|
||||
<ChatItemBody message={message} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatItemHeader({ message }: { message: Message }) {
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
`}
|
||||
>
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`}
|
||||
>
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid gray;
|
||||
border-radius: 50%;
|
||||
`}
|
||||
>
|
||||
{message.role === "user" ? <User size={16} /> : <Bot size={16} />}
|
||||
</div>
|
||||
<span
|
||||
css={css`
|
||||
margin-left: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
`}
|
||||
>
|
||||
{message.role === "user" ? "You" : "Obsidian Copilot"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 1px solid gray;
|
||||
border-radius: 5px;
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
&:active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
`}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(message.content);
|
||||
}}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatItemBody({ message }: { message: Message }) {
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
font-size: 13px;
|
||||
`}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/chat/view.tsx
Normal file
48
src/chat/view.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { ItemView, WorkspaceLeaf } from "obsidian";
|
||||
import * as React from "react";
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
import ObsidianCopilot from "src/main";
|
||||
import { App } from "./App";
|
||||
|
||||
export const CHAT_VIEW_TYPE = "obsidian-copilot-chat-view";
|
||||
|
||||
export class ChatView extends ItemView {
|
||||
private root: Root;
|
||||
|
||||
constructor(
|
||||
leaf: WorkspaceLeaf,
|
||||
private plugin: ObsidianCopilot
|
||||
) {
|
||||
super(leaf);
|
||||
}
|
||||
|
||||
getViewType() {
|
||||
return CHAT_VIEW_TYPE;
|
||||
}
|
||||
|
||||
getDisplayText() {
|
||||
return "Obsidian Copilot";
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
// Using icon from Lucide:
|
||||
// https://lucide.dev/icons/bot
|
||||
return "bot";
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
const { containerEl } = this;
|
||||
|
||||
containerEl.empty();
|
||||
this.root = createRoot(containerEl);
|
||||
this.root.render(
|
||||
<React.StrictMode>
|
||||
<App plugin={this.plugin} />
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
this.root.unmount();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +1,26 @@
|
|||
import { EditorState } from "@codemirror/state";
|
||||
import { debounceAsyncFunc } from "../utils";
|
||||
import { dismissCompletionOnEscape, triggerCompletionOnTab } from "./keymap";
|
||||
import { showCompletionOnUpdate } from "./listener";
|
||||
import { completionStateField } from "./state";
|
||||
import { debounceAsyncFunc } from "./utils";
|
||||
import { completionRenderPlugin } from "./view";
|
||||
|
||||
export type CompletionFetcher = (state: EditorState) => Promise<string>;
|
||||
export type CompletionFetcher = (
|
||||
language: string,
|
||||
prefix: string,
|
||||
suffix: string
|
||||
) => Promise<string | undefined>;
|
||||
|
||||
export type CompletionCancel = () => void;
|
||||
export type CompletionForce = () => void;
|
||||
|
||||
export function inlineCompletionExtension() {
|
||||
const fetcher = async (state: EditorState) => {
|
||||
return "hello, world!";
|
||||
};
|
||||
export function inlineCompletionExtension(fetcher: CompletionFetcher) {
|
||||
const { debounced, cancel, force } = debounceAsyncFunc(fetcher, 500);
|
||||
|
||||
return [
|
||||
completionStateField,
|
||||
completionRenderPlugin,
|
||||
showCompletionOnUpdate(debounced),
|
||||
triggerCompletionOnTab(debounced, force),
|
||||
triggerCompletionOnTab(force),
|
||||
dismissCompletionOnEscape(cancel),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,10 @@
|
|||
import { Prec } from "@codemirror/state";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import {
|
||||
CompletionCancel,
|
||||
CompletionFetcher,
|
||||
CompletionForce,
|
||||
} from "./extension";
|
||||
import {
|
||||
completionStateField,
|
||||
setCompletionEffect,
|
||||
unsetCompletionEffect,
|
||||
} from "./state";
|
||||
import { CompletionCancel, CompletionForce } from "./extension";
|
||||
import { completionStateField, unsetCompletionEffect } from "./state";
|
||||
|
||||
export function triggerCompletionOnTab(
|
||||
fetcher: CompletionFetcher,
|
||||
force: CompletionForce
|
||||
) {
|
||||
let latestCompletionId = 0;
|
||||
let latestCompletionTime = 0;
|
||||
export function triggerCompletionOnTab(force: CompletionForce) {
|
||||
let lastCompletionTime = 0;
|
||||
|
||||
function run(view: EditorView) {
|
||||
const { state } = view;
|
||||
|
|
@ -55,26 +43,13 @@ export function triggerCompletionOnTab(
|
|||
});
|
||||
|
||||
// If the completion is triggered within 500ms, force the previous one.
|
||||
const previousCompletionTime = lastCompletionTime;
|
||||
const currentCompletionTime = Date.now();
|
||||
if (currentCompletionTime - latestCompletionTime < 500) {
|
||||
lastCompletionTime = Date.now();
|
||||
if (currentCompletionTime - previousCompletionTime < 500) {
|
||||
force();
|
||||
return true;
|
||||
}
|
||||
latestCompletionTime = Date.now();
|
||||
|
||||
// Re-fetch the next completion in a callback.
|
||||
(async function () {
|
||||
const currentCompletionId = ++latestCompletionId;
|
||||
const completion = await fetcher(state);
|
||||
// If there is a newer completion request, ignore the current one.
|
||||
if (currentCompletionId !== latestCompletionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
effects: [setCompletionEffect.of({ completion })],
|
||||
});
|
||||
})();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
47
src/editor/languages.ts
Normal file
47
src/editor/languages.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Language aliases supported by CodeMirror
|
||||
// https://github.com/codemirror/language-data/blob/main/src/language-data.ts
|
||||
|
||||
export const languagesAliases = {
|
||||
cpp: "c++",
|
||||
cassandra: "cql",
|
||||
xhtml: "html",
|
||||
ecmascript: "javascript",
|
||||
js: "javascript",
|
||||
node: "javascript",
|
||||
json5: "json",
|
||||
ts: "typescript",
|
||||
rss: "xml",
|
||||
wsdl: "xml",
|
||||
xsd: "xml",
|
||||
yml: "yaml",
|
||||
asciiarmor: "pgp",
|
||||
csharp: "c#",
|
||||
cs: "c#",
|
||||
coffee: "coffeescript",
|
||||
"coffee-script": "coffeescript",
|
||||
lisp: "common lisp",
|
||||
fsharp: "f#",
|
||||
jsonld: "json-ld",
|
||||
ls: "livescript",
|
||||
"objective-c": "objective-c",
|
||||
objc: "objective-c",
|
||||
"objective-c++": "objective-c++",
|
||||
"objc++": "objective-c++",
|
||||
ini: "properties files",
|
||||
properties: "properties files",
|
||||
rscript: "r",
|
||||
jruby: "ruby",
|
||||
macruby: "ruby",
|
||||
rake: "ruby",
|
||||
rb: "ruby",
|
||||
rbx: "ruby",
|
||||
bash: "shell",
|
||||
sh: "shell",
|
||||
zsh: "shell",
|
||||
sparul: "sparql",
|
||||
excel: "spreadsheet",
|
||||
formula: "spreadsheet",
|
||||
tex: "latex",
|
||||
};
|
||||
|
||||
export type LanguageAlias = keyof typeof languagesAliases;
|
||||
|
|
@ -1,35 +1,58 @@
|
|||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { Notice } from "obsidian";
|
||||
import { CompletionFetcher } from "./extension";
|
||||
import { LanguageAlias, languagesAliases } from "./languages";
|
||||
import { setCompletionEffect, unsetCompletionEffect } from "./state";
|
||||
|
||||
function showCompletion(fetcher: CompletionFetcher) {
|
||||
let lastHead = -1;
|
||||
let latestCompletionId = 0;
|
||||
|
||||
return async (update: ViewUpdate) => {
|
||||
const { state, view } = update;
|
||||
|
||||
// If the document hasn't changed or there is a selection, do nothing.
|
||||
if (
|
||||
!update.docChanged ||
|
||||
state.selection.ranges.length > 1 ||
|
||||
!state.selection.main.empty
|
||||
) {
|
||||
// If the document has not changed and the head has not moved, keep the completion.
|
||||
const previousHead = lastHead;
|
||||
const currentHead = state.selection.main.head;
|
||||
lastHead = currentHead;
|
||||
if (!update.docChanged && currentHead === previousHead) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide the current completion first.
|
||||
view.dispatch({
|
||||
effects: [unsetCompletionEffect.of(null)],
|
||||
});
|
||||
|
||||
// If there are multiple or non-empty selection, skip showing the completion.
|
||||
if (state.selection.ranges.length > 1 || !state.selection.main.empty) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the suffix does not end with a punctuation or space, ignore.
|
||||
const head = state.selection.main.head;
|
||||
const char = state.sliceDoc(head, head + 1);
|
||||
if (char.length == 1 && !char.match(/^[\p{P}\s]/u)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCompletionId = ++latestCompletionId;
|
||||
const completion = await fetcher(state).catch((error) => {
|
||||
console.error("Failed to fetch completion: ", error);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Get the completion context with code blocks taken into account.
|
||||
const { language, prefix, suffix } = getCompletionContext(state);
|
||||
// Fetch completion from the server.
|
||||
const completion = await fetcher(language, prefix, suffix).catch(
|
||||
(error) => {
|
||||
new Notice("Failed to fetch completion: ", error);
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
// if completion has failed, ignore and return.
|
||||
if (completion === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there is a newer completion request, ignore the current one.
|
||||
if (currentCompletionId !== latestCompletionId) {
|
||||
return;
|
||||
|
|
@ -41,5 +64,72 @@ function showCompletion(fetcher: CompletionFetcher) {
|
|||
};
|
||||
}
|
||||
|
||||
// NOTE:
|
||||
// This is a bare-bone implementation
|
||||
// because I was unable to find a parser that outputs an AST
|
||||
// with the information indicating where each node spans.
|
||||
function getCompletionContext(state: EditorState) {
|
||||
const head = state.selection.main.head;
|
||||
const length = state.doc.length;
|
||||
const prefix = state.sliceDoc(0, head);
|
||||
const suffix = state.sliceDoc(head, length);
|
||||
|
||||
const limit = 1000;
|
||||
const context = {
|
||||
language: "markdown",
|
||||
prefix: prefix.slice(prefix.length - limit, prefix.length),
|
||||
suffix: suffix.slice(0, limit),
|
||||
};
|
||||
|
||||
// Pattern for the code block delimiter e.g. ```python or ```
|
||||
let pattern;
|
||||
|
||||
let prefixChars = 0;
|
||||
const prefixLines = prefix.split("\n").reverse();
|
||||
|
||||
for (const [i, line] of prefixLines.entries()) {
|
||||
// Check if the line starts with a code block pattern.
|
||||
const parts = /^(```|````|~~~|~~~~)/.exec(line);
|
||||
if (parts !== null) {
|
||||
pattern = parts[1];
|
||||
|
||||
// Check if the line ends with a language identifier.
|
||||
const language = line.slice(pattern.length).trim();
|
||||
if (language === "") {
|
||||
// Return default context as closing code block pattern is detected.
|
||||
return context;
|
||||
} else {
|
||||
// Otherwise update the context with the language and prefix.
|
||||
context.language =
|
||||
languagesAliases[language as LanguageAlias] || language.toLowerCase();
|
||||
context.prefix = prefix.slice(
|
||||
prefix.length - prefixChars,
|
||||
prefix.length
|
||||
);
|
||||
break;
|
||||
}
|
||||
} else if (i === prefixLines.length - 1) {
|
||||
// Return default context as no code block pattern is detected.
|
||||
return context;
|
||||
}
|
||||
prefixChars += line.length + 1;
|
||||
}
|
||||
|
||||
let suffixChars = 0;
|
||||
const suffixLines = suffix.split("\n");
|
||||
|
||||
for (const line of suffixLines) {
|
||||
// Check if the line ends with the code block pattern detected above.
|
||||
const parts = new RegExp(`^${pattern}\\s*$`).exec(line);
|
||||
if (parts !== null) {
|
||||
context.suffix = suffix.slice(0, suffixChars);
|
||||
break;
|
||||
}
|
||||
suffixChars += line.length + 1;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export const showCompletionOnUpdate = (fetcher: CompletionFetcher) =>
|
||||
EditorView.updateListener.of(showCompletion(fetcher));
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@ class CompletionWidget extends WidgetType {
|
|||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const span = document.createElement("span");
|
||||
span.style.opacity = "0.5";
|
||||
span.textContent = this.completion;
|
||||
return span;
|
||||
const spanEl = document.createElement("span");
|
||||
spanEl.style.opacity = "0.5";
|
||||
spanEl.textContent = this.completion;
|
||||
return spanEl;
|
||||
}
|
||||
|
||||
get lineBreaks() {
|
||||
|
|
@ -30,8 +30,6 @@ class CompletionWidget extends WidgetType {
|
|||
class CompletionRenderPluginValue implements PluginValue {
|
||||
public decorations: DecorationSet = Decoration.none;
|
||||
|
||||
constructor(view: EditorView) {}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
const { state } = update;
|
||||
|
||||
|
|
|
|||
174
src/main.ts
174
src/main.ts
|
|
@ -1,102 +1,47 @@
|
|||
import {
|
||||
App,
|
||||
Editor,
|
||||
MarkdownView,
|
||||
Modal,
|
||||
Notice,
|
||||
Plugin,
|
||||
PluginSettingTab,
|
||||
Setting,
|
||||
} from "obsidian";
|
||||
import { Plugin } from "obsidian";
|
||||
import { OpenAIClient } from "./api/client";
|
||||
import { CHAT_VIEW_TYPE, ChatView } from "./chat/view";
|
||||
import { inlineCompletionExtension } from "./editor/extension";
|
||||
import { ObsidianCopilotSettingTab } from "./settings";
|
||||
|
||||
// Remember to rename these classes and interfaces!
|
||||
|
||||
interface MyPluginSettings {
|
||||
mySetting: string;
|
||||
interface ObsidianCopilotSettings {
|
||||
apiKey: string | undefined;
|
||||
enableCompletion: boolean;
|
||||
enableChat: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: MyPluginSettings = {
|
||||
mySetting: "default",
|
||||
const DEFAULT_SETTINGS: ObsidianCopilotSettings = {
|
||||
apiKey: undefined,
|
||||
enableCompletion: true,
|
||||
enableChat: true,
|
||||
};
|
||||
|
||||
export default class MyPlugin extends Plugin {
|
||||
settings: MyPluginSettings;
|
||||
export default class ObsidianCopilot extends Plugin {
|
||||
settings: ObsidianCopilotSettings;
|
||||
client: OpenAIClient;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new ObsidianCopilotSettingTab(this.app, this));
|
||||
|
||||
// This creates an icon in the left ribbon.
|
||||
const ribbonIconEl = this.addRibbonIcon(
|
||||
"dice",
|
||||
"Sample Plugin",
|
||||
(evt: MouseEvent) => {
|
||||
// Called when the user clicks the icon.
|
||||
new Notice("This is a notice!");
|
||||
},
|
||||
);
|
||||
// Perform additional things with the ribbon
|
||||
ribbonIconEl.addClass("my-plugin-ribbon-class");
|
||||
this.client = new OpenAIClient(this);
|
||||
|
||||
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
|
||||
const statusBarItemEl = this.addStatusBarItem();
|
||||
statusBarItemEl.setText("Status Bar Text");
|
||||
|
||||
// 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",
|
||||
// @ts-ignore
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// This adds a settings tab so the user can configure various aspects of the plugin
|
||||
this.addSettingTab(new SampleSettingTab(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);
|
||||
});
|
||||
|
||||
// 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),
|
||||
);
|
||||
const fetcher = async (
|
||||
language: string,
|
||||
prefix: string,
|
||||
suffix: string
|
||||
) => {
|
||||
if (this.settings.enableCompletion) {
|
||||
return this.client.fetchCompletions(language, prefix, suffix);
|
||||
}
|
||||
};
|
||||
this.registerEditorExtension(inlineCompletionExtension(fetcher));
|
||||
this.registerView(CHAT_VIEW_TYPE, (leaf) => new ChatView(leaf, this));
|
||||
if (this.settings.enableChat) {
|
||||
this.activateView();
|
||||
}
|
||||
}
|
||||
|
||||
onunload() {}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
|
@ -104,48 +49,25 @@ export default class MyPlugin extends Plugin {
|
|||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
|
||||
class SampleModal extends Modal {
|
||||
constructor(app: App) {
|
||||
super(app);
|
||||
async activateView() {
|
||||
const { workspace } = this.app;
|
||||
|
||||
const leaves = workspace.getLeavesOfType(CHAT_VIEW_TYPE);
|
||||
if (leaves.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newLeaf = workspace.getRightLeaf(false);
|
||||
await newLeaf?.setViewState({ type: CHAT_VIEW_TYPE, active: true });
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.setText("Woah!");
|
||||
}
|
||||
async deactivateView() {
|
||||
const { workspace } = this.app;
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
||||
class SampleSettingTab extends PluginSettingTab {
|
||||
plugin: MyPlugin;
|
||||
|
||||
constructor(app: App, plugin: MyPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
|
||||
containerEl.empty();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Setting #1")
|
||||
.setDesc("It's a secret")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("Enter your secret")
|
||||
.setValue(this.plugin.settings.mySetting)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.mySetting = value;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
const leaves = workspace.getLeavesOfType(CHAT_VIEW_TYPE);
|
||||
for (const leaf of leaves) {
|
||||
leaf.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
58
src/settings.ts
Normal file
58
src/settings.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||
|
||||
import ObsidianCopilot from "./main";
|
||||
|
||||
export class ObsidianCopilotSettingTab extends PluginSettingTab {
|
||||
constructor(
|
||||
app: App,
|
||||
private plugin: ObsidianCopilot
|
||||
) {
|
||||
super(app, plugin);
|
||||
}
|
||||
|
||||
async display() {
|
||||
const { containerEl } = this;
|
||||
|
||||
containerEl.empty();
|
||||
containerEl.createEl("h2", { text: "Obsidian Copilot" });
|
||||
|
||||
const { plugin } = this;
|
||||
const { settings } = plugin;
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("OpenAI API Key")
|
||||
.setDesc("Enter your OpenAI API key to enable GPT.")
|
||||
.addText((text) =>
|
||||
text.setValue(settings.apiKey ?? "").onChange(async (value) => {
|
||||
settings.apiKey = value;
|
||||
await plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Enable inline completion")
|
||||
.setDesc("Turn this on to enable inline completion with GPT.")
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(settings.enableCompletion).onChange(async (value) => {
|
||||
settings.enableCompletion = value;
|
||||
await plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Enable chat")
|
||||
.setDesc("Turn this on to enable chat features with GPT.")
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(settings.enableChat).onChange(async (value) => {
|
||||
settings.enableChat = value;
|
||||
await plugin.saveSettings();
|
||||
|
||||
if (value) {
|
||||
plugin.activateView();
|
||||
} else {
|
||||
plugin.deactivateView();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,12 @@
|
|||
// Sleep for `duration` milliseconds.
|
||||
export function sleep(duration: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, duration));
|
||||
}
|
||||
|
||||
export function uuid(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Debounce an async function by waiting for `wait` milliseconds before resolving.
|
||||
// If a new request is made before the timeout, the previous request is cancelled.
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
|
@ -5,7 +14,7 @@ export function debounceAsyncFunc<T>(
|
|||
func: (...args: any[]) => Promise<T>,
|
||||
wait: number
|
||||
): {
|
||||
debounced: (...args: any[]) => Promise<T>;
|
||||
debounced: (...args: any[]) => Promise<T | undefined>;
|
||||
cancel: () => void;
|
||||
force: () => void;
|
||||
} {
|
||||
|
|
@ -16,13 +25,13 @@ export function debounceAsyncFunc<T>(
|
|||
force: () => {},
|
||||
};
|
||||
|
||||
function debounced(...args: any[]): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
function debounced(...args: any[]): Promise<T | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
previous.cancel();
|
||||
const timer = setTimeout(() => func(...args).then(resolve), wait);
|
||||
previous.cancel = () => {
|
||||
clearTimeout(timer);
|
||||
reject({ reason: "Cancelled" });
|
||||
resolve(undefined);
|
||||
};
|
||||
previous.force = () => {
|
||||
clearTimeout(timer);
|
||||
8
styles.css
Normal file
8
styles.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/* Hide Unicode math symbols displayed by Rehype's KaTex plugin */
|
||||
.katex-html {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.katex-html {
|
||||
|
||||
}
|
||||
|
|
@ -8,16 +8,20 @@
|
|||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"strictNullChecks": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@emotion/react"
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
}
|
||||
|
|
|
|||
14
version-bump.mjs
Normal file
14
version-bump.mjs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"0.1.0": "0.15.0"
|
||||
"0.1.0": "0.15.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue