init
This commit is contained in:
41
src/utils/apply.ts
Normal file
41
src/utils/apply.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { TFile } from 'obsidian'
|
||||
|
||||
// 替换指定行范围的内容
|
||||
const replaceLines = (content: string, startLine: number, endLine: number, newContent: string): string => {
|
||||
const lines = content.split('\n')
|
||||
const beforeLines = lines.slice(0, startLine - 1)
|
||||
const afterLines = lines.slice(endLine) // 这里不需要 +1 因为 endLine 指向的是要替换的最后一行
|
||||
return [...beforeLines, newContent, ...afterLines].join('\n')
|
||||
}
|
||||
|
||||
export const manualApplyChangesToFile = async (
|
||||
content: string,
|
||||
currentFile: TFile,
|
||||
currentFileContent: string,
|
||||
startLine?: number,
|
||||
endLine?: number,
|
||||
): Promise<string | null> => {
|
||||
console.log('Manual apply changes to file:', currentFile.path)
|
||||
console.log('Start line:', startLine)
|
||||
console.log('End line:', endLine)
|
||||
console.log('Content:', content)
|
||||
try {
|
||||
if (!startLine || !endLine) {
|
||||
console.error('Missing line numbers for edit')
|
||||
return null
|
||||
}
|
||||
|
||||
// 直接替换指定行范围的内容
|
||||
const newContent = replaceLines(
|
||||
currentFileContent,
|
||||
startLine,
|
||||
endLine,
|
||||
content
|
||||
)
|
||||
|
||||
return newContent
|
||||
} catch (error) {
|
||||
console.error('Error applying changes:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
226
src/utils/auto-complete.ts
Normal file
226
src/utils/auto-complete.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { cloneDeep, each, get, has, isArray, isEqual, isNumber, isObject, isString, set, unset } from "lodash";
|
||||
import * as mm from "micromatch";
|
||||
import { err, ok, Result } from "neverthrow";
|
||||
import { z, ZodError, ZodIssueCode, ZodType } from 'zod';
|
||||
|
||||
import { DEFAULT_SETTINGS, PluginData, Settings, settingsSchema } from "../settings/versions";
|
||||
import { isSettingsV0, isSettingsV1, migrateFromV0ToV1 } from "../settings/versions/migration";
|
||||
import { InfioSettings } from '../types/settings';
|
||||
|
||||
type JSONObject = Record<string, any>;
|
||||
|
||||
export function checkForErrors(settings: InfioSettings) {
|
||||
const errors = new Map<string, string>();
|
||||
const parsingResult = parseWithSchema(settingsSchema, settings);
|
||||
|
||||
if (parsingResult.isOk()) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (parsingResult.error instanceof ZodError) {
|
||||
for (const issue of parsingResult.error.issues) {
|
||||
errors.set(issue.path.join('.'), issue.message);
|
||||
}
|
||||
} else {
|
||||
throw parsingResult.error;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
|
||||
export function fixStructureAndValueErrors<T extends ZodType>(
|
||||
schema: T,
|
||||
value: any | null | undefined,
|
||||
defaultValue: z.infer<T>,
|
||||
): Result<ReturnType<T["parse"]>, Error> {
|
||||
if (value === null || value === undefined) {
|
||||
value = {};
|
||||
}
|
||||
let result = parseWithSchema(schema, value);
|
||||
|
||||
if (result.isErr()) {
|
||||
value = addMissingKeys(value, result.error, defaultValue);
|
||||
value = removeUnrecognizedKeys(value, result.error);
|
||||
result = parseWithSchema(schema, value);
|
||||
}
|
||||
|
||||
if (result.isErr() && value !== null && value !== undefined) {
|
||||
value = replaceValuesWithErrorsByDefaultValue(value, result.error, defaultValue);
|
||||
result = parseWithSchema(schema, value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseWithSchema<T extends ZodType>(
|
||||
schema: T,
|
||||
value: JSONObject | null | undefined
|
||||
): Result<ReturnType<T["parse"]>, ZodError> {
|
||||
const parsingResult = schema.safeParse(value);
|
||||
return parsingResult.success ? ok(parsingResult.data) : err(parsingResult.error);
|
||||
}
|
||||
|
||||
function addMissingKeys<T extends object>(value: JSONObject, error: ZodError, defaultValue: T): JSONObject {
|
||||
const invalidTypeIssues = error.issues.filter(issue => issue.code === ZodIssueCode.invalid_type);
|
||||
const errorPaths = invalidTypeIssues
|
||||
.map(issue => issue.path)
|
||||
.map(path => reduceArrayPathToFirstObjectPath(path))
|
||||
.map(path => path.join('.'));
|
||||
|
||||
return replaceValueWithDefaultValue(value, errorPaths, defaultValue);
|
||||
}
|
||||
|
||||
function replaceValueWithDefaultValue<V, T>(
|
||||
value: any,
|
||||
paths: string[],
|
||||
defaultValue: T,
|
||||
): V {
|
||||
const result = cloneDeep(value) as any;
|
||||
paths.forEach(path => {
|
||||
const originalValue = has(defaultValue, path) ? get(defaultValue, path) : undefined;
|
||||
set(result, path, originalValue);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
function removeUnrecognizedKeys(value: JSONObject | null | undefined, error: ZodError): JSONObject {
|
||||
if (typeof value !== 'object' || value === null || value === undefined) {
|
||||
return {};
|
||||
}
|
||||
// Zod unrecognized_keys issues consist of two parts:
|
||||
// - path to the nested object where the unrecognized key was found
|
||||
// - the key itself which is unrecognized
|
||||
const unrecognizedPaths = error.issues
|
||||
|
||||
.filter(issue => issue.code === ZodIssueCode.unrecognized_keys)
|
||||
// Array path will be handled separately by the value replacement function
|
||||
.filter(issue => !isAnArrayPath(issue.path))
|
||||
.flatMap(issue => {
|
||||
// @ts-ignore
|
||||
const keys = issue.keys;
|
||||
return keys.map(key => [...issue.path, key].join('.'));
|
||||
});
|
||||
|
||||
unrecognizedPaths.forEach(path => {
|
||||
unset(value, path);
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
function replaceValuesWithErrorsByDefaultValue<T>(
|
||||
value: JSONObject,
|
||||
error: ZodError,
|
||||
defaultValue: T
|
||||
): T {
|
||||
const errorPaths = error.issues
|
||||
.map(issue => issue.path)
|
||||
.map(path => reduceArrayPathToFirstObjectPath(path))
|
||||
.map(path => path.join('.'));
|
||||
|
||||
return replaceValueWithDefaultValue(value, errorPaths, defaultValue);
|
||||
}
|
||||
|
||||
function reduceArrayPathToFirstObjectPath(path: (string | number)[]): (string | number)[] {
|
||||
const result: (string | number)[] = [];
|
||||
for (const key of path) {
|
||||
if (typeof key === 'number') {
|
||||
break;
|
||||
}
|
||||
result.push(key);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function isAnArrayPath(path: (string | number)[]): boolean {
|
||||
return path.some(key => typeof key === 'number');
|
||||
}
|
||||
|
||||
export function serializeSettings(settings: Settings): PluginData {
|
||||
return { settings: settings };
|
||||
}
|
||||
|
||||
export function deserializeSettings(data: JSONObject | null | undefined): Result<Settings, Error> {
|
||||
let settings: any;
|
||||
if (data === null || data === undefined || !data.hasOwnProperty("settings")) {
|
||||
settings = {};
|
||||
} else {
|
||||
settings = data.settings;
|
||||
}
|
||||
|
||||
if (isSettingsV0(settings)) {
|
||||
console.log("Migrating settings from v0 to v1");
|
||||
settings = migrateFromV0ToV1(settings);
|
||||
}
|
||||
if (!isSettingsV1(settings)) {
|
||||
console.log("Fixing settings structure and value errors");
|
||||
return fixStructureAndValueErrors(settingsSchema, settings, DEFAULT_SETTINGS);
|
||||
}
|
||||
|
||||
return parseWithSchema(settingsSchema, settings);
|
||||
}
|
||||
|
||||
export function isRegexValid(value: string): boolean {
|
||||
try {
|
||||
const regex = new RegExp(value);
|
||||
regex.test("");
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidIgnorePattern(value: string): boolean {
|
||||
try {
|
||||
mm.isMatch("", value);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function findEqualPaths(obj1: any, obj2: any, basePath = ''): string[] {
|
||||
let paths: string[] = [];
|
||||
|
||||
if (
|
||||
basePath === ''
|
||||
&& (
|
||||
!isObject(obj1)
|
||||
|| !isObject(obj2)
|
||||
|| isArray(obj1)
|
||||
|| isArray(obj2)
|
||||
|| isNumber(obj1)
|
||||
|| isNumber(obj2)
|
||||
|| isString(obj1)
|
||||
|| isString(obj2)
|
||||
)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Function to iterate over keys and compare values
|
||||
function iterateKeys(value: any, key: string | number): void {
|
||||
const path = basePath ? `${basePath}.${key}` : `${key}`;
|
||||
if (isObject(value) && isObject(get(obj2, key))) {
|
||||
// Recursively find paths for nested objects
|
||||
paths = paths.concat(findEqualPaths(value, get(obj2, key), path));
|
||||
} else if (isEqual(value, get(obj2, key))) {
|
||||
// Add path to array if values are equal
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
// If both are arrays, iterate using each index
|
||||
if (isArray(obj1) && isArray(obj2)) {
|
||||
each(obj1, (value, index) => iterateKeys(value, `[${index}]`));
|
||||
} else {
|
||||
// Iterate over keys of the first object
|
||||
each(obj1, iterateKeys);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
9
src/utils/content-filter.ts
Normal file
9
src/utils/content-filter.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Removes AI code block tags from content
|
||||
* @param content The content to filter
|
||||
* @returns The filtered content without AI code block tags
|
||||
*/
|
||||
export const removeAITags = (content: string): string => {
|
||||
// Remove ```infioedit\n and ``` tags
|
||||
return content.replace(/```infioedit\n```\n/g, '');
|
||||
}
|
||||
207
src/utils/fuzzy-search.ts
Normal file
207
src/utils/fuzzy-search.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import fuzzysort from 'fuzzysort'
|
||||
import { App, TFile, TFolder } from 'obsidian'
|
||||
|
||||
import {
|
||||
MentionableFile,
|
||||
MentionableFolder,
|
||||
MentionableVault,
|
||||
} from '../types/mentionable'
|
||||
|
||||
import { calculateFileDistance, getOpenFiles } from './obsidian'
|
||||
|
||||
export type SearchableMentionable =
|
||||
| MentionableFile
|
||||
| MentionableFolder
|
||||
| MentionableVault
|
||||
|
||||
type VaultSearchItem = {
|
||||
type: 'vault'
|
||||
path: string
|
||||
}
|
||||
|
||||
type FileWithMetadata = {
|
||||
type: 'file'
|
||||
path: string
|
||||
name: string
|
||||
file: TFile
|
||||
opened: boolean
|
||||
distance: number | null
|
||||
daysSinceLastModified: number
|
||||
}
|
||||
|
||||
type FolderWithMetadata = {
|
||||
type: 'folder'
|
||||
path: string
|
||||
name: string
|
||||
folder: TFolder
|
||||
distance: number | null
|
||||
}
|
||||
|
||||
type SearchItem = FolderWithMetadata | FileWithMetadata | VaultSearchItem
|
||||
|
||||
function scoreFnWithBoost({
|
||||
searchItem,
|
||||
pathScore,
|
||||
nameScore,
|
||||
}: {
|
||||
searchItem: SearchItem
|
||||
pathScore: number
|
||||
nameScore: number
|
||||
}): number {
|
||||
const score = Math.max(pathScore, nameScore)
|
||||
|
||||
let boost = 1
|
||||
switch (searchItem.type) {
|
||||
case 'file': {
|
||||
const { opened, distance, daysSinceLastModified } = searchItem
|
||||
|
||||
// Boost for open files
|
||||
if (opened) boost = Math.max(boost, 3)
|
||||
|
||||
// Boost for recently modified files
|
||||
if (daysSinceLastModified < 30) {
|
||||
const recentBoost = 1 + 2 / (daysSinceLastModified + 2)
|
||||
boost = Math.max(boost, recentBoost)
|
||||
}
|
||||
|
||||
// Boost for nearby files
|
||||
if (distance !== null && distance > 0 && distance <= 5) {
|
||||
const nearbyBoost = 1 + 0.5 / Math.max(distance - 1, 1)
|
||||
boost = Math.max(boost, nearbyBoost)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case 'folder': {
|
||||
const { distance } = searchItem
|
||||
|
||||
// Boost for nearby folders
|
||||
if (distance !== null && distance > 0 && distance <= 5) {
|
||||
const nearbyBoost = 1 + 0.5 / Math.max(distance - 1, 1)
|
||||
boost = Math.max(boost, nearbyBoost)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case 'vault': {
|
||||
if (score === 1) {
|
||||
boost = 3
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize the boost
|
||||
const normalizedScore =
|
||||
boost > 1 ? Math.log(boost * score + 1) / Math.log(boost + 1) : score
|
||||
return normalizedScore
|
||||
}
|
||||
|
||||
function getEmptyQueryResult(
|
||||
searchItems: SearchItem[],
|
||||
limit: number,
|
||||
): SearchableMentionable[] {
|
||||
// Sort files based on a custom scoring function
|
||||
const sortedFiles = searchItems.sort((a, b) => {
|
||||
const scoreA = scoreFnWithBoost({
|
||||
searchItem: a,
|
||||
pathScore: 0.5, // Use 0.5 as a base score
|
||||
nameScore: 0.5,
|
||||
})
|
||||
const scoreB = scoreFnWithBoost({
|
||||
searchItem: b,
|
||||
pathScore: 0.5,
|
||||
nameScore: 0.5,
|
||||
})
|
||||
return scoreB - scoreA // Sort in descending order
|
||||
})
|
||||
|
||||
// Return only the top 'limit' files
|
||||
return sortedFiles
|
||||
.slice(0, limit)
|
||||
.map((item) => searchItemToMentionable(item))
|
||||
}
|
||||
|
||||
export function fuzzySearch(app: App, query: string): SearchableMentionable[] {
|
||||
const currentFile = app.workspace.getActiveFile()
|
||||
const openFiles = getOpenFiles(app)
|
||||
|
||||
const allSupportedFiles = app.vault.getFiles().filter((file) => {
|
||||
const extension = file.extension
|
||||
return extension === 'md'
|
||||
})
|
||||
|
||||
const allFilesWithMetadata: SearchItem[] = allSupportedFiles.map((file) => ({
|
||||
type: 'file',
|
||||
path: file.path,
|
||||
name: file.name,
|
||||
file,
|
||||
opened: openFiles.some((f) => f.path === file.path),
|
||||
distance: currentFile
|
||||
? currentFile === file
|
||||
? null
|
||||
: calculateFileDistance(currentFile, file)
|
||||
: null,
|
||||
daysSinceLastModified:
|
||||
(Date.now() - file.stat.mtime) / (1000 * 60 * 60 * 24),
|
||||
}))
|
||||
|
||||
const allFolders = app.vault.getAllFolders()
|
||||
const allFoldersWithMetadata: SearchItem[] = allFolders.map((folder) => ({
|
||||
type: 'folder',
|
||||
path: folder.path,
|
||||
name: folder.name,
|
||||
folder,
|
||||
distance: currentFile ? calculateFileDistance(currentFile, folder) : null,
|
||||
}))
|
||||
|
||||
const vaultItem: VaultSearchItem = {
|
||||
type: 'vault',
|
||||
path: 'vault',
|
||||
}
|
||||
|
||||
const searchItems: SearchItem[] = [
|
||||
...allFilesWithMetadata,
|
||||
...allFoldersWithMetadata,
|
||||
vaultItem,
|
||||
]
|
||||
|
||||
if (!query) {
|
||||
return getEmptyQueryResult(searchItems, 20)
|
||||
}
|
||||
|
||||
const results = fuzzysort.go(query, searchItems, {
|
||||
keys: ['path', 'name'],
|
||||
threshold: 0.2,
|
||||
limit: 20,
|
||||
all: true,
|
||||
scoreFn: (result) =>
|
||||
scoreFnWithBoost({
|
||||
searchItem: result.obj,
|
||||
pathScore: result[0].score,
|
||||
nameScore: result[1].score,
|
||||
}),
|
||||
})
|
||||
|
||||
return results.map((result) => searchItemToMentionable(result.obj))
|
||||
}
|
||||
|
||||
function searchItemToMentionable(item: SearchItem): SearchableMentionable {
|
||||
switch (item.type) {
|
||||
case 'file':
|
||||
return {
|
||||
type: 'file',
|
||||
file: item.file,
|
||||
}
|
||||
case 'folder':
|
||||
return {
|
||||
type: 'folder',
|
||||
folder: item.folder,
|
||||
}
|
||||
case 'vault':
|
||||
return {
|
||||
type: 'vault',
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/utils/glob-utils.ts
Normal file
12
src/utils/glob-utils.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { minimatch } from 'minimatch'
|
||||
import { Vault } from 'obsidian'
|
||||
|
||||
export const findFilesMatchingPatterns = async (
|
||||
patterns: string[],
|
||||
vault: Vault,
|
||||
) => {
|
||||
const files = vault.getMarkdownFiles()
|
||||
return files.filter((file) => {
|
||||
return patterns.some((pattern) => minimatch(file.path, pattern))
|
||||
})
|
||||
}
|
||||
34
src/utils/image.ts
Normal file
34
src/utils/image.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { MentionableImage } from '../types/mentionable'
|
||||
|
||||
export function parseImageDataUrl(dataUrl: string): {
|
||||
mimeType: string
|
||||
base64Data: string
|
||||
} {
|
||||
const matches = dataUrl.match(/^data:([^;]+);base64,(.+)/)
|
||||
if (!matches) {
|
||||
throw new Error('Invalid image data URL format')
|
||||
}
|
||||
const [, mimeType, base64Data] = matches
|
||||
return { mimeType, base64Data }
|
||||
}
|
||||
|
||||
export async function fileToMentionableImage(
|
||||
file: File,
|
||||
): Promise<MentionableImage> {
|
||||
const base64Data = await fileToBase64(file)
|
||||
return {
|
||||
type: 'image',
|
||||
name: file.name,
|
||||
mimeType: file.type,
|
||||
data: base64Data,
|
||||
}
|
||||
}
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = () => reject(new Error('Failed to read file'))
|
||||
})
|
||||
}
|
||||
164
src/utils/mentionable.ts
Normal file
164
src/utils/mentionable.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { App } from 'obsidian'
|
||||
|
||||
import { Mentionable, SerializedMentionable } from '../types/mentionable'
|
||||
|
||||
export const serializeMentionable = (
|
||||
mentionable: Mentionable,
|
||||
): SerializedMentionable => {
|
||||
switch (mentionable.type) {
|
||||
case 'file':
|
||||
return {
|
||||
type: 'file',
|
||||
file: mentionable.file.path,
|
||||
}
|
||||
case 'folder':
|
||||
return {
|
||||
type: 'folder',
|
||||
folder: mentionable.folder.path,
|
||||
}
|
||||
case 'vault':
|
||||
return {
|
||||
type: 'vault',
|
||||
}
|
||||
case 'current-file':
|
||||
return {
|
||||
type: 'current-file',
|
||||
file: mentionable.file?.path ?? null,
|
||||
}
|
||||
case 'block':
|
||||
return {
|
||||
type: 'block',
|
||||
content: mentionable.content,
|
||||
file: mentionable.file.path,
|
||||
startLine: mentionable.startLine,
|
||||
endLine: mentionable.endLine,
|
||||
}
|
||||
case 'url':
|
||||
return {
|
||||
type: 'url',
|
||||
url: mentionable.url,
|
||||
}
|
||||
case 'image':
|
||||
return {
|
||||
type: 'image',
|
||||
name: mentionable.name,
|
||||
mimeType: mentionable.mimeType,
|
||||
data: mentionable.data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const deserializeMentionable = (
|
||||
mentionable: SerializedMentionable,
|
||||
app: App,
|
||||
): Mentionable | null => {
|
||||
try {
|
||||
switch (mentionable.type) {
|
||||
case 'file': {
|
||||
const file = app.vault.getFileByPath(mentionable.file)
|
||||
if (!file) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
type: 'file',
|
||||
file: file,
|
||||
}
|
||||
}
|
||||
case 'folder': {
|
||||
const folder = app.vault.getFolderByPath(mentionable.folder)
|
||||
if (!folder) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
type: 'folder',
|
||||
folder: folder,
|
||||
}
|
||||
}
|
||||
case 'vault':
|
||||
return {
|
||||
type: 'vault',
|
||||
}
|
||||
case 'current-file': {
|
||||
if (!mentionable.file) {
|
||||
return {
|
||||
type: 'current-file',
|
||||
file: null,
|
||||
}
|
||||
}
|
||||
const file = app.vault.getFileByPath(mentionable.file)
|
||||
return {
|
||||
type: 'current-file',
|
||||
file: file,
|
||||
}
|
||||
}
|
||||
case 'block': {
|
||||
const file = app.vault.getFileByPath(mentionable.file)
|
||||
if (!file) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
type: 'block',
|
||||
content: mentionable.content,
|
||||
file: file,
|
||||
startLine: mentionable.startLine,
|
||||
endLine: mentionable.endLine,
|
||||
}
|
||||
}
|
||||
case 'url': {
|
||||
return {
|
||||
type: 'url',
|
||||
url: mentionable.url,
|
||||
}
|
||||
}
|
||||
case 'image': {
|
||||
return {
|
||||
type: 'image',
|
||||
name: mentionable.name,
|
||||
mimeType: mentionable.mimeType,
|
||||
data: mentionable.data,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error deserializing mentionable', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getMentionableKey(mentionable: SerializedMentionable): string {
|
||||
switch (mentionable.type) {
|
||||
case 'file':
|
||||
return `file:${mentionable.file}`
|
||||
case 'folder':
|
||||
return `folder:${mentionable.folder}`
|
||||
case 'vault':
|
||||
return 'vault'
|
||||
case 'current-file':
|
||||
return `current-file:${mentionable.file ?? 'current'}`
|
||||
case 'block':
|
||||
return `block:${mentionable.file}:${mentionable.startLine}:${mentionable.endLine}:${mentionable.content}`
|
||||
case 'url':
|
||||
return `url:${mentionable.url}`
|
||||
case 'image':
|
||||
return `image:${mentionable.name}:${mentionable.data.length}:${mentionable.data.slice(-32)}`
|
||||
}
|
||||
}
|
||||
|
||||
export function getMentionableName(mentionable: Mentionable): string {
|
||||
switch (mentionable.type) {
|
||||
case 'file':
|
||||
return mentionable.file.name
|
||||
case 'folder':
|
||||
return mentionable.folder.name
|
||||
case 'vault':
|
||||
return 'Vault'
|
||||
case 'current-file':
|
||||
return mentionable.file?.name ?? 'Current File'
|
||||
case 'block':
|
||||
return `${mentionable.file.name} (${mentionable.startLine}:${mentionable.endLine})`
|
||||
case 'url':
|
||||
return mentionable.url
|
||||
case 'image':
|
||||
return mentionable.name
|
||||
}
|
||||
}
|
||||
60
src/utils/obsidian.test.ts
Normal file
60
src/utils/obsidian.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { TFile, TFolder } from 'obsidian'
|
||||
|
||||
import { calculateFileDistance } from './obsidian'
|
||||
|
||||
describe('calculateFileDistance', () => {
|
||||
// Mock TFile class
|
||||
class MockTFile {
|
||||
path: string
|
||||
constructor(path: string) {
|
||||
this.path = path
|
||||
}
|
||||
}
|
||||
|
||||
it('should calculate the correct distance between files in the same folder', () => {
|
||||
const file1 = new MockTFile('folder/file1.md') as TFile
|
||||
const file2 = new MockTFile('folder/file2.md') as TFile
|
||||
|
||||
const result = calculateFileDistance(file1, file2)
|
||||
expect(result).toBe(2)
|
||||
})
|
||||
|
||||
it('should calculate the correct distance between files in different subfolders', () => {
|
||||
const file1 = new MockTFile('folder1/folder2/file1.md') as TFile
|
||||
const file2 = new MockTFile('folder1/folder3/file2.md') as TFile
|
||||
|
||||
const result = calculateFileDistance(file1, file2)
|
||||
expect(result).toBe(4)
|
||||
})
|
||||
|
||||
it('should return null for files in different top-level folders', () => {
|
||||
const file1 = new MockTFile('folder1/file1.md') as TFile
|
||||
const file2 = new MockTFile('folder2/file2.md') as TFile
|
||||
|
||||
const result = calculateFileDistance(file1, file2)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle files at different depths', () => {
|
||||
const file1 = new MockTFile('folder1/folder2/subfolder/file1.md') as TFile
|
||||
const file2 = new MockTFile('folder1/folder3/file2.md') as TFile
|
||||
|
||||
const result = calculateFileDistance(file1, file2)
|
||||
expect(result).toBe(5)
|
||||
})
|
||||
|
||||
it('should return 0 for the same file', () => {
|
||||
const file = new MockTFile('folder/file.md') as TFile
|
||||
|
||||
const result = calculateFileDistance(file, file)
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it('should calculate the correct distance between a folder and a file', () => {
|
||||
const file = new MockTFile('folder1/folder2/file1.md') as TFile
|
||||
const folder = new MockTFile('folder1/folder2') as TFolder
|
||||
|
||||
const result = calculateFileDistance(file, folder)
|
||||
expect(result).toBe(1)
|
||||
})
|
||||
})
|
||||
124
src/utils/obsidian.ts
Normal file
124
src/utils/obsidian.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { App, Editor, MarkdownView, TFile, TFolder, Vault } from 'obsidian'
|
||||
|
||||
import { MentionableBlockData } from '../types/mentionable'
|
||||
|
||||
export async function readTFileContent(
|
||||
file: TFile,
|
||||
vault: Vault,
|
||||
): Promise<string> {
|
||||
return await vault.cachedRead(file)
|
||||
}
|
||||
|
||||
export async function readMultipleTFiles(
|
||||
files: TFile[],
|
||||
vault: Vault,
|
||||
): Promise<string[]> {
|
||||
// Read files in parallel
|
||||
const readPromises = files.map((file) => readTFileContent(file, vault))
|
||||
return await Promise.all(readPromises)
|
||||
}
|
||||
|
||||
export function getNestedFiles(folder: TFolder, vault: Vault): TFile[] {
|
||||
const files: TFile[] = []
|
||||
for (const child of folder.children) {
|
||||
if (child instanceof TFile) {
|
||||
files.push(child)
|
||||
} else if (child instanceof TFolder) {
|
||||
files.push(...getNestedFiles(child, vault))
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
export async function getMentionableBlockData(
|
||||
editor: Editor,
|
||||
view: MarkdownView,
|
||||
): Promise<MentionableBlockData | null> {
|
||||
const file = view.file
|
||||
if (!file) return null
|
||||
|
||||
const selection = editor.getSelection()
|
||||
if (!selection) return null
|
||||
|
||||
const startLine = editor.getCursor('from').line
|
||||
const endLine = editor.getCursor('to').line
|
||||
const selectionContent = editor
|
||||
.getValue()
|
||||
.split('\n')
|
||||
.slice(startLine, endLine + 1)
|
||||
.join('\n')
|
||||
|
||||
return {
|
||||
content: selectionContent,
|
||||
file,
|
||||
startLine: startLine + 1, // +1 because startLine is 0-indexed
|
||||
endLine: endLine + 1, // +1 because startLine is 0-indexed
|
||||
}
|
||||
}
|
||||
|
||||
export function getOpenFiles(app: App): TFile[] {
|
||||
try {
|
||||
const leaves = app.workspace.getLeavesOfType('markdown')
|
||||
|
||||
return leaves.map((v) => (v.view as MarkdownView).file).filter((v) => !!v)
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateFileDistance(
|
||||
file1: TFile | TFolder,
|
||||
file2: TFile | TFolder,
|
||||
): number | null {
|
||||
const path1 = file1.path.split('/')
|
||||
const path2 = file2.path.split('/')
|
||||
|
||||
// Check if files are in different top-level folders
|
||||
if (path1[0] !== path2[0]) {
|
||||
return null
|
||||
}
|
||||
|
||||
let distance = 0
|
||||
let i = 0
|
||||
|
||||
// Find the common ancestor
|
||||
while (i < path1.length && i < path2.length && path1[i] === path2[i]) {
|
||||
i++
|
||||
}
|
||||
|
||||
// Calculate distance from common ancestor to each file
|
||||
distance += path1.length - i
|
||||
distance += path2.length - i
|
||||
|
||||
return distance
|
||||
}
|
||||
|
||||
export function openMarkdownFile(
|
||||
app: App,
|
||||
filePath: string,
|
||||
startLine?: number,
|
||||
) {
|
||||
const file = app.vault.getFileByPath(filePath)
|
||||
if (!file) return
|
||||
|
||||
const existingLeaf = app.workspace
|
||||
.getLeavesOfType('markdown')
|
||||
.find(
|
||||
(leaf) =>
|
||||
leaf.view instanceof MarkdownView && leaf.view.file?.path === file.path,
|
||||
)
|
||||
|
||||
if (existingLeaf) {
|
||||
app.workspace.setActiveLeaf(existingLeaf, { focus: true })
|
||||
|
||||
if (startLine) {
|
||||
const view = existingLeaf.view as MarkdownView
|
||||
view.setEphemeralState({ line: startLine - 1 }) // -1 because line is 0-indexed
|
||||
}
|
||||
} else {
|
||||
const leaf = app.workspace.getLeaf('tab')
|
||||
leaf.openFile(file, {
|
||||
eState: startLine ? { line: startLine - 1 } : undefined, // -1 because line is 0-indexed
|
||||
})
|
||||
}
|
||||
}
|
||||
12
src/utils/ollama.ts
Normal file
12
src/utils/ollama.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { requestUrl } from 'obsidian'
|
||||
|
||||
export async function getOllamaModels(ollamaUrl: string) {
|
||||
try {
|
||||
const response = (await requestUrl(`${ollamaUrl}/api/tags`)).json as {
|
||||
models: { name: string }[]
|
||||
}
|
||||
return response.models.map((model) => model.name)
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
12
src/utils/open-settings-modal.ts
Normal file
12
src/utils/open-settings-modal.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { App } from 'obsidian'
|
||||
|
||||
import { OpenSettingsModal } from '../open-settings-modal'
|
||||
|
||||
export function openSettingsModalWithError(app: App, errorMessage: string) {
|
||||
new OpenSettingsModal(app, errorMessage, () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const setting = (app as any).setting
|
||||
setting.open()
|
||||
setting.openTabById('infio-copilot')
|
||||
}).open()
|
||||
}
|
||||
174
src/utils/parse-infio-block.test.ts
Normal file
174
src/utils/parse-infio-block.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { ParsedinfioBlock, parseinfioBlocks } from './parse-infio-block'
|
||||
|
||||
describe('parseinfioBlocks', () => {
|
||||
it('should parse a string with infio_block elements', () => {
|
||||
const input = `Some text before
|
||||
<infio_block language="markdown" filename="example.md">
|
||||
# Example Markdown
|
||||
|
||||
This is a sample markdown content for testing purposes.
|
||||
|
||||
## Features
|
||||
|
||||
- Lists
|
||||
- **Bold text**
|
||||
- *Italic text*
|
||||
- [Links](https://example.com)
|
||||
|
||||
### Code Block
|
||||
\`\`\`python
|
||||
print("Hello, world!")
|
||||
\`\`\`
|
||||
</infio_block>
|
||||
Some text after`
|
||||
|
||||
const expected: ParsedinfioBlock[] = [
|
||||
{ type: 'string', content: 'Some text before\n' },
|
||||
{
|
||||
type: 'infio_block',
|
||||
content: `
|
||||
# Example Markdown
|
||||
|
||||
This is a sample markdown content for testing purposes.
|
||||
|
||||
## Features
|
||||
|
||||
- Lists
|
||||
- **Bold text**
|
||||
- *Italic text*
|
||||
- [Links](https://example.com)
|
||||
|
||||
### Code Block
|
||||
\`\`\`python
|
||||
print("Hello, world!")
|
||||
\`\`\`
|
||||
`,
|
||||
language: 'markdown',
|
||||
filename: 'example.md',
|
||||
},
|
||||
{ type: 'string', content: '\nSome text after' },
|
||||
]
|
||||
|
||||
const result = parseinfioBlocks(input)
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should handle empty infio_block elements', () => {
|
||||
const input = `
|
||||
<infio_block language="python"></infio_block>
|
||||
`
|
||||
|
||||
const expected: ParsedinfioBlock[] = [
|
||||
{ type: 'string', content: '\n ' },
|
||||
{
|
||||
type: 'infio_block',
|
||||
content: '',
|
||||
language: 'python',
|
||||
filename: undefined,
|
||||
},
|
||||
{ type: 'string', content: '\n ' },
|
||||
]
|
||||
|
||||
const result = parseinfioBlocks(input)
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should handle input without infio_block elements', () => {
|
||||
const input = 'Just a regular string without any infio_block elements.'
|
||||
|
||||
const expected: ParsedinfioBlock[] = [{ type: 'string', content: input }]
|
||||
|
||||
const result = parseinfioBlocks(input)
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should handle multiple infio_block elements', () => {
|
||||
const input = `Start
|
||||
<infio_block language="python" filename="script.py">
|
||||
def greet(name):
|
||||
print(f"Hello, {name}!")
|
||||
</infio_block>
|
||||
Middle
|
||||
<infio_block language="markdown" filename="example.md">
|
||||
# Using tildes for code blocks
|
||||
|
||||
Did you know that you can use tildes for code blocks?
|
||||
|
||||
~~~python
|
||||
print("Hello, world!")
|
||||
~~~
|
||||
</infio_block>
|
||||
End`
|
||||
|
||||
const expected: ParsedinfioBlock[] = [
|
||||
{ type: 'string', content: 'Start\n' },
|
||||
{
|
||||
type: 'infio_block',
|
||||
content: `
|
||||
def greet(name):
|
||||
print(f"Hello, {name}!")
|
||||
`,
|
||||
language: 'python',
|
||||
filename: 'script.py',
|
||||
},
|
||||
{ type: 'string', content: '\nMiddle\n' },
|
||||
{
|
||||
type: 'infio_block',
|
||||
content: `
|
||||
# Using tildes for code blocks
|
||||
|
||||
Did you know that you can use tildes for code blocks?
|
||||
|
||||
~~~python
|
||||
print("Hello, world!")
|
||||
~~~
|
||||
`,
|
||||
language: 'markdown',
|
||||
filename: 'example.md',
|
||||
},
|
||||
{ type: 'string', content: '\nEnd' },
|
||||
]
|
||||
|
||||
const result = parseinfioBlocks(input)
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should handle unfinished infio_block with only opening tag', () => {
|
||||
const input = `Start
|
||||
<infio_block language="markdown">
|
||||
# Unfinished infio_block
|
||||
|
||||
Some text after without closing tag`
|
||||
const expected: ParsedinfioBlock[] = [
|
||||
{ type: 'string', content: 'Start\n' },
|
||||
{
|
||||
type: 'infio_block',
|
||||
content: `
|
||||
# Unfinished infio_block
|
||||
|
||||
Some text after without closing tag`,
|
||||
language: 'markdown',
|
||||
filename: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
const result = parseinfioBlocks(input)
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should handle infio_block with startline and endline attributes', () => {
|
||||
const input = `<infio_block language="markdown" startline="2" endline="5"></infio_block>`
|
||||
const expected: ParsedinfioBlock[] = [
|
||||
{
|
||||
type: 'infio_block',
|
||||
content: '',
|
||||
language: 'markdown',
|
||||
startLine: 2,
|
||||
endLine: 5,
|
||||
},
|
||||
]
|
||||
|
||||
const result = parseinfioBlocks(input)
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
})
|
||||
80
src/utils/parse-infio-block.ts
Normal file
80
src/utils/parse-infio-block.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { parseFragment } from 'parse5'
|
||||
|
||||
export type ParsedinfioBlock =
|
||||
| { type: 'string'; content: string }
|
||||
| {
|
||||
type: 'infio_block'
|
||||
content: string
|
||||
language?: string
|
||||
filename?: string
|
||||
startLine?: number
|
||||
endLine?: number
|
||||
action?: 'edit' | 'new' | 'reference'
|
||||
}
|
||||
|
||||
export function parseinfioBlocks(input: string): ParsedinfioBlock[] {
|
||||
const parsedResult: ParsedinfioBlock[] = []
|
||||
const fragment = parseFragment(input, {
|
||||
sourceCodeLocationInfo: true,
|
||||
})
|
||||
let lastEndOffset = 0
|
||||
for (const node of fragment.childNodes) {
|
||||
if (node.nodeName === 'infio_block') {
|
||||
if (!node.sourceCodeLocation) {
|
||||
throw new Error('sourceCodeLocation is undefined')
|
||||
}
|
||||
const startOffset = node.sourceCodeLocation.startOffset
|
||||
const endOffset = node.sourceCodeLocation.endOffset
|
||||
if (startOffset > lastEndOffset) {
|
||||
parsedResult.push({
|
||||
type: 'string',
|
||||
content: input.slice(lastEndOffset, startOffset),
|
||||
})
|
||||
}
|
||||
|
||||
const language = node.attrs.find((attr) => attr.name === 'language')?.value
|
||||
const filename = node.attrs.find((attr) => attr.name === 'filename')?.value
|
||||
const startLine = node.attrs.find((attr) => attr.name === 'startline')?.value
|
||||
const endLine = node.attrs.find((attr) => attr.name === 'endline')?.value
|
||||
const action = node.attrs.find((attr) => attr.name === 'type')?.value as 'edit' | 'new' | 'reference'
|
||||
|
||||
const children = node.childNodes
|
||||
if (children.length === 0) {
|
||||
parsedResult.push({
|
||||
type: 'infio_block',
|
||||
content: '',
|
||||
language,
|
||||
filename,
|
||||
startLine: startLine ? parseInt(startLine) : undefined,
|
||||
endLine: endLine ? parseInt(endLine) : undefined,
|
||||
action: action,
|
||||
})
|
||||
} else {
|
||||
const innerContentStartOffset =
|
||||
children[0].sourceCodeLocation?.startOffset
|
||||
const innerContentEndOffset =
|
||||
children[children.length - 1].sourceCodeLocation?.endOffset
|
||||
if (!innerContentStartOffset || !innerContentEndOffset) {
|
||||
throw new Error('sourceCodeLocation is undefined')
|
||||
}
|
||||
parsedResult.push({
|
||||
type: 'infio_block',
|
||||
content: input.slice(innerContentStartOffset, innerContentEndOffset),
|
||||
language,
|
||||
filename,
|
||||
startLine: startLine ? parseInt(startLine) : undefined,
|
||||
endLine: endLine ? parseInt(endLine) : undefined,
|
||||
action: action,
|
||||
})
|
||||
}
|
||||
lastEndOffset = endOffset
|
||||
}
|
||||
}
|
||||
if (lastEndOffset < input.length) {
|
||||
parsedResult.push({
|
||||
type: 'string',
|
||||
content: input.slice(lastEndOffset),
|
||||
})
|
||||
}
|
||||
return parsedResult
|
||||
}
|
||||
58
src/utils/price-calculator.ts
Normal file
58
src/utils/price-calculator.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
ANTHROPIC_PRICES,
|
||||
GEMINI_PRICES,
|
||||
GROQ_PRICES,
|
||||
OPENAI_PRICES,
|
||||
} from '../constants'
|
||||
import { CustomLLMModel } from '../types/llm/model'
|
||||
import { ResponseUsage } from '../types/llm/response'
|
||||
|
||||
// Returns the cost in dollars. Returns null if the model is not supported.
|
||||
export const calculateLLMCost = ({
|
||||
model,
|
||||
usage,
|
||||
}: {
|
||||
model: CustomLLMModel
|
||||
usage: ResponseUsage
|
||||
}): number | null => {
|
||||
switch (model.provider) {
|
||||
case 'openai': {
|
||||
const modelPricing = OPENAI_PRICES[model.name]
|
||||
if (!modelPricing) return null
|
||||
return (
|
||||
(usage.prompt_tokens * modelPricing.input +
|
||||
usage.completion_tokens * modelPricing.output) /
|
||||
1_000_000
|
||||
)
|
||||
}
|
||||
case 'anthropic': {
|
||||
const modelPricing = ANTHROPIC_PRICES[model.name]
|
||||
if (!modelPricing) return null
|
||||
return (
|
||||
(usage.prompt_tokens * modelPricing.input +
|
||||
usage.completion_tokens * modelPricing.output) /
|
||||
1_000_000
|
||||
)
|
||||
}
|
||||
case 'gemini': {
|
||||
const modelPricing = GEMINI_PRICES[model.name]
|
||||
if (!modelPricing) return null
|
||||
return (
|
||||
(usage.prompt_tokens * modelPricing.input +
|
||||
usage.completion_tokens * modelPricing.output) /
|
||||
1_000_000
|
||||
)
|
||||
}
|
||||
case 'groq': {
|
||||
const modelPricing = GROQ_PRICES[model.name]
|
||||
if (!modelPricing) return null
|
||||
return (
|
||||
(usage.prompt_tokens * modelPricing.input +
|
||||
usage.completion_tokens * modelPricing.output) /
|
||||
1_000_000
|
||||
)
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
470
src/utils/prompt-generator.ts
Normal file
470
src/utils/prompt-generator.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
import { App, TFile, htmlToMarkdown, requestUrl } from 'obsidian'
|
||||
|
||||
import { editorStateToPlainText } from '../components/chat-view/chat-input/utils/editor-state-to-plain-text'
|
||||
import { QueryProgressState } from '../components/chat-view/QueryProgress'
|
||||
import { RAGEngine } from '../core/rag/rag-engine'
|
||||
import { SelectVector } from '../database/schema'
|
||||
import { ChatMessage, ChatUserMessage } from '../types/chat'
|
||||
import { ContentPart, RequestMessage } from '../types/llm/request'
|
||||
import {
|
||||
MentionableBlock,
|
||||
MentionableFile,
|
||||
MentionableFolder,
|
||||
MentionableImage,
|
||||
MentionableUrl,
|
||||
MentionableVault,
|
||||
} from '../types/mentionable'
|
||||
import { InfioSettings } from '../types/settings'
|
||||
|
||||
import {
|
||||
getNestedFiles,
|
||||
readMultipleTFiles,
|
||||
readTFileContent,
|
||||
} from './obsidian'
|
||||
import { tokenCount } from './token'
|
||||
import { YoutubeTranscript, isYoutubeUrl } from './youtube-transcript'
|
||||
|
||||
export class PromptGenerator {
|
||||
private getRagEngine: () => Promise<RAGEngine>
|
||||
private app: App
|
||||
private settings: InfioSettings
|
||||
|
||||
constructor(
|
||||
getRagEngine: () => Promise<RAGEngine>,
|
||||
app: App,
|
||||
settings: InfioSettings,
|
||||
) {
|
||||
this.getRagEngine = getRagEngine
|
||||
this.app = app
|
||||
this.settings = settings
|
||||
}
|
||||
|
||||
public async generateRequestMessages({
|
||||
messages,
|
||||
useVaultSearch,
|
||||
onQueryProgressChange,
|
||||
type,
|
||||
}: {
|
||||
messages: ChatMessage[]
|
||||
useVaultSearch?: boolean
|
||||
onQueryProgressChange?: (queryProgress: QueryProgressState) => void
|
||||
type?: string
|
||||
}): Promise<{
|
||||
requestMessages: RequestMessage[]
|
||||
compiledMessages: ChatMessage[]
|
||||
}> {
|
||||
if (messages.length === 0) {
|
||||
throw new Error('No messages provided')
|
||||
}
|
||||
const lastUserMessage = messages[messages.length - 1]
|
||||
if (lastUserMessage.role !== 'user') {
|
||||
throw new Error('Last message is not a user message')
|
||||
}
|
||||
|
||||
const { promptContent, shouldUseRAG, similaritySearchResults } =
|
||||
await this.compileUserMessagePrompt({
|
||||
message: lastUserMessage,
|
||||
useVaultSearch,
|
||||
onQueryProgressChange,
|
||||
})
|
||||
let compiledMessages = [
|
||||
...messages.slice(0, -1),
|
||||
{
|
||||
...lastUserMessage,
|
||||
promptContent,
|
||||
similaritySearchResults,
|
||||
},
|
||||
]
|
||||
|
||||
// Safeguard: ensure all user messages have parsed content
|
||||
compiledMessages = await Promise.all(
|
||||
compiledMessages.map(async (message) => {
|
||||
if (message.role === 'user' && !message.promptContent) {
|
||||
const { promptContent, similaritySearchResults } =
|
||||
await this.compileUserMessagePrompt({
|
||||
message,
|
||||
})
|
||||
return {
|
||||
...message,
|
||||
promptContent,
|
||||
similaritySearchResults,
|
||||
}
|
||||
}
|
||||
return message
|
||||
}),
|
||||
)
|
||||
|
||||
const systemMessage = this.getSystemMessage(shouldUseRAG, type)
|
||||
|
||||
const customInstructionMessage = this.getCustomInstructionMessage()
|
||||
|
||||
const currentFile = lastUserMessage.mentionables.find(
|
||||
(m) => m.type === 'current-file',
|
||||
)?.file
|
||||
const currentFileMessage = currentFile
|
||||
? await this.getCurrentFileMessage(currentFile)
|
||||
: undefined
|
||||
|
||||
const requestMessages: RequestMessage[] = [
|
||||
systemMessage,
|
||||
...(customInstructionMessage ? [customInstructionMessage] : []),
|
||||
...(currentFileMessage ? [currentFileMessage] : []),
|
||||
...compiledMessages.slice(-20).map((message): RequestMessage => {
|
||||
if (message.role === 'user') {
|
||||
return {
|
||||
role: 'user',
|
||||
content: message.promptContent ?? '',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: message.content,
|
||||
}
|
||||
}
|
||||
}),
|
||||
...(shouldUseRAG ? [this.getRagInstructionMessage()] : []),
|
||||
]
|
||||
|
||||
return {
|
||||
requestMessages,
|
||||
compiledMessages,
|
||||
}
|
||||
}
|
||||
|
||||
private async compileUserMessagePrompt({
|
||||
message,
|
||||
useVaultSearch,
|
||||
onQueryProgressChange,
|
||||
}: {
|
||||
message: ChatUserMessage
|
||||
useVaultSearch?: boolean
|
||||
onQueryProgressChange?: (queryProgress: QueryProgressState) => void
|
||||
}): Promise<{
|
||||
promptContent: ChatUserMessage['promptContent']
|
||||
shouldUseRAG: boolean
|
||||
similaritySearchResults?: (Omit<SelectVector, 'embedding'> & {
|
||||
similarity: number
|
||||
})[]
|
||||
}> {
|
||||
if (!message.content) {
|
||||
return {
|
||||
promptContent: '',
|
||||
shouldUseRAG: false,
|
||||
}
|
||||
}
|
||||
const query = editorStateToPlainText(message.content)
|
||||
let similaritySearchResults = undefined
|
||||
|
||||
useVaultSearch =
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
useVaultSearch ||
|
||||
message.mentionables.some(
|
||||
(m): m is MentionableVault => m.type === 'vault',
|
||||
)
|
||||
|
||||
onQueryProgressChange?.({
|
||||
type: 'reading-mentionables',
|
||||
})
|
||||
const files = message.mentionables
|
||||
.filter((m): m is MentionableFile => m.type === 'file')
|
||||
.map((m) => m.file)
|
||||
const folders = message.mentionables
|
||||
.filter((m): m is MentionableFolder => m.type === 'folder')
|
||||
.map((m) => m.folder)
|
||||
const nestedFiles = folders.flatMap((folder) =>
|
||||
getNestedFiles(folder, this.app.vault),
|
||||
)
|
||||
const allFiles = [...files, ...nestedFiles]
|
||||
const fileContents = await readMultipleTFiles(allFiles, this.app.vault)
|
||||
|
||||
// Count tokens incrementally to avoid long processing times on large content sets
|
||||
const exceedsTokenThreshold = async () => {
|
||||
let accTokenCount = 0
|
||||
for (const content of fileContents) {
|
||||
const count = await tokenCount(content)
|
||||
accTokenCount += count
|
||||
if (accTokenCount > this.settings.ragOptions.thresholdTokens) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
const shouldUseRAG = useVaultSearch || (await exceedsTokenThreshold())
|
||||
|
||||
let filePrompt: string
|
||||
if (shouldUseRAG) {
|
||||
similaritySearchResults = useVaultSearch
|
||||
? await (
|
||||
await this.getRagEngine()
|
||||
).processQuery({
|
||||
query,
|
||||
onQueryProgressChange: onQueryProgressChange,
|
||||
}) // TODO: Add similarity boosting for mentioned files or folders
|
||||
: await (
|
||||
await this.getRagEngine()
|
||||
).processQuery({
|
||||
query,
|
||||
scope: {
|
||||
files: files.map((f) => f.path),
|
||||
folders: folders.map((f) => f.path),
|
||||
},
|
||||
onQueryProgressChange: onQueryProgressChange,
|
||||
})
|
||||
filePrompt = `## Potentially Relevant Snippets from the current vault
|
||||
${similaritySearchResults
|
||||
.map(({ path, content, metadata }) => {
|
||||
const contentWithLineNumbers = this.addLineNumbersToContent({
|
||||
content,
|
||||
startLine: metadata.startLine,
|
||||
})
|
||||
return `\`\`\`${path}\n${contentWithLineNumbers}\n\`\`\`\n`
|
||||
})
|
||||
.join('')}\n`
|
||||
} else {
|
||||
filePrompt = allFiles
|
||||
.map((file, index) => {
|
||||
return `\`\`\`${file.path}\n${fileContents[index]}\n\`\`\`\n`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
const blocks = message.mentionables.filter(
|
||||
(m): m is MentionableBlock => m.type === 'block',
|
||||
)
|
||||
const blockPrompt = blocks
|
||||
.map(({ file, content, startLine, endLine }) => {
|
||||
return `\`\`\`${file.path}#L${startLine}-${endLine}\n${content}\n\`\`\`\n`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const urls = message.mentionables.filter(
|
||||
(m): m is MentionableUrl => m.type === 'url',
|
||||
)
|
||||
|
||||
const urlPrompt =
|
||||
urls.length > 0
|
||||
? `## Potentially Relevant Websearch Results
|
||||
${(
|
||||
await Promise.all(
|
||||
urls.map(
|
||||
async ({ url }) => `\`\`\`
|
||||
Website URL: ${url}
|
||||
Website Content:
|
||||
${await this.getWebsiteContent(url)}
|
||||
\`\`\``,
|
||||
),
|
||||
)
|
||||
).join('\n')}
|
||||
`
|
||||
: ''
|
||||
|
||||
const imageDataUrls = message.mentionables
|
||||
.filter((m): m is MentionableImage => m.type === 'image')
|
||||
.map(({ data }) => data)
|
||||
|
||||
return {
|
||||
promptContent: [
|
||||
...imageDataUrls.map(
|
||||
(data): ContentPart => ({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: data,
|
||||
},
|
||||
}),
|
||||
),
|
||||
{
|
||||
type: 'text',
|
||||
text: `${filePrompt}${blockPrompt}${urlPrompt}\n\n${query}\n\n`,
|
||||
},
|
||||
],
|
||||
shouldUseRAG,
|
||||
similaritySearchResults: similaritySearchResults,
|
||||
}
|
||||
}
|
||||
|
||||
private getSystemMessage(shouldUseRAG: boolean, type?: string): RequestMessage {
|
||||
const systemPromptEdit = `You are an intelligent assistant to help edit text content based on user instructions. You will be given the current text content and the user's instruction for how to modify it.
|
||||
|
||||
1. Your response should contain the modified text content wrapped in <infio_block> tags with appropriate attributes:
|
||||
<infio_block filename="path/to/file.md" language="markdown" startLine="10" endLine="20" type="edit">
|
||||
[modified content here]
|
||||
</infio_block>
|
||||
|
||||
2. Preserve the original formatting, indentation and line breaks unless specifically instructed otherwise.
|
||||
|
||||
3. Make minimal changes necessary to fulfill the user's instruction. Do not modify parts of the text that don't need to change.
|
||||
|
||||
4. If the instruction is unclear or cannot be fulfilled, respond with "ERROR: " followed by a brief explanation.`
|
||||
|
||||
const systemPrompt = `You are an intelligent assistant to help answer any questions that the user has, particularly about editing and organizing markdown files in Obsidian.
|
||||
|
||||
1. Please keep your response as concise as possible. Avoid being verbose.
|
||||
|
||||
2. When the user is asking for edits to their markdown, please provide a simplified version of the markdown block emphasizing only the changes. Use comments to show where unchanged content has been skipped. Wrap the markdown block with <infio_block> tags. Add filename, language, startLine, endLine and type attributes to the <infio_block> tags. If the user provides line numbers in the file path (e.g. file.md#L10-20), use those line numbers in the startLine and endLine attributes. For example:
|
||||
<infio_block filename="path/to/file.md" language="markdown" startLine="10" endLine="20" type="edit">
|
||||
<!-- ... existing content ... -->
|
||||
{{ edit_1 }}
|
||||
<!-- ... existing content ... -->
|
||||
{{ edit_2 }}
|
||||
<!-- ... existing content ... -->
|
||||
</infio_block>
|
||||
The user has full access to the file, so they prefer seeing only the changes in the markdown. Often this will mean that the start/end of the file will be skipped, but that's okay! Rewrite the entire file only if specifically requested. Always provide a brief explanation of the updates, except when the user specifically asks for just the content.
|
||||
|
||||
3. Do not lie or make up facts.
|
||||
|
||||
4. Respond in the same language as the user's message.
|
||||
|
||||
5. Format your response in markdown.
|
||||
|
||||
6. When writing out new markdown blocks, also wrap them with <infio_block> tags. For example:
|
||||
<infio_block language="markdown" type="new">
|
||||
{{ content }}
|
||||
</infio_block>
|
||||
|
||||
7. When providing markdown blocks for an existing file, add the filename and language attributes to the <infio_block> tags. Restate the relevant section or heading, so the user knows which part of the file you are editing. For example:
|
||||
<infio_block filename="path/to/file.md" language="markdown" type="reference">
|
||||
## Section Title
|
||||
...
|
||||
{{ content }}
|
||||
...
|
||||
</infio_block>`
|
||||
|
||||
const systemPromptRAG = `You are an intelligent assistant to help answer any questions that the user has, particularly about editing and organizing markdown files in Obsidian. You will be given your conversation history with them and potentially relevant blocks of markdown content from the current vault.
|
||||
|
||||
1. Do not lie or make up facts.
|
||||
|
||||
2. Respond in the same language as the user's message.
|
||||
|
||||
3. Format your response in markdown.
|
||||
|
||||
4. When referencing markdown blocks in your answer, keep the following guidelines in mind:
|
||||
|
||||
a. Never include line numbers in the output markdown.
|
||||
|
||||
b. Wrap the markdown block with <infio_block> tags. Include language attribute and type. For example:
|
||||
<infio_block language="markdown" type="new">
|
||||
{{ content }}
|
||||
</infio_block>
|
||||
|
||||
c. When providing markdown blocks for an existing file, also include the filename attribute to the <infio_block> tags. For example:
|
||||
<infio_block filename="path/to/file.md" language="markdown" type="reference">
|
||||
{{ content }}
|
||||
</infio_block>
|
||||
|
||||
d. When referencing a markdown block the user gives you, add the startLine and endLine attributes to the <infio_block> tags. Write related content outside of the <infio_block> tags. The content inside the <infio_block> tags will be ignored and replaced with the actual content of the markdown block. For example:
|
||||
<infio_block filename="path/to/file.md" language="markdown" startLine="2" endLine="30" type="reference"></infio_block>`
|
||||
|
||||
if (type === 'edit') {
|
||||
return {
|
||||
role: 'system',
|
||||
content: systemPromptEdit,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
role: 'system',
|
||||
content: shouldUseRAG ? systemPromptRAG : systemPrompt,
|
||||
}
|
||||
}
|
||||
|
||||
private getCustomInstructionMessage(): RequestMessage | null {
|
||||
const customInstruction = this.settings.systemPrompt.trim()
|
||||
if (!customInstruction) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
role: 'user',
|
||||
content: `Here are additional instructions to follow in your responses when relevant. There's no need to explicitly acknowledge them:
|
||||
<custom_instructions>
|
||||
${customInstruction}
|
||||
</custom_instructions>`,
|
||||
}
|
||||
}
|
||||
|
||||
private async getCurrentFileMessage(
|
||||
currentFile: TFile,
|
||||
): Promise<RequestMessage> {
|
||||
const fileContent = await readTFileContent(currentFile, this.app.vault)
|
||||
return {
|
||||
role: 'user',
|
||||
content: `# Inputs
|
||||
## Current File
|
||||
Here is the file I'm looking at.
|
||||
\`\`\`${currentFile.path}
|
||||
${fileContent}
|
||||
\`\`\`\n\n`,
|
||||
}
|
||||
}
|
||||
|
||||
public async generateEditMessages({
|
||||
currentFile,
|
||||
selectedContent,
|
||||
instruction,
|
||||
startLine,
|
||||
endLine,
|
||||
}: {
|
||||
currentFile: TFile
|
||||
selectedContent: string
|
||||
instruction: string
|
||||
startLine: number
|
||||
endLine: number
|
||||
}): Promise<RequestMessage[]> {
|
||||
const systemMessage = this.getSystemMessage(false, 'edit')
|
||||
const currentFileMessage = await this.getCurrentFileMessage(currentFile)
|
||||
const userMessage: RequestMessage = {
|
||||
role: 'user',
|
||||
content: `Selected text (lines ${startLine}-${endLine}):\n${selectedContent}\n\nInstruction:\n${instruction}`,
|
||||
}
|
||||
|
||||
return [systemMessage, currentFileMessage, userMessage]
|
||||
}
|
||||
|
||||
private getRagInstructionMessage(): RequestMessage {
|
||||
return {
|
||||
role: 'user',
|
||||
content: `If you need to reference any of the markdown blocks I gave you, add the startLine and endLine attributes to the <infio_block> tags without any content inside. For example:
|
||||
<infio_block filename="path/to/file.md" language="markdown" startLine="200" endLine="310" type="reference"></infio_block>
|
||||
|
||||
When writing out new markdown blocks, remember not to include "line_number|" at the beginning of each line.`,
|
||||
}
|
||||
}
|
||||
|
||||
private addLineNumbersToContent({
|
||||
content,
|
||||
startLine,
|
||||
}: {
|
||||
content: string
|
||||
startLine: number
|
||||
}): string {
|
||||
const lines = content.split('\n')
|
||||
const linesWithNumbers = lines.map((line, index) => {
|
||||
return `${startLine + index}|${line}`
|
||||
})
|
||||
return linesWithNumbers.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Improve markdown conversion logic
|
||||
* - filter visually hidden elements
|
||||
* ...
|
||||
*/
|
||||
private async getWebsiteContent(url: string): Promise<string> {
|
||||
if (isYoutubeUrl(url)) {
|
||||
try {
|
||||
// TODO: pass language based on user preferences
|
||||
const { title, transcript } =
|
||||
await YoutubeTranscript.fetchTranscriptAndMetadata(url)
|
||||
|
||||
return `Title: ${title}
|
||||
Video Transcript:
|
||||
${transcript.map((t) => `${t.offset}: ${t.text}`).join('\n')}`
|
||||
} catch (error) {
|
||||
console.error('Error fetching YouTube transcript', error)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await requestUrl({ url })
|
||||
|
||||
return htmlToMarkdown(response.text)
|
||||
}
|
||||
}
|
||||
11
src/utils/token.ts
Normal file
11
src/utils/token.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { getEncoding } from 'js-tiktoken'
|
||||
|
||||
// TODO: Replace js-tiktoken with tiktoken library for better performance
|
||||
// Note: tiktoken uses WebAssembly, requiring esbuild configuration
|
||||
|
||||
// Caution: tokenCount is computationally expensive for large inputs.
|
||||
// Frequent use, especially on large files, may significantly impact performance.
|
||||
export async function tokenCount(text: string): Promise<number> {
|
||||
const encoder = getEncoding('cl100k_base')
|
||||
return encoder.encode(text).length
|
||||
}
|
||||
198
src/utils/youtube-transcript.ts
Normal file
198
src/utils/youtube-transcript.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* This source code is licensed under the MIT license.
|
||||
* Original source: https://github.com/Kakulukian/youtube-transcript
|
||||
*
|
||||
* Modified from the original code
|
||||
*/
|
||||
|
||||
import { requestUrl } from 'obsidian'
|
||||
|
||||
const RE_YOUTUBE =
|
||||
/(?:youtube\.com\/(?:[^/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?/\s]{11})/i
|
||||
const USER_AGENT =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36,gzip(gfe)'
|
||||
const RE_XML_TRANSCRIPT = /<text start="([^"]*)" dur="([^"]*)">([^<]*)<\/text>/g
|
||||
|
||||
export function isYoutubeUrl(url: string) {
|
||||
return RE_YOUTUBE.test(url)
|
||||
}
|
||||
|
||||
export class YoutubeTranscriptError extends Error {
|
||||
constructor(message: string) {
|
||||
super(`[YoutubeTranscript] 🚨 ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export class YoutubeTranscriptTooManyRequestError extends YoutubeTranscriptError {
|
||||
constructor() {
|
||||
super(
|
||||
'YouTube is receiving too many requests from this IP and now requires solving a captcha to continue',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class YoutubeTranscriptVideoUnavailableError extends YoutubeTranscriptError {
|
||||
constructor(videoId: string) {
|
||||
super(`The video is no longer available (${videoId})`)
|
||||
}
|
||||
}
|
||||
|
||||
export class YoutubeTranscriptDisabledError extends YoutubeTranscriptError {
|
||||
constructor(videoId: string) {
|
||||
super(`Transcript is disabled on this video (${videoId})`)
|
||||
}
|
||||
}
|
||||
|
||||
export class YoutubeTranscriptNotAvailableError extends YoutubeTranscriptError {
|
||||
constructor(videoId: string) {
|
||||
super(`No transcripts are available for this video (${videoId})`)
|
||||
}
|
||||
}
|
||||
|
||||
export class YoutubeTranscriptNotAvailableLanguageError extends YoutubeTranscriptError {
|
||||
constructor(lang: string, availableLangs: string[], videoId: string) {
|
||||
super(
|
||||
`No transcripts are available in ${lang} this video (${videoId}). Available languages: ${availableLangs.join(
|
||||
', ',
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export type TranscriptConfig = {
|
||||
lang?: string
|
||||
}
|
||||
export type Transcript = {
|
||||
text: string
|
||||
duration: number
|
||||
offset: number
|
||||
lang?: string
|
||||
}
|
||||
|
||||
export type TranscriptAndMetadataResponse = {
|
||||
title: string
|
||||
transcript: Transcript[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Class to retrieve transcript if exist
|
||||
*/
|
||||
export class YoutubeTranscript {
|
||||
/**
|
||||
* Fetch transcript from YTB Video
|
||||
* @param videoId Video url or video identifier
|
||||
* @param config Get transcript in a specific language ISO
|
||||
*/
|
||||
public static async fetchTranscriptAndMetadata(
|
||||
videoId: string,
|
||||
config?: TranscriptConfig,
|
||||
): Promise<TranscriptAndMetadataResponse> {
|
||||
const identifier = this.retrieveVideoId(videoId)
|
||||
const videoPageResponse = await requestUrl({
|
||||
url: `https://www.youtube.com/watch?v=${identifier}`,
|
||||
headers: {
|
||||
...(config?.lang && { 'Accept-Language': config.lang }),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
})
|
||||
const videoPageBody = videoPageResponse.text
|
||||
|
||||
// Extract title using regex from <title> tags
|
||||
const titleMatch = videoPageBody.match(/<title>(.*?)<\/title>/)
|
||||
const title = titleMatch
|
||||
? titleMatch[1].replace(' - YouTube', '').trim()
|
||||
: ''
|
||||
|
||||
const splittedHTML = videoPageBody.split('"captions":')
|
||||
|
||||
if (splittedHTML.length <= 1) {
|
||||
if (videoPageBody.includes('class="g-recaptcha"')) {
|
||||
throw new YoutubeTranscriptTooManyRequestError()
|
||||
}
|
||||
if (!videoPageBody.includes('"playabilityStatus":')) {
|
||||
throw new YoutubeTranscriptVideoUnavailableError(videoId)
|
||||
}
|
||||
throw new YoutubeTranscriptDisabledError(videoId)
|
||||
}
|
||||
|
||||
const captions = (() => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return JSON.parse(
|
||||
splittedHTML[1].split(',"videoDetails')[0].replace('\n', ''),
|
||||
)
|
||||
} catch (e) {
|
||||
return undefined
|
||||
}
|
||||
})()?.playerCaptionsTracklistRenderer
|
||||
|
||||
if (!captions) {
|
||||
throw new YoutubeTranscriptDisabledError(videoId)
|
||||
}
|
||||
|
||||
if (!('captionTracks' in captions)) {
|
||||
throw new YoutubeTranscriptNotAvailableError(videoId)
|
||||
}
|
||||
|
||||
if (
|
||||
config?.lang &&
|
||||
!captions.captionTracks.some(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(track: any) => track.languageCode === config?.lang,
|
||||
)
|
||||
) {
|
||||
throw new YoutubeTranscriptNotAvailableLanguageError(
|
||||
config?.lang,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
|
||||
captions.captionTracks.map((track: any) => track.languageCode),
|
||||
videoId,
|
||||
)
|
||||
}
|
||||
|
||||
const transcriptURL: string = (
|
||||
config?.lang
|
||||
? captions.captionTracks.find(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(track: any) => track.languageCode === config?.lang,
|
||||
)
|
||||
: captions.captionTracks[0]
|
||||
).baseUrl
|
||||
|
||||
const transcriptResponse = await requestUrl({
|
||||
url: transcriptURL,
|
||||
headers: {
|
||||
...(config?.lang && { 'Accept-Language': config.lang }),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
})
|
||||
if (transcriptResponse.status !== 200) {
|
||||
throw new YoutubeTranscriptNotAvailableError(videoId)
|
||||
}
|
||||
const transcriptBody = transcriptResponse.text
|
||||
const results = [...transcriptBody.matchAll(RE_XML_TRANSCRIPT)]
|
||||
return {
|
||||
title,
|
||||
transcript: results.map((result) => ({
|
||||
text: result[3],
|
||||
duration: parseFloat(result[2]),
|
||||
offset: parseFloat(result[1]),
|
||||
lang: config?.lang ?? captions.captionTracks[0].languageCode,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve video id from url or string
|
||||
* @param videoId video url or video id
|
||||
*/
|
||||
private static retrieveVideoId(videoId: string) {
|
||||
if (videoId.length === 11) {
|
||||
return videoId
|
||||
}
|
||||
const matchId = videoId.match(RE_YOUTUBE)
|
||||
if (matchId?.length) {
|
||||
return matchId[1]
|
||||
}
|
||||
throw new YoutubeTranscriptError('Impossible to retrieve Youtube video ID.')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user