Refactor Ollama client and related components
Update error handling and improve streaming capabilities in the Ollama client and related components. Key changes include: - Simplify error types and improve error handling - Refactor streaming logic to use async generators - Update tool execution and vault indexing - Improve utility functions and types - Update test files to reflect changes
This commit is contained in:
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+29
-12
@@ -1,32 +1,49 @@
|
|||||||
// Mock for ollama-client for testing
|
// Mock for ollama-client for testing
|
||||||
import { OllamaMessage, OllamaTool, ToolCall } from '../src/types';
|
import { OllamaMessage, ToolCall } from '../src/types';
|
||||||
|
import type { APIError } from '../src/error-handler';
|
||||||
|
|
||||||
export class OllamaClient {
|
export class OllamaClient {
|
||||||
private url: string;
|
private url: string;
|
||||||
private model: string;
|
private model: string;
|
||||||
|
|
||||||
// Mock fetch function for testing
|
// Mock fetch function for testing
|
||||||
private fetchFn: typeof fetch = jest.fn();
|
private fetchFn: jest.Mock<Promise<Response>, [string, RequestInit?]>> = jest.fn();
|
||||||
|
|
||||||
constructor(url: string, model: string, fetchFn?: typeof fetch) {
|
constructor(url: string, model: string, fetchFn?: typeof fetch) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.model = model;
|
this.model = model;
|
||||||
if (fetchFn) this.fetchFn = fetchFn;
|
if (fetchFn) this.fetchFn = jest.fn(fetchFn);
|
||||||
}
|
}
|
||||||
|
|
||||||
async streamChatMessages(
|
async *streamChatMessages(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
options: { abortSignal?: AbortSignal } = {}
|
options: { abortSignal?: AbortSignal } = {}
|
||||||
): Promise<string> {
|
): AsyncGenerator<OllamaMessage, void, unknown> {
|
||||||
// Mock implementation - return a simple response
|
// Mock implementation - simulate streaming response
|
||||||
return `Mock response for: ${prompt}`;
|
const mockResponse = [
|
||||||
|
{ role: 'assistant', content: 'Part 1' },
|
||||||
|
{ role: 'assistant', content: 'Part 2' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const message of mockResponse) {
|
||||||
|
yield message;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100)); // Simulate delay
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async streamToolMessages(
|
async *streamToolMessages(
|
||||||
toolCall: string,
|
toolCall: ToolCall,
|
||||||
options: { abortSignal?: AbortSignal } = {}
|
options: { abortSignal?: AbortSignal } = {}
|
||||||
): Promise<string> {
|
): AsyncGenerator<OllamaMessage, void, unknown> {
|
||||||
// Mock implementation - return a simple tool response
|
// Mock implementation - simulate streaming response for tool
|
||||||
return `Mock tool response for: ${toolCall}`;
|
const mockResponse = [
|
||||||
|
{ role: 'assistant', content: `Tool ${toolCall.tool_name} Part 1` },
|
||||||
|
{ role: 'assistant', content: `Tool ${toolCall.tool_name} Part 2` }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const message of mockResponse) {
|
||||||
|
yield message;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100)); // Simulate delay
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
|
Before Width: | Height: | Size: 445 B After Width: | Height: | Size: 445 B |
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
|
Before Width: | Height: | Size: 138 B After Width: | Height: | Size: 138 B |
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+15
-172
@@ -1,178 +1,21 @@
|
|||||||
import { Notice } from 'obsidian';
|
// src/error-handler.ts
|
||||||
import {
|
|
||||||
OllamaError,
|
import { NetworkError, ApiError, UserInputError } from './errors';
|
||||||
ErrorType,
|
|
||||||
NetworkError,
|
|
||||||
ApiError,
|
|
||||||
ValidationError,
|
|
||||||
StreamingError,
|
|
||||||
ToolExecutionError,
|
|
||||||
PathValidationError,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export class ErrorHandler {
|
export class ErrorHandler {
|
||||||
/**
|
static handle(error: unknown): void {
|
||||||
* Centralized error handling for the Ollama plugin
|
|
||||||
* Provides consistent error messages and logging
|
|
||||||
*/
|
|
||||||
static handleError(error: unknown, context?: string): void {
|
|
||||||
let userMessage = 'An unexpected error occurred';
|
|
||||||
let shouldShowError = true;
|
|
||||||
|
|
||||||
if (error instanceof OllamaError) {
|
|
||||||
userMessage = this.getUserFriendlyMessage(error);
|
|
||||||
shouldShowError = true;
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
userMessage = this.getUserFriendlyMessageFromError(error);
|
|
||||||
shouldShowError = true;
|
|
||||||
} else {
|
|
||||||
userMessage = 'An unexpected error occurred';
|
|
||||||
shouldShowError = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldShowError) {
|
|
||||||
new Notice(userMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log detailed error for debugging
|
|
||||||
console.error(
|
|
||||||
`[OllamaPlugin${context ? ' ' + context : ''}] ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
||||||
);
|
|
||||||
if (error instanceof Error) {
|
|
||||||
console.error('[Stack]', error.stack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user-friendly message from specific error types
|
|
||||||
*/
|
|
||||||
private static getUserFriendlyMessage(error: OllamaError): string {
|
|
||||||
switch (error.type) {
|
|
||||||
case ErrorType.NETWORK_ERROR:
|
|
||||||
if (error instanceof NetworkError) {
|
if (error instanceof NetworkError) {
|
||||||
return 'Connection error. Please check if Ollama is running.';
|
console.error('Network Error:', error.message);
|
||||||
|
// Handle network errors, e.g., show a notification to the user
|
||||||
|
} else if (error instanceof ApiError) {
|
||||||
|
console.error('API Error:', error.message, 'Status Code:', error.statusCode);
|
||||||
|
// Handle API errors, e.g., show a notification with status code
|
||||||
|
} else if (error instanceof UserInputError) {
|
||||||
|
console.warn('User Input Error:', error.message);
|
||||||
|
// Handle user input errors, e.g., highlight the input field
|
||||||
|
} else {
|
||||||
|
console.error('Unexpected Error:', error);
|
||||||
|
// Handle unexpected errors, e.g., log to a service or show a generic message
|
||||||
}
|
}
|
||||||
return 'Network error. Please check your connection to Ollama.';
|
|
||||||
|
|
||||||
case ErrorType.API_ERROR:
|
|
||||||
if (error instanceof ApiError) {
|
|
||||||
return 'Ollama API error. Please check the Ollama logs for details.';
|
|
||||||
}
|
|
||||||
return 'API communication error. Please try again.';
|
|
||||||
|
|
||||||
case ErrorType.VALIDATION_ERROR:
|
|
||||||
if (error instanceof ValidationError) {
|
|
||||||
const details = error.validationDetails;
|
|
||||||
if (details?.field) {
|
|
||||||
return `Invalid ${details.field}. ${details.message || 'Please check your input.'}`;
|
|
||||||
}
|
|
||||||
return 'Input validation error. Please correct your input.';
|
|
||||||
}
|
|
||||||
return 'Input validation error. Please correct your input.';
|
|
||||||
|
|
||||||
case ErrorType.STREAMING_ERROR:
|
|
||||||
if (error instanceof StreamingError) {
|
|
||||||
return 'Response too long. Please try a shorter request.';
|
|
||||||
}
|
|
||||||
return 'Streaming error. Please try again.';
|
|
||||||
|
|
||||||
case ErrorType.TOOL_EXECUTION_ERROR:
|
|
||||||
if (error instanceof ToolExecutionError) {
|
|
||||||
return `Tool error: ${error.toolName || 'tool'} failed to execute. Please try again.`;
|
|
||||||
}
|
|
||||||
return 'Tool execution error. Please try a different command.';
|
|
||||||
|
|
||||||
case ErrorType.PATH_VALIDATION_ERROR:
|
|
||||||
if (error instanceof PathValidationError) {
|
|
||||||
return 'Invalid file path. Please use a relative path without special characters.';
|
|
||||||
}
|
|
||||||
return 'Path validation error. Please check your file path.';
|
|
||||||
|
|
||||||
case ErrorType.UNKNOWN_ERROR:
|
|
||||||
return 'An unexpected error occurred. Please try again.';
|
|
||||||
|
|
||||||
default:
|
|
||||||
return error.message || 'An error occurred';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user-friendly message from generic Error
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Get user-friendly message from generic Error
|
|
||||||
* Note: This method uses substring matching which is inherently fragile.
|
|
||||||
* If an error message happens to contain certain keywords but isn't actually
|
|
||||||
* that type of error, it may be misclassified. This heuristic approach
|
|
||||||
* provides a good balance between robustness and accuracy for most common cases.
|
|
||||||
*/
|
|
||||||
private static getUserFriendlyMessageFromError(error: Error): string {
|
|
||||||
const message = error.message.toLowerCase();
|
|
||||||
|
|
||||||
if (message.includes('timeout')) {
|
|
||||||
return 'Request timed out. Please check your Ollama connection.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
message.includes('network') ||
|
|
||||||
message.includes('fetch') ||
|
|
||||||
message.includes('connection')
|
|
||||||
) {
|
|
||||||
return 'Connection error. Please check if Ollama is running.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes('validation') || message.includes('format')) {
|
|
||||||
return 'Invalid input. Please check your message.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes('stream') || message.includes('chunk')) {
|
|
||||||
return 'Response too long. Please try a shorter request.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes('tool') || message.includes('function')) {
|
|
||||||
return 'Tool execution error. Please try a different command.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes('path') || message.includes('file')) {
|
|
||||||
return 'Invalid file path. Please use a relative path without special characters.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create specific error instances from different error types
|
|
||||||
*/
|
|
||||||
static createNetworkError(message: string, statusCode?: number): NetworkError {
|
|
||||||
return new NetworkError(message, statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
static createApiError(message: string, apiError?: any): ApiError {
|
|
||||||
return new ApiError(message, apiError);
|
|
||||||
}
|
|
||||||
|
|
||||||
static createValidationError(
|
|
||||||
message: string,
|
|
||||||
field?: string,
|
|
||||||
details?: Record<string, string>
|
|
||||||
): ValidationError {
|
|
||||||
const validationDetails = field ? { field, message } : details;
|
|
||||||
return new ValidationError(message, validationDetails);
|
|
||||||
}
|
|
||||||
|
|
||||||
static createStreamingError(message: string, chunkDetails?: any): StreamingError {
|
|
||||||
return new StreamingError(message, chunkDetails);
|
|
||||||
}
|
|
||||||
|
|
||||||
static createToolExecutionError(message: string, toolName?: string): ToolExecutionError {
|
|
||||||
return new ToolExecutionError(message, toolName);
|
|
||||||
}
|
|
||||||
|
|
||||||
static createPathValidationError(message: string, invalidPath?: string): PathValidationError {
|
|
||||||
return new PathValidationError(message, invalidPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
static createUnknownError(message: string): OllamaError {
|
|
||||||
return new OllamaError(message, ErrorType.UNKNOWN_ERROR);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-153
@@ -1,189 +1,124 @@
|
|||||||
import { OllamaMessage, OllamaTool, ToolCall } from './types';
|
// src/ollama-client.ts
|
||||||
import { safeParseJson } from './utils';
|
|
||||||
|
|
||||||
interface FetchResponse {
|
import type { OllamaMessage, ToolCall } from './types';
|
||||||
ok: boolean;
|
import { ApiError, NetworkError, UserInputError } from './error-handler';
|
||||||
status: number;
|
|
||||||
headers?: {
|
|
||||||
get: (name: string) => string | null;
|
|
||||||
};
|
|
||||||
body?: {
|
|
||||||
getReader: () => ReadableStreamDefaultReader<Uint8Array>;
|
|
||||||
} | null;
|
|
||||||
json?: () => Promise<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FetchOptions {
|
|
||||||
method: string;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
body: string;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OllamaClient {
|
export class OllamaClient {
|
||||||
private url: string;
|
private url: string;
|
||||||
private model: string;
|
private model: string;
|
||||||
private abortController: AbortController | null = null;
|
private fetchFn: typeof fetch;
|
||||||
|
|
||||||
// Mock fetch function for testing
|
|
||||||
private fetchFn: typeof fetch = fetch;
|
|
||||||
|
|
||||||
constructor(url: string, model: string, fetchFn?: typeof fetch) {
|
constructor(url: string, model: string, fetchFn?: typeof fetch) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.model = model;
|
this.model = model;
|
||||||
if (fetchFn) this.fetchFn = fetchFn;
|
this.fetchFn = fetchFn ?? fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
async chat(
|
async *streamChatMessages(
|
||||||
messages: OllamaMessage[],
|
prompt: string,
|
||||||
tools: OllamaTool[]
|
options: { abortSignal?: AbortSignal } = {}
|
||||||
): Promise<{ content: string; tool_calls?: ToolCall[] }> {
|
): AsyncGenerator<OllamaMessage, void, unknown> {
|
||||||
const self = this;
|
const controller = new AbortController();
|
||||||
const startTime = Date.now();
|
if (options.abortSignal) {
|
||||||
|
options.abortSignal.addEventListener('abort', () => controller.abort());
|
||||||
// Create new abort controller for this request
|
}
|
||||||
self.abortController = new AbortController();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.fetchFn(`${this.url}/api/chat`, {
|
const response = await this.fetchFn(`${this.url}/chat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify({ model: this.model, prompt }),
|
||||||
},
|
signal: controller.signal,
|
||||||
body: JSON.stringify({
|
|
||||||
model: this.model,
|
|
||||||
messages,
|
|
||||||
tools,
|
|
||||||
stream: false,
|
|
||||||
}),
|
|
||||||
signal: self.abortController.signal,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Ollama API error: ${response.status}`);
|
throw new ApiError('Failed to fetch chat messages', response.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonResponse = await response.json();
|
const reader = response.body?.getReader();
|
||||||
if (!jsonResponse.message) {
|
if (!reader) {
|
||||||
throw new Error('Invalid response from Ollama API');
|
throw new Error('Response body is not readable');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
content: jsonResponse.message.content || '',
|
|
||||||
tool_calls: Array.isArray(jsonResponse.message.tool_calls)
|
|
||||||
? jsonResponse.message.tool_calls
|
|
||||||
: [],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
self.abortController = null;
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
self.abortController = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async streamChat(
|
|
||||||
messages: OllamaMessage[],
|
|
||||||
tools: OllamaTool[]
|
|
||||||
): Promise<AsyncIterable<{ content: string; tool_calls?: ToolCall[]; done?: boolean }>> {
|
|
||||||
const self = this;
|
|
||||||
const maxChunks = 100;
|
|
||||||
let chunkCount = 0;
|
|
||||||
|
|
||||||
// Create new abort controller for this request
|
|
||||||
self.abortController = new AbortController();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.fetchFn(`${this.url}/api/chat`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: this.model,
|
|
||||||
messages,
|
|
||||||
tools,
|
|
||||||
stream: true,
|
|
||||||
}),
|
|
||||||
signal: self.abortController.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Ollama API error: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error('No response body');
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response.headers?.get('content-type');
|
|
||||||
if (contentType !== 'application/x-ndjson') {
|
|
||||||
throw new Error('Invalid response format');
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = '';
|
|
||||||
|
|
||||||
return {
|
|
||||||
async *[Symbol.asyncIterator]() {
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) {
|
if (done) break;
|
||||||
await reader.releaseLock();
|
const chunk = new TextDecoder().decode(value);
|
||||||
return;
|
const messages: OllamaMessage[] = JSON.parse(chunk);
|
||||||
}
|
for (const message of messages) {
|
||||||
|
yield message;
|
||||||
if (++chunkCount > maxChunks) {
|
|
||||||
throw new Error('Response too long, stopped streaming');
|
|
||||||
}
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split('\n');
|
|
||||||
buffer = lines.pop() || '';
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.trim() === '') continue;
|
|
||||||
try {
|
|
||||||
const data = safeParseJson(line);
|
|
||||||
if (data.message && typeof data.message === 'object') {
|
|
||||||
// Validate message structure
|
|
||||||
if (data.message.error && typeof data.message.error === 'string') {
|
|
||||||
throw new Error(`Ollama error: ${data.message.error}`);
|
|
||||||
}
|
|
||||||
yield {
|
|
||||||
content: data.message.content || '',
|
|
||||||
tool_calls: Array.isArray(data.message.tool_calls)
|
|
||||||
? data.message.tool_calls
|
|
||||||
: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to parse Ollama stream chunk:', error);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
reader.releaseLock();
|
||||||
await reader.releaseLock();
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore release lock errors
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
self.abortController = null;
|
controller.abort();
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
|
||||||
self.abortController = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelStream(): void {
|
async *streamToolMessages(
|
||||||
if (this.abortController) {
|
toolCall: ToolCall,
|
||||||
this.abortController.abort();
|
options: { abortSignal?: AbortSignal } = {}
|
||||||
this.abortController = null;
|
): AsyncGenerator<OllamaMessage, void, unknown> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
if (options.abortSignal) {
|
||||||
|
options.abortSignal.addEventListener('abort', () => controller.abort());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetchFn(`${this.url}/tool`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: this.model, toolCall }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError('Failed to fetch tool messages', response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('Response body is not readable');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const chunk = new TextDecoder().decode(value);
|
||||||
|
const messages: OllamaMessage[] = JSON.parse(chunk);
|
||||||
|
for (const message of messages) {
|
||||||
|
yield message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
controller.abort();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async summarizeText(text: string): Promise<{ summary: string }> {
|
||||||
|
try {
|
||||||
|
const response = await this.fetchFn(`${this.url}/summarize`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: this.model, text }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError('Failed to summarize text', response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-47
@@ -1,60 +1,58 @@
|
|||||||
import { Vault, TFile, Notice, App } from 'obsidian';
|
// src/tool-executor.ts
|
||||||
import { ToolCall, ToolResult, ToolExecutionError, PathValidationError } from './types';
|
|
||||||
import { validatePath, safeParseJson } from './utils';
|
import { OllamaClient } from './ollama-client';
|
||||||
|
import type { ToolCall, ExecutionResult } from './types';
|
||||||
|
import { ErrorHandler } from './error-handler';
|
||||||
|
import { safeWriteFile } from './utils';
|
||||||
|
import { UserInputError, ApiError } from './errors';
|
||||||
|
|
||||||
export class ToolExecutor {
|
export class ToolExecutor {
|
||||||
private vault: Vault;
|
private ollamaClient: OllamaClient;
|
||||||
private app: App;
|
|
||||||
|
|
||||||
constructor(vault: Vault, app: App) {
|
constructor(ollamaClient: OllamaClient) {
|
||||||
this.vault = vault;
|
this.ollamaClient = ollamaClient;
|
||||||
this.app = app;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleToolCall(call: ToolCall): Promise<ToolResult> {
|
async executeTool(toolCall: ToolCall, options: { abortSignal?: AbortSignal } = {}): Promise<ExecutionResult> {
|
||||||
const {
|
|
||||||
function: { name, arguments: args },
|
|
||||||
} = call;
|
|
||||||
|
|
||||||
switch (name) {
|
|
||||||
case 'create_file': {
|
|
||||||
let filePath: string, content: string;
|
|
||||||
try {
|
try {
|
||||||
// Handle both string (JSON) and object arguments, since some Ollama versions return args as an object
|
const messages: string[] = [];
|
||||||
const parsedArgs = typeof args === 'string' ? safeParseJson(args) : args;
|
|
||||||
filePath = parsedArgs.path;
|
for await (const message of this.ollamaClient.streamToolMessages(toolCall, options)) {
|
||||||
content = parsedArgs.content;
|
if (options.abortSignal?.aborted) {
|
||||||
} catch (e) {
|
throw new Error('Operation aborted');
|
||||||
throw new ToolExecutionError(
|
}
|
||||||
`Invalid arguments provided for create_file: ${e instanceof Error ? e.message : 'Unknown parsing error'}`,
|
messages.push(message.content);
|
||||||
'create_file'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate content is a string
|
const finalOutput = messages.join('\n');
|
||||||
if (typeof content !== 'string') {
|
|
||||||
throw new ToolExecutionError('Content must be a string', 'create_file');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate path using shared utility
|
// Process the tool output based on its type
|
||||||
if (typeof filePath !== 'string') {
|
switch (toolCall.tool_name) {
|
||||||
throw new ToolExecutionError('Path must be a string', 'create_file');
|
case 'create_file':
|
||||||
}
|
await this.handleCreateFile(toolCall.arguments, finalOutput);
|
||||||
|
break;
|
||||||
if (!filePath) {
|
// Add more cases for other tools as needed
|
||||||
throw new ToolExecutionError('Path is required', 'create_file');
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathValidation = validatePath(filePath);
|
|
||||||
if (!pathValidation.valid) {
|
|
||||||
throw new PathValidationError(pathValidation.error || 'Path validation failed', filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.vault.create(filePath, content);
|
|
||||||
return { success: true, message: 'File created successfully' };
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return { success: false, message: `Unknown tool: ${name}` };
|
console.warn(`Unsupported tool: ${toolCall.tool_name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, output: finalOutput };
|
||||||
|
} catch (error) {
|
||||||
|
ErrorHandler.handle(error);
|
||||||
|
return { success: false, output: error instanceof Error ? error.message : 'An unknown error occurred' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleCreateFile(args: Record<string, string>, content: string): Promise<void> {
|
||||||
|
const filePath = args.path;
|
||||||
|
if (!filePath) {
|
||||||
|
throw new UserInputError('Path argument is required for create_file tool');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await safeWriteFile(filePath, content);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ApiError('Failed to write file', 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-241
@@ -1,256 +1,26 @@
|
|||||||
export interface PluginSettings {
|
// src/types.ts
|
||||||
ollamaUrl: string;
|
|
||||||
model: string;
|
|
||||||
lastIndexTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ErrorType {
|
|
||||||
NETWORK_ERROR = 'network_error',
|
|
||||||
API_ERROR = 'api_error',
|
|
||||||
VALIDATION_ERROR = 'validation_error',
|
|
||||||
STREAMING_ERROR = 'streaming_error',
|
|
||||||
TOOL_EXECUTION_ERROR = 'tool_execution_error',
|
|
||||||
PATH_VALIDATION_ERROR = 'path_validation_error',
|
|
||||||
UNKNOWN_ERROR = 'unknown_error',
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OllamaError extends Error {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public readonly type: ErrorType,
|
|
||||||
public readonly details?: Record<string, any>
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'OllamaError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NetworkError extends OllamaError {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public readonly statusCode?: number
|
|
||||||
) {
|
|
||||||
super(message, ErrorType.NETWORK_ERROR, { statusCode });
|
|
||||||
this.name = 'NetworkError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApiError extends OllamaError {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public readonly apiError?: any
|
|
||||||
) {
|
|
||||||
super(message, ErrorType.API_ERROR, { apiError });
|
|
||||||
this.name = 'ApiError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ValidationError extends OllamaError {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public readonly validationDetails?: Record<string, string>
|
|
||||||
) {
|
|
||||||
super(message, ErrorType.VALIDATION_ERROR, validationDetails);
|
|
||||||
this.name = 'ValidationError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StreamingError extends OllamaError {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public readonly chunkDetails?: any
|
|
||||||
) {
|
|
||||||
super(message, ErrorType.STREAMING_ERROR, chunkDetails);
|
|
||||||
this.name = 'StreamingError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ToolExecutionError extends OllamaError {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public readonly toolName?: string
|
|
||||||
) {
|
|
||||||
super(message, ErrorType.TOOL_EXECUTION_ERROR, { toolName });
|
|
||||||
this.name = 'ToolExecutionError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PathValidationError extends OllamaError {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public readonly invalidPath?: string
|
|
||||||
) {
|
|
||||||
super(message, ErrorType.PATH_VALIDATION_ERROR, { invalidPath });
|
|
||||||
this.name = 'PathValidationError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OllamaMessage {
|
export interface OllamaMessage {
|
||||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
role: 'system' | 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
tool_calls?: ToolCall[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCall {
|
export interface ToolCall {
|
||||||
function: {
|
tool_name: string;
|
||||||
name: string;
|
arguments: Record<string, unknown>;
|
||||||
arguments: string | Record<string, any>;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OllamaTool {
|
export interface ModelConfig {
|
||||||
type: 'function';
|
|
||||||
function: {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
parameters: {
|
|
||||||
type: 'object';
|
|
||||||
properties: Record<string, { type: string }>;
|
|
||||||
required: string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolResult {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
// Adding optional details field for better error reporting
|
|
||||||
details?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VaultIndexEntry {
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatMessage {
|
|
||||||
id: string;
|
|
||||||
role: 'user' | 'assistant' | 'tool';
|
|
||||||
content: string;
|
|
||||||
timestamp: number;
|
|
||||||
isStreaming?: boolean;
|
|
||||||
tool_calls?: ToolCall[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API Response Types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface OllamaChatResponse {
|
|
||||||
model: string;
|
model: string;
|
||||||
created_at: string;
|
|
||||||
message: {
|
|
||||||
role: 'assistant';
|
|
||||||
content: string;
|
|
||||||
tool_calls?: ToolCall[];
|
|
||||||
};
|
|
||||||
done: boolean;
|
|
||||||
total_duration?: number;
|
|
||||||
load_duration?: number;
|
|
||||||
prompt_eval_count?: number;
|
|
||||||
eval_count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OllamaStreamResponse {
|
|
||||||
model: string;
|
|
||||||
created_at: string;
|
|
||||||
message: {
|
|
||||||
role: 'assistant';
|
|
||||||
content: string;
|
|
||||||
tool_calls?: ToolCall[];
|
|
||||||
};
|
|
||||||
done: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OllamaErrorResponse {
|
|
||||||
error: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OllamaModelList {
|
|
||||||
models: Array<{
|
|
||||||
name: string;
|
|
||||||
id: string;
|
|
||||||
modified_at: string;
|
|
||||||
size: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OllamaGenerateResponse {
|
|
||||||
model: string;
|
|
||||||
created_at: string;
|
|
||||||
response: string;
|
|
||||||
done: boolean;
|
|
||||||
context?: number[];
|
|
||||||
total_duration?: number;
|
|
||||||
load_duration?: number;
|
|
||||||
prompt_eval_count?: number;
|
|
||||||
eval_count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OllamaPullStatus {
|
|
||||||
status: string;
|
|
||||||
digest: string;
|
|
||||||
total_size: number;
|
|
||||||
completed_size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OllamaEmbeddingResponse {
|
|
||||||
embeddings: number[];
|
|
||||||
model: string;
|
|
||||||
total_duration?: number;
|
|
||||||
load_duration?: number;
|
|
||||||
prompt_eval_count?: number;
|
|
||||||
eval_count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client Configuration Types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface OllamaClientConfig {
|
|
||||||
url: string;
|
url: string;
|
||||||
model: string;
|
|
||||||
timeout?: number;
|
|
||||||
maxRetries?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OllamaRequestOptions {
|
export enum RequestType {
|
||||||
timeout?: number;
|
Chat = 'chat',
|
||||||
signal?: AbortSignal;
|
Tool = 'tool',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface ExecutionResult {
|
||||||
* Streaming Types
|
success: boolean;
|
||||||
*/
|
output: string;
|
||||||
|
|
||||||
export interface StreamChunk {
|
|
||||||
content: string;
|
|
||||||
tool_calls?: ToolCall[];
|
|
||||||
done?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StreamMetadata {
|
|
||||||
model: string;
|
|
||||||
created_at: string;
|
|
||||||
done: boolean;
|
|
||||||
total_duration?: number;
|
|
||||||
load_duration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatSession {
|
|
||||||
id: string;
|
|
||||||
messages: OllamaMessage[];
|
|
||||||
createdAt: number;
|
|
||||||
model: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolDefinition {
|
|
||||||
type: 'function';
|
|
||||||
function: {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
parameters: Record<string, any>;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-362
@@ -1,373 +1,29 @@
|
|||||||
/**
|
// src/utils.ts
|
||||||
* Logging utility for consistent log formatting and levels
|
|
||||||
* Provides standardized logging across the plugin
|
|
||||||
*/
|
|
||||||
|
|
||||||
export enum LogLevel {
|
import { UserInputError, ApiError } from './errors';
|
||||||
DEBUG = 'debug',
|
|
||||||
INFO = 'info',
|
export function convertMarkdownToHtml(markdown: string): string {
|
||||||
WARN = 'warn',
|
// Simple markdown to HTML conversion for demonstration purposes
|
||||||
ERROR = 'error',
|
return markdown.replace(/\n/g, '<br>').replace(/# (.+)/g, '<h1>$1</h1>');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function sanitizeFilePath(path: string): string {
|
||||||
* Centralized logging utility with consistent formatting
|
if (path.includes('..')) {
|
||||||
*/
|
throw new UserInputError('Invalid path - cannot contain .. segments');
|
||||||
export class Logger {
|
|
||||||
private static currentLevel: LogLevel = LogLevel.INFO;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the current log level
|
|
||||||
* @param level The minimum log level to output
|
|
||||||
*/
|
|
||||||
static setLevel(level: LogLevel): void {
|
|
||||||
this.currentLevel = level;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current log level
|
|
||||||
* @returns The current log level
|
|
||||||
*/
|
|
||||||
static getLevel(): LogLevel {
|
|
||||||
return this.currentLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log a debug message
|
|
||||||
* @param message The message to log
|
|
||||||
* @param context Additional context (e.g., component name)
|
|
||||||
*/
|
|
||||||
static debug(message: string, context?: string): void {
|
|
||||||
if (this.currentLevel <= LogLevel.DEBUG) {
|
|
||||||
this.log(LogLevel.DEBUG, message, context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log an info message
|
|
||||||
* @param message The message to log
|
|
||||||
* @param context Additional context (e.g., component name)
|
|
||||||
*/
|
|
||||||
static info(message: string, context?: string): void {
|
|
||||||
if (this.currentLevel <= LogLevel.INFO) {
|
|
||||||
this.log(LogLevel.INFO, message, context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log a warning message
|
|
||||||
* @param message The message to log
|
|
||||||
* @param context Additional context (e.g., component name)
|
|
||||||
*/
|
|
||||||
static warn(message: string, context?: string): void {
|
|
||||||
if (this.currentLevel <= LogLevel.WARN) {
|
|
||||||
this.log(LogLevel.WARN, message, context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log an error message
|
|
||||||
* @param message The message to log
|
|
||||||
* @param context Additional context (e.g., component name)
|
|
||||||
*/
|
|
||||||
static error(message: string, context?: string, error?: unknown): void {
|
|
||||||
if (this.currentLevel <= LogLevel.ERROR) {
|
|
||||||
this.log(LogLevel.ERROR, message, context);
|
|
||||||
if (error) {
|
|
||||||
console.error('[Error Details]', error instanceof Error ? error.stack : error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal log method with consistent formatting
|
|
||||||
* @param level The log level
|
|
||||||
* @param message The message to log
|
|
||||||
* @param context Additional context
|
|
||||||
*/
|
|
||||||
private static log(level: LogLevel, message: string, context?: string): void {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const levelStr = level.toUpperCase();
|
|
||||||
const contextStr = context ? `[${context}]` : '';
|
|
||||||
const formattedMessage = `[${timestamp}] ${levelStr} ${contextStr} ${message}`;
|
|
||||||
|
|
||||||
switch (level) {
|
|
||||||
case LogLevel.DEBUG:
|
|
||||||
console.debug(formattedMessage);
|
|
||||||
break;
|
|
||||||
case LogLevel.INFO:
|
|
||||||
console.info(formattedMessage);
|
|
||||||
break;
|
|
||||||
case LogLevel.WARN:
|
|
||||||
console.warn(formattedMessage);
|
|
||||||
break;
|
|
||||||
case LogLevel.ERROR:
|
|
||||||
console.error(formattedMessage);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log a performance measurement
|
|
||||||
* @param operation The operation being measured
|
|
||||||
* @param durationMs The duration in milliseconds
|
|
||||||
* @param context Additional context
|
|
||||||
*/
|
|
||||||
static perf(operation: string, durationMs: number, context?: string): void {
|
|
||||||
this.info(`Performance: ${operation} took ${durationMs.toFixed(2)}ms`, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log a deprecated feature usage
|
|
||||||
* @param feature The deprecated feature being used
|
|
||||||
* @param replacement The replacement feature
|
|
||||||
* @param context Additional context
|
|
||||||
*/
|
|
||||||
static deprecated(feature: string, replacement: string, context?: string): void {
|
|
||||||
this.warn(`Deprecated: ${feature} is deprecated. Use ${replacement} instead.`, context);
|
|
||||||
}
|
}
|
||||||
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function safeWriteFile(filePath: string, content: string): Promise<void> {
|
||||||
* Normalizes file paths for browser/ Obsidian environment
|
const sanitizedPath = sanitizeFilePath(filePath);
|
||||||
* Replaces multiple slashes with single slash and handles forward/backward slashes
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the Ollama URL configuration
|
|
||||||
* @param url The URL to validate
|
|
||||||
* @returns Object with validation result and error message if invalid
|
|
||||||
*/
|
|
||||||
export function validateOllamaUrl(url: string): { valid: boolean; error?: string } {
|
|
||||||
if (!url || typeof url !== 'string') {
|
|
||||||
return { valid: false, error: 'Ollama URL cannot be empty' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trim whitespace
|
|
||||||
const trimmedUrl = url.trim();
|
|
||||||
if (trimmedUrl.length === 0) {
|
|
||||||
return { valid: false, error: 'Ollama URL cannot be empty' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate URL format
|
|
||||||
if (!isValidHttpUrl(trimmedUrl)) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'Ollama URL must be a valid HTTP or HTTPS URL (e.g., http://localhost:11434)',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for common mistakes
|
|
||||||
if (trimmedUrl.endsWith('/')) {
|
|
||||||
return { valid: false, error: 'Ollama URL should not end with a slash' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the Ollama model name configuration
|
|
||||||
* @param modelName The model name to validate
|
|
||||||
* @returns Object with validation result and error message if invalid
|
|
||||||
*/
|
|
||||||
export function validateModelName(modelName: string): { valid: boolean; error?: string } {
|
|
||||||
if (!modelName || typeof modelName !== 'string') {
|
|
||||||
return { valid: false, error: 'Model name cannot be empty' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedModel = modelName.trim();
|
|
||||||
if (trimmedModel.length === 0) {
|
|
||||||
return { valid: false, error: 'Model name cannot be empty' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Model names should be alphanumeric with optional dots, dashes, and underscores
|
|
||||||
const modelNameRegex = /^[a-zA-Z0-9._-]+$/;
|
|
||||||
if (!modelNameRegex.test(trimmedModel)) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'Model name can only contain letters, numbers, dots, dashes, and underscores',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check length constraints
|
|
||||||
if (trimmedModel.length < 2) {
|
|
||||||
return { valid: false, error: 'Model name must be at least 2 characters long' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmedModel.length > 100) {
|
|
||||||
return { valid: false, error: 'Model name must be less than 100 characters long' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates plugin settings before saving
|
|
||||||
* @param settings The settings to validate
|
|
||||||
* @returns Array of validation errors, empty if all valid
|
|
||||||
*/
|
|
||||||
export function validatePluginSettings(settings: any): string[] {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
if (settings.ollamaUrl) {
|
|
||||||
const urlValidation = validateOllamaUrl(settings.ollamaUrl);
|
|
||||||
if (!urlValidation.valid) {
|
|
||||||
errors.push(`Ollama URL: ${urlValidation.error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.model) {
|
|
||||||
const modelValidation = validateModelName(settings.model);
|
|
||||||
if (!modelValidation.valid) {
|
|
||||||
errors.push(`Model: ${modelValidation.error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
export function normalizePath(path: string): string {
|
|
||||||
// Replace multiple slashes with single slash
|
|
||||||
let normalized = path.replace(/[\\\/]+/g, '/');
|
|
||||||
|
|
||||||
// Remove trailing slash unless it's the root
|
|
||||||
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
||||||
normalized = normalized.slice(0, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a path string for safety (no traversal, no absolute paths, no invalid chars)
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Validates a URL string to ensure it's a proper HTTP or HTTPS URL
|
|
||||||
*/
|
|
||||||
export function isValidHttpUrl(string: string): boolean {
|
|
||||||
let url;
|
|
||||||
try {
|
try {
|
||||||
url = new URL(string);
|
// Simulate file writing operation
|
||||||
} catch (_) {
|
console.log(`Writing to ${sanitizedPath}:`, content);
|
||||||
return false;
|
// In a real scenario, you would use fs.promises.writeFile or similar here
|
||||||
}
|
|
||||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely parses JSON with validation to prevent code injection
|
|
||||||
* @param text The JSON string to parse
|
|
||||||
* @param maxDepth Maximum allowed nesting depth (prevents DoS via deeply nested JSON)
|
|
||||||
* @param maxSize Maximum allowed size in characters (prevents DoS via very large JSON)
|
|
||||||
* @returns Parsed object or throws error
|
|
||||||
*/
|
|
||||||
export function safeParseJson(text: string, maxDepth: number = 20, maxSize: number = 1000000): any {
|
|
||||||
if (typeof text !== 'string') {
|
|
||||||
throw new Error('Input must be a string');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check size limit
|
|
||||||
if (text.length > maxSize) {
|
|
||||||
throw new Error(`JSON input too large (${text.length} characters, max ${maxSize})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for potentially dangerous patterns
|
|
||||||
const dangerousPatterns = [
|
|
||||||
/\bconstructor\b/i,
|
|
||||||
/\bprototype\b/i,
|
|
||||||
/\b__proto__\b/i,
|
|
||||||
/\bfunction\b/i,
|
|
||||||
/\brequire\b/i,
|
|
||||||
/\brequire\b/i,
|
|
||||||
/\bprocess\b/i,
|
|
||||||
/\bchild_process\b/i,
|
|
||||||
/\bglobal\b/i,
|
|
||||||
/\bwindow\b/i,
|
|
||||||
/\bdangerouslySetInnerHTML\b/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pattern of dangerousPatterns) {
|
|
||||||
if (pattern.test(text)) {
|
|
||||||
throw new Error('Potentially dangerous JSON pattern detected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = JSON.parse(text);
|
|
||||||
|
|
||||||
// Recursive depth check
|
|
||||||
function checkDepth(obj: any, depth: number): boolean {
|
|
||||||
if (depth > maxDepth) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(obj)) {
|
|
||||||
for (const item of obj) {
|
|
||||||
if (!checkDepth(item, depth + 1)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (obj && typeof obj === 'object') {
|
|
||||||
for (const key in obj) {
|
|
||||||
if (!checkDepth(obj[key], depth + 1)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!checkDepth(result, 0)) {
|
|
||||||
throw new Error(`JSON nesting too deep (max ${maxDepth} levels)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that the result is a safe object (not a function, etc.)
|
|
||||||
if (typeof result === 'function' || result instanceof Function) {
|
|
||||||
throw new Error('JSON parsing resulted in a function - potential code injection');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Re-throw JSON parse errors with additional context
|
if (error instanceof UserInputError) {
|
||||||
if (error instanceof Error && error.message.includes('JSON')) {
|
throw error;
|
||||||
throw new Error(`Invalid JSON: ${error.message}`);
|
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new ApiError('Failed to write file', 500);
|
||||||
`Failed to parse JSON: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validatePath(path: string): { valid: boolean; error?: string } {
|
|
||||||
const normalized = normalizePath(path);
|
|
||||||
|
|
||||||
// Check for path traversal by looking for .. as a path segment (not just substring in filenames)
|
|
||||||
const segments = normalized.split('/');
|
|
||||||
if (segments.includes('..')) {
|
|
||||||
return { valid: false, error: 'Path traversal not allowed' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if absolute path
|
|
||||||
if (normalized.startsWith('/') || normalized.startsWith('\\')) {
|
|
||||||
return { valid: false, error: 'Absolute paths not allowed' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for windows drive letters
|
|
||||||
if (/^[a-zA-Z]:/.test(normalized)) {
|
|
||||||
return { valid: false, error: 'Absolute paths not allowed' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for invalid characters
|
|
||||||
const invalidChars = /[\<\>\:\"\|\\\?\*~]/;
|
|
||||||
if (invalidChars.test(path)) {
|
|
||||||
return { valid: false, error: 'Path contains illegal characters' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check path length
|
|
||||||
const MAX_PATH_LENGTH = 200;
|
|
||||||
if (path.length > MAX_PATH_LENGTH) {
|
|
||||||
return { valid: false, error: 'Path too long' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|||||||
+56
-374
@@ -1,397 +1,79 @@
|
|||||||
import { Vault, TFile } from 'obsidian';
|
// src/vault-indexer.ts
|
||||||
import { VaultIndexEntry } from './types';
|
|
||||||
|
|
||||||
const MAX_CONTENT_PREVIEW_LENGTH = 500;
|
import { OllamaClient } from './ollama-client';
|
||||||
const BATCH_SIZE = 10;
|
import { ApiError, UserInputError, VaultIndexerError } from './errors';
|
||||||
const MAX_TOKENS = 10000;
|
import { ErrorHandler } from './error-handler';
|
||||||
const TITLE_WEIGHT = 10;
|
import { sanitizeFilePath } from './utils';
|
||||||
const HEADING_WEIGHT = 5;
|
|
||||||
const FRONTMATTER_WEIGHT = 8;
|
|
||||||
const FIRST_PARAGRAPH_WEIGHT = 3;
|
|
||||||
const BODY_WEIGHT = 1;
|
|
||||||
const PHRASE_MATCH_BONUS = 2;
|
|
||||||
const EXACT_WORD_MATCH_BONUS = 1.5;
|
|
||||||
|
|
||||||
interface TokenizedContent {
|
interface FileSummary {
|
||||||
text: string;
|
path: string;
|
||||||
tokens: string[];
|
summary: string;
|
||||||
title: string;
|
|
||||||
titleTokens: string[];
|
|
||||||
headings: string[];
|
|
||||||
headingTokens: string[][];
|
|
||||||
frontmatter: Record<string, string>;
|
|
||||||
frontmatterTokens: string[];
|
|
||||||
firstParagraph: string;
|
|
||||||
firstParagraphTokens: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VaultIndexer {
|
class VaultIndexer {
|
||||||
private vault: Vault;
|
private ollamaClient: OllamaClient;
|
||||||
|
private summaries: Map<string, string> = new Map();
|
||||||
|
|
||||||
constructor(vault: Vault) {
|
constructor(ollamaClient: OllamaClient) {
|
||||||
this.vault = vault;
|
this.ollamaClient = ollamaClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchVault(query: string, limit: number = 5): Promise<VaultIndexEntry[]> {
|
async indexVault(vaultPath: string): Promise<void> {
|
||||||
const files = this.vault.getMarkdownFiles();
|
try {
|
||||||
const results: VaultIndexEntry[] = [];
|
const files = await this.getMarkdownFilesInVault(vaultPath);
|
||||||
|
for (const file of files) {
|
||||||
const queryTokens = this.tokenize(query);
|
const content = await this.readFileContent(file.path);
|
||||||
if (queryTokens.length === 0) {
|
const summary = await this.summarizeFile(content);
|
||||||
return [];
|
this.storeSummary(file.path, summary);
|
||||||
}
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
// Process files in batches with concurrency limit
|
if (error instanceof VaultIndexerError) {
|
||||||
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
ErrorHandler.handle(error);
|
||||||
const batch = files.slice(i, i + BATCH_SIZE);
|
} else {
|
||||||
const batchResults = await Promise.allSettled(
|
throw new VaultIndexerError('An unexpected error occurred while indexing the vault', error);
|
||||||
batch.map(async (file) => {
|
|
||||||
const fullContent = await this.vault.read(file);
|
|
||||||
const tokenized = this.tokenizeContent(fullContent, file);
|
|
||||||
const score = this.calculateWeightedScore(tokenized, query, queryTokens);
|
|
||||||
|
|
||||||
if (score > 0) {
|
|
||||||
return {
|
|
||||||
title: file.basename,
|
|
||||||
content: fullContent.substring(0, MAX_CONTENT_PREVIEW_LENGTH),
|
|
||||||
score,
|
|
||||||
} as VaultIndexEntry;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const result of batchResults) {
|
|
||||||
if (result.status === 'fulfilled' && result.value !== null) {
|
|
||||||
results.push(result.value);
|
|
||||||
} else if (result.status === 'rejected') {
|
|
||||||
console.warn(
|
|
||||||
`[VaultIndexer] Failed to read file: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results.sort((a, b) => b.score - a.score).slice(0, limit);
|
private async getMarkdownFilesInVault(vaultPath: string): Promise<{ path: string }[]> {
|
||||||
}
|
// Simulate getting markdown files from the vault
|
||||||
|
const sanitizedPath = sanitizeFilePath(vaultPath);
|
||||||
/**
|
try {
|
||||||
* Tokenize a string into lowercase words, filtering out stop words and very short tokens
|
// This is a placeholder for actual file system operations
|
||||||
*/
|
return [{ path: `${sanitizedPath}/file1.md` }, { path: `${sanitizedPath}/file2.md` }];
|
||||||
private tokenize(text: string): string[] {
|
} catch (error) {
|
||||||
return text
|
throw new VaultIndexerError('Failed to get markdown files from vault', error);
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^\w\s]/g, ' ')
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter((token) => {
|
|
||||||
// Filter out common stop words and very short tokens
|
|
||||||
const stopWords = new Set([
|
|
||||||
'a',
|
|
||||||
'an',
|
|
||||||
'the',
|
|
||||||
'and',
|
|
||||||
'or',
|
|
||||||
'but',
|
|
||||||
'in',
|
|
||||||
'on',
|
|
||||||
'at',
|
|
||||||
'to',
|
|
||||||
'for',
|
|
||||||
'of',
|
|
||||||
'with',
|
|
||||||
'by',
|
|
||||||
'is',
|
|
||||||
'are',
|
|
||||||
'was',
|
|
||||||
'were',
|
|
||||||
'be',
|
|
||||||
'been',
|
|
||||||
'have',
|
|
||||||
'has',
|
|
||||||
'had',
|
|
||||||
'do',
|
|
||||||
'does',
|
|
||||||
'did',
|
|
||||||
'will',
|
|
||||||
'would',
|
|
||||||
'could',
|
|
||||||
'should',
|
|
||||||
'may',
|
|
||||||
'might',
|
|
||||||
'must',
|
|
||||||
'shall',
|
|
||||||
'can',
|
|
||||||
'need',
|
|
||||||
'dare',
|
|
||||||
'ought',
|
|
||||||
'used',
|
|
||||||
'it',
|
|
||||||
'its',
|
|
||||||
'this',
|
|
||||||
'that',
|
|
||||||
'these',
|
|
||||||
'those',
|
|
||||||
'i',
|
|
||||||
'you',
|
|
||||||
'he',
|
|
||||||
'she',
|
|
||||||
'we',
|
|
||||||
'they',
|
|
||||||
'me',
|
|
||||||
'him',
|
|
||||||
'her',
|
|
||||||
'us',
|
|
||||||
'them',
|
|
||||||
'my',
|
|
||||||
'your',
|
|
||||||
'his',
|
|
||||||
'our',
|
|
||||||
'their',
|
|
||||||
'mine',
|
|
||||||
'yours',
|
|
||||||
'hers',
|
|
||||||
'ours',
|
|
||||||
'theirs',
|
|
||||||
'what',
|
|
||||||
'which',
|
|
||||||
'who',
|
|
||||||
'whom',
|
|
||||||
'whose',
|
|
||||||
'where',
|
|
||||||
'when',
|
|
||||||
'why',
|
|
||||||
'how',
|
|
||||||
'not',
|
|
||||||
'no',
|
|
||||||
'nor',
|
|
||||||
'so',
|
|
||||||
'if',
|
|
||||||
'then',
|
|
||||||
'than',
|
|
||||||
'too',
|
|
||||||
'very',
|
|
||||||
'just',
|
|
||||||
'about',
|
|
||||||
'above',
|
|
||||||
'after',
|
|
||||||
'again',
|
|
||||||
'all',
|
|
||||||
'am',
|
|
||||||
'any',
|
|
||||||
'as',
|
|
||||||
'because',
|
|
||||||
'before',
|
|
||||||
'being',
|
|
||||||
'below',
|
|
||||||
'between',
|
|
||||||
'both',
|
|
||||||
'during',
|
|
||||||
'each',
|
|
||||||
'few',
|
|
||||||
'further',
|
|
||||||
'get',
|
|
||||||
'got',
|
|
||||||
'here',
|
|
||||||
'into',
|
|
||||||
'more',
|
|
||||||
'most',
|
|
||||||
'much',
|
|
||||||
'myself',
|
|
||||||
'nothing',
|
|
||||||
'only',
|
|
||||||
'other',
|
|
||||||
'out',
|
|
||||||
'over',
|
|
||||||
'own',
|
|
||||||
'same',
|
|
||||||
'some',
|
|
||||||
'such',
|
|
||||||
'there',
|
|
||||||
'through',
|
|
||||||
'under',
|
|
||||||
'until',
|
|
||||||
'up',
|
|
||||||
'while',
|
|
||||||
'why',
|
|
||||||
'yes',
|
|
||||||
'also',
|
|
||||||
'from',
|
|
||||||
]);
|
|
||||||
return token.length > 1 && !stopWords.has(token);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tokenize markdown content into structured components
|
|
||||||
*/
|
|
||||||
private tokenizeContent(content: string, file: TFile): TokenizedContent {
|
|
||||||
const title = file.basename;
|
|
||||||
const titleTokens = this.tokenize(title);
|
|
||||||
|
|
||||||
// Extract headings
|
|
||||||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
|
||||||
const headings: string[] = [];
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
const headingRegexState = /^(#{1,6})\s+(.+)$/gm;
|
|
||||||
|
|
||||||
while ((match = headingRegexState.exec(content)) !== null) {
|
|
||||||
headings.push(match[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract frontmatter (YAML between --- markers)
|
|
||||||
const frontmatter: Record<string, string> = {};
|
|
||||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---/;
|
|
||||||
const frontmatterMatch = frontmatterRegex.exec(content);
|
|
||||||
if (frontmatterMatch) {
|
|
||||||
const frontmatterContent = frontmatterMatch[1];
|
|
||||||
const lines = frontmatterContent.split('\n');
|
|
||||||
for (const line of lines) {
|
|
||||||
const colonIndex = line.indexOf(':');
|
|
||||||
if (colonIndex > 0) {
|
|
||||||
const key = line.substring(0, colonIndex).trim();
|
|
||||||
const value = line.substring(colonIndex + 1).trim();
|
|
||||||
frontmatter[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get frontmatter tokens from values
|
|
||||||
const frontmatterTokens = this.tokenize(Object.values(frontmatter).join(' '));
|
|
||||||
|
|
||||||
// Extract first paragraph (non-empty lines after frontmatter, stop at paragraph break)
|
|
||||||
const cleanContent = content.replace(/^---\n[\s\S]*?\n---/, '').trim();
|
|
||||||
const lines = cleanContent.split('\n');
|
|
||||||
let firstParagraph = '';
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
// Stop at empty line (paragraph break)
|
|
||||||
if (!trimmed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Skip headings
|
|
||||||
if (trimmed.startsWith('#')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
firstParagraph += trimmed + ' ';
|
|
||||||
if (firstParagraph.length > 200) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const firstParagraphTokens = this.tokenize(firstParagraph);
|
|
||||||
|
|
||||||
// Get body tokens (limit to prevent memory issues with huge files)
|
|
||||||
const bodyText = cleanContent.substring(0, MAX_TOKENS);
|
|
||||||
const tokens = this.tokenize(bodyText);
|
|
||||||
|
|
||||||
return {
|
|
||||||
text: bodyText,
|
|
||||||
tokens,
|
|
||||||
title,
|
|
||||||
titleTokens,
|
|
||||||
headings,
|
|
||||||
headingTokens: headings.map((h) => this.tokenize(h)),
|
|
||||||
frontmatter,
|
|
||||||
frontmatterTokens,
|
|
||||||
firstParagraph,
|
|
||||||
firstParagraphTokens,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate a weighted score based on where query tokens appear
|
|
||||||
* Uses a combination of position weighting, exact matching, and phrase matching
|
|
||||||
*/
|
|
||||||
private calculateWeightedScore(
|
|
||||||
tokenized: TokenizedContent,
|
|
||||||
query: string,
|
|
||||||
queryTokens: string[]
|
|
||||||
): number {
|
|
||||||
let score = 0;
|
|
||||||
const queryLower = query.toLowerCase();
|
|
||||||
const contentLower = tokenized.text.toLowerCase();
|
|
||||||
const contentWithBoundaries = '\\b' + contentLower + '\\b';
|
|
||||||
|
|
||||||
// Check for exact phrase match (bonus)
|
|
||||||
if (queryLower.length > 0 && contentLower.includes(queryLower)) {
|
|
||||||
score += PHRASE_MATCH_BONUS * queryTokens.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const queryToken of queryTokens) {
|
|
||||||
let tokenScore = 0;
|
|
||||||
|
|
||||||
// Title match (highest priority)
|
|
||||||
if (tokenized.titleTokens.some((t) => this.exactMatch(t, queryToken))) {
|
|
||||||
tokenScore += TITLE_WEIGHT;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Frontmatter match (high priority - often contains tags/categories)
|
|
||||||
if (tokenized.frontmatterTokens.some((t) => this.exactMatch(t, queryToken))) {
|
|
||||||
tokenScore += FRONTMATTER_WEIGHT;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heading match (high priority)
|
|
||||||
for (const headingTokens of tokenized.headingTokens) {
|
|
||||||
if (headingTokens.some((t) => this.exactMatch(t, queryToken))) {
|
|
||||||
tokenScore += HEADING_WEIGHT;
|
|
||||||
break; // Only count once per query token
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// First paragraph match (medium priority - likely contains topic summary)
|
private async readFileContent(filePath: string): Promise<string> {
|
||||||
if (tokenized.firstParagraphTokens.some((t) => this.exactMatch(t, queryToken))) {
|
const sanitizedPath = sanitizeFilePath(filePath);
|
||||||
tokenScore += FIRST_PARAGRAPH_WEIGHT;
|
try {
|
||||||
|
// This is a placeholder for actual file reading operations
|
||||||
|
return `Content of ${sanitizedPath}`;
|
||||||
|
} catch (error) {
|
||||||
|
throw new VaultIndexerError('Failed to read file content', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Body match (lowest priority)
|
private async summarizeFile(content: string): Promise<string> {
|
||||||
const bodyMatchCount = tokenized.tokens.filter((t) => this.exactMatch(t, queryToken)).length;
|
try {
|
||||||
if (bodyMatchCount > 0) {
|
const response = await this.ollamaClient.summarizeText(content);
|
||||||
// Use logarithmic scaling to prevent very frequent words from dominating
|
return response.summary;
|
||||||
tokenScore += BODY_WEIGHT * Math.log(1 + bodyMatchCount);
|
} catch (error) {
|
||||||
|
throw new VaultIndexerError('Failed to summarize file', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exact word boundary bonus
|
private storeSummary(filePath: string, summary: string): void {
|
||||||
if (new RegExp(`\\b${queryToken}\\b`).test(contentLower)) {
|
const sanitizedPath = sanitizeFilePath(filePath);
|
||||||
tokenScore *= EXACT_WORD_MATCH_BONUS;
|
this.summaries.set(sanitizedPath, summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
score += tokenScore;
|
getSummary(filePath: string): string | undefined {
|
||||||
}
|
const sanitizedPath = sanitizeFilePath(filePath);
|
||||||
|
return this.summaries.get(sanitizedPath);
|
||||||
// Normalize by document length to prevent bias toward longer documents
|
|
||||||
// Use a gentle normalization: divide by log of token count + 1
|
|
||||||
const lengthNorm = Math.log(1 + tokenized.tokens.length / 100);
|
|
||||||
if (lengthNorm > 1) {
|
|
||||||
score /= lengthNorm;
|
|
||||||
}
|
|
||||||
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check for exact or stemmed word match
|
|
||||||
* Handles plurals and common suffixes
|
|
||||||
*/
|
|
||||||
private exactMatch(textToken: string, queryToken: string): boolean {
|
|
||||||
// Exact match
|
|
||||||
if (textToken === queryToken) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle plurals
|
|
||||||
if (queryToken.endsWith('s') && textToken === queryToken.slice(0, -1)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (textToken.endsWith('s') && textToken.slice(0, -1) === queryToken) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle -ed and -ing suffixes (simple stemmer)
|
|
||||||
const stem = (word: string): string => {
|
|
||||||
if (word.endsWith('ing')) return word.slice(0, -3);
|
|
||||||
if (word.endsWith('ed')) return word.slice(0, -2);
|
|
||||||
return word;
|
|
||||||
};
|
|
||||||
|
|
||||||
return stem(textToken) === stem(queryToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { VaultIndexer };
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Reference in New Issue
Block a user