This commit is contained in:
duanfuxiang
2025-01-05 11:51:39 +08:00
commit 0c7ee142cb
215 changed files with 20611 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
import { generateRandomString } from "./utils";
const UNIQUE_CURSOR = `${generateRandomString(16)}`;
const HEADER_REGEX = `^#+\\s.*${UNIQUE_CURSOR}.*$`;
const UNORDERED_LIST_REGEX = `^\\s*(-|\\*)\\s.*${UNIQUE_CURSOR}.*$`;
const TASK_LIST_REGEX = `^\\s*(-|[0-9]+\\.) +\\[.\\]\\s.*${UNIQUE_CURSOR}.*$`;
const BLOCK_QUOTES_REGEX = `^\\s*>.*${UNIQUE_CURSOR}.*$`;
const NUMBERED_LIST_REGEX = `^\\s*\\d+\\.\\s.*${UNIQUE_CURSOR}.*$`
const MATH_BLOCK_REGEX = /\$\$[\s\S]*?\$\$/g;
const INLINE_MATH_BLOCK_REGEX = /\$[\s\S]*?\$/g;
const CODE_BLOCK_REGEX = /```[\s\S]*?```/g;
const INLINE_CODE_BLOCK_REGEX = /`.*`/g;
enum Context {
Text = "Text",
Heading = "Heading",
BlockQuotes = "BlockQuotes",
UnorderedList = "UnorderedList",
NumberedList = "NumberedList",
CodeBlock = "CodeBlock",
MathBlock = "MathBlock",
TaskList = "TaskList",
}
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Context {
export function values(): Array<Context> {
return Object.values(Context).filter(
(value) => typeof value === "string"
) as Array<Context>;
}
export function getContext(prefix: string, suffix: string): Context {
if (new RegExp(HEADER_REGEX, "gm").test(prefix + UNIQUE_CURSOR + suffix)) {
return Context.Heading;
}
if (new RegExp(BLOCK_QUOTES_REGEX, "gm").test(prefix + UNIQUE_CURSOR + suffix)) {
return Context.BlockQuotes;
}
if (new RegExp(TASK_LIST_REGEX, "gm").test(prefix + UNIQUE_CURSOR + suffix)) {
return Context.TaskList;
}
if (
isCursorInRegexBlock(prefix, suffix, MATH_BLOCK_REGEX) ||
isCursorInRegexBlock(prefix, suffix, INLINE_MATH_BLOCK_REGEX)
) {
return Context.MathBlock;
}
if (isCursorInRegexBlock(prefix, suffix, CODE_BLOCK_REGEX) || isCursorInRegexBlock(prefix, suffix, INLINE_CODE_BLOCK_REGEX)) {
return Context.CodeBlock;
}
if (new RegExp(NUMBERED_LIST_REGEX, "gm").test(prefix + UNIQUE_CURSOR + suffix)) {
return Context.NumberedList;
}
if (new RegExp(UNORDERED_LIST_REGEX, "gm").test(prefix + UNIQUE_CURSOR + suffix)) {
return Context.UnorderedList;
}
return Context.Text;
}
export function get(value: string) {
for (const context of Context.values()) {
if (value === context) {
return context;
}
}
return undefined;
}
}
function isCursorInRegexBlock(
prefix: string,
suffix: string,
regex: RegExp
): boolean {
const text = prefix + UNIQUE_CURSOR + suffix;
const codeBlocks = extractBlocks(text, regex);
for (const block of codeBlocks) {
if (block.includes(UNIQUE_CURSOR)) {
return true;
}
}
return false;
}
function extractBlocks(text: string, regex: RegExp) {
const codeBlocks = text.match(regex);
return codeBlocks ? codeBlocks.map((block) => block.trim()) : [];
}
export default Context;

View File

@@ -0,0 +1,273 @@
import * as Handlebars from "handlebars";
import { err, ok, Result } from "neverthrow";
import { FewShotExample } from "../../settings/versions";
import { CustomLLMModel } from "../../types/llm/model";
import { RequestMessage } from '../../types/llm/request';
import { InfioSettings } from "../../types/settings";
import LLMManager from '../llm/manager';
import Context from "./context-detection";
import RemoveCodeIndicators from "./post-processors/remove-code-indicators";
import RemoveMathIndicators from "./post-processors/remove-math-indicators";
import RemoveOverlap from "./post-processors/remove-overlap";
import RemoveWhitespace from "./post-processors/remove-whitespace";
import DataViewRemover from "./pre-processors/data-view-remover";
import LengthLimiter from "./pre-processors/length-limiter";
import {
AutocompleteService,
ChatMessage,
PostProcessor,
PreProcessor,
UserMessageFormatter,
UserMessageFormattingInputs
} from "./types";
class LLMClient {
private llm: LLMManager;
private model: CustomLLMModel;
constructor(llm: LLMManager, model: CustomLLMModel) {
this.llm = llm;
this.model = model;
}
async queryChatModel(messages: RequestMessage[]): Promise<Result<string, Error>> {
const data = await this.llm.generateResponse(this.model, {
model: this.model.name,
messages: messages,
stream: false,
})
return ok(data.choices[0].message.content);
}
}
class AutoComplete implements AutocompleteService {
private readonly client: LLMClient;
private readonly systemMessage: string;
private readonly userMessageFormatter: UserMessageFormatter;
private readonly removePreAnswerGenerationRegex: string;
private readonly preProcessors: PreProcessor[];
private readonly postProcessors: PostProcessor[];
private readonly fewShotExamples: FewShotExample[];
private debugMode: boolean;
private constructor(
client: LLMClient,
systemMessage: string,
userMessageFormatter: UserMessageFormatter,
removePreAnswerGenerationRegex: string,
preProcessors: PreProcessor[],
postProcessors: PostProcessor[],
fewShotExamples: FewShotExample[],
debugMode: boolean,
) {
this.client = client;
this.systemMessage = systemMessage;
this.userMessageFormatter = userMessageFormatter;
this.removePreAnswerGenerationRegex = removePreAnswerGenerationRegex;
this.preProcessors = preProcessors;
this.postProcessors = postProcessors;
this.fewShotExamples = fewShotExamples;
this.debugMode = debugMode;
}
public static fromSettings(settings: InfioSettings): AutocompleteService {
const formatter = Handlebars.compile<UserMessageFormattingInputs>(
settings.userMessageTemplate,
{ noEscape: true, strict: true }
);
const preProcessors: PreProcessor[] = [];
if (settings.dontIncludeDataviews) {
preProcessors.push(new DataViewRemover());
}
preProcessors.push(
new LengthLimiter(
settings.maxPrefixCharLimit,
settings.maxSuffixCharLimit
)
);
const postProcessors: PostProcessor[] = [];
if (settings.removeDuplicateMathBlockIndicator) {
postProcessors.push(new RemoveMathIndicators());
}
if (settings.removeDuplicateCodeBlockIndicator) {
postProcessors.push(new RemoveCodeIndicators());
}
postProcessors.push(new RemoveOverlap());
postProcessors.push(new RemoveWhitespace());
const llm_manager = new LLMManager({
deepseek: settings.deepseekApiKey,
openai: settings.openAIApiKey,
anthropic: settings.anthropicApiKey,
gemini: settings.geminiApiKey,
groq: settings.groqApiKey,
infio: settings.infioApiKey,
})
const model: CustomLLMModel = settings.activeModels.find(
(option) => option.name === settings.chatModelId,
)
const llm = new LLMClient(llm_manager, model);
return new AutoComplete(
llm,
settings.systemMessage,
formatter,
settings.chainOfThoughRemovalRegex,
preProcessors,
postProcessors,
settings.fewShotExamples,
settings.debugMode,
);
}
async fetchPredictions(
prefix: string,
suffix: string
): Promise<Result<string, Error>> {
const context: Context = Context.getContext(prefix, suffix);
for (const preProcessor of this.preProcessors) {
if (preProcessor.removesCursor(prefix, suffix)) {
return ok("");
}
({ prefix, suffix } = preProcessor.process(
prefix,
suffix,
context
));
}
const examples = this.fewShotExamples.filter(
(example) => example.context === context
);
const fewShotExamplesChatMessages =
fewShotExamplesToChatMessages(examples);
const messages: RequestMessage[] = [
{
content: this.getSystemMessageFor(context),
role: "system"
},
...fewShotExamplesChatMessages,
{
role: "user",
content: this.userMessageFormatter({
suffix,
prefix,
}),
},
];
if (this.debugMode) {
console.log("Copilot messages send:\n", messages);
}
let result = await this.client.queryChatModel(messages);
if (this.debugMode && result.isOk()) {
console.log("Copilot response:\n", result.value);
}
result = this.extractAnswerFromChainOfThoughts(result);
for (const postProcessor of this.postProcessors) {
result = result.map((r) => postProcessor.process(prefix, suffix, r, context));
}
result = this.checkAgainstGuardRails(result);
return result;
}
private getSystemMessageFor(context: Context): string {
if (context === Context.Text) {
return this.systemMessage + "\n\n" + "The <mask/> is located in a paragraph. Your answer must complete this paragraph or sentence in a way that fits the surrounding text without overlapping with it. It must be in the same language as the paragraph.";
}
if (context === Context.Heading) {
return this.systemMessage + "\n\n" + "The <mask/> is located in the Markdown heading. Your answer must complete this title in a way that fits the content of this paragraph and be in the same language as the paragraph.";
}
if (context === Context.BlockQuotes) {
return this.systemMessage + "\n\n" + "The <mask/> is located within a quote. Your answer must complete this quote in a way that fits the context of the paragraph.";
}
if (context === Context.UnorderedList) {
return this.systemMessage + "\n\n" + "The <mask/> is located in an unordered list. Your answer must include one or more list items that fit with the surrounding list without overlapping with it.";
}
if (context === Context.NumberedList) {
return this.systemMessage + "\n\n" + "The <mask/> is located in a numbered list. Your answer must include one or more list items that fit the sequence and context of the surrounding list without overlapping with it.";
}
if (context === Context.CodeBlock) {
return this.systemMessage + "\n\n" + "The <mask/> is located in a code block. Your answer must complete this code block in the same programming language and support the surrounding code and text outside of the code block.";
}
if (context === Context.MathBlock) {
return this.systemMessage + "\n\n" + "The <mask/> is located in a math block. Your answer must only contain LaTeX code that captures the math discussed in the surrounding text. No text or explaination only LaTex math code.";
}
if (context === Context.TaskList) {
return this.systemMessage + "\n\n" + "The <mask/> is located in a task list. Your answer must include one or more (sub)tasks that are logical given the other tasks and the surrounding text.";
}
return this.systemMessage;
}
private extractAnswerFromChainOfThoughts(
result: Result<string, Error>
): Result<string, Error> {
if (result.isErr()) {
return result;
}
const chainOfThoughts = result.value;
const regex = new RegExp(this.removePreAnswerGenerationRegex, "gm");
const match = regex.exec(chainOfThoughts);
if (match === null) {
return err(new Error("No match found"));
}
return ok(chainOfThoughts.replace(regex, ""));
}
private checkAgainstGuardRails(
result: Result<string, Error>
): Result<string, Error> {
if (result.isErr()) {
return result;
}
if (result.value.length === 0) {
return err(new Error("Empty result"));
}
if (result.value.contains("<mask/>")) {
return err(new Error("Mask in result"));
}
return result;
}
}
function fewShotExamplesToChatMessages(
examples: FewShotExample[]
): ChatMessage[] {
return examples
.map((example): ChatMessage[] => {
return [
{
role: "user",
content: example.input,
},
{
role: "assistant",
content: example.answer,
},
];
})
.flat();
}
export default AutoComplete;

View File

@@ -0,0 +1,22 @@
import Context from "../context-detection";
import { PostProcessor } from "../types";
class RemoveCodeIndicators implements PostProcessor {
process(
prefix: string,
suffix: string,
completion: string,
context: Context
): string {
if (context === Context.CodeBlock) {
completion = completion.replace(/```[a-zA-z]+[ \t]*\n?/g, "");
completion = completion.replace(/\n?```[ \t]*\n?/g, "");
completion = completion.replace(/`/g, "");
}
return completion;
}
}
export default RemoveCodeIndicators;

View File

@@ -0,0 +1,20 @@
import Context from "../context-detection";
import { PostProcessor } from "../types";
class RemoveMathIndicators implements PostProcessor {
process(
prefix: string,
suffix: string,
completion: string,
context: Context
): string {
if (context === Context.MathBlock) {
completion = completion.replace(/\n?\$\$\n?/g, "");
completion = completion.replace(/\$/g, "");
}
return completion;
}
}
export default RemoveMathIndicators;

View File

@@ -0,0 +1,96 @@
import Context from "../context-detection";
import { PostProcessor } from "../types";
class RemoveOverlap implements PostProcessor {
process(
prefix: string,
suffix: string,
completion: string,
context: Context
): string {
completion = removeWordOverlapPrefix(prefix, completion);
completion = removeWordOverlapSuffix(completion, suffix);
completion = removeWhiteSpaceOverlapPrefix(suffix, completion);
completion = removeWhiteSpaceOverlapSuffix(completion, suffix);
return completion;
}
}
function removeWhiteSpaceOverlapPrefix(prefix: string, completion: string): string {
let prefixIdx = prefix.length - 1;
while (completion.length > 0 && completion[0] === prefix[prefixIdx]) {
completion = completion.slice(1);
prefixIdx--;
}
return completion;
}
function removeWhiteSpaceOverlapSuffix(completion: string, suffix: string): string {
let suffixIdx = 0;
while (completion.length > 0 && completion[completion.length - 1] === suffix[suffixIdx]) {
completion = completion.slice(0, -1);
suffixIdx++;
}
return completion;
}
function removeWordOverlapPrefix(prefix: string, completion: string): string {
const rightTrimmed = completion.trimStart();
const startIdxOfEachWord = startLocationOfEachWord(prefix);
while (startIdxOfEachWord.length > 0) {
const idx = startIdxOfEachWord.pop();
const leftSubstring = prefix.slice(idx);
if (rightTrimmed.startsWith(leftSubstring)) {
return rightTrimmed.replace(leftSubstring, "");
}
}
return completion;
}
function removeWordOverlapSuffix(completion: string, suffix: string): string {
const suffixTrimmed = removeLeadingWhiteSpace(suffix);
const startIdxOfEachWord = startLocationOfEachWord(completion);
while (startIdxOfEachWord.length > 0) {
const idx = startIdxOfEachWord.pop();
const suffixSubstring = completion.slice(idx);
if (suffixTrimmed.startsWith(suffixSubstring)) {
return completion.replace(suffixSubstring, "");
}
}
return completion;
}
function removeLeadingWhiteSpace(completion: string): string {
return completion.replace(/^[ \t\f\r\v]+/, "");
}
function startLocationOfEachWord(text: string): number[] {
const locations: number[] = [];
if (text.length > 0 && !isWhiteSpaceChar(text[0])) {
locations.push(0);
}
for (let i = 1; i < text.length; i++) {
if (isWhiteSpaceChar(text[i - 1]) && !isWhiteSpaceChar(text[i])) {
locations.push(i);
}
}
return locations;
}
function isWhiteSpaceChar(char: string | undefined): boolean {
return char !== undefined && char.match(/\s/) !== null;
}
export default RemoveOverlap;

View File

@@ -0,0 +1,24 @@
import Context from "../context-detection";
import { PostProcessor } from "../types";
class RemoveWhitespace implements PostProcessor {
process(
prefix: string,
suffix: string,
completion: string,
context: Context
): string {
if (context === Context.Text || context === Context.Heading || context === Context.MathBlock || context === Context.TaskList || context === Context.NumberedList || context === Context.UnorderedList) {
if (prefix.endsWith(" ") || suffix.endsWith("\n")) {
completion = completion.trimStart();
}
if (suffix.startsWith(" ")) {
completion = completion.trimEnd();
}
}
return completion;
}
}
export default RemoveWhitespace;

View File

@@ -0,0 +1,34 @@
import { generateRandomString } from "../utils";
import Context from "../context-detection";
import { PrefixAndSuffix, PreProcessor } from "../types";
const DATA_VIEW_REGEX = /```dataview(js){0,1}(.|\n)*?```/gm;
const UNIQUE_CURSOR = `${generateRandomString(16)}`;
class DataViewRemover implements PreProcessor {
process(prefix: string, suffix: string, context: Context): PrefixAndSuffix {
let text = prefix + UNIQUE_CURSOR + suffix;
text = text.replace(DATA_VIEW_REGEX, "");
const [prefixNew, suffixNew] = text.split(UNIQUE_CURSOR);
return { prefix: prefixNew, suffix: suffixNew };
}
removesCursor(prefix: string, suffix: string): boolean {
const text = prefix + UNIQUE_CURSOR + suffix;
const dataviewAreasWithCursor = text
.match(DATA_VIEW_REGEX)
?.filter((dataviewArea) => dataviewArea.includes(UNIQUE_CURSOR));
if (
dataviewAreasWithCursor !== undefined &&
dataviewAreasWithCursor.length > 0
) {
return true;
}
return false;
}
}
export default DataViewRemover;

View File

@@ -0,0 +1,24 @@
import Context from "../context-detection";
import { PrefixAndSuffix, PreProcessor } from "../types";
class LengthLimiter implements PreProcessor {
private readonly maxPrefixChars: number;
private readonly maxSuffixChars: number;
constructor(maxPrefixChars: number, maxSuffixChars: number) {
this.maxPrefixChars = maxPrefixChars;
this.maxSuffixChars = maxSuffixChars;
}
process(prefix: string, suffix: string, context: Context): PrefixAndSuffix {
prefix = prefix.slice(-this.maxPrefixChars);
suffix = suffix.slice(0, this.maxSuffixChars);
return { prefix, suffix };
}
removesCursor(prefix: string, suffix: string): boolean {
return false;
}
}
export default LengthLimiter;

View File

@@ -0,0 +1,35 @@
import { TFile } from "obsidian";
import { InfioSettings } from "../../../types/settings";
import State from "./state";
class DisabledFileSpecificState extends State {
getStatusBarText(): string {
return "Disabled for this file";
}
handleSettingChanged(settings: InfioSettings) {
if (!this.context.settings.autocompleteEnabled) {
this.context.transitionToDisabledManualState();
}
if (!this.context.isCurrentFilePathIgnored() || !this.context.currentFileContainsIgnoredTag()) {
this.context.transitionToIdleState();
}
}
handleFileChange(file: TFile): void {
if (this.context.isCurrentFilePathIgnored() || this.context.currentFileContainsIgnoredTag()) {
return;
}
if (this.context.settings.autocompleteEnabled) {
this.context.transitionToIdleState();
} else {
this.context.transitionToDisabledManualState();
}
}
}
export default DisabledFileSpecificState;

View File

@@ -0,0 +1,25 @@
import { InfioSettings } from "../../../types/settings";
import { checkForErrors } from "../../../utils/auto-complete";
import State from "./state";
class DisabledInvalidSettingsState extends State {
getStatusBarText(): string {
return "Disabled invalid settings";
}
handleSettingChanged(settings: InfioSettings) {
const settingErrors = checkForErrors(settings);
if (settingErrors.size > 0) {
return
}
if (this.context.settings.autocompleteEnabled) {
this.context.transitionToIdleState();
} else {
this.context.transitionToDisabledManualState();
}
}
}
export default DisabledInvalidSettingsState;

View File

@@ -0,0 +1,21 @@
import { TFile } from "obsidian";
import { InfioSettings } from "../../../types/settings";
import State from "./state";
class DisabledManualState extends State {
getStatusBarText(): string {
return "Disabled";
}
handleSettingChanged(settings: InfioSettings): void {
if (this.context.settings.autocompleteEnabled) {
this.context.transitionToIdleState();
}
}
handleFileChange(file: TFile): void { }
}
export default DisabledManualState;

View File

@@ -0,0 +1,46 @@
import { DocumentChanges } from "../../../render-plugin/document-changes-listener";
import State from "./state";
class IdleState extends State {
async handleDocumentChange(
documentChanges: DocumentChanges
): Promise<void> {
if (
!documentChanges.isDocInFocus()
|| !documentChanges.hasDocChanged()
|| documentChanges.hasUserDeleted()
|| documentChanges.hasMultipleCursors()
|| documentChanges.hasSelection()
|| documentChanges.hasUserUndone()
|| documentChanges.hasUserRedone()
) {
return;
}
const cachedSuggestion = this.context.getCachedSuggestionFor(documentChanges.getPrefix(), documentChanges.getSuffix());
const isThereCachedSuggestion = cachedSuggestion !== undefined && cachedSuggestion.trim().length > 0;
if (this.context.settings.cacheSuggestions && isThereCachedSuggestion) {
this.context.transitionToSuggestingState(cachedSuggestion, documentChanges.getPrefix(), documentChanges.getSuffix());
return;
}
if (this.context.containsTriggerCharacters(documentChanges)) {
this.context.transitionToQueuedState(documentChanges.getPrefix(), documentChanges.getSuffix());
}
}
handlePredictCommand(prefix: string, suffix: string): void {
this.context.transitionToPredictingState(prefix, suffix);
}
getStatusBarText(): string {
return "Idle";
}
}
export default IdleState;

View File

@@ -0,0 +1,38 @@
import { TFile } from "obsidian";
import { InfioSettings } from "../../../types/settings";
import { DocumentChanges } from "../../../render-plugin/document-changes-listener";
import { EventHandler } from "./types";
class InitState implements EventHandler {
async handleDocumentChange(documentChanges: DocumentChanges): Promise<void> { }
handleSettingChanged(settings: InfioSettings): void { }
handleAcceptKeyPressed(): boolean {
return false;
}
handlePartialAcceptKeyPressed(): boolean {
return false;
}
handleCancelKeyPressed(): boolean {
return false;
}
handlePredictCommand(): void { }
handleAcceptCommand(): void { }
getStatusBarText(): string {
return "Initializing...";
}
handleFileChange(file: TFile): void {
}
}
export default InitState;

View File

@@ -0,0 +1,94 @@
import { Notice } from "obsidian";
import Context from "../context-detection";
import EventListener from "../../../event-listener";
import { DocumentChanges } from "../../../render-plugin/document-changes-listener";
import State from "./state";
class PredictingState extends State {
private predictionPromise: Promise<void> | null = null;
private isStillNeeded = true;
private readonly prefix: string;
private readonly suffix: string;
constructor(context: EventListener, prefix: string, suffix: string) {
super(context);
this.prefix = prefix;
this.suffix = suffix;
}
static createAndStartPredicting(
context: EventListener,
prefix: string,
suffix: string
): PredictingState {
const predictingState = new PredictingState(context, prefix, suffix);
predictingState.startPredicting();
context.setContext(Context.getContext(prefix, suffix));
return predictingState;
}
handleCancelKeyPressed(): boolean {
this.cancelPrediction();
return true;
}
async handleDocumentChange(
documentChanges: DocumentChanges
): Promise<void> {
if (
documentChanges.hasCursorMoved() ||
documentChanges.hasUserTyped() ||
documentChanges.hasUserDeleted() ||
documentChanges.isTextAdded()
) {
this.cancelPrediction();
}
}
private cancelPrediction(): void {
this.isStillNeeded = false;
this.context.transitionToIdleState();
}
startPredicting(): void {
this.predictionPromise = this.predict();
}
private async predict(): Promise<void> {
const result =
await this.context.autocomplete?.fetchPredictions(
this.prefix,
this.suffix
);
if (!this.isStillNeeded) {
return;
}
if (result.isErr()) {
new Notice(
`Copilot: Something went wrong cannot make a prediction. Full error is available in the dev console. Please check your settings. `
);
console.error(result.error);
this.context.transitionToIdleState();
}
const prediction = result.unwrapOr("");
if (prediction === "") {
this.context.transitionToIdleState();
return;
}
this.context.transitionToSuggestingState(prediction, this.prefix, this.suffix);
}
getStatusBarText(): string {
return `Predicting for ${this.context.context}`;
}
}
export default PredictingState;

View File

@@ -0,0 +1,84 @@
import Context from "../context-detection";
import EventListener from "../../../event-listener";
import { DocumentChanges } from "../../../render-plugin/document-changes-listener";
import State from "./state";
class QueuedState extends State {
private timer: ReturnType<typeof setTimeout> | null = null;
private readonly prefix: string;
private readonly suffix: string;
private constructor(
context: EventListener,
prefix: string,
suffix: string
) {
super(context);
this.prefix = prefix;
this.suffix = suffix;
}
static createAndStartTimer(
context: EventListener,
prefix: string,
suffix: string
): QueuedState {
const state = new QueuedState(context, prefix, suffix);
state.startTimer();
context.setContext(Context.getContext(prefix, suffix));
return state;
}
handleCancelKeyPressed(): boolean {
this.cancelTimer();
this.context.transitionToIdleState();
return true;
}
async handleDocumentChange(
documentChanges: DocumentChanges
): Promise<void> {
if (
documentChanges.isDocInFocus() &&
documentChanges.isTextAdded() &&
this.context.containsTriggerCharacters(documentChanges)
) {
this.cancelTimer();
this.context.transitionToQueuedState(documentChanges.getPrefix(), documentChanges.getSuffix());
return
}
if (
(documentChanges.hasCursorMoved() ||
documentChanges.hasUserTyped() ||
documentChanges.hasUserDeleted() ||
documentChanges.isTextAdded() ||
!documentChanges.isDocInFocus())
) {
this.cancelTimer();
this.context.transitionToIdleState();
}
}
startTimer(): void {
this.cancelTimer();
this.timer = setTimeout(() => {
this.context.transitionToPredictingState(this.prefix, this.suffix);
}, this.context.settings.delay);
}
private cancelTimer(): void {
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
}
getStatusBarText(): string {
return `Queued (${this.context.settings.delay} ms)`;
}
}
export default QueuedState;

View File

@@ -0,0 +1,67 @@
import { Notice, TFile } from "obsidian";
import EventListener from "../../../event-listener";
import { DocumentChanges } from "../../../render-plugin/document-changes-listener";
// import { Settings } from "../settings/versions";
import { InfioSettings } from "../../../types/settings";
import { checkForErrors } from "../../../utils/auto-complete";
import { EventHandler } from "./types";
abstract class State implements EventHandler {
protected readonly context: EventListener;
constructor(context: EventListener) {
this.context = context;
}
handleSettingChanged(settings: InfioSettings): void {
const settingErrors = checkForErrors(settings);
if (!settings.autocompleteEnabled) {
new Notice("Copilot is now disabled.");
this.context.transitionToDisabledManualState()
} else if (settingErrors.size > 0) {
new Notice(
`Copilot: There are ${settingErrors.size} errors in your settings. The plugin will be disabled until they are fixed.`
);
this.context.transitionToDisabledInvalidSettingsState();
} else if (this.context.isCurrentFilePathIgnored() || this.context.currentFileContainsIgnoredTag()) {
this.context.transitionToDisabledFileSpecificState();
}
}
async handleDocumentChange(
documentChanges: DocumentChanges
): Promise<void> {
}
handleAcceptKeyPressed(): boolean {
return false;
}
handlePartialAcceptKeyPressed(): boolean {
return false;
}
handleCancelKeyPressed(): boolean {
return false;
}
handlePredictCommand(prefix: string, suffix: string): void {
}
handleAcceptCommand(): void {
}
abstract getStatusBarText(): string;
handleFileChange(file: TFile): void {
if (this.context.isCurrentFilePathIgnored() || this.context.currentFileContainsIgnoredTag()) {
this.context.transitionToDisabledFileSpecificState();
} else if (this.context.isDisabled()) {
this.context.transitionToIdleState();
}
}
}
export default State;

View File

@@ -0,0 +1,177 @@
import { Settings } from "../../../settings/versions";
import { extractNextWordAndRemaining } from "../utils";
import EventListener from "../../../event-listener";
import { DocumentChanges } from "../../../render-plugin/document-changes-listener";
import State from "./state";
class SuggestingState extends State {
private readonly suggestion: string;
private readonly prefix: string;
private readonly suffix: string;
constructor(context: EventListener, suggestion: string, prefix: string, suffix: string) {
super(context);
this.suggestion = suggestion;
this.prefix = prefix;
this.suffix = suffix;
}
async handleDocumentChange(
documentChanges: DocumentChanges
): Promise<void> {
if (
documentChanges.hasCursorMoved()
|| documentChanges.hasUserUndone()
|| documentChanges.hasUserDeleted()
|| documentChanges.hasUserRedone()
|| !documentChanges.isDocInFocus()
|| documentChanges.hasSelection()
|| documentChanges.hasMultipleCursors()
) {
this.clearPrediction();
return;
}
if (
documentChanges.noUserEvents()
|| !documentChanges.hasDocChanged()
) {
return;
}
if (this.hasUserAddedPartOfSuggestion(documentChanges)) {
this.acceptPartialAddedText(documentChanges);
return
}
const currentPrefix = documentChanges.getPrefix();
const currentSuffix = documentChanges.getSuffix();
const suggestion = this.context.getCachedSuggestionFor(currentPrefix, currentSuffix);
const isThereCachedSuggestion = suggestion !== undefined;
const isCachedSuggestionDifferent = suggestion !== this.suggestion;
if (!isCachedSuggestionDifferent) {
return;
}
if (isThereCachedSuggestion) {
this.context.transitionToSuggestingState(suggestion, currentPrefix, currentSuffix);
return;
}
this.clearPrediction();
}
hasUserAddedPartOfSuggestion(documentChanges: DocumentChanges): boolean {
const addedPrefixText = documentChanges.getAddedPrefixText();
const addedSuffixText = documentChanges.getAddedSuffixText();
return addedPrefixText !== undefined
&& addedSuffixText !== undefined
&& this.suggestion.toLowerCase().startsWith(addedPrefixText.toLowerCase())
&& this.suggestion.toLowerCase().endsWith(addedSuffixText.toLowerCase());
}
acceptPartialAddedText(documentChanges: DocumentChanges): void {
const addedPrefixText = documentChanges.getAddedPrefixText();
const addedSuffixText = documentChanges.getAddedSuffixText();
if (addedSuffixText === undefined || addedPrefixText === undefined) {
return;
}
const startIdx = addedPrefixText.length;
const endIdx = this.suggestion.length - addedSuffixText.length
const remainingSuggestion = this.suggestion.substring(startIdx, endIdx);
if (remainingSuggestion.trim() === "") {
this.clearPrediction();
} else {
this.context.transitionToSuggestingState(remainingSuggestion, documentChanges.getPrefix(), documentChanges.getSuffix());
}
}
private clearPrediction(): void {
this.context.transitionToIdleState();
}
handleAcceptKeyPressed(): boolean {
this.accept();
return true;
}
private accept() {
this.addPartialSuggestionCaches(this.suggestion);
this.context.insertCurrentSuggestion(this.suggestion);
this.context.transitionToIdleState();
}
handlePartialAcceptKeyPressed(): boolean {
this.acceptNextWord();
return true;
}
private acceptNextWord() {
const [nextWord, remaining] = extractNextWordAndRemaining(this.suggestion);
if (nextWord !== undefined && remaining !== undefined) {
const updatedPrefix = this.prefix + nextWord;
this.addPartialSuggestionCaches(nextWord, remaining);
this.context.insertCurrentSuggestion(nextWord);
this.context.transitionToSuggestingState(remaining, updatedPrefix, this.suffix, false);
} else {
this.accept();
}
}
private addPartialSuggestionCaches(acceptSuggestion: string, remainingSuggestion = "") {
// store the sub-suggestions in the cache
// so that we can have partial suggestions if the user edits a part
for (let i = 0; i < acceptSuggestion.length; i++) {
const prefix = this.prefix + acceptSuggestion.substring(0, i);
const suggestion = acceptSuggestion.substring(i) + remainingSuggestion;
this.context.addSuggestionToCache(prefix, this.suffix, suggestion);
}
}
private getNextWordAndRemaining(): [string | undefined, string | undefined] {
const words = this.suggestion.split(" ");
if (words.length === 0) {
return ["", ""];
}
if (words.length === 1) {
return [words[0] + " ", ""];
}
return [words[0] + " ", words.slice(1).join(" ")];
}
handleCancelKeyPressed(): boolean {
this.context.clearSuggestionsCache();
this.clearPrediction();
return true;
}
handleAcceptCommand() {
this.accept();
}
getStatusBarText(): string {
return `Suggesting for ${this.context.context}`;
}
handleSettingChanged(settings: Settings): void {
if (!settings.cacheSuggestions) {
this.clearPrediction();
}
}
}
export default SuggestingState;

View File

@@ -0,0 +1,25 @@
import { TFile } from "obsidian";
import { InfioSettings } from "../../../types/settings";
import { DocumentChanges } from "../../../render-plugin/document-changes-listener";
export interface EventHandler {
handleSettingChanged(settings: InfioSettings): void;
handleDocumentChange(documentChanges: DocumentChanges): Promise<void>;
handleAcceptKeyPressed(): boolean;
handlePartialAcceptKeyPressed(): boolean;
handleCancelKeyPressed(): boolean;
handlePredictCommand(prefix: string, suffix: string): void;
handleAcceptCommand(): void;
getStatusBarText(): string;
handleFileChange(file: TFile): void;
}

View File

@@ -0,0 +1,57 @@
import { Result } from "neverthrow";
import Context from "./context-detection";
export interface AutocompleteService {
fetchPredictions(
prefix: string,
suffix: string
): Promise<Result<string, Error>>;
}
export interface PostProcessor {
process(
prefix: string,
suffix: string,
completion: string,
context: Context
): string;
}
export interface PreProcessor {
process(prefix: string, suffix: string, context: Context): PrefixAndSuffix;
removesCursor(prefix: string, suffix: string): boolean;
}
export interface PrefixAndSuffix {
prefix: string;
suffix: string;
}
export interface ChatMessage {
content: string;
role: "user" | "assistant" | "system";
}
export interface UserMessageFormattingInputs {
prefix: string;
suffix: string;
}
export type UserMessageFormatter = (
inputs: UserMessageFormattingInputs
) => string;
export interface ApiClient {
queryChatModel(messages: ChatMessage[]): Promise<Result<string, Error>>;
checkIfConfiguredCorrectly?(): Promise<string[]>;
}
export interface ModelOptions {
temperature: number;
top_p: number;
frequency_penalty: number;
presence_penalty: number;
max_tokens: number;
}

View File

@@ -0,0 +1,68 @@
import * as mm from "micromatch";
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function enumKeys<O extends object, K extends keyof O = keyof O>(
obj: O
): K[] {
return Object.keys(obj).filter((k) => Number.isNaN(+k)) as K[];
}
export function generateRandomString(n: number): string {
let result = '';
const characters = '0123456789abcdef';
for (let i = 0; i < n; i++) {
const randomIndex = Math.floor(Math.random() * characters.length);
result += characters[randomIndex];
}
return result;
}
export function isMatchBetweenPathAndPatterns(
path: string,
patterns: string[],
): boolean {
patterns = patterns
.map(p => p.trim())
.filter((p) => p.length > 0);
if (patterns.length === 0) {
return false;
}
const exclusionPatterns = patterns.filter((p) => p.startsWith('!')).map(p => p.slice(1));
const inclusionPatterns = patterns.filter((p) => !p.startsWith('!'));
return mm.some(path, inclusionPatterns) && !mm.some(path, exclusionPatterns);
}
export function extractNextWordAndRemaining(suggestion: string): [string | undefined, string | undefined] {
const leadingWhitespacesMatch = suggestion.match(/^(\s*)/);
const leadingWhitespaces = leadingWhitespacesMatch ? leadingWhitespacesMatch[0] : '';
const trimmedSuggestion = suggestion.slice(leadingWhitespaces.length);
let nextWord: string | undefined;
let remaining: string | undefined = undefined;
const whitespaceAfterNextWordMatch = trimmedSuggestion.match(/\s+/);
if (!whitespaceAfterNextWordMatch) {
nextWord = trimmedSuggestion || undefined;
} else {
const whitespaceAfterNextWordStartingIndex = whitespaceAfterNextWordMatch.index!;
const whitespaceAfterNextWord = whitespaceAfterNextWordMatch[0];
const whitespaceLength = whitespaceAfterNextWord.length;
const startOfWhitespaceAfterNextWordIndex = whitespaceAfterNextWordStartingIndex + whitespaceLength;
nextWord = trimmedSuggestion.substring(0, whitespaceAfterNextWordStartingIndex);
if (startOfWhitespaceAfterNextWordIndex < trimmedSuggestion.length) {
remaining = trimmedSuggestion.slice(startOfWhitespaceAfterNextWordIndex);
nextWord += whitespaceAfterNextWord;
}
}
return [nextWord ? leadingWhitespaces + nextWord : undefined, remaining];
}