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:
2026-05-05 17:09:16 +02:00
parent cc580889d2
commit 59df2f6856
44 changed files with 270 additions and 1369 deletions
+18 -362
View File
@@ -1,373 +1,29 @@
/**
* Logging utility for consistent log formatting and levels
* Provides standardized logging across the plugin
*/
// src/utils.ts
export enum LogLevel {
DEBUG = 'debug',
INFO = 'info',
WARN = 'warn',
ERROR = 'error',
import { UserInputError, ApiError } from './errors';
export function convertMarkdownToHtml(markdown: string): string {
// Simple markdown to HTML conversion for demonstration purposes
return markdown.replace(/\n/g, '<br>').replace(/# (.+)/g, '<h1>$1</h1>');
}
/**
* Centralized logging utility with consistent formatting
*/
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);
export function sanitizeFilePath(path: string): string {
if (path.includes('..')) {
throw new UserInputError('Invalid path - cannot contain .. segments');
}
return path;
}
/**
* Normalizes file paths for browser/ Obsidian environment
* 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;
export async function safeWriteFile(filePath: string, content: string): Promise<void> {
const sanitizedPath = sanitizeFilePath(filePath);
try {
url = new URL(string);
} catch (_) {
return false;
}
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;
// Simulate file writing operation
console.log(`Writing to ${sanitizedPath}:`, content);
// In a real scenario, you would use fs.promises.writeFile or similar here
} catch (error) {
// Re-throw JSON parse errors with additional context
if (error instanceof Error && error.message.includes('JSON')) {
throw new Error(`Invalid JSON: ${error.message}`);
if (error instanceof UserInputError) {
throw error;
}
throw new Error(
`Failed to parse JSON: ${error instanceof Error ? error.message : 'Unknown error'}`
);
throw new ApiError('Failed to write file', 500);
}
}
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 };
}