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:
+18
-362
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user