Files
obsidian_ollama/main.js
T
fegger 26e178fa96 Fix Electron resolution for optional chromadb dependency
Replace dynamic ESM import with require() for chromadb to ensure
Electron can resolve the package against the plugin's node_modules.
Also wrap default fetch fallback in an arrow function to avoid
potential strict mode issues with global fetch.
2026-05-19 20:19:28 +02:00

1793 lines
60 KiB
JavaScript

"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
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 __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 chromadb = require("chromadb");
const { ChromaClient } = 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 ?? ((url, init) => fetch(url, init));
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();
}
};