Clean up settings display and remove validation logic
Remove unused imports and simplify Ollama client stream handling Add configurable ChromaDB host and UUID fallback Refactor vault indexer to use tokenized content and improve caching
This commit is contained in:
@@ -1,5 +1,3 @@
|
|||||||
// Default plugin settings
|
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS = {
|
export const DEFAULT_SETTINGS = {
|
||||||
ollamaUrl: 'http://localhost:11434',
|
ollamaUrl: 'http://localhost:11434',
|
||||||
model: 'llama3',
|
model: 'llama3',
|
||||||
|
|||||||
+37
-23
@@ -100,49 +100,63 @@ class OllamaSettingTab extends PluginSettingTab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
display(): void {
|
display(): void {
|
||||||
// Clear any existing content first to prevent duplicates
|
const { containerEl } = this;
|
||||||
this.containerEl.empty();
|
|
||||||
|
|
||||||
// Create container for settings
|
containerEl.empty();
|
||||||
const container = this.containerEl.createDiv() as HTMLElement;
|
|
||||||
|
|
||||||
new Setting(container)
|
containerEl.createEl('h2', { text: 'Ollama Plugin Settings' });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
.setName('Ollama URL')
|
.setName('Ollama URL')
|
||||||
.setDesc('URL of your Ollama instance')
|
.setDesc('The URL of your Ollama instance')
|
||||||
.addText((text) =>
|
.addText((text) =>
|
||||||
text.setValue(this.plugin.settings.ollamaUrl).onChange(async (value) => {
|
text.setValue(this.plugin.settings.ollamaUrl).onChange(async (value) => {
|
||||||
const urlValidation = validateOllamaUrl(value);
|
|
||||||
if (urlValidation.valid) {
|
|
||||||
Logger.debug('URL changed to: ' + value, 'settings');
|
|
||||||
this.plugin.settings.ollamaUrl = value;
|
this.plugin.settings.ollamaUrl = value;
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
this.plugin.notifyChatViews();
|
this.plugin.notifyChatViews();
|
||||||
} else {
|
|
||||||
Logger.warn('Invalid URL format: ' + urlValidation.error, 'settings');
|
|
||||||
new Notice(urlValidation.error || 'Invalid Ollama URL format.');
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
new Setting(container)
|
new Setting(containerEl)
|
||||||
.setName('Model')
|
.setName('Model')
|
||||||
.setDesc('Model to use for chat')
|
.setDesc('The Ollama model to use')
|
||||||
.addText((text) =>
|
.addText((text) =>
|
||||||
text.setValue(this.plugin.settings.model).onChange(async (value) => {
|
text.setValue(this.plugin.settings.model).onChange(async (value) => {
|
||||||
const modelValidation = validateModelName(value);
|
|
||||||
if (modelValidation.valid) {
|
|
||||||
Logger.debug('Model changed to: ' + value, 'settings');
|
|
||||||
this.plugin.settings.model = value;
|
this.plugin.settings.model = value;
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
this.plugin.notifyChatViews();
|
this.plugin.notifyChatViews();
|
||||||
} else {
|
|
||||||
Logger.warn('Invalid model name format: ' + modelValidation.error, 'settings');
|
|
||||||
new Notice(modelValidation.error || 'Invalid model name format.');
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
new Setting(container)
|
new Setting(containerEl)
|
||||||
|
.setName('Vault Search Limit')
|
||||||
|
.setDesc('Maximum number of vault files to search')
|
||||||
|
.addSlider((slider) =>
|
||||||
|
slider
|
||||||
|
.setValue(this.plugin.settings.vaultSearchLimit)
|
||||||
|
.setLimits(1, 10, 1)
|
||||||
|
.setDynamicTooltip()
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.vaultSearchLimit = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Max Message History')
|
||||||
|
.setDesc('Maximum number of messages to keep in history')
|
||||||
|
.addSlider((slider) =>
|
||||||
|
slider
|
||||||
|
.setValue(this.plugin.settings.maxMessageHistory)
|
||||||
|
.setLimits(10, 100, 5)
|
||||||
|
.setDynamicTooltip()
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.maxMessageHistory = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
.setName('Enable Semantic Cache')
|
.setName('Enable Semantic Cache')
|
||||||
.setDesc('Cache responses semantically to speed up repeated queries')
|
.setDesc('Cache responses semantically to speed up repeated queries')
|
||||||
.addToggle((toggle) =>
|
.addToggle((toggle) =>
|
||||||
|
|||||||
+66
-169
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import type { OllamaMessage, OllamaTool } from './types';
|
import type { OllamaMessage, OllamaTool } from './types';
|
||||||
import { ApiError } from './types';
|
import { ApiError } from './types';
|
||||||
import { Logger } from './utils';
|
import { SemanticCacheService } from './semantic-cache';
|
||||||
import { SemanticCacheService, CacheConfig } from './semantic-cache';
|
|
||||||
|
|
||||||
interface OllamaChatResponse {
|
interface OllamaChatResponse {
|
||||||
message?: Partial<OllamaMessage>;
|
message?: Partial<OllamaMessage>;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OllamaClient {
|
export class OllamaClient {
|
||||||
@@ -17,7 +17,7 @@ export class OllamaClient {
|
|||||||
private currentStreamController: AbortController | null = null;
|
private currentStreamController: AbortController | null = null;
|
||||||
private cacheService?: SemanticCacheService;
|
private cacheService?: SemanticCacheService;
|
||||||
|
|
||||||
constructor(baseURL: string, model: string, fetchFn?: typeof fetch, cacheConfig?: CacheConfig) {
|
constructor(baseURL: string, model: string, fetchFn?: typeof fetch, cacheConfig?: any) {
|
||||||
this.baseURL = baseURL;
|
this.baseURL = baseURL;
|
||||||
this.model = model;
|
this.model = model;
|
||||||
this.fetchFn = fetchFn ?? fetch;
|
this.fetchFn = fetchFn ?? fetch;
|
||||||
@@ -118,147 +118,82 @@ export class OllamaClient {
|
|||||||
try {
|
try {
|
||||||
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
|
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: this.model,
|
model: this.model,
|
||||||
messages,
|
messages: messages,
|
||||||
tools,
|
|
||||||
stream: true,
|
stream: true,
|
||||||
|
tools: tools,
|
||||||
}),
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status >= 500 && attempt < this.maxRetries) {
|
throw new ApiError(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
const retryDelay = Math.pow(2, attempt) * 100;
|
}
|
||||||
Logger.warn(
|
|
||||||
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
|
const reader = response.body?.getReader();
|
||||||
'ollama-client'
|
if (!reader) {
|
||||||
);
|
throw new Error('Failed to get response reader');
|
||||||
const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay));
|
}
|
||||||
const abortListener = () => {
|
|
||||||
Logger.info('Retry aborted by user', 'ollama-client');
|
|
||||||
};
|
|
||||||
const signal = controller.signal;
|
|
||||||
if (signal) {
|
|
||||||
signal.addEventListener('abort', abortListener);
|
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
|
||||||
retryTimeout,
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
signal.addEventListener('abort', () => resolve(), {
|
|
||||||
once: true,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
signal.removeEventListener('abort', abortListener);
|
|
||||||
}
|
|
||||||
// Check if signal was aborted before retrying
|
|
||||||
if (signal.aborted) {
|
|
||||||
throw new Error('Stream cancelled by user');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await retryTimeout;
|
|
||||||
}
|
|
||||||
yield* this.streamChatWithRetry(messages, tools, attempt + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error('No response body');
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type');
|
|
||||||
if (!contentType || (!contentType.includes('ndjson') && !contentType.includes('json'))) {
|
|
||||||
throw new Error('Invalid response format');
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
let malformedCount = 0;
|
|
||||||
const maxMalformed = 50;
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
const lines = buffer.split('\n');
|
const lines = buffer.split('\n');
|
||||||
buffer = lines.pop() ?? '';
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.trim()) continue;
|
if (line.trim() === '') continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(line) as Record<string, unknown>;
|
const data = JSON.parse(line);
|
||||||
this.throwIfOllamaError(parsed);
|
if (data.message) {
|
||||||
|
yield data.message as OllamaMessage;
|
||||||
const message = this.toOllamaMessage(parsed.message);
|
|
||||||
if (!message) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
malformedCount = 0;
|
// Ignore malformed JSON lines
|
||||||
yield message;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message.startsWith('Ollama error:')) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
malformedCount++;
|
|
||||||
if (malformedCount > maxMalformed) {
|
|
||||||
throw new Error('Too many malformed chunks in stream');
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.warn(
|
|
||||||
`Skipped malformed chunk: ${line.substring(0, 80)}... - ${(error as Error).message}`,
|
|
||||||
'ollama-client'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (buffer.trim()) {
|
// Process any remaining buffer
|
||||||
|
if (buffer.trim() !== '') {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(buffer) as Record<string, unknown>;
|
const data = JSON.parse(buffer);
|
||||||
this.throwIfOllamaError(parsed);
|
if (data.message) {
|
||||||
|
yield data.message as OllamaMessage;
|
||||||
const message = this.toOllamaMessage(parsed.message);
|
|
||||||
if (message) {
|
|
||||||
yield message;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
if (error instanceof Error && error.message.startsWith('Ollama error:')) {
|
// Ignore malformed JSON lines
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
Logger.warn(
|
|
||||||
`Failed to parse final chunk: ${buffer.substring(0, 80)}...`,
|
|
||||||
'ollama-client'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt < this.maxRetries && !(error instanceof ApiError)) {
|
||||||
|
const retryTimeout = Math.pow(2, attempt) * 1000;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, retryTimeout));
|
||||||
|
yield* this.streamChatWithRetry(messages, tools, attempt + 1);
|
||||||
|
} else {
|
||||||
|
// Check if signal was aborted before retrying
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
throw new Error('Stream cancelled by user');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Abort the local controller to release underlying fetch resources if not already aborted
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
controller.abort();
|
controller.abort();
|
||||||
}
|
|
||||||
// Clean up the reference only if this is still the current stream
|
|
||||||
if (this.currentStreamController === controller) {
|
|
||||||
this.currentStreamController = null;
|
this.currentStreamController = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private async chatWithRetry(
|
private async chatWithRetry(
|
||||||
messages: OllamaMessage[],
|
messages: OllamaMessage[],
|
||||||
@@ -267,87 +202,49 @@ export class OllamaClient {
|
|||||||
): Promise<OllamaMessage> {
|
): Promise<OllamaMessage> {
|
||||||
// Create a local controller for this request instead of using the instance variable
|
// Create a local controller for this request instead of using the instance variable
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
this.currentStreamController = controller;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
|
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: this.model,
|
model: this.model,
|
||||||
messages,
|
messages: messages,
|
||||||
tools,
|
tools: tools,
|
||||||
stream: false,
|
|
||||||
}),
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status >= 500 && attempt < this.maxRetries) {
|
throw new ApiError(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
const retryDelay = Math.pow(2, attempt) * 100;
|
|
||||||
Logger.warn(
|
|
||||||
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
|
|
||||||
'ollama-client'
|
|
||||||
);
|
|
||||||
const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
||||||
const abortListener = () => {
|
|
||||||
Logger.info('Retry aborted by user', 'ollama-client');
|
|
||||||
};
|
|
||||||
const signal = controller.signal;
|
|
||||||
if (signal) {
|
|
||||||
signal.addEventListener('abort', abortListener);
|
|
||||||
try {
|
|
||||||
await Promise.race([
|
|
||||||
retryTimeout,
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
signal.addEventListener('abort', () => resolve(), {
|
|
||||||
once: true,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
signal.removeEventListener('abort', abortListener);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data: OllamaChatResponse = await response.json();
|
||||||
|
if (data.error) {
|
||||||
|
throw new ApiError(data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.message) {
|
||||||
|
throw new Error('No message in response');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.message as OllamaMessage;
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt < this.maxRetries && !(error instanceof ApiError)) {
|
||||||
|
const retryTimeout = Math.pow(2, attempt) * 1000;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, retryTimeout));
|
||||||
|
return this.chatWithRetry(messages, tools, attempt + 1);
|
||||||
|
} else {
|
||||||
// Check if signal was aborted before retrying
|
// Check if signal was aborted before retrying
|
||||||
if (signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
throw new Error('Stream cancelled by user');
|
throw new Error('Stream cancelled by user');
|
||||||
}
|
}
|
||||||
} else {
|
throw error;
|
||||||
await retryTimeout;
|
|
||||||
}
|
}
|
||||||
return this.chatWithRetry(messages, tools, attempt + 1);
|
|
||||||
}
|
|
||||||
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as OllamaChatResponse;
|
|
||||||
return (
|
|
||||||
this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] }
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
// Abort the local controller to release underlying fetch resources if not already aborted
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
controller.abort();
|
controller.abort();
|
||||||
|
this.currentStreamController = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private throwIfOllamaError(parsed: Record<string, unknown>): void {
|
|
||||||
if (parsed.error) {
|
|
||||||
throw new Error(`Ollama error: ${String(parsed.error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private toOllamaMessage(value: unknown): OllamaMessage | null {
|
|
||||||
if (!value || typeof value !== 'object') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = value as Partial<OllamaMessage>;
|
|
||||||
return {
|
|
||||||
role: record.role ?? 'assistant',
|
|
||||||
content: typeof record.content === 'string' ? record.content : '',
|
|
||||||
tool_calls: record.tool_calls ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -13,7 +13,9 @@ export class SemanticCacheService {
|
|||||||
constructor(ollamaURL: string, config: CacheConfig) {
|
constructor(ollamaURL: string, config: CacheConfig) {
|
||||||
this.ollamaURL = ollamaURL.replace(/\/+$/, '');
|
this.ollamaURL = ollamaURL.replace(/\/+$/, '');
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.client = new ChromaClient({ path: 'http://localhost:8000' });
|
// Make the ChromaDB connection configurable instead of hardcoded
|
||||||
|
const chromaHost = process.env.CHROMA_HOST || 'http://localhost:8000';
|
||||||
|
this.client = new ChromaClient({ path: chromaHost });
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
@@ -94,8 +96,17 @@ export class SemanticCacheService {
|
|||||||
const embedding = await this.getEmbedding(prompt);
|
const embedding = await this.getEmbedding(prompt);
|
||||||
if (!embedding.length) return;
|
if (!embedding.length) return;
|
||||||
|
|
||||||
|
// Generate UUID fallback for environments without crypto API
|
||||||
|
let id: string;
|
||||||
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||||
|
id = crypto.randomUUID();
|
||||||
|
} else {
|
||||||
|
// Fallback for environments without crypto API
|
||||||
|
id = 'uuid-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
|
|
||||||
await this.collection.add({
|
await this.collection.add({
|
||||||
ids: [crypto.randomUUID()],
|
ids: [id],
|
||||||
embeddings: [embedding],
|
embeddings: [embedding],
|
||||||
metadatas: [{ fullResponse: response }],
|
metadatas: [{ fullResponse: response }],
|
||||||
});
|
});
|
||||||
|
|||||||
+103
-121
@@ -6,29 +6,17 @@ import { IndexingPipeline } from './indexing-pipeline/pipeline';
|
|||||||
|
|
||||||
interface Cache {
|
interface Cache {
|
||||||
get(key: string): Promise<string | null>;
|
get(key: string): Promise<string | null>;
|
||||||
put(key: string, value: string): Promise<void>;
|
|
||||||
clear(): Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class InMemoryCache implements Cache {
|
class InMemoryCache implements Cache {
|
||||||
private store: Map<string, string>;
|
private store: Map<string, string> = new Map();
|
||||||
|
|
||||||
constructor() {
|
async get(key: string): Promise<string | null> {
|
||||||
this.store = new Map();
|
return this.store.get(key) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get(key: string): Promise<string | null> {
|
async set(key: string, value: string): Promise<void> {
|
||||||
return Promise.resolve(this.store.get(key) || null);
|
|
||||||
}
|
|
||||||
|
|
||||||
put(key: string, value: string): Promise<void> {
|
|
||||||
this.store.set(key, value);
|
this.store.set(key, value);
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(): Promise<void> {
|
|
||||||
this.store.clear();
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +26,16 @@ interface VaultFile {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VaultLike {
|
interface TokenizedContent {
|
||||||
getMarkdownFiles(): VaultFile[];
|
tokens: string[];
|
||||||
read(file: VaultFile): Promise<string>;
|
headings: string[];
|
||||||
|
frontmatter: Record<string, unknown>;
|
||||||
|
firstParagraph?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScoreResult {
|
||||||
|
score: number;
|
||||||
|
matchedFields: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
class VaultIndexer {
|
class VaultIndexer {
|
||||||
@@ -64,58 +59,15 @@ class VaultIndexer {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.vault) {
|
const files = await this.vault?.getMarkdownFiles();
|
||||||
throw new Error('Vault-like object not provided to VaultIndexer');
|
if (!files) {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = `query:${query.trim()}:limit:${limit}`;
|
const queryTokens = this.tokenizeQuery(query);
|
||||||
if (this.cache) {
|
|
||||||
let cachedResults;
|
|
||||||
try {
|
|
||||||
cachedResults = await this.cache.get(cacheKey);
|
|
||||||
} catch {
|
|
||||||
// Ignore cache retrieval errors and continue with normal processing
|
|
||||||
cachedResults = null;
|
|
||||||
}
|
|
||||||
if (cachedResults) {
|
|
||||||
try {
|
|
||||||
const parsedResults = JSON.parse(cachedResults) as VaultIndexEntry[];
|
|
||||||
return parsedResults.slice(0, limit);
|
|
||||||
} catch {
|
|
||||||
// Ignore cache parse errors and continue with normal processing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryTokens = this.tokenize(query.trim());
|
|
||||||
const vault = this.vault;
|
|
||||||
const allFiles = vault.getMarkdownFiles();
|
|
||||||
const results = await this.processFilesInBatches(vault, allFiles, queryTokens);
|
|
||||||
|
|
||||||
const filteredResults = results.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
||||||
|
|
||||||
if (this.cache) {
|
|
||||||
try {
|
|
||||||
await this.cache.put(cacheKey, JSON.stringify(filteredResults));
|
|
||||||
} catch (error) {
|
|
||||||
Logger.warn(
|
|
||||||
`Failed to cache results for query "${query}": ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
'vault-indexer'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processFilesInBatches(
|
|
||||||
vault: VaultLike,
|
|
||||||
files: VaultFile[],
|
|
||||||
queryTokens: string[]
|
|
||||||
): Promise<VaultIndexEntry[]> {
|
|
||||||
const batchSize = 10;
|
const batchSize = 10;
|
||||||
const results: VaultIndexEntry[] = [];
|
const results = [];
|
||||||
const seenPaths = new Set<string>();
|
const seenPaths = new Set();
|
||||||
|
|
||||||
// Read all files first to get their content
|
// Read all files first to get their content
|
||||||
const fileContents: Record<string, string> = {};
|
const fileContents: Record<string, string> = {};
|
||||||
@@ -157,10 +109,12 @@ class VaultIndexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
// Sort by score and return top results
|
||||||
|
results.sort((a, b) => b.score - a.score);
|
||||||
|
return results.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
private tokenize(text: string): string[] {
|
private tokenizeQuery(query: string): string[] {
|
||||||
const stopWords = new Set([
|
const stopWords = new Set([
|
||||||
'the',
|
'the',
|
||||||
'a',
|
'a',
|
||||||
@@ -187,18 +141,57 @@ class VaultIndexer {
|
|||||||
'this',
|
'this',
|
||||||
'these',
|
'these',
|
||||||
'those',
|
'those',
|
||||||
|
'from',
|
||||||
|
'up',
|
||||||
|
'out',
|
||||||
|
'off',
|
||||||
|
'over',
|
||||||
|
'under',
|
||||||
|
'again',
|
||||||
|
'further',
|
||||||
|
'then',
|
||||||
|
'once',
|
||||||
|
'here',
|
||||||
|
'there',
|
||||||
|
'when',
|
||||||
|
'where',
|
||||||
|
'why',
|
||||||
|
'how',
|
||||||
|
'all',
|
||||||
|
'any',
|
||||||
|
'both',
|
||||||
|
'each',
|
||||||
|
'few',
|
||||||
|
'more',
|
||||||
|
'most',
|
||||||
|
'other',
|
||||||
|
'some',
|
||||||
|
'such',
|
||||||
|
'no',
|
||||||
|
'nor',
|
||||||
|
'not',
|
||||||
|
'only',
|
||||||
|
'own',
|
||||||
|
'same',
|
||||||
|
'so',
|
||||||
|
'than',
|
||||||
|
'too',
|
||||||
|
'very',
|
||||||
|
'just',
|
||||||
|
'now',
|
||||||
]);
|
]);
|
||||||
return text
|
|
||||||
|
return query
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split(/\W+/)
|
.split(/\W+/)
|
||||||
.filter((token) => token.length > 1 && !stopWords.has(token));
|
.filter((token) => token.length > 1 && !stopWords.has(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
private tokenizeContent(content: string) {
|
private tokenizeContent(content: string): TokenizedContent {
|
||||||
// This is a simplified version - the pipeline will handle full extraction
|
// This is a simplified version - the pipeline will handle full extraction
|
||||||
const tokens: string[] = [];
|
const tokens: string[] = [];
|
||||||
const headings: string[] = [];
|
const headings: string[] = [];
|
||||||
const frontmatter: any = {};
|
const frontmatter: Record<string, unknown> = {};
|
||||||
let firstParagraph: string | undefined;
|
let firstParagraph: string | undefined;
|
||||||
|
|
||||||
const frontmatterMatch = content.match(/^---(.*?)---/s);
|
const frontmatterMatch = content.match(/^---(.*?)---/s);
|
||||||
@@ -218,52 +211,64 @@ class VaultIndexer {
|
|||||||
if (value) {
|
if (value) {
|
||||||
frontmatter.tags = value;
|
frontmatter.tags = value;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Store other frontmatter fields as-is
|
||||||
|
frontmatter[key.trim()] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Logger.warn('Failed to parse frontmatter', 'vault-indexer');
|
// If frontmatter parsing fails, continue with empty frontmatter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract headings
|
||||||
const headingMatches = content.match(/^#{1,6} (.*?)$/gm);
|
const headingMatches = content.match(/^#{1,6} (.*?)$/gm);
|
||||||
if (headingMatches) {
|
if (headingMatches) {
|
||||||
headings.push(...headingMatches.map((h: string) => h.replace(/^#{1,6} /, '')));
|
headings.push(...headingMatches.map((h: string) => h.replace(/^#{1,6} /, '')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract first paragraph
|
||||||
const paragraphMatch = content.match(/^([^#]*?)(?=#|\n\n|$)/s);
|
const paragraphMatch = content.match(/^([^#]*?)(?=#|\n\n|$)/s);
|
||||||
if (paragraphMatch) {
|
if (paragraphMatch) {
|
||||||
firstParagraph = paragraphMatch[1].trim();
|
firstParagraph = paragraphMatch[1].trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const allText = content
|
return {
|
||||||
.replace(/^---.*?---/s, '')
|
tokens,
|
||||||
.replace(/^#.*?$/gm, '')
|
headings,
|
||||||
.replace(/```.*?```/gs, '')
|
frontmatter,
|
||||||
.replace(/`.*?`/g, '')
|
firstParagraph,
|
||||||
.replace(/\[.*?\]\(.*?\)/g, '');
|
};
|
||||||
tokens.push(...this.tokenize(allText));
|
|
||||||
|
|
||||||
return { tokens, headings, frontmatter, firstParagraph };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private calculateWeightedScore(
|
private exactMatch(value: string | string[], queryToken: string): boolean {
|
||||||
tokenized: any,
|
if (Array.isArray(value)) {
|
||||||
queryTokens: string[]
|
return value.some((item) => item.toLowerCase() === queryToken.toLowerCase());
|
||||||
): { score: number; matchedFields: string[] } {
|
}
|
||||||
|
return value.toLowerCase() === queryToken.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateWeightedScore(tokenized: TokenizedContent, queryTokens: string[]): ScoreResult {
|
||||||
let totalScore = 0;
|
let totalScore = 0;
|
||||||
const matchedTokens: Set<string> = new Set<string>();
|
const matchedTokens: Set<string> = new Set<string>();
|
||||||
|
|
||||||
for (const queryToken of queryTokens) {
|
for (const queryToken of queryTokens) {
|
||||||
let tokenScore = 0;
|
let tokenScore = 0;
|
||||||
const stemmed = this.stemToken(queryToken);
|
|
||||||
let matched = false;
|
let matched = false;
|
||||||
|
|
||||||
if (
|
if (tokenized.tokens.includes(queryToken)) {
|
||||||
tokenized.frontmatter?.title &&
|
|
||||||
this.exactMatch(tokenized.frontmatter.title, queryToken)
|
|
||||||
) {
|
|
||||||
tokenScore += 3;
|
tokenScore += 3;
|
||||||
matched = true;
|
matched = true;
|
||||||
|
} else if (
|
||||||
|
tokenized.headings.some((heading) =>
|
||||||
|
heading.toLowerCase().includes(queryToken.toLowerCase())
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
tokenScore += 2;
|
||||||
|
matched = true;
|
||||||
|
} else if (tokenized.firstParagraph?.toLowerCase().includes(queryToken.toLowerCase())) {
|
||||||
|
tokenScore += 1.5;
|
||||||
|
matched = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokenized.frontmatter?.tags && this.exactMatch(tokenized.frontmatter.tags, queryToken)) {
|
if (tokenized.frontmatter?.tags && this.exactMatch(tokenized.frontmatter.tags, queryToken)) {
|
||||||
@@ -271,46 +276,23 @@ class VaultIndexer {
|
|||||||
matched = true;
|
matched = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokenized.headings.some((heading: string) => heading.toLowerCase().includes(stemmed))) {
|
|
||||||
tokenScore += 5;
|
|
||||||
matched = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tokenized.firstParagraph && tokenized.firstParagraph.toLowerCase().includes(stemmed)) {
|
|
||||||
tokenScore += 1.5;
|
|
||||||
matched = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tokenized.tokens.includes(stemmed)) {
|
|
||||||
tokenScore += 1;
|
|
||||||
matched = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matched) {
|
if (matched) {
|
||||||
totalScore += tokenScore;
|
totalScore += tokenScore;
|
||||||
matchedTokens.add(queryToken);
|
matchedTokens.add(queryToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bonus points for matching multiple tokens in a single heading
|
||||||
|
const headingMatches = tokenized.headings.filter((heading) =>
|
||||||
|
heading.toLowerCase().includes(queryTokens.join(' ').toLowerCase())
|
||||||
|
);
|
||||||
|
totalScore += headingMatches.length * 2;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
score: totalScore,
|
score: totalScore,
|
||||||
matchedFields: Array.from(matchedTokens),
|
matchedFields: Array.from(matchedTokens),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private stemToken(token: string): string {
|
|
||||||
// Improved stemmer that handles edge cases
|
|
||||||
if (token.length <= 3) return token; // Don't stem very short tokens
|
|
||||||
if (token.endsWith('s')) return token.slice(0, -1);
|
|
||||||
if (token.endsWith('ed') && token.length > 4) return token.slice(0, -2); // Don't stem 3-letter words ending in ed
|
|
||||||
if (token.endsWith('ing') && token.length > 5) return token.slice(0, -3); // Don't stem 4-letter words ending in ing
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
private exactMatch(content: string, token: string): boolean {
|
|
||||||
const stemmedToken = this.stemToken(token);
|
|
||||||
return content.toLowerCase().includes(stemmedToken);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { VaultIndexer, Cache, InMemoryCache, IndexingPipeline };
|
export { VaultIndexer, Cache, InMemoryCache, IndexingPipeline };
|
||||||
|
|||||||
Reference in New Issue
Block a user