9367811c5a
The root cause: Obsidian's plugin loader expects main.js at the plugin root alongside manifest.json. The previous 'dist/' output + shim approach caused 'Cannot find module ./dist/main.js' because dist/ was either missing or not resolved correctly in Obsidian's module loader. Changes: - build: use esbuild to bundle all source into a single main.js (61KB) tsc --noEmit for type checking; esbuild for the actual bundle - main.js: no longer a shim — it's the fully bundled plugin - package.json: added esbuild as devDependency; obsidian moved to devDeps - install.sh: remove dist/ copy step, add cleanup of old dist/ from vault - README.md: updated manual install steps to reflect bundling
1802 lines
60 KiB
JavaScript
1802 lines
60 KiB
JavaScript
"use strict";
|
|
var __create = Object.create;
|
|
var __defProp = Object.defineProperty;
|
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
var __getProtoOf = Object.getPrototypeOf;
|
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
var __export = (target, all) => {
|
|
for (var name in all)
|
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
};
|
|
var __copyProps = (to, from, except, desc) => {
|
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
for (let key of __getOwnPropNames(from))
|
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
}
|
|
return to;
|
|
};
|
|
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
// If the importer is in node compatibility mode or this is not an ESM
|
|
// file that has been converted to a CommonJS file using a Babel-
|
|
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
mod
|
|
));
|
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
|
|
// src/main.ts
|
|
var main_exports = {};
|
|
__export(main_exports, {
|
|
default: () => OllamaPlugin
|
|
});
|
|
module.exports = __toCommonJS(main_exports);
|
|
var import_obsidian4 = require("obsidian");
|
|
|
|
// src/chat-view.ts
|
|
var import_obsidian3 = require("obsidian");
|
|
|
|
// src/types.ts
|
|
var OllamaError = class _OllamaError extends Error {
|
|
constructor(message, type) {
|
|
super(message);
|
|
this.type = type;
|
|
Object.setPrototypeOf(this, _OllamaError.prototype);
|
|
}
|
|
};
|
|
var NetworkError = class _NetworkError extends OllamaError {
|
|
constructor(message, statusCode) {
|
|
super(message, "network_error" /* NETWORK_ERROR */);
|
|
this.statusCode = statusCode;
|
|
Object.setPrototypeOf(this, _NetworkError.prototype);
|
|
}
|
|
};
|
|
var ApiError = class _ApiError extends OllamaError {
|
|
constructor(message, statusCode) {
|
|
super(message, "api_error" /* API_ERROR */);
|
|
this.statusCode = statusCode;
|
|
Object.setPrototypeOf(this, _ApiError.prototype);
|
|
}
|
|
};
|
|
var ValidationError = class _ValidationError extends OllamaError {
|
|
constructor(message, details) {
|
|
super(message, "validation_error" /* VALIDATION_ERROR */);
|
|
this.details = details;
|
|
Object.setPrototypeOf(this, _ValidationError.prototype);
|
|
}
|
|
};
|
|
var StreamingError = class _StreamingError extends OllamaError {
|
|
constructor(message) {
|
|
super(message, "streaming_error" /* STREAMING_ERROR */);
|
|
Object.setPrototypeOf(this, _StreamingError.prototype);
|
|
}
|
|
};
|
|
var ToolExecutionError = class _ToolExecutionError extends OllamaError {
|
|
constructor(message, toolName = "unknown") {
|
|
super(message, "tool_execution_error" /* TOOL_EXECUTION_ERROR */);
|
|
this.toolName = toolName;
|
|
Object.setPrototypeOf(this, _ToolExecutionError.prototype);
|
|
}
|
|
};
|
|
var PathValidationError = class _PathValidationError extends OllamaError {
|
|
constructor(message, path = "") {
|
|
super(message, "path_validation_error" /* PATH_VALIDATION_ERROR */);
|
|
this.path = path;
|
|
Object.setPrototypeOf(this, _PathValidationError.prototype);
|
|
}
|
|
};
|
|
|
|
// src/utils.ts
|
|
var SEVERITY_ORDER = {
|
|
debug: 0 /* DEBUG */,
|
|
info: 1 /* INFO */,
|
|
warn: 2 /* WARN */,
|
|
error: 3 /* ERROR */
|
|
};
|
|
var _Logger = class _Logger {
|
|
static setLevel(level) {
|
|
if (typeof level === "string") {
|
|
const lowerLevel = level.toLowerCase();
|
|
_Logger.minLevel = SEVERITY_ORDER[lowerLevel] ?? 0 /* DEBUG */;
|
|
} else {
|
|
_Logger.minLevel = level;
|
|
}
|
|
}
|
|
static debug(message, category = "general") {
|
|
if (0 /* DEBUG */ >= _Logger.minLevel) {
|
|
console.debug(`[${category}] DEBUG: ${message}`);
|
|
}
|
|
}
|
|
static info(message, category = "general") {
|
|
if (1 /* INFO */ >= _Logger.minLevel) {
|
|
console.info(`[${category}] INFO: ${message}`);
|
|
}
|
|
}
|
|
static warn(message, category = "general") {
|
|
if (2 /* WARN */ >= _Logger.minLevel) {
|
|
console.warn(`[${category}] WARN: ${message}`);
|
|
}
|
|
}
|
|
static error(message, category = "general") {
|
|
if (3 /* ERROR */ >= _Logger.minLevel) {
|
|
console.error(`[${category}] ERROR: ${message}`);
|
|
}
|
|
}
|
|
};
|
|
_Logger.minLevel = 0 /* DEBUG */;
|
|
var Logger = _Logger;
|
|
var MAX_JSON_SIZE = 1e6;
|
|
var MAX_JSON_NESTING = 24;
|
|
function countNestingDepth(value, depth = 0) {
|
|
if (depth > MAX_JSON_NESTING) {
|
|
return depth;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return Math.max(...value.map((item) => countNestingDepth(item, depth + 1)), depth);
|
|
}
|
|
if (value !== null && typeof value === "object") {
|
|
const entries = Object.values(value);
|
|
if (entries.length === 0) return depth;
|
|
return Math.max(...entries.map((item) => countNestingDepth(item, depth + 1)), depth);
|
|
}
|
|
return depth;
|
|
}
|
|
function safeParseJson(jsonString) {
|
|
if (typeof jsonString !== "string") {
|
|
throw new Error("Input must be a string");
|
|
}
|
|
if (jsonString.length > MAX_JSON_SIZE) {
|
|
throw new Error("JSON input too large");
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(jsonString);
|
|
} catch {
|
|
throw new Error("Invalid JSON");
|
|
}
|
|
const checkDangerousPatterns = (obj) => {
|
|
if (typeof obj !== "object" || obj === null) {
|
|
return false;
|
|
}
|
|
const dangerousKeys = ["constructor", "prototype", "__proto__"];
|
|
if (dangerousKeys.some((key) => Object.keys(obj).includes(key))) {
|
|
return true;
|
|
}
|
|
const record = obj;
|
|
for (const key of Object.keys(obj)) {
|
|
if (checkDangerousPatterns(record[key])) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
if (checkDangerousPatterns(parsed)) {
|
|
throw new Error("dangerous code pattern detected");
|
|
}
|
|
if (countNestingDepth(parsed) > MAX_JSON_NESTING) {
|
|
throw new Error("JSON nesting too deep");
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
// src/semantic-cache.ts
|
|
var SemanticCacheService = class _SemanticCacheService {
|
|
constructor(ollamaURL, config) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
this.client = null;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
this.collection = null;
|
|
this.ollamaURL = ollamaURL.replace(/\/+$/, "");
|
|
this.config = config;
|
|
}
|
|
async initialize() {
|
|
if (!this.config.enabled) return;
|
|
try {
|
|
const { ChromaClient } = await import("chromadb");
|
|
const chromaURL = this.config.chromaURL || "http://localhost:8000";
|
|
this.client = new ChromaClient({ path: chromaURL });
|
|
this.collection = await this.client.getOrCreateCollection({
|
|
name: this.config.collectionName,
|
|
metadata: { "hnsw:space": "cosine" }
|
|
});
|
|
Logger.info(`Semantic cache initialized: ${this.config.collectionName}`, "semantic-cache");
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
Logger.error(`Failed to initialize semantic cache: ${errorMessage}`, "semantic-cache");
|
|
throw error;
|
|
}
|
|
}
|
|
async getCache(query) {
|
|
if (!this.config.enabled || !this.collection) return null;
|
|
try {
|
|
const results = await this.collection.query({
|
|
query_embeddings: await this.generateEmbedding(query),
|
|
n_results: 1,
|
|
where: { source: "ollama" }
|
|
});
|
|
if (results.ids[0] && results.ids[0].length > 0) {
|
|
if (results.distances[0] && results.distances[0][0] > this.config.similarityThreshold) {
|
|
return results.documents[0][0];
|
|
}
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
Logger.warn(`Cache lookup failed: ${errorMessage}`, "semantic-cache");
|
|
return null;
|
|
}
|
|
}
|
|
static generateId() {
|
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
return crypto.randomUUID();
|
|
}
|
|
return "cache_" + Date.now() + "_" + Math.random().toString(36).substring(2, 11);
|
|
}
|
|
async setCache(query, response) {
|
|
if (!this.config.enabled || !this.collection) return;
|
|
try {
|
|
await this.collection.upsert({
|
|
ids: [_SemanticCacheService.generateId()],
|
|
documents: [response],
|
|
embeddings: await this.generateEmbedding(query),
|
|
metadatas: [{ source: "ollama" }]
|
|
});
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
Logger.warn(`Cache set failed: ${errorMessage}`, "semantic-cache");
|
|
}
|
|
}
|
|
async clearCache() {
|
|
if (!this.config.enabled || !this.collection) return;
|
|
try {
|
|
await this.collection.reset();
|
|
Logger.info("Semantic cache cleared", "semantic-cache");
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
Logger.error(`Failed to clear semantic cache: ${errorMessage}`, "semantic-cache");
|
|
}
|
|
}
|
|
async generateEmbedding(text) {
|
|
const response = await fetch(`${this.ollamaURL}/api/embeddings`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
model: this.config.embeddingModel,
|
|
prompt: text
|
|
})
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to generate embedding: ${response.status} ${response.statusText}`);
|
|
}
|
|
const data = await response.json();
|
|
return data.embedding;
|
|
}
|
|
};
|
|
|
|
// src/ollama-client.ts
|
|
var OllamaClient = class {
|
|
constructor(baseURL, model, fetchFn, cacheConfig) {
|
|
this.maxRetries = 3;
|
|
this.maxMalformedChunks = 50;
|
|
this.currentStreamController = null;
|
|
this.baseURL = baseURL;
|
|
this.model = model;
|
|
this.fetchFn = fetchFn ?? fetch;
|
|
if (cacheConfig?.enabled) {
|
|
this.cacheService = new SemanticCacheService(baseURL, cacheConfig);
|
|
void this.cacheService.initialize();
|
|
}
|
|
}
|
|
async initializeCache() {
|
|
if (this.cacheService) {
|
|
await this.cacheService.initialize();
|
|
}
|
|
}
|
|
async clearCache() {
|
|
if (this.cacheService) {
|
|
await this.cacheService.clearCache();
|
|
}
|
|
}
|
|
cancelStream() {
|
|
if (this.currentStreamController) {
|
|
this.currentStreamController.abort();
|
|
this.currentStreamController = null;
|
|
}
|
|
}
|
|
async *streamChat(messages, tools = []) {
|
|
if (tools.length > 0) {
|
|
yield* this.streamChatWithRetry(messages, tools, 0);
|
|
return;
|
|
}
|
|
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
|
if (lastUserMsg && this.cacheService) {
|
|
const cached = await this.cacheService.getCache(lastUserMsg.content);
|
|
if (cached) {
|
|
yield { role: "assistant", content: cached, tool_calls: [] };
|
|
return;
|
|
}
|
|
}
|
|
const chunks = [];
|
|
for await (const chunk of this.streamChatWithRetry(messages, tools, 0)) {
|
|
chunks.push(chunk);
|
|
yield chunk;
|
|
}
|
|
const fullContent = chunks.map((c) => c.content).join("");
|
|
if (this.cacheService && lastUserMsg) {
|
|
void this.cacheService.setCache(lastUserMsg.content, fullContent);
|
|
}
|
|
}
|
|
async chat(messages, tools = []) {
|
|
if (tools.length > 0) {
|
|
return this.chatWithRetry(messages, tools, 0);
|
|
}
|
|
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
|
if (lastUserMsg && this.cacheService) {
|
|
const cached = await this.cacheService.getCache(lastUserMsg.content);
|
|
if (cached) {
|
|
return { role: "assistant", content: cached };
|
|
}
|
|
}
|
|
const response = await this.chatWithRetry(messages, tools, 0);
|
|
if (this.cacheService && lastUserMsg) {
|
|
void this.cacheService.setCache(lastUserMsg.content, response.content);
|
|
}
|
|
return response;
|
|
}
|
|
async streamChatAsPromise(messages, tools = []) {
|
|
let content = "";
|
|
let role = "assistant";
|
|
let toolCalls;
|
|
for await (const chunk of this.streamChat(messages, tools)) {
|
|
role = chunk.role ?? role;
|
|
content += chunk.content ?? "";
|
|
if (chunk.tool_calls) {
|
|
toolCalls = [...toolCalls ?? [], ...chunk.tool_calls];
|
|
}
|
|
}
|
|
return { role, content, tool_calls: toolCalls };
|
|
}
|
|
async *streamChatWithRetry(messages, tools = [], retryCount) {
|
|
const controller = new AbortController();
|
|
this.currentStreamController = controller;
|
|
let reader = null;
|
|
try {
|
|
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
model: this.model,
|
|
messages,
|
|
tools,
|
|
stream: true
|
|
}),
|
|
signal: controller.signal
|
|
});
|
|
if (!response.ok) {
|
|
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("application/x-ndjson")) {
|
|
throw new Error("Invalid response format");
|
|
}
|
|
reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
let malformedChunks = 0;
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
buffer += decoder.decode(value);
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || "";
|
|
for (const line of lines) {
|
|
if (line.trim() === "") {
|
|
continue;
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = this.parseChatResponse(line);
|
|
} catch (error) {
|
|
malformedChunks++;
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
Logger.warn(
|
|
`Skipped malformed chunk: ${line.slice(0, 50)}... - ${errorMessage}`,
|
|
"ollama-client"
|
|
);
|
|
if (malformedChunks > this.maxMalformedChunks) {
|
|
throw new Error("Too many malformed chunks in Ollama response");
|
|
}
|
|
continue;
|
|
}
|
|
if (parsed.error) {
|
|
const errorMsg = typeof parsed.error === "string" ? parsed.error : JSON.stringify(parsed.error);
|
|
throw new Error(`Ollama error: ${errorMsg}`);
|
|
}
|
|
yield this.normalizeMessage(parsed.message);
|
|
}
|
|
}
|
|
if (buffer.trim() !== "") {
|
|
let parsed = null;
|
|
try {
|
|
parsed = this.parseChatResponse(buffer);
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
Logger.warn(
|
|
`Skipped malformed chunk: ${buffer.slice(0, 50)}... - ${errorMessage}`,
|
|
"ollama-client"
|
|
);
|
|
}
|
|
if (parsed?.error) {
|
|
const errorMsg = typeof parsed.error === "string" ? parsed.error : JSON.stringify(parsed.error);
|
|
throw new Error(`Ollama error: ${errorMsg}`);
|
|
}
|
|
if (parsed?.message) {
|
|
yield this.normalizeMessage(parsed.message);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (retryCount < this.maxRetries && this.isRetryableError(error, controller)) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
Logger.warn(
|
|
`Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`,
|
|
"ollama-client"
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 10 * Math.pow(2, retryCount)));
|
|
yield* this.streamChatWithRetry(messages, tools, retryCount + 1);
|
|
} else {
|
|
throw error;
|
|
}
|
|
} finally {
|
|
reader?.releaseLock();
|
|
if (this.currentStreamController === controller) {
|
|
this.currentStreamController = null;
|
|
}
|
|
}
|
|
}
|
|
async chatWithRetry(messages, tools = [], retryCount) {
|
|
const controller = new AbortController();
|
|
this.currentStreamController = controller;
|
|
try {
|
|
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
model: this.model,
|
|
messages,
|
|
tools,
|
|
stream: false
|
|
}),
|
|
signal: controller.signal
|
|
});
|
|
if (!response.ok) {
|
|
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
|
|
}
|
|
const data = await response.json();
|
|
if (!this.isChatResponse(data)) {
|
|
return this.normalizeMessage();
|
|
}
|
|
return this.normalizeMessage(data.message);
|
|
} catch (error) {
|
|
if (retryCount < this.maxRetries && this.isRetryableError(error, controller)) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
Logger.warn(
|
|
`Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`,
|
|
"ollama-client"
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 10 * Math.pow(2, retryCount)));
|
|
return this.chatWithRetry(messages, tools, retryCount + 1);
|
|
} else {
|
|
throw error;
|
|
}
|
|
} finally {
|
|
if (this.currentStreamController === controller) {
|
|
this.currentStreamController = null;
|
|
}
|
|
}
|
|
}
|
|
normalizeMessage(message) {
|
|
return {
|
|
role: message?.role ?? "assistant",
|
|
content: message?.content ?? "",
|
|
tool_calls: message?.tool_calls ?? [],
|
|
tool_call_id: message?.tool_call_id
|
|
};
|
|
}
|
|
parseChatResponse(raw) {
|
|
const parsed = JSON.parse(raw);
|
|
if (!this.isChatResponse(parsed)) {
|
|
throw new Error("Invalid chat response");
|
|
}
|
|
return parsed;
|
|
}
|
|
isChatResponse(data) {
|
|
if (typeof data !== "object" || data === null) {
|
|
return false;
|
|
}
|
|
const response = data;
|
|
return (response.error === void 0 || typeof response.error === "string") && (response.message === void 0 || this.isPartialMessage(response.message));
|
|
}
|
|
isPartialMessage(data) {
|
|
if (typeof data !== "object" || data === null) {
|
|
return false;
|
|
}
|
|
const message = data;
|
|
const validRole = message.role === void 0 || message.role === "system" || message.role === "user" || message.role === "assistant" || message.role === "tool";
|
|
return validRole && (message.content === void 0 || typeof message.content === "string") && (message.tool_calls === void 0 || Array.isArray(message.tool_calls)) && (message.tool_call_id === void 0 || typeof message.tool_call_id === "string");
|
|
}
|
|
isRetryableError(error, controller) {
|
|
if (controller.signal.aborted) {
|
|
return false;
|
|
}
|
|
if (error instanceof ApiError && error.statusCode >= 400 && error.statusCode < 500) {
|
|
return false;
|
|
}
|
|
if (error instanceof Error) {
|
|
if (error.name === "AbortError") {
|
|
return false;
|
|
}
|
|
if (error.message.startsWith("Ollama error:") || error.message.includes("Too many malformed chunks") || error.message === "No response body" || error.message === "Invalid response format") {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
};
|
|
|
|
// src/vault-indexer.ts
|
|
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
"a",
|
|
"an",
|
|
"the",
|
|
"is",
|
|
"it",
|
|
"in",
|
|
"on",
|
|
"at",
|
|
"to",
|
|
"for",
|
|
"of",
|
|
"and",
|
|
"or",
|
|
"but",
|
|
"with",
|
|
"by",
|
|
"from",
|
|
"up",
|
|
"about",
|
|
"into",
|
|
"this",
|
|
"that",
|
|
"these",
|
|
"those",
|
|
"be",
|
|
"been",
|
|
"being",
|
|
"have",
|
|
"has",
|
|
"had",
|
|
"do",
|
|
"does",
|
|
"did",
|
|
"will",
|
|
"would",
|
|
"could",
|
|
"should",
|
|
"may",
|
|
"might",
|
|
"can",
|
|
"are",
|
|
"was",
|
|
"were",
|
|
"as",
|
|
"so",
|
|
"if",
|
|
"not",
|
|
"no",
|
|
"my",
|
|
"your",
|
|
"our",
|
|
"its",
|
|
"we",
|
|
"you",
|
|
"he",
|
|
"she",
|
|
"they"
|
|
]);
|
|
var CONTENT_PREVIEW_LENGTH = 500;
|
|
var VaultIndexer = class {
|
|
constructor(vault, cache) {
|
|
this.SCORING_WEIGHTS = {
|
|
TITLE: 5,
|
|
FRONTMATTER_TITLE: 4,
|
|
FRONTMATTER_TAGS: 3,
|
|
HEADINGS: 2,
|
|
CONTENT: 1
|
|
};
|
|
this.vault = vault;
|
|
this.cache = cache;
|
|
}
|
|
tokenize(text) {
|
|
return text.toLowerCase().replace(/[^\w\s]/g, "").split(/\s+/).filter((token) => token.length > 1 && !STOP_WORDS.has(token));
|
|
}
|
|
tokenizeContent(content, file) {
|
|
const parsed = this.parseMarkdown(content);
|
|
const bodyWithoutFrontmatter = content.replace(/^---[\s\S]*?---\n/, "");
|
|
const paragraphs = bodyWithoutFrontmatter.split(/\n\n+/).map((p) => p.trim()).filter((p) => p && !p.startsWith("#"));
|
|
const firstParagraph = paragraphs[0] || "";
|
|
return {
|
|
title: parsed.title || file.basename,
|
|
headings: parsed.headings,
|
|
frontmatter: parsed.frontmatter,
|
|
firstParagraph,
|
|
content: parsed.content,
|
|
basename: file.basename
|
|
};
|
|
}
|
|
calculateWeightedScore(tokenized, queryTokens) {
|
|
let score = 0;
|
|
for (const token of queryTokens) {
|
|
if (tokenized.frontmatter?.title && this.exactMatch(tokenized.frontmatter.title, token)) {
|
|
score += this.SCORING_WEIGHTS.FRONTMATTER_TITLE;
|
|
}
|
|
if (tokenized.basename && this.exactMatch(tokenized.basename, token)) {
|
|
score += this.SCORING_WEIGHTS.FRONTMATTER_TITLE;
|
|
}
|
|
if (tokenized.frontmatter?.tags && this.exactMatch(tokenized.frontmatter.tags, token)) {
|
|
score += this.SCORING_WEIGHTS.FRONTMATTER_TAGS;
|
|
}
|
|
if (tokenized.headings.some((h) => h.toLowerCase().includes(token.toLowerCase()))) {
|
|
score += this.SCORING_WEIGHTS.HEADINGS;
|
|
}
|
|
if (tokenized.content.toLowerCase().includes(token.toLowerCase())) {
|
|
score += this.SCORING_WEIGHTS.CONTENT;
|
|
}
|
|
if (tokenized.title && this.exactMatch(tokenized.title, token)) {
|
|
score += this.SCORING_WEIGHTS.TITLE;
|
|
}
|
|
}
|
|
return { score };
|
|
}
|
|
async getVaultEntries() {
|
|
const files = this.vault.getMarkdownFiles();
|
|
const entries = [];
|
|
for (const file of files) {
|
|
try {
|
|
const content = typeof this.vault.cachedRead === "function" ? await this.vault.cachedRead(file) : await this.vault.read(file);
|
|
const parsed = this.parseMarkdown(content);
|
|
entries.push({
|
|
file,
|
|
title: parsed.frontmatter.title || file.basename,
|
|
frontmatter: parsed.frontmatter,
|
|
headings: parsed.headings,
|
|
content: parsed.content.slice(0, CONTENT_PREVIEW_LENGTH),
|
|
basename: file.basename,
|
|
score: 0
|
|
});
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
Logger.warn(`Failed to read file ${file.path}: ${errorMessage}`, "vault-indexer");
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
async searchVault(query, limit = 3) {
|
|
if (!query || !query.trim()) {
|
|
return [];
|
|
}
|
|
const cacheKey = `query:${query.trim()}:limit:${limit}`;
|
|
if (this.cache) {
|
|
let cachedResults = null;
|
|
try {
|
|
cachedResults = await this.cache.get(cacheKey);
|
|
} catch {
|
|
cachedResults = null;
|
|
}
|
|
if (cachedResults) {
|
|
try {
|
|
const parsedResults = JSON.parse(cachedResults);
|
|
return parsedResults.slice(0, limit);
|
|
} catch {
|
|
}
|
|
}
|
|
}
|
|
const queryTokens = this.tokenize(query);
|
|
if (queryTokens.length === 0) {
|
|
return [];
|
|
}
|
|
const entries = await this.getVaultEntries();
|
|
const scored = entries.map((entry) => {
|
|
const { score } = this.calculateWeightedScore(
|
|
{
|
|
title: entry.title,
|
|
headings: entry.headings,
|
|
frontmatter: entry.frontmatter,
|
|
firstParagraph: "",
|
|
content: entry.content,
|
|
basename: entry.basename
|
|
},
|
|
queryTokens
|
|
);
|
|
return { ...entry, score };
|
|
}).filter((e) => e.score > 0);
|
|
scored.sort((a, b) => b.score - a.score);
|
|
const results = scored.slice(0, limit);
|
|
if (this.cache) {
|
|
try {
|
|
await this.cache.put(cacheKey, JSON.stringify(results));
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
Logger.warn(
|
|
`Failed to cache results for query "${query}": ${errorMessage}`,
|
|
"vault-indexer"
|
|
);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
stemToken(token) {
|
|
if (token.endsWith("ing") && token.length > 4) return token.slice(0, -3);
|
|
if (token.endsWith("ed") && token.length > 3) return token.slice(0, -2);
|
|
if (token.endsWith("s") && token.length > 2) return token.slice(0, -1);
|
|
return token;
|
|
}
|
|
exactMatch(text, queryToken) {
|
|
if (!text) return false;
|
|
const textLower = text.toLowerCase();
|
|
const queryLower = queryToken.toLowerCase();
|
|
const queryStem = this.stemToken(queryLower);
|
|
return textLower.includes(queryLower) || textLower.includes(queryStem);
|
|
}
|
|
parseMarkdown(content) {
|
|
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
|
|
const frontmatterMatch = content.match(frontmatterRegex);
|
|
const frontmatter = {};
|
|
if (frontmatterMatch) {
|
|
try {
|
|
const lines = frontmatterMatch[1].trim().split("\n");
|
|
for (const line of lines) {
|
|
const [key, ...valueParts] = line.split(":");
|
|
if (!key) continue;
|
|
const value = valueParts.join(":").trim();
|
|
if (key.trim() === "title" && value) frontmatter.title = value;
|
|
else if (key.trim() === "tags" && value) frontmatter.tags = value;
|
|
}
|
|
} catch {
|
|
Logger.warn("Failed to parse frontmatter", "vault-indexer");
|
|
}
|
|
}
|
|
const titleMatch = content.match(/^# (.+)$/m);
|
|
const title = titleMatch ? titleMatch[1] : "";
|
|
const headings = [];
|
|
const headingRegex = /^#{1,6} (.+)$/gm;
|
|
let headingMatch;
|
|
while ((headingMatch = headingRegex.exec(content)) !== null) {
|
|
headings.push(headingMatch[1]);
|
|
}
|
|
const bodyWithoutFrontmatter = frontmatterMatch ? content.substring(frontmatterMatch[0].length) : content;
|
|
const bodyText = bodyWithoutFrontmatter.replace(/#{1,6} .+/g, "").replace(/^\s*[\r\n]/gm, "").trim();
|
|
return { frontmatter, title, headings, content: bodyText };
|
|
}
|
|
};
|
|
|
|
// src/tool-executor.ts
|
|
var import_obsidian = require("obsidian");
|
|
var INVALID_PATH_CHARS = /[<>:"|?*~]/;
|
|
var MAX_PATH_LENGTH = 200;
|
|
var FORBIDDEN_DIRS = [".obsidian", ".git"];
|
|
var ToolExecutor = class {
|
|
constructor(vault, app) {
|
|
this.vault = vault;
|
|
this.app = app;
|
|
}
|
|
isSafePath(path) {
|
|
if (!path || path.trim().length === 0) {
|
|
return false;
|
|
}
|
|
if (path.length > MAX_PATH_LENGTH) {
|
|
return false;
|
|
}
|
|
if (INVALID_PATH_CHARS.test(path)) {
|
|
return false;
|
|
}
|
|
if (path.startsWith("/") || path.startsWith("\\")) {
|
|
return false;
|
|
}
|
|
if (/^[a-zA-Z]:/.test(path)) {
|
|
return false;
|
|
}
|
|
if (path.includes("\\")) {
|
|
return false;
|
|
}
|
|
const normalized = path.replace(/^(\.\/)+/, "");
|
|
if (normalized.split("/").includes("..")) {
|
|
return false;
|
|
}
|
|
for (const dir of FORBIDDEN_DIRS) {
|
|
if (normalized.startsWith(`${dir}/`) || normalized.startsWith(`${dir}\\`)) {
|
|
return false;
|
|
}
|
|
if (normalized.includes(`/${dir}/`) || normalized.includes(`\\${dir}\\`)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
async handleToolCall(toolCall) {
|
|
try {
|
|
const toolName = toolCall.function?.name;
|
|
const rawArgs = toolCall.function?.arguments;
|
|
if (!toolName) {
|
|
throw new Error("Tool name is required");
|
|
}
|
|
let parsedArgs;
|
|
if (typeof rawArgs === "string") {
|
|
try {
|
|
parsedArgs = safeParseJson(rawArgs);
|
|
} catch {
|
|
throw new Error("Invalid JSON arguments");
|
|
}
|
|
} else if (rawArgs && typeof rawArgs === "object") {
|
|
parsedArgs = rawArgs;
|
|
} else {
|
|
throw new Error("Arguments must be an object or JSON string");
|
|
}
|
|
switch (toolName) {
|
|
case "create_file":
|
|
return await this.handleCreateFile(parsedArgs);
|
|
case "read_vault_file":
|
|
return await this.handleReadVaultFile(parsedArgs);
|
|
case "search_vault_files":
|
|
return this.handleSearchVaultFiles(parsedArgs);
|
|
default:
|
|
return { success: false, message: `Unknown tool: ${toolName}` };
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
throw new Error(errorMessage);
|
|
}
|
|
}
|
|
async handleCreateFile(args) {
|
|
const path = args.path;
|
|
const content = args.content;
|
|
if (typeof path !== "string") {
|
|
throw new Error("Path must be a string");
|
|
}
|
|
if (typeof content !== "string") {
|
|
throw new Error("Content must be a string");
|
|
}
|
|
if (!this.isSafePath(path)) {
|
|
throw new Error("Invalid file path detected");
|
|
}
|
|
try {
|
|
await this.vault.create(path, content);
|
|
return { success: true, message: "File created successfully" };
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
throw new Error(errorMessage);
|
|
}
|
|
}
|
|
async executeTool(name, args) {
|
|
return this.handleToolCall({
|
|
id: crypto.randomUUID(),
|
|
type: "function",
|
|
function: {
|
|
name,
|
|
arguments: args
|
|
}
|
|
});
|
|
}
|
|
async handleReadVaultFile(args) {
|
|
const path = args.path;
|
|
if (typeof path !== "string") {
|
|
throw new Error("Path must be a string");
|
|
}
|
|
if (!this.isSafePath(path)) {
|
|
throw new Error("Invalid file path detected");
|
|
}
|
|
const file = this.vault.getAbstractFileByPath(path);
|
|
if (!(file instanceof import_obsidian.TFile)) {
|
|
throw new Error(`File not found: ${path}`);
|
|
}
|
|
const content = await this.vault.cachedRead(file);
|
|
return {
|
|
success: true,
|
|
message: "File read successfully",
|
|
data: { path, content }
|
|
};
|
|
}
|
|
handleSearchVaultFiles(args) {
|
|
const query = args.query;
|
|
const limitArg = args.limit;
|
|
if (typeof query !== "string") {
|
|
throw new Error("Query must be a string");
|
|
}
|
|
const limit = typeof limitArg === "number" && Number.isFinite(limitArg) ? limitArg : 10;
|
|
const normalizedQuery = query.toLowerCase();
|
|
const files = this.vault.getMarkdownFiles().filter((file) => file.path.toLowerCase().includes(normalizedQuery)).slice(0, limit).map((file) => ({ path: file.path, basename: file.basename }));
|
|
return {
|
|
success: true,
|
|
message: `Found ${files.length} matching files`,
|
|
data: files
|
|
};
|
|
}
|
|
};
|
|
|
|
// src/conversation-state.ts
|
|
var ConversationStateManager = class {
|
|
constructor() {
|
|
this.shortTermContext = [];
|
|
this.mediumTermContext = [];
|
|
this.longTermContext = [];
|
|
this.maxShortTermTurns = 10;
|
|
this.maxMediumTermMessages = 20;
|
|
this.longTermContext = [
|
|
{
|
|
role: "system",
|
|
content: `You are an assistant that can help answer questions using the contents of a vault.
|
|
The user can ask questions about their vault contents, and you should provide helpful responses based on the files.
|
|
When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool.
|
|
Only use the tools if you need to access vault content that is not already in the context.`
|
|
}
|
|
];
|
|
}
|
|
/**
|
|
* Updates the short-term context with a new message
|
|
* @param message The message to add to short-term context
|
|
*/
|
|
updateShortTermContext(message) {
|
|
this.shortTermContext.push(message);
|
|
if (this.shortTermContext.length > this.maxShortTermTurns) {
|
|
this.shortTermContext = this.shortTermContext.slice(-this.maxShortTermTurns);
|
|
}
|
|
}
|
|
/**
|
|
* Updates the medium-term context with a new message
|
|
* @param message The message to add to medium-term context
|
|
*/
|
|
updateMediumTermContext(message) {
|
|
this.mediumTermContext.push(message);
|
|
if (this.mediumTermContext.length > this.maxMediumTermMessages) {
|
|
this.mediumTermContext = this.mediumTermContext.slice(-this.maxMediumTermMessages);
|
|
}
|
|
}
|
|
/**
|
|
* Sets the user's persona or core knowledge as long-term context
|
|
* @param personaContent The persona or core knowledge content
|
|
*/
|
|
setPersona(personaContent) {
|
|
this.longTermContext = this.longTermContext.filter(
|
|
(msg) => msg.role !== "system" || !msg.content.includes(
|
|
"You are an assistant that can help answer questions using the contents of a vault"
|
|
)
|
|
);
|
|
this.longTermContext.push({
|
|
role: "system",
|
|
content: personaContent
|
|
});
|
|
}
|
|
/**
|
|
* Gets the combined conversation context for the current turn
|
|
* @param userMessage The user's current message
|
|
* @returns Complete conversation context with all three layers
|
|
*/
|
|
getConversationContext(_userMessage) {
|
|
return {
|
|
shortTermContext: this.shortTermContext,
|
|
mediumTermContext: this.mediumTermContext,
|
|
longTermContext: this.longTermContext
|
|
};
|
|
}
|
|
/**
|
|
* Gets the complete messages array for sending to the LLM
|
|
* @param userMessage The user's current message
|
|
* @returns Complete message array for the LLM
|
|
*/
|
|
getCompleteMessages(userMessage) {
|
|
const userMessageWithContext = {
|
|
role: "user",
|
|
content: userMessage
|
|
};
|
|
return [
|
|
...this.longTermContext,
|
|
...this.mediumTermContext,
|
|
...this.shortTermContext,
|
|
userMessageWithContext
|
|
];
|
|
}
|
|
/**
|
|
* Clears all conversation context
|
|
*/
|
|
clear() {
|
|
this.shortTermContext = [];
|
|
this.mediumTermContext = [];
|
|
this.longTermContext = [
|
|
{
|
|
role: "system",
|
|
content: `You are an assistant that can help answer questions using the contents of a vault.
|
|
The user can ask questions about their vault contents, and you should provide helpful responses based on the files.
|
|
When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool.
|
|
Only use the tools if you need to access vault content that is not already in the context.`
|
|
}
|
|
];
|
|
}
|
|
/**
|
|
* Sets the medium-term context from a knowledge base query result
|
|
* @param queryResult The result from a knowledge base query
|
|
*/
|
|
setMediumTermContextFromQuery(queryResult) {
|
|
this.mediumTermContext = [];
|
|
if (queryResult.trim()) {
|
|
this.mediumTermContext.push({
|
|
role: "system",
|
|
content: `Knowledge base results for current query:
|
|
${queryResult}`
|
|
});
|
|
}
|
|
}
|
|
/**
|
|
* Gets the current short-term context
|
|
*/
|
|
getShortTermContext() {
|
|
return [...this.shortTermContext];
|
|
}
|
|
/**
|
|
* Gets the current medium-term context
|
|
*/
|
|
getMediumTermContext() {
|
|
return [...this.mediumTermContext];
|
|
}
|
|
/**
|
|
* Gets the current long-term context
|
|
*/
|
|
getLongTermContext() {
|
|
return [...this.longTermContext];
|
|
}
|
|
};
|
|
|
|
// src/error-handler.ts
|
|
var import_obsidian2 = require("obsidian");
|
|
var ErrorHandler = class {
|
|
static handleError(error, context) {
|
|
const message = this.getUserFriendlyMessage(error);
|
|
new import_obsidian2.Notice(message);
|
|
if (error instanceof Error) {
|
|
const ctx = context ? ` [${context}]` : "";
|
|
console.error(`Ollama Plugin Error${ctx}: ${error.message}`);
|
|
if (error.stack) {
|
|
console.error(error.stack);
|
|
}
|
|
} else {
|
|
const ctx = context ? ` [${context}]` : "";
|
|
console.error(`Ollama Plugin Error${ctx}:`, error);
|
|
}
|
|
}
|
|
static getUserFriendlyMessage(error) {
|
|
if (error instanceof OllamaError) {
|
|
return this.getUserFriendlyMessageFromOllamaError(error);
|
|
}
|
|
if (error instanceof Error) {
|
|
return this.getUserFriendlyMessageFromError(error);
|
|
}
|
|
return "An unexpected error occurred";
|
|
}
|
|
static getUserFriendlyMessageFromOllamaError(error) {
|
|
switch (error.type) {
|
|
case "network_error" /* NETWORK_ERROR */:
|
|
return "Connection error. Please check if Ollama is running.";
|
|
case "api_error" /* API_ERROR */:
|
|
return `API error: ${error.message}`;
|
|
case "validation_error" /* VALIDATION_ERROR */:
|
|
return this.getUserFriendlyValidationMessage(error);
|
|
case "streaming_error" /* STREAMING_ERROR */:
|
|
return "Response too long. Please try a shorter request.";
|
|
case "tool_execution_error" /* TOOL_EXECUTION_ERROR */:
|
|
return `Tool error for ${error.toolName}. ${error.message}`;
|
|
case "path_validation_error" /* PATH_VALIDATION_ERROR */:
|
|
return `Invalid file path: ${error.path}`;
|
|
case "unknown_error" /* UNKNOWN_ERROR */:
|
|
return "An unexpected error occurred";
|
|
default:
|
|
return "An unexpected error occurred";
|
|
}
|
|
}
|
|
static getUserFriendlyValidationMessage(error) {
|
|
if (error instanceof ValidationError && error.details?.field) {
|
|
const fieldMsg = error.details.field.charAt(0).toUpperCase() + error.details.field.slice(1);
|
|
return `Invalid ${fieldMsg.toLowerCase()}. ${error.details.message ?? error.message}`;
|
|
}
|
|
return "Input validation error. Please correct your input.";
|
|
}
|
|
static getUserFriendlyMessageFromError(error) {
|
|
const msg = error.message.toLowerCase();
|
|
if (msg.includes("timeout") || msg.includes("timed out") || msg.includes("time out")) {
|
|
return "Request timed out. Please check your Ollama connection.";
|
|
}
|
|
if (msg.includes("network") || msg.includes("connection") || msg.includes("fetch")) {
|
|
return "Connection error. Please check if Ollama is running.";
|
|
}
|
|
if (msg.includes("validation") || msg.includes("invalid")) {
|
|
return "Invalid input. Please correct your input.";
|
|
}
|
|
if (msg.includes("stream") || msg.includes("chunk")) {
|
|
return "Response too long. Please try a shorter request.";
|
|
}
|
|
if (msg.includes("tool") || msg.includes("function")) {
|
|
return "Tool error. Please try again.";
|
|
}
|
|
if (msg.includes("path") || msg.includes("file")) {
|
|
return "Invalid file path. Please check the path and try again.";
|
|
}
|
|
return "An unexpected error occurred";
|
|
}
|
|
// -- Factory methods --
|
|
static createNetworkError(message, statusCode) {
|
|
return new NetworkError(message, statusCode);
|
|
}
|
|
static createApiError(message, statusCode) {
|
|
return new ApiError(message, statusCode ?? 500);
|
|
}
|
|
static createValidationError(message, field) {
|
|
const details = field ? { field, message } : void 0;
|
|
return new ValidationError(message, details);
|
|
}
|
|
static createStreamingError(message) {
|
|
return new StreamingError(message);
|
|
}
|
|
static createToolExecutionError(message, toolName) {
|
|
return new ToolExecutionError(message, toolName ?? "unknown");
|
|
}
|
|
static createPathValidationError(message, path) {
|
|
return new PathValidationError(message, path ?? "");
|
|
}
|
|
static createUnknownError(message) {
|
|
return new OllamaError(message, "unknown_error" /* UNKNOWN_ERROR */);
|
|
}
|
|
};
|
|
|
|
// src/chat-view.ts
|
|
var ChatView = class extends import_obsidian3.ItemView {
|
|
constructor(leaf, settings) {
|
|
super(leaf);
|
|
// State
|
|
this.messages = [];
|
|
this.lastMessageEl = null;
|
|
this.newChatButton = null;
|
|
this.sendButton = null;
|
|
this.inputEl = null;
|
|
this.chatContainer = null;
|
|
this.sendButtonClickHandler = null;
|
|
this.inputKeyDownHandler = null;
|
|
this.newChatButtonClickHandler = null;
|
|
this.sendButtonClickWrapper = null;
|
|
this.inputKeyDownWrapper = null;
|
|
this.newChatButtonClickWrapper = null;
|
|
this.listenersAttached = false;
|
|
this.messages = [];
|
|
this.lastMessageEl = null;
|
|
this.newChatButton = null;
|
|
this.sendButton = null;
|
|
this.inputEl = null;
|
|
this.chatContainer = null;
|
|
this.sendButtonClickHandler = null;
|
|
this.inputKeyDownHandler = null;
|
|
this.newChatButtonClickHandler = null;
|
|
this.sendButtonClickWrapper = null;
|
|
this.inputKeyDownWrapper = null;
|
|
this.newChatButtonClickWrapper = null;
|
|
this.listenersAttached = false;
|
|
this.settings = settings;
|
|
this.ollamaClient = new OllamaClient(
|
|
settings.ollamaUrl,
|
|
settings.model,
|
|
void 0,
|
|
settings.cacheConfig
|
|
);
|
|
this.vaultIndexer = new VaultIndexer(this.app.vault);
|
|
this.toolExecutor = new ToolExecutor(this.app.vault, this.app);
|
|
this.conversationStateManager = new ConversationStateManager();
|
|
}
|
|
// Getters for testing
|
|
getSendButtonClickHandler() {
|
|
return this.sendButtonClickHandler;
|
|
}
|
|
getInputKeyDownHandler() {
|
|
return this.inputKeyDownHandler;
|
|
}
|
|
getNewChatButtonClickHandler() {
|
|
return this.newChatButtonClickHandler;
|
|
}
|
|
updateSettings(newSettings) {
|
|
this.settings = newSettings;
|
|
this.ollamaClient = new OllamaClient(
|
|
newSettings.ollamaUrl,
|
|
newSettings.model,
|
|
void 0,
|
|
newSettings.cacheConfig
|
|
);
|
|
void this.ollamaClient.initializeCache().catch(() => {
|
|
new import_obsidian3.Notice(
|
|
"Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings."
|
|
);
|
|
});
|
|
}
|
|
async clearCache() {
|
|
await this.ollamaClient.clearCache();
|
|
}
|
|
getViewType() {
|
|
return "ollama-chat-view";
|
|
}
|
|
getDisplayText() {
|
|
return "Ollama Chat";
|
|
}
|
|
async onOpen() {
|
|
try {
|
|
await this.ollamaClient.initializeCache();
|
|
} catch {
|
|
new import_obsidian3.Notice(
|
|
"Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings."
|
|
);
|
|
}
|
|
this.render();
|
|
this.removeEventListeners();
|
|
this.setupEventListeners();
|
|
}
|
|
onSettingsChange(newSettings) {
|
|
this.updateSettings(newSettings);
|
|
}
|
|
async onClose() {
|
|
this.ollamaClient.cancelStream();
|
|
this.removeEventListeners();
|
|
this.cleanupStreamingResources();
|
|
this.lastMessageEl = null;
|
|
this.sendButton = null;
|
|
this.inputEl = null;
|
|
this.chatContainer = null;
|
|
return Promise.resolve();
|
|
}
|
|
cleanupStreamingResources() {
|
|
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
|
|
if (streamingMessage && this.lastMessageEl && this.lastMessageEl.parentElement) {
|
|
this.lastMessageEl.parentElement.removeChild(this.lastMessageEl);
|
|
this.lastMessageEl = null;
|
|
}
|
|
}
|
|
render() {
|
|
const container = this.chatContainer || this.contentEl.createEl("div", { cls: "ollama-chat-container" });
|
|
this.chatContainer = container;
|
|
const inputContainer = this.contentEl.querySelector(".ollama-input-container") || this.contentEl.createEl("div", { cls: "ollama-input-container" });
|
|
const newChatContainer = this.contentEl.querySelector(".ollama-new-chat-container") || this.contentEl.createEl("div", { cls: "ollama-new-chat-container" });
|
|
const messagesSnapshot = [...this.messages];
|
|
const nonStreamingMessages = messagesSnapshot.filter((msg) => !msg.isStreaming);
|
|
const existingMessages = container.querySelectorAll(".ollama-message");
|
|
for (const el of Array.from(existingMessages)) {
|
|
const id = el.getAttribute("data-msg-id");
|
|
if (!id || !nonStreamingMessages.some((m) => m.id === id)) {
|
|
el.remove();
|
|
}
|
|
}
|
|
for (const msg of nonStreamingMessages) {
|
|
const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
|
|
if (existingEl) {
|
|
const contentEl = existingEl.querySelector(".ollama-message-content");
|
|
if (contentEl) {
|
|
contentEl.textContent = msg.content;
|
|
}
|
|
} else {
|
|
const messageEl = container.createEl("div", { cls: "ollama-message" });
|
|
messageEl.setAttribute("data-msg-id", msg.id);
|
|
messageEl.createEl("div", { cls: "ollama-message-role", text: msg.role });
|
|
const contentEl = messageEl.createEl("div", { cls: "ollama-message-content" });
|
|
contentEl.textContent = msg.content;
|
|
}
|
|
}
|
|
const streamingMessage = messagesSnapshot.find((msg) => msg.isStreaming);
|
|
if (streamingMessage && this.lastMessageEl) {
|
|
const existingStreamingEl = container.querySelector(
|
|
`.ollama-message[data-msg-id="${streamingMessage.id}"]`
|
|
);
|
|
if (!existingStreamingEl) {
|
|
container.appendChild(this.lastMessageEl);
|
|
}
|
|
}
|
|
if (!this.newChatButton) {
|
|
this.newChatButton = newChatContainer.createEl("button", {
|
|
cls: "ollama-new-chat-button",
|
|
text: "New Chat"
|
|
});
|
|
} else {
|
|
newChatContainer.appendChild(this.newChatButton);
|
|
}
|
|
if (!this.inputEl) {
|
|
this.inputEl = inputContainer.createEl("textarea", {
|
|
cls: "ollama-input",
|
|
attr: { placeholder: "Type your message..." }
|
|
});
|
|
} else {
|
|
inputContainer.appendChild(this.inputEl);
|
|
}
|
|
if (!this.sendButton) {
|
|
this.sendButton = inputContainer.createEl("button", {
|
|
cls: "ollama-send-button",
|
|
text: "Send"
|
|
});
|
|
} else {
|
|
inputContainer.appendChild(this.sendButton);
|
|
}
|
|
this.contentEl.appendChild(newChatContainer);
|
|
this.contentEl.appendChild(inputContainer);
|
|
this.contentEl.appendChild(container);
|
|
this.inputEl.focus();
|
|
}
|
|
setupEventListeners() {
|
|
if (this.listenersAttached) {
|
|
return;
|
|
}
|
|
this.sendButtonClickHandler = () => {
|
|
void this.handleUserInput(this.inputEl?.value);
|
|
};
|
|
this.inputKeyDownHandler = (event) => {
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
void this.handleUserInput(this.inputEl?.value);
|
|
}
|
|
};
|
|
this.newChatButtonClickHandler = () => {
|
|
this.clearConversation();
|
|
};
|
|
if (this.sendButton && this.sendButtonClickHandler) {
|
|
this.sendButton.addEventListener("click", this.sendButtonClickHandler);
|
|
}
|
|
if (this.inputEl && this.inputKeyDownHandler) {
|
|
this.inputEl.addEventListener("keydown", this.inputKeyDownHandler);
|
|
}
|
|
if (this.newChatButton && this.newChatButtonClickHandler) {
|
|
this.newChatButton.addEventListener("click", this.newChatButtonClickHandler);
|
|
}
|
|
this.listenersAttached = true;
|
|
}
|
|
removeEventListeners() {
|
|
if (!this.listenersAttached) {
|
|
return;
|
|
}
|
|
if (this.sendButton && this.sendButtonClickHandler) {
|
|
this.sendButton.removeEventListener("click", this.sendButtonClickHandler);
|
|
}
|
|
if (this.inputEl && this.inputKeyDownHandler) {
|
|
this.inputEl.removeEventListener("keydown", this.inputKeyDownHandler);
|
|
}
|
|
if (this.newChatButton && this.newChatButtonClickHandler) {
|
|
this.newChatButton.removeEventListener("click", this.newChatButtonClickHandler);
|
|
}
|
|
this.listenersAttached = false;
|
|
}
|
|
clearConversation() {
|
|
this.messages = [];
|
|
this.conversationStateManager.clear();
|
|
this.render();
|
|
}
|
|
updateMessageById(id, updates) {
|
|
const index = this.messages.findIndex((m) => m.id === id);
|
|
if (index !== -1) {
|
|
this.messages[index] = { ...this.messages[index], ...updates };
|
|
this.render();
|
|
}
|
|
}
|
|
updateLastMessage(updates) {
|
|
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
|
|
if (streamingMessage) {
|
|
const index = this.messages.findIndex((msg) => msg.id === streamingMessage.id);
|
|
if (index !== -1) {
|
|
this.messages[index] = { ...this.messages[index], ...updates };
|
|
this.render();
|
|
}
|
|
}
|
|
}
|
|
getTools() {
|
|
return [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "read_vault_file",
|
|
description: "Reads the content of a file from the vault",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
path: {
|
|
type: "string",
|
|
description: "The path to the file to read"
|
|
},
|
|
content: {
|
|
type: "string",
|
|
description: "The content of the file to read"
|
|
}
|
|
},
|
|
required: ["path"]
|
|
}
|
|
}
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "search_vault_files",
|
|
description: "Searches for files in the vault that match a given query",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
query: {
|
|
type: "string",
|
|
description: "The search query to use"
|
|
},
|
|
limit: {
|
|
type: "number",
|
|
description: "The maximum number of results to return"
|
|
}
|
|
},
|
|
required: ["query"]
|
|
}
|
|
}
|
|
}
|
|
];
|
|
}
|
|
buildMessages(userMessageContent, tools) {
|
|
const systemContent = `You are an assistant that can help answer questions using the contents of a vault.
|
|
The user can ask questions about their vault contents, and you should provide helpful responses based on the files.
|
|
When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool.
|
|
Only use the tools if you need to access vault content that is not already in the context.`;
|
|
const systemMessage = {
|
|
role: "system",
|
|
content: systemContent
|
|
};
|
|
const userMessage = {
|
|
role: "user",
|
|
content: userMessageContent
|
|
};
|
|
const messages = [systemMessage, userMessage];
|
|
if (tools && tools.length > 0) {
|
|
messages.push({
|
|
role: "assistant",
|
|
content: "I have access to the following tools to help answer your questions:"
|
|
});
|
|
}
|
|
return messages;
|
|
}
|
|
async processToolCalls(toolCalls, messages, tools, fullResponse, assistantMessageId) {
|
|
const toolResults = (await Promise.all(
|
|
toolCalls.slice(0, MAX_TOOL_CALLS).map(async (toolCall) => {
|
|
try {
|
|
const toolResult = await this.toolExecutor.handleToolCall(toolCall);
|
|
return { ...toolResult, id: toolCall.id };
|
|
} catch (error) {
|
|
ErrorHandler.handleError(error, "ChatView.handleUserInput");
|
|
return null;
|
|
}
|
|
})
|
|
)).filter((result) => result !== null);
|
|
const followUpMessages = toolResults.map((result) => {
|
|
return {
|
|
role: "tool",
|
|
content: JSON.stringify(result),
|
|
tool_call_id: result.id ?? ""
|
|
};
|
|
});
|
|
const followUp = {
|
|
role: "assistant",
|
|
content: "I have processed your request using the following tools. Here are the results:",
|
|
tool_calls: toolCalls
|
|
};
|
|
if (followUpMessages.length > 0) {
|
|
const finalMessages = [...messages, followUp, ...followUpMessages];
|
|
const response = await this.ollamaClient.chat(finalMessages, tools);
|
|
const finalResponse = response.content || fullResponse;
|
|
this.updateMessageById(assistantMessageId, {
|
|
content: finalResponse,
|
|
isStreaming: false
|
|
});
|
|
}
|
|
}
|
|
async handleUserInput(inputValue) {
|
|
const userMessage = (inputValue ?? this.inputEl?.value ?? "").trim();
|
|
if (!userMessage) {
|
|
return;
|
|
}
|
|
const MAX_CONTEXT_LENGTH = 2e3;
|
|
const tools = this.getTools();
|
|
const messageId = crypto.randomUUID();
|
|
const userMessageId = `${messageId}-user`;
|
|
const assistantMessageId = `${messageId}-assistant`;
|
|
const userChatMessage = {
|
|
id: userMessageId,
|
|
role: "user",
|
|
content: userMessage,
|
|
timestamp: Date.now()
|
|
};
|
|
const assistantMessage = {
|
|
id: assistantMessageId,
|
|
role: "assistant",
|
|
content: "",
|
|
timestamp: Date.now(),
|
|
isStreaming: true
|
|
};
|
|
const previousStreamingEl = this.lastMessageEl;
|
|
this.messages = [...this.messages, userChatMessage, assistantMessage];
|
|
this.render();
|
|
if (this.inputEl) {
|
|
this.inputEl.value = "";
|
|
}
|
|
this.lastMessageEl = this.chatContainer?.querySelector(`.ollama-message[data-msg-id="${assistantMessageId}"]`) ?? null;
|
|
if (!this.lastMessageEl && previousStreamingEl) {
|
|
previousStreamingEl.classList.add("ollama-message");
|
|
previousStreamingEl.setAttribute("data-msg-id", assistantMessageId);
|
|
this.contentEl.appendChild(previousStreamingEl);
|
|
this.lastMessageEl = previousStreamingEl;
|
|
}
|
|
try {
|
|
const entries = await this.vaultIndexer.searchVault(userMessage, this.settings.vaultSearchLimit);
|
|
const context = entries.map((entry) => `${entry.title}
|
|
${entry.content}`).join("\n\n").slice(0, MAX_CONTEXT_LENGTH);
|
|
const userMessageWithContext = context ? `Relevant vault context:
|
|
${context}
|
|
|
|
User question:
|
|
${userMessage}` : userMessage;
|
|
const completeMessages = this.conversationStateManager.getCompleteMessages(userMessageWithContext);
|
|
const stream = this.ollamaClient.streamChat(completeMessages, tools);
|
|
let fullResponse = "";
|
|
let toolCalls = [];
|
|
let chunkCount = 0;
|
|
for await (const chunk of stream) {
|
|
if (chunk.content) {
|
|
fullResponse += chunk.content;
|
|
this.updateLastMessage({
|
|
content: fullResponse,
|
|
isStreaming: true
|
|
});
|
|
}
|
|
if (chunk.tool_calls) {
|
|
toolCalls = [...toolCalls, ...chunk.tool_calls];
|
|
}
|
|
chunkCount++;
|
|
if (chunkCount > MAX_STREAM_CHUNKS) {
|
|
break;
|
|
}
|
|
}
|
|
if (toolCalls.length > 0) {
|
|
await this.processToolCalls(
|
|
toolCalls,
|
|
completeMessages,
|
|
tools,
|
|
fullResponse,
|
|
assistantMessageId
|
|
);
|
|
}
|
|
if (toolCalls.length === 0) {
|
|
this.updateMessageById(assistantMessageId, {
|
|
content: fullResponse,
|
|
isStreaming: false
|
|
});
|
|
}
|
|
this.conversationStateManager.updateShortTermContext({ role: "user", content: userMessage });
|
|
this.conversationStateManager.updateShortTermContext({
|
|
role: "assistant",
|
|
content: fullResponse
|
|
});
|
|
if (this.messages.length > this.settings.maxMessageHistory) {
|
|
this.messages = this.messages.slice(-this.settings.maxMessageHistory);
|
|
}
|
|
this.render();
|
|
} catch (error) {
|
|
ErrorHandler.handleError(error, "ChatView.handleUserInput");
|
|
this.updateMessageById(assistantMessageId, {
|
|
content: "An error occurred while processing your request.",
|
|
isStreaming: false
|
|
});
|
|
} finally {
|
|
this.cleanupStreamingResources();
|
|
}
|
|
}
|
|
};
|
|
var MAX_STREAM_CHUNKS = 1e3;
|
|
var MAX_TOOL_CALLS = 5;
|
|
|
|
// src/constants.ts
|
|
var DEFAULT_SETTINGS = {
|
|
ollamaUrl: "http://localhost:11434",
|
|
model: "llama3",
|
|
vaultSearchLimit: 3,
|
|
maxMessageHistory: 50,
|
|
lastIndexTime: 0,
|
|
cacheConfig: {
|
|
enabled: false,
|
|
similarityThreshold: 0.85,
|
|
collectionName: "ollama_semantic_cache",
|
|
embeddingModel: "nomic-embed-text",
|
|
chromaURL: "http://localhost:8000"
|
|
}
|
|
};
|
|
|
|
// src/main.ts
|
|
var OllamaPlugin = class extends import_obsidian4.Plugin {
|
|
constructor() {
|
|
super(...arguments);
|
|
this.settings = DEFAULT_SETTINGS;
|
|
}
|
|
async onload() {
|
|
await this.loadSettings();
|
|
this.registerView(
|
|
"ollama-chat-view",
|
|
(leaf) => new ChatView(leaf, this.settings)
|
|
);
|
|
this.addCommand({
|
|
id: "open-ollama-chat",
|
|
name: "Open Ollama Chat",
|
|
callback: async () => {
|
|
await this.activateChatView();
|
|
}
|
|
});
|
|
this.addCommand({
|
|
id: "clear-semantic-cache",
|
|
name: "Clear Semantic Cache",
|
|
callback: async () => {
|
|
await this.clearSemanticCache();
|
|
new import_obsidian4.Notice("Semantic cache cleared.");
|
|
}
|
|
});
|
|
this.addSettingTab(new OllamaSettingTab(this.app, this));
|
|
if (this.settings.cacheConfig) {
|
|
this.semanticCache = new SemanticCacheService(
|
|
this.settings.ollamaUrl,
|
|
this.settings.cacheConfig
|
|
);
|
|
try {
|
|
await this.semanticCache.initialize();
|
|
} catch {
|
|
new import_obsidian4.Notice("Semantic cache initialization failed. Check console for details.");
|
|
}
|
|
}
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
onunload() {
|
|
if (this.semanticCache) {
|
|
void this.semanticCache.clearCache();
|
|
}
|
|
}
|
|
async loadSettings() {
|
|
const loadedSettings = await this.loadData() ?? {};
|
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings);
|
|
}
|
|
async saveSettings() {
|
|
await this.saveData(this.settings);
|
|
}
|
|
async activateChatView() {
|
|
const existing = this.app.workspace.getLeavesOfType("ollama-chat-view");
|
|
if (existing.length > 0) {
|
|
await this.app.workspace.revealLeaf(existing[0]);
|
|
} else {
|
|
const leaf = this.app.workspace.getRightLeaf(false);
|
|
if (leaf) {
|
|
await leaf.setViewState({
|
|
type: "ollama-chat-view",
|
|
active: true
|
|
});
|
|
}
|
|
}
|
|
}
|
|
async clearSemanticCache() {
|
|
if (this.semanticCache) {
|
|
await this.semanticCache.clearCache();
|
|
}
|
|
}
|
|
notifyChatViews() {
|
|
const leaves = this.app.workspace.getLeavesOfType("ollama-chat-view");
|
|
leaves.forEach((leaf) => {
|
|
if (leaf.view instanceof ChatView) {
|
|
leaf.view.updateSettings(this.settings);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
|
|
constructor(app, plugin) {
|
|
super(app, plugin);
|
|
this.plugin = plugin;
|
|
}
|
|
display() {
|
|
const { containerEl } = this;
|
|
containerEl.empty();
|
|
containerEl.createEl("h2", { text: "Ollama Settings" });
|
|
new import_obsidian4.Setting(containerEl).setName("Ollama URL").setDesc("URL for your Ollama instance (default: http://localhost:11434)").addText(
|
|
(text) => text.setValue(this.plugin.settings.ollamaUrl).onChange(async (value) => {
|
|
this.plugin.settings.ollamaUrl = value;
|
|
await this.plugin.saveSettings();
|
|
})
|
|
);
|
|
new import_obsidian4.Setting(containerEl).setName("Model").setDesc("Ollama model to use (default: llama3)").addText(
|
|
(text) => text.setValue(this.plugin.settings.model).onChange(async (value) => {
|
|
this.plugin.settings.model = value;
|
|
await this.plugin.saveSettings();
|
|
})
|
|
);
|
|
new import_obsidian4.Setting(containerEl).setName("Vault Search Limit").setDesc("Maximum number of vault entries to include in context (default: 3)").addText(
|
|
(text) => text.setValue(String(this.plugin.settings.vaultSearchLimit)).onChange(async (value) => {
|
|
const parsed = parseInt(value);
|
|
if (!isNaN(parsed) && parsed > 0) {
|
|
this.plugin.settings.vaultSearchLimit = parsed;
|
|
await this.plugin.saveSettings();
|
|
} else {
|
|
new import_obsidian4.Notice("Vault search limit must be a positive integer.");
|
|
}
|
|
})
|
|
);
|
|
new import_obsidian4.Setting(containerEl).setName("Max Message History").setDesc("Maximum number of messages to keep in conversation history (default: 50)").addText(
|
|
(text) => text.setValue(String(this.plugin.settings.maxMessageHistory)).onChange(async (value) => {
|
|
const parsed = parseInt(value);
|
|
if (!isNaN(parsed) && parsed > 0) {
|
|
this.plugin.settings.maxMessageHistory = parsed;
|
|
await this.plugin.saveSettings();
|
|
} else {
|
|
new import_obsidian4.Notice("Max message history must be a positive integer.");
|
|
}
|
|
})
|
|
);
|
|
new import_obsidian4.Setting(containerEl).setName("Enable Semantic Cache").setDesc("Use semantic cache to store and retrieve previous responses").addToggle(
|
|
(toggle) => toggle.setValue(this.plugin.settings.cacheConfig.enabled).onChange(async (value) => {
|
|
this.plugin.settings.cacheConfig.enabled = value;
|
|
await this.plugin.saveSettings();
|
|
this.plugin.notifyChatViews();
|
|
})
|
|
);
|
|
new import_obsidian4.Setting(containerEl).setName("ChromaDB URL").setDesc("URL for your ChromaDB instance (default: http://localhost:8000)").addText(
|
|
(text) => text.setValue(this.plugin.settings.cacheConfig.chromaURL || "http://localhost:8000").onChange(async (value) => {
|
|
this.plugin.settings.cacheConfig.chromaURL = value;
|
|
await this.plugin.saveSettings();
|
|
})
|
|
);
|
|
new import_obsidian4.Setting(containerEl).setName("Cache Embedding Model").setDesc("Ollama model used to generate embeddings for the semantic cache").addText(
|
|
(text) => text.setValue(this.plugin.settings.cacheConfig.embeddingModel).onChange(async (value) => {
|
|
this.plugin.settings.cacheConfig.embeddingModel = value;
|
|
await this.plugin.saveSettings();
|
|
this.plugin.notifyChatViews();
|
|
})
|
|
);
|
|
new import_obsidian4.Setting(containerEl).setName("Cache Similarity Threshold").setDesc(
|
|
"Minimum cosine similarity (0\u20131) for a cache hit. Higher values require closer matches."
|
|
).addText(
|
|
(text) => text.setValue(String(this.plugin.settings.cacheConfig.similarityThreshold)).onChange(async (value) => {
|
|
const parsed = parseFloat(value);
|
|
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
|
this.plugin.settings.cacheConfig.similarityThreshold = parsed;
|
|
await this.plugin.saveSettings();
|
|
} else {
|
|
new import_obsidian4.Notice("Similarity threshold must be a number between 0 and 1.");
|
|
}
|
|
})
|
|
);
|
|
new import_obsidian4.Setting(containerEl).setName("Clear Semantic Cache").setDesc("Delete all cached responses from ChromaDB").addButton(
|
|
(button) => button.setButtonText("Clear Cache").onClick(async () => {
|
|
try {
|
|
await this.plugin.clearSemanticCache();
|
|
new import_obsidian4.Notice("Semantic cache cleared.");
|
|
} catch {
|
|
new import_obsidian4.Notice("Failed to clear semantic cache. Is ChromaDB running?");
|
|
}
|
|
})
|
|
);
|
|
}
|
|
hide() {
|
|
this.containerEl.empty();
|
|
}
|
|
};
|