diff --git a/.gitignore b/.gitignore index eca2080..a549d13 100755 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ npm-debug.log* # review agents agent_loop_gemma4/ +rules diff --git a/CHANGES.md b/CHANGES.md deleted file mode 100755 index 84b6170..0000000 --- a/CHANGES.md +++ /dev/null @@ -1,38 +0,0 @@ -# Changes - -## [Unreleased] - -### Fixed - -- **CRITICAL**: Fixed `removeEventListeners` in `chat-view.ts` which was incorrectly adding listeners instead of removing them, causing memory leaks and duplicate event handlers -- **CRITICAL**: Added guards in `setupEventListeners` to prevent duplicate event listeners from accumulating on repeated open/close cycles -- **CRITICAL**: Added path sanitization in `tool-executor.ts` to prevent path traversal attacks by validating that paths are relative and don't contain `..` segments -- **MAJOR**: Added proper cleanup in `ollama-client.ts` async generator with try/finally block to ensure `reader.releaseLock()` and abort controller cleanup on parse errors -- **MAJOR**: Made `fetchFn` injectable in `OllamaClient` constructor to improve testability - -### Added - -- **MAJOR**: Added conversation history management with "New Chat" button to clear messages and start fresh conversations -- **MINOR**: Added `.gitignore` file to exclude backup files, build artifacts, and other unwanted files -- **MINOR**: Created `README.md` with setup instructions and usage documentation -- **MINOR**: Added sensible default values for Ollama URL (`http://localhost:11434`) and model (`llama3`) to prevent confusing failures -- **NIT**: Added `CHANGES.md` file to document changes - -### Changed - -- **MINOR**: Fixed `tsconfig.json` to include `main.ts` in the root directory instead of incorrectly scoping to `./src` -- **NIT**: Refactored code formatting and structure for better readability - -### Security - -- Added path validation to prevent directory traversal attacks in file creation tool -- Added content type validation to ensure proper argument parsing - -### Technical Debt - -- Removed committed backup file (`src/chat-view.ts.backup`) -- Removed references to hardcoded paths and improved modularity - -## [Previous Versions] - -No formal versioning was maintained prior to these changes. diff --git a/coverage/lcov-report/base.css b/coverage/lcov-report/base.css deleted file mode 100644 index f418035..0000000 --- a/coverage/lcov-report/base.css +++ /dev/null @@ -1,224 +0,0 @@ -body, html { - margin:0; padding: 0; - height: 100%; -} -body { - font-family: Helvetica Neue, Helvetica, Arial; - font-size: 14px; - color:#333; -} -.small { font-size: 12px; } -*, *:after, *:before { - -webkit-box-sizing:border-box; - -moz-box-sizing:border-box; - box-sizing:border-box; - } -h1 { font-size: 20px; margin: 0;} -h2 { font-size: 14px; } -pre { - font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; - margin: 0; - padding: 0; - -moz-tab-size: 2; - -o-tab-size: 2; - tab-size: 2; -} -a { color:#0074D9; text-decoration:none; } -a:hover { text-decoration:underline; } -.strong { font-weight: bold; } -.space-top1 { padding: 10px 0 0 0; } -.pad2y { padding: 20px 0; } -.pad1y { padding: 10px 0; } -.pad2x { padding: 0 20px; } -.pad2 { padding: 20px; } -.pad1 { padding: 10px; } -.space-left2 { padding-left:55px; } -.space-right2 { padding-right:20px; } -.center { text-align:center; } -.clearfix { display:block; } -.clearfix:after { - content:''; - display:block; - height:0; - clear:both; - visibility:hidden; - } -.fl { float: left; } -@media only screen and (max-width:640px) { - .col3 { width:100%; max-width:100%; } - .hide-mobile { display:none!important; } -} - -.quiet { - color: #7f7f7f; - color: rgba(0,0,0,0.5); -} -.quiet a { opacity: 0.7; } - -.fraction { - font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; - font-size: 10px; - color: #555; - background: #E8E8E8; - padding: 4px 5px; - border-radius: 3px; - vertical-align: middle; -} - -div.path a:link, div.path a:visited { color: #333; } -table.coverage { - border-collapse: collapse; - margin: 10px 0 0 0; - padding: 0; -} - -table.coverage td { - margin: 0; - padding: 0; - vertical-align: top; -} -table.coverage td.line-count { - text-align: right; - padding: 0 5px 0 20px; -} -table.coverage td.line-coverage { - text-align: right; - padding-right: 10px; - min-width:20px; -} - -table.coverage td span.cline-any { - display: inline-block; - padding: 0 5px; - width: 100%; -} -.missing-if-branch { - display: inline-block; - margin-right: 5px; - border-radius: 3px; - position: relative; - padding: 0 4px; - background: #333; - color: yellow; -} - -.skip-if-branch { - display: none; - margin-right: 10px; - position: relative; - padding: 0 4px; - background: #ccc; - color: white; -} -.missing-if-branch .typ, .skip-if-branch .typ { - color: inherit !important; -} -.coverage-summary { - border-collapse: collapse; - width: 100%; -} -.coverage-summary tr { border-bottom: 1px solid #bbb; } -.keyline-all { border: 1px solid #ddd; } -.coverage-summary td, .coverage-summary th { padding: 10px; } -.coverage-summary tbody { border: 1px solid #bbb; } -.coverage-summary td { border-right: 1px solid #bbb; } -.coverage-summary td:last-child { border-right: none; } -.coverage-summary th { - text-align: left; - font-weight: normal; - white-space: nowrap; -} -.coverage-summary th.file { border-right: none !important; } -.coverage-summary th.pct { } -.coverage-summary th.pic, -.coverage-summary th.abs, -.coverage-summary td.pct, -.coverage-summary td.abs { text-align: right; } -.coverage-summary td.file { white-space: nowrap; } -.coverage-summary td.pic { min-width: 120px !important; } -.coverage-summary tfoot td { } - -.coverage-summary .sorter { - height: 10px; - width: 7px; - display: inline-block; - margin-left: 0.5em; - background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; -} -.coverage-summary .sorted .sorter { - background-position: 0 -20px; -} -.coverage-summary .sorted-desc .sorter { - background-position: 0 -10px; -} -.status-line { height: 10px; } -/* yellow */ -.cbranch-no { background: yellow !important; color: #111; } -/* dark red */ -.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } -.low .chart { border:1px solid #C21F39 } -.highlighted, -.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ - background: #C21F39 !important; -} -/* medium red */ -.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } -/* light red */ -.low, .cline-no { background:#FCE1E5 } -/* light green */ -.high, .cline-yes { background:rgb(230,245,208) } -/* medium green */ -.cstat-yes { background:rgb(161,215,106) } -/* dark green */ -.status-line.high, .high .cover-fill { background:rgb(77,146,33) } -.high .chart { border:1px solid rgb(77,146,33) } -/* dark yellow (gold) */ -.status-line.medium, .medium .cover-fill { background: #f9cd0b; } -.medium .chart { border:1px solid #f9cd0b; } -/* light yellow */ -.medium { background: #fff4c2; } - -.cstat-skip { background: #ddd; color: #111; } -.fstat-skip { background: #ddd; color: #111 !important; } -.cbranch-skip { background: #ddd !important; color: #111; } - -span.cline-neutral { background: #eaeaea; } - -.coverage-summary td.empty { - opacity: .5; - padding-top: 4px; - padding-bottom: 4px; - line-height: 1; - color: #888; -} - -.cover-fill, .cover-empty { - display:inline-block; - height: 12px; -} -.chart { - line-height: 0; -} -.cover-empty { - background: white; -} -.cover-full { - border-right: none !important; -} -pre.prettyprint { - border: none !important; - padding: 0 !important; - margin: 0 !important; -} -.com { color: #999 !important; } -.ignore-none { color: #999; font-weight: normal; } - -.wrapper { - min-height: 100%; - height: auto !important; - height: 100%; - margin: 0 auto -48px; -} -.footer, .push { - height: 48px; -} diff --git a/coverage/lcov-report/block-navigation.js b/coverage/lcov-report/block-navigation.js deleted file mode 100644 index 530d1ed..0000000 --- a/coverage/lcov-report/block-navigation.js +++ /dev/null @@ -1,87 +0,0 @@ -/* eslint-disable */ -var jumpToCode = (function init() { - // Classes of code we would like to highlight in the file view - var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; - - // Elements to highlight in the file listing view - var fileListingElements = ['td.pct.low']; - - // We don't want to select elements that are direct descendants of another match - var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` - - // Selector that finds elements on the page to which we can jump - var selector = - fileListingElements.join(', ') + - ', ' + - notSelector + - missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` - - // The NodeList of matching elements - var missingCoverageElements = document.querySelectorAll(selector); - - var currentIndex; - - function toggleClass(index) { - missingCoverageElements - .item(currentIndex) - .classList.remove('highlighted'); - missingCoverageElements.item(index).classList.add('highlighted'); - } - - function makeCurrent(index) { - toggleClass(index); - currentIndex = index; - missingCoverageElements.item(index).scrollIntoView({ - behavior: 'smooth', - block: 'center', - inline: 'center' - }); - } - - function goToPrevious() { - var nextIndex = 0; - if (typeof currentIndex !== 'number' || currentIndex === 0) { - nextIndex = missingCoverageElements.length - 1; - } else if (missingCoverageElements.length > 1) { - nextIndex = currentIndex - 1; - } - - makeCurrent(nextIndex); - } - - function goToNext() { - var nextIndex = 0; - - if ( - typeof currentIndex === 'number' && - currentIndex < missingCoverageElements.length - 1 - ) { - nextIndex = currentIndex + 1; - } - - makeCurrent(nextIndex); - } - - return function jump(event) { - if ( - document.getElementById('fileSearch') === document.activeElement && - document.activeElement != null - ) { - // if we're currently focused on the search input, we don't want to navigate - return; - } - - switch (event.which) { - case 78: // n - case 74: // j - goToNext(); - break; - case 66: // b - case 75: // k - case 80: // p - goToPrevious(); - break; - } - }; -})(); -window.addEventListener('keydown', jumpToCode); diff --git a/coverage/lcov-report/chat-view.ts.html b/coverage/lcov-report/chat-view.ts.html deleted file mode 100644 index af9ceaa..0000000 --- a/coverage/lcov-report/chat-view.ts.html +++ /dev/null @@ -1,1516 +0,0 @@ - - - - -
-- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160 -161 -162 -163 -164 -165 -166 -167 -168 -169 -170 -171 -172 -173 -174 -175 -176 -177 -178 -179 -180 -181 -182 -183 -184 -185 -186 -187 -188 -189 -190 -191 -192 -193 -194 -195 -196 -197 -198 -199 -200 -201 -202 -203 -204 -205 -206 -207 -208 -209 -210 -211 -212 -213 -214 -215 -216 -217 -218 -219 -220 -221 -222 -223 -224 -225 -226 -227 -228 -229 -230 -231 -232 -233 -234 -235 -236 -237 -238 -239 -240 -241 -242 -243 -244 -245 -246 -247 -248 -249 -250 -251 -252 -253 -254 -255 -256 -257 -258 -259 -260 -261 -262 -263 -264 -265 -266 -267 -268 -269 -270 -271 -272 -273 -274 -275 -276 -277 -278 -279 -280 -281 -282 -283 -284 -285 -286 -287 -288 -289 -290 -291 -292 -293 -294 -295 -296 -297 -298 -299 -300 -301 -302 -303 -304 -305 -306 -307 -308 -309 -310 -311 -312 -313 -314 -315 -316 -317 -318 -319 -320 -321 -322 -323 -324 -325 -326 -327 -328 -329 -330 -331 -332 -333 -334 -335 -336 -337 -338 -339 -340 -341 -342 -343 -344 -345 -346 -347 -348 -349 -350 -351 -352 -353 -354 -355 -356 -357 -358 -359 -360 -361 -362 -363 -364 -365 -366 -367 -368 -369 -370 -371 -372 -373 -374 -375 -376 -377 -378 -379 -380 -381 -382 -383 -384 -385 -386 -387 -388 -389 -390 -391 -392 -393 -394 -395 -396 -397 -398 -399 -400 -401 -402 -403 -404 -405 -406 -407 -408 -409 -410 -411 -412 -413 -414 -415 -416 -417 -418 -419 -420 -421 -422 -423 -424 -425 -426 -427 -428 -429 -430 -431 -432 -433 -434 -435 -436 -437 -438 -439 -440 -441 -442 -443 -444 -445 -446 -447 -448 -449 -450 -451 -452 -453 -454 -455 -456 -457 -458 -459 -460 -461 -462 -463 -464 -465 -466 -467 -468 -469 -470 -471 -472 -473 -474 -475 -476 -477 -478 | 1x - - - - - - -1x -1x -1x - - - - - - - - -1x -1x -1x -1x - -1x - -36x - - - -36x -36x -36x -36x -36x -36x -36x -36x -36x -36x -36x -36x - - - -2x - - - -4x - - - -2x - - - -36x -36x -36x -36x -36x - - - - - - - - -1x - - - -1x - - - -5x -5x -5x -5x - - - - - - - -3x -3x -3x -3x -3x -3x -3x -3x - - - - -146x -28x - - - - - - - -43x -43x - -43x - - -43x -4x - -43x -4x - - -4x - - -43x - -27x - -27x - - -27x -27x - - - -43x - - -282x - - -43x - -43x -262x -262x -109x - -153x - - -153x -153x - - - - -43x -133x -3661x -24x - - - - -282x -43x -1x - - -1x -1x - - - - - -13x - - -13x -13x -2x -2x -2x - - - -13x -13x -4x -2x -2x -2x - - - - -13x - - -13x - - -13x - - - - -13x -13x -13x -7x -7x - -7x - -13x - - - -8x -2x - -8x -2x - -8x -2x - -8x -8x -8x -8x - - - - -2x -2x -2x -2x - - - -292x -26x -26x - - - - -26x - - - -158x -19x -12x - - -12x - -19x -19x - - - - -21x -21x - -21x - -21x -21x - - -19x -19x - - -19x -19x - - - -19x - - -19x - - - -19x - - - - -19x - - - -120x - - - - - - - - -19x - - - - - - - - - - - - - - - - - - - - -19x - -19x -19x - - -19x - - - - - - -19x - - - - - - - - -19x - -19x -19x - -19x -19x -19x -19x -19x -17x -17x - - - -17x -17x - - -17x -4x - - -17x - - - -13x - - - - - -13x - -4x -4x - - - - -4x -6x - - -4x -4x -6x -2x - - -4x - - - - -4x - -2x - - -2x - - - - - -2x -2x -2x - - -2x - - - - - - -2x - - - - - - - -13x -9x - - - - - -13x -2x - -13x - - -19x - - - -6x - - - - -6x -12x - -6x - -21x -21x - - - - - | import { ItemView, WorkspaceLeaf, Notice } from 'obsidian';
-/// <reference lib="dom" />
-// Use global types from JSDOM setup
-type KeyboardEvent = globalThis.KeyboardEvent;
-type HTMLTextAreaElement = globalThis.HTMLTextAreaElement;
-type HTMLButtonElement = globalThis.HTMLButtonElement;
-
-const DEFAULT_VAULT_SEARCH_LIMIT = 3;
-const MAX_MESSAGE_HISTORY = 50;
-const MAX_STREAM_CHUNKS = 1000;
-import {
- PluginSettings,
- OllamaMessage,
- ChatMessage,
- OllamaTool,
- ToolCall,
- ToolResult,
-} from './types';
-import { OllamaClient } from './ollama-client';
-import { VaultIndexer } from './vault-indexer';
-import { ToolExecutor } from './tool-executor';
-import { ErrorHandler } from './error-handler';
-
-export class ChatView extends ItemView {
- private settings: PluginSettings;
- private messages: ChatMessage[] = [];
- private ollamaClient: OllamaClient;
- private vaultIndexer: VaultIndexer;
- private toolExecutor: ToolExecutor;
- private lastMessageEl: HTMLElement | null = null;
- private newChatButton: HTMLButtonElement | null = null;
- private sendButton: HTMLButtonElement | null = null;
- private inputEl: HTMLTextAreaElement | null = null;
- private chatContainer: HTMLElement | null = null;
- private sendButtonClickHandler: (() => Promise<void>) | null = null;
- private inputKeyDownHandler: ((e: KeyboardEvent) => Promise<void>) | null = null;
- private newChatButtonClickHandler: (() => void) | null = null;
- private sendButtonClickWrapper: (() => void) | null = null;
- private inputKeyDownWrapper: ((e: KeyboardEvent) => void) | null = null;
- private newChatButtonClickWrapper: (() => void) | null = null;
- private listenersAttached = false;
-
- // Getters for testing
- public getSendButtonClickHandler(): (() => Promise<void>) | null {
- return this.sendButtonClickHandler;
- }
-
- public getInputKeyDownHandler(): ((e: KeyboardEvent) => Promise<void>) | null {
- return this.inputKeyDownHandler;
- }
-
- public getNewChatButtonClickHandler(): (() => void) | null {
- return this.newChatButtonClickHandler;
- }
-
- constructor(leaf: WorkspaceLeaf, settings: PluginSettings) {
- super(leaf);
- this.settings = settings;
- this.ollamaClient = new OllamaClient(settings.ollamaUrl, settings.model);
- this.vaultIndexer = new VaultIndexer(this.app.vault);
- this.toolExecutor = new ToolExecutor(this.app.vault, this.app);
- }
-
- public updateSettings(newSettings: PluginSettings): void {
- this.settings = newSettings;
- this.ollamaClient = new OllamaClient(newSettings.ollamaUrl, newSettings.model);
- }
-
- getViewType(): string {
- return 'ollama-chat-view';
- }
-
- getDisplayText(): string {
- return 'Ollama Chat';
- }
-
- onOpen(): Promise<void> {
- this.render();
- this.removeEventListeners(); // Clean up any existing listeners before reattaching
- this.setupEventListeners();
- return Promise.resolve();
- }
-
- public onSettingsChange(newSettings: PluginSettings): void {
- this.updateSettings(newSettings);
- }
-
- onClose(): Promise<void> {
- this.ollamaClient.cancelStream();
- this.removeEventListeners();
- this.cleanupStreamingResources();
- this.lastMessageEl = null;
- this.sendButton = null;
- this.inputEl = null;
- this.chatContainer = null;
- return Promise.resolve();
- }
-
- private cleanupStreamingResources(): void {
- // Only cleanup if there's still an active streaming message
- const streamingMessage = this.messages.find((msg) => msg.isStreaming);
- Iif (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' });
-
- if (!this.inputEl) {
- this.inputEl = inputContainer.createEl('textarea', { cls: 'ollama-input' });
- }
- if (!this.sendButton) {
- this.sendButton = inputContainer.createEl('button', {
- cls: 'ollama-send-button',
- });
- this.sendButton.textContent = 'Send';
- }
-
- if (!this.newChatButton) {
- const newChatContainer =
- this.contentEl.querySelector('.ollama-new-chat') ||
- this.contentEl.createEl('div', { cls: 'ollama-new-chat' });
- this.newChatButton = newChatContainer.createEl('button', {
- cls: 'ollama-new-chat-button',
- });
- this.newChatButton.textContent = '🔄 New Chat';
- this.newChatButton.title = 'Start a new conversation';
- }
-
- // Create immutable snapshot for rendering
- const messagesSnapshot = [...this.messages];
-
- // Only render messages that are not currently streaming
- const nonStreamingMessages = messagesSnapshot.filter((msg) => !msg.isStreaming);
-
- // Differential update: only update messages that have changed
- const existingMessages = container.querySelectorAll('.ollama-message');
-
- for (const msg of nonStreamingMessages) {
- const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
- if (existingEl) {
- existingEl.textContent = msg.content;
- } else {
- const messageEl = container.createEl('div', {
- cls: `ollama-message ${msg.role}`,
- }) as HTMLElement;
- messageEl.setAttribute('data-msg-id', msg.id);
- messageEl.textContent = msg.content;
- }
- }
-
- // Remove messages that are no longer in the array
- for (const el of Array.from(existingMessages)) {
- const id = el.getAttribute('data-msg-id');
- if (!id || !nonStreamingMessages.some((m) => m.id === id)) {
- el.remove();
- }
- }
-
- // Re-attach streaming message if it exists
- 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);
- }
- }
- }
-
- private setupEventListeners(): void {
- Iif (!this.sendButton || !this.inputEl || this.listenersAttached) return;
-
- // Create handlers if they don't exist
- if (!this.sendButtonClickHandler) {
- this.sendButtonClickHandler = async () => {
- Iif (!this.inputEl) return;
- await this.handleUserInput(this.inputEl.value);
- this.inputEl.value = '';
- };
- }
-
- if (!this.inputKeyDownHandler) {
- this.inputKeyDownHandler = async (e: KeyboardEvent) => {
- if (!this.inputEl || e.key !== 'Enter' || e.shiftKey) return;
- e.preventDefault();
- await this.handleUserInput(this.inputEl.value);
- this.inputEl.value = '';
- };
- }
-
- // Create wrapper functions for event listeners
- this.sendButtonClickWrapper = () => {
- void this.sendButtonClickHandler?.();
- };
- this.inputKeyDownWrapper = (e: KeyboardEvent) => {
- void this.inputKeyDownHandler?.(e);
- };
- this.newChatButtonClickWrapper = () => {
- void this.newChatButtonClickHandler?.();
- };
-
- // Add event listeners using wrappers
- this.sendButton.addEventListener('click', this.sendButtonClickWrapper);
- this.inputEl.addEventListener('keydown', this.inputKeyDownWrapper);
- if (this.newChatButton) {
- if (!this.newChatButtonClickHandler) {
- this.newChatButtonClickHandler = () => this.clearConversation();
- }
- this.newChatButton.addEventListener('click', this.newChatButtonClickWrapper);
- }
- this.listenersAttached = true;
- }
-
- private removeEventListeners(): void {
- if (this.sendButton && this.sendButtonClickWrapper) {
- this.sendButton.removeEventListener('click', this.sendButtonClickWrapper);
- }
- if (this.inputEl && this.inputKeyDownWrapper) {
- this.inputEl.removeEventListener('keydown', this.inputKeyDownWrapper);
- }
- if (this.newChatButton && this.newChatButtonClickWrapper) {
- this.newChatButton.removeEventListener('click', this.newChatButtonClickWrapper);
- }
- this.sendButtonClickWrapper = null;
- this.inputKeyDownWrapper = null;
- this.newChatButtonClickWrapper = null;
- this.listenersAttached = false;
- }
-
- private clearConversation(): void {
- // Create new array to ensure immutability
- this.messages = [];
- this.lastMessageEl = null;
- this.render();
- new Notice('Conversation cleared');
- }
-
- private updateMessageById(id: string, partial: Partial<ChatMessage>): boolean {
- const index = this.messages.findIndex((m) => m.id === id);
- Iif (index < 0) return false;
- this.messages = [
- ...this.messages.slice(0, index),
- { ...this.messages[index], ...partial },
- ...this.messages.slice(index + 1),
- ];
- return true;
- }
-
- private updateLastMessage(content: string) {
- const streamingMessage = this.messages.find((msg) => msg.isStreaming);
- if (streamingMessage && !this.lastMessageEl) {
- this.lastMessageEl = this.contentEl.createEl('div', {
- cls: `ollama-message assistant`,
- }) as HTMLElement;
- this.lastMessageEl.setAttribute('data-msg-id', streamingMessage.id);
- }
- if (this.lastMessageEl) {
- this.lastMessageEl.textContent = content;
- }
- }
-
- private async handleUserInput(content: string) {
- Iif (!this.sendButton || !this.inputEl) return;
- this.sendButton.disabled = true;
-
- try {
- // Guard against empty messages
- const userMessage = content.trim();
- if (!userMessage) return;
-
- // Search vault using user message as query
- const entries = await this.vaultIndexer.searchVault(userMessage, DEFAULT_VAULT_SEARCH_LIMIT);
- let context = entries.map((entry) => `### ${entry.title}\n${entry.content}`).join('\n\n');
-
- // Cap context size to prevent prompt bloat with large vaults
- const MAX_CONTEXT_LENGTH = 4000;
- Iif (context.length > MAX_CONTEXT_LENGTH) {
- context = context.substring(0, MAX_CONTEXT_LENGTH) + '\n\n... (truncated)';
- }
-
- const systemContent = context
- ? `You are a helpful assistant.\n\nRelevant vault context:\n${context}`
- : 'You are a helpful assistant.';
- const systemMessage: OllamaMessage = {
- role: 'system',
- content: systemContent,
- };
- const userMessageWithContext: OllamaMessage = {
- role: 'user',
- content: userMessage,
- };
-
- const messages: OllamaMessage[] = [
- systemMessage,
- ...this.messages.map(
- (m) =>
- ({
- role: m.role,
- content: m.content,
- tool_calls: m.tool_calls,
- }) as OllamaMessage
- ),
- userMessageWithContext,
- ];
-
- const tools: OllamaTool[] = [
- {
- type: 'function',
- function: {
- name: 'create_file',
- description: 'Create a new file in the vault',
- parameters: {
- type: 'object' as const,
- properties: {
- path: {
- type: 'string' as const,
- description: "Relative path within the vault, e.g. 'Notes/todo.md'",
- },
- content: { type: 'string' as const, description: 'Content of the file to create' },
- },
- required: ['path', 'content'],
- },
- },
- },
- ];
-
- const messageId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
-
- const userMessageId = messageId;
- const assistantMessageId = `${messageId}-assistant`;
-
- // Store user message in conversation history
- const userChatMessage: ChatMessage = {
- id: userMessageId,
- role: 'user' as const,
- content: userMessage,
- timestamp: Date.now(),
- };
-
- const assistantMessage: ChatMessage = {
- id: assistantMessageId,
- role: 'assistant' as const,
- content: '',
- timestamp: Date.now(),
- isStreaming: true,
- };
-
- // Update messages immutably
- this.messages = [...this.messages, userChatMessage, assistantMessage];
-
- try {
- this.render();
-
- const stream = this.ollamaClient.streamChat(messages, tools);
- let fullResponse = '';
- let toolCalls: ToolCall[] = [];
- let chunkCount = 0;
- for await (const chunk of stream) {
- chunkCount++;
- Iif (chunkCount > MAX_STREAM_CHUNKS) {
- throw new Error('Response too long, stopped streaming');
- }
-
- if (chunk.content) {
- fullResponse += chunk.content;
- }
-
- if (chunk.tool_calls) {
- toolCalls = toolCalls.concat(chunk.tool_calls);
- }
-
- this.updateLastMessage(fullResponse);
- }
-
- // Update the assistant message with the full response immutably
- this.updateMessageById(assistantMessageId, {
- content: fullResponse,
- tool_calls: toolCalls,
- });
-
- // Process tool calls with proper follow-up context
- if (toolCalls.length > 0) {
- // Validate tool calls before processing
- const MAX_TOOL_CALLS = 10;
- Iif (toolCalls.length > MAX_TOOL_CALLS) {
- throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`);
- }
-
- // Collect all tool results using allSettled to support partial results
- const settledResults = await Promise.allSettled(
- toolCalls.map((call) => this.toolExecutor.handleToolCall(call))
- );
-
- const toolResults: ToolResult[] = [];
- for (const result of settledResults) {
- if (result.status === 'fulfilled') {
- toolResults.push(result.value);
- } else {
- // Use centralized error handler for tool errors
- ErrorHandler.handleError(result.reason, 'ChatView.handleUserInput');
- }
- }
-
- // Only create follow-up when we have tool results
- if (toolResults.length > 0) {
- // Create follow-up messages including the assistant's tool calls and results
- const followUpMessages: OllamaMessage[] = [
- ...messages,
- { role: 'assistant' as const, content: fullResponse, tool_calls: toolCalls },
- ...toolResults.map((result) => ({
- role: 'tool' as const,
- content: JSON.stringify(result),
- })),
- ];
-
- const followUp = await this.ollamaClient.chat(followUpMessages, tools);
- fullResponse += followUp.content;
- this.updateLastMessage(fullResponse);
-
- // Update the assistant message with the final response immutably
- this.updateMessageById(assistantMessageId, {
- content: fullResponse,
- isStreaming: false,
- });
- } else {
- // Even if no tool results were successful, mark streaming as complete
- // to prevent the assistant message from disappearing
- this.updateMessageById(assistantMessageId, {
- content: fullResponse,
- isStreaming: false,
- });
- }
- }
-
- // Update assistant message immutably — only if no tool calls were processed
- if (toolCalls.length === 0) {
- this.updateMessageById(assistantMessageId, {
- isStreaming: false,
- });
- }
-
- // Limit conversation history to prevent memory issues
- if (this.messages.length > MAX_MESSAGE_HISTORY) {
- this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
- }
- this.render();
- } finally {
- // Clean up streaming resources regardless of outcome
- this.cleanupStreamingResources();
- }
- } catch (error) {
- // Use centralized error handler
- ErrorHandler.handleError(error, 'ChatView.handleUserInput');
- // Update any streaming messages to non-streaming state to prevent stale messages
- // This ensures that if an error occurs during streaming, the assistant message
- // is still visible (with any partial content received) but won't cause issues
- // in subsequent requests due to stale isStreaming: true flag
- this.messages = this.messages.map((msg) =>
- msg.isStreaming ? { ...msg, isStreaming: false } : msg
- );
- this.cleanupStreamingResources();
- } finally {
- if (this.sendButton) {
- this.sendButton.disabled = false;
- }
- }
- }
-}
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 | - -2x -2x - - - - - - - - - - -2x - -9x -9x - -9x -8x - -8x -8x -8x - - -1x -1x - - - - -15x -7x - - -8x -7x - - -1x - - - -7x - -2x - - - -2x - -1x - -1x - -1x - - - - - - - - -2x -1x -1x - -1x - - - -13x - - -13x -2x - - -11x -2x - - -9x -1x - - -8x -1x - - -7x -1x - - -6x -1x - - -5x - - - - - -1x - - - -1x - - - -1x -1x - - - -1x - - - -1x - - - -1x - - - -1x - - - | // src/error-handler.ts
-
-import { Notice } from 'obsidian';
-import {
- OllamaError,
- ErrorType,
- NetworkError,
- ApiError,
- ValidationError,
- StreamingError,
- ToolExecutionError,
- PathValidationError,
-} from './types';
-
-export class ErrorHandler {
- static handleError(error: unknown, context?: string): void {
- const message = this.getUserFriendlyMessage(error);
- new Notice(message);
-
- if (error instanceof Error) {
- const ctx = context ? ` [${context}]` : '';
- // Use console.error instead of ErrorHandler.error for fatal errors
- 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);
- }
- }
-
- private static getUserFriendlyMessage(error: unknown): string {
- if (error instanceof OllamaError) {
- return this.getUserFriendlyMessageFromOllamaError(error);
- }
-
- if (error instanceof Error) {
- return this.getUserFriendlyMessageFromError(error);
- }
-
- return 'An unexpected error occurred';
- }
-
- private static getUserFriendlyMessageFromOllamaError(error: OllamaError): string {
- switch (error.type) {
- case ErrorType.NETWORK_ERROR:
- return 'Connection error. Please check if Ollama is running.';
- case ErrorType.API_ERROR:
- return `API error: ${error.message}`;
- case ErrorType.VALIDATION_ERROR:
- return this.getUserFriendlyValidationMessage(error);
- case ErrorType.STREAMING_ERROR:
- return 'Response too long. Please try a shorter request.';
- case ErrorType.TOOL_EXECUTION_ERROR:
- return `Tool error for ${(error as ToolExecutionError).toolName}. ${error.message}`;
- case ErrorType.PATH_VALIDATION_ERROR:
- return `Invalid file path: ${(error as PathValidationError).path}`;
- case ErrorType.UNKNOWN_ERROR:
- return 'An unexpected error occurred';
- default:
- return 'An unexpected error occurred';
- }
- }
-
- private static getUserFriendlyValidationMessage(error: OllamaError): string {
- 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.';
- }
-
- private static getUserFriendlyMessageFromError(error: Error): string {
- const msg = error.message.toLowerCase();
-
- // Check timeout BEFORE network (more specific matches first)
- 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: string, statusCode?: number): NetworkError {
- return new NetworkError(message, statusCode);
- }
-
- static createApiError(message: string, statusCode?: number): ApiError {
- return new ApiError(message, statusCode);
- }
-
- static createValidationError(message: string, field?: string): ValidationError {
- const details = field ? { field, message } : undefined;
- return new ValidationError(message, details);
- }
-
- static createStreamingError(message: string): StreamingError {
- return new StreamingError(message);
- }
-
- static createToolExecutionError(message: string, toolName?: string): ToolExecutionError {
- return new ToolExecutionError(message, toolName ?? 'unknown');
- }
-
- static createPathValidationError(message: string, path?: string): PathValidationError {
- return new PathValidationError(message, path ?? '');
- }
-
- static createUnknownError(message: string): OllamaError {
- return new OllamaError(message, ErrorType.UNKNOWN_ERROR);
- }
-}
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| File | -- | Statements | -- | Branches | -- | Functions | -- | Lines | -- |
|---|---|---|---|---|---|---|---|---|---|
| chat-view.ts | -
-
- |
- 92.55% | -199/215 | -85.71% | -60/70 | -82.85% | -29/35 | -94.52% | -190/201 | -
| error-handler.ts | -
-
- |
- 94.23% | -49/52 | -87.5% | -42/48 | -100% | -12/12 | -94.23% | -49/52 | -
| ollama-client.ts | -
-
- |
- 91.8% | -112/122 | -72.72% | -40/55 | -88.23% | -15/17 | -91.37% | -106/116 | -
| tool-executor.ts | -
-
- |
- 91.37% | -53/58 | -78.78% | -26/33 | -100% | -4/4 | -91.37% | -53/58 | -
| types.ts | -
-
- |
- 100% | -35/35 | -100% | -2/2 | -100% | -8/8 | -100% | -35/35 | -
| utils.ts | -
-
- |
- 95.65% | -88/92 | -84.78% | -39/46 | -93.33% | -14/15 | -96.59% | -85/88 | -
| vault-indexer.ts | -
-
- |
- 88.51% | -131/148 | -77.77% | -42/54 | -83.33% | -20/24 | -89.85% | -124/138 | -
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160 -161 -162 -163 -164 -165 -166 -167 -168 -169 -170 -171 -172 -173 -174 -175 -176 -177 -178 -179 -180 -181 -182 -183 -184 -185 -186 -187 -188 -189 -190 -191 -192 -193 -194 -195 -196 -197 -198 -199 -200 -201 -202 -203 -204 -205 -206 -207 -208 -209 -210 -211 -212 -213 -214 -215 -216 -217 -218 -219 -220 -221 -222 -223 -224 -225 -226 -227 -228 -229 -230 -231 -232 -233 -234 -235 -236 -237 -238 -239 -240 -241 -242 -243 -244 -245 -246 -247 -248 -249 -250 -251 -252 -253 -254 -255 -256 -257 -258 -259 -260 -261 -262 -263 -264 -265 -266 -267 -268 -269 -270 -271 -272 -273 -274 -275 -276 -277 -278 -279 -280 -281 -282 -283 -284 -285 -286 -287 -288 | - - -2x -2x - - - - - -2x - - - -61x -61x - - -61x -61x -61x - - - -6x -2x -2x - - - - - - - -24x - - - - - - -3x -3x - - - - - - - - - - - -29x -29x - -29x -29x - - - - - - - - - - - - - -27x -8x -5x -5x - - - -5x -4x -4x - - -4x -4x -4x -4x -4x - - -4x - - - - - -4x - - - - - -5x -2x - -3x - - -15x -1x - - -14x -14x -1x - - -13x -13x -13x -13x -13x - -13x -13x -22x -22x - -11x - -11x -11x - -11x -69x - -68x -68x -15x - -14x -14x - - - -14x -14x - -54x -1x - - -53x -53x -1x - - -52x - - - - - - - -11x -1x -1x -1x - -1x -1x -1x - - - - - - - - - - - - -13x - - - -29x - - -11x - - - -5x - - - - - - - - -8x -8x -8x - - - - - - - - - - - - - -8x -4x -3x -3x - - - -3x -2x -2x - - -2x -2x -2x -2x -2x - - -2x - - - - - -2x - - - - - -3x - -1x - - -4x -4x - - - - -8x - - - - -16x -1x - - - - -19x -1x - - -18x -18x - - - - - - - | // src/ollama-client.ts
-
-import type { OllamaMessage, OllamaTool } from './types';
-import { ApiError } from './types';
-import { Logger } from './utils';
-
-interface OllamaChatResponse {
- message?: Partial<OllamaMessage>;
-}
-
-export class OllamaClient {
- private baseURL: string;
- private model: string;
- private fetchFn: typeof fetch;
- private readonly maxRetries: number = 3;
- private currentStreamController: AbortController | null = null;
-
- constructor(baseURL: string, model: string, fetchFn?: typeof fetch) {
- this.baseURL = baseURL;
- this.model = model;
- this.fetchFn = fetchFn ?? fetch;
- }
-
- cancelStream(): void {
- if (this.currentStreamController) {
- this.currentStreamController.abort();
- this.currentStreamController = null;
- }
- }
-
- async *streamChat(
- messages: OllamaMessage[],
- tools: OllamaTool[] = []
- ): AsyncGenerator<OllamaMessage, void, unknown> {
- yield* this.streamChatWithRetry(messages, tools, 0);
- }
-
- async streamChatAsPromise(
- messages: OllamaMessage[],
- tools: OllamaTool[] = []
- ): Promise<OllamaMessage[]> {
- const chunks: OllamaMessage[] = [];
- for await (const chunk of this.streamChat(messages, tools)) {
- chunks.push(chunk);
- }
- return chunks;
- }
-
- private async *streamChatWithRetry(
- messages: OllamaMessage[],
- tools: OllamaTool[] = [],
- attempt: number = 0
- ): AsyncGenerator<OllamaMessage, void, unknown> {
- // Create a local controller for this request instead of using the instance variable
- 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: true,
- }),
- signal: controller.signal,
- });
-
- if (!response.ok) {
- if (response.status >= 500 && attempt < this.maxRetries) {
- const retryDelay = Math.pow(2, attempt) * 100;
- Logger.warn(
- `Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
- 'ollama-client'
- );
- if (attempt < this.maxRetries - 1) {
- const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay));
- const abortListener = () => {
- Logger.info('Retry aborted by user', 'ollama-client');
- };
- const signal = controller.signal;
- if (signal) {
- signal.addEventListener('abort', abortListener);
- try {
- await Promise.race([
- retryTimeout,
- new Promise<void>((resolve) => {
- signal.addEventListener('abort', () => resolve(), {
- once: true,
- });
- }),
- ]);
- } finally {
- signal.removeEventListener('abort', abortListener);
- }
- } else E{
- await retryTimeout;
- }
- }
- yield* this.streamChatWithRetry(messages, tools, attempt + 1);
- return;
- }
- 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('ndjson') && !contentType.includes('json'))) {
- throw new Error('Invalid response format');
- }
-
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = '';
- let malformedCount = 0;
- const maxMalformed = 50;
-
- try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- buffer += decoder.decode(value, { stream: true });
-
- const lines = buffer.split('\n');
- buffer = lines.pop() ?? '';
-
- for (const line of lines) {
- if (!line.trim()) continue;
-
- try {
- const parsed = JSON.parse(line) as Record<string, unknown>;
- this.throwIfOllamaError(parsed);
-
- const message = this.toOllamaMessage(parsed.message);
- Iif (!message) {
- continue;
- }
-
- malformedCount = 0;
- yield message;
- } catch (error) {
- if (error instanceof Error && error.message.startsWith('Ollama error:')) {
- throw error;
- }
-
- malformedCount++;
- if (malformedCount > maxMalformed) {
- throw new Error('Too many malformed chunks in stream');
- }
-
- Logger.warn(
- `Skipped malformed chunk: ${line.substring(0, 80)}... - ${(error as Error).message}`,
- 'ollama-client'
- );
- }
- }
- }
-
- if (buffer.trim()) {
- try {
- const parsed = JSON.parse(buffer) as Record<string, unknown>;
- this.throwIfOllamaError(parsed);
-
- const message = this.toOllamaMessage(parsed.message);
- if (message) {
- yield message;
- }
- } catch (error) {
- Iif (error instanceof Error && error.message.startsWith('Ollama error:')) {
- throw error;
- }
- Logger.warn(
- `Failed to parse final chunk: ${buffer.substring(0, 80)}...`,
- 'ollama-client'
- );
- }
- }
- } finally {
- reader.releaseLock();
- }
- } finally {
- // Abort the local controller
- controller.abort();
- }
- // Clean up the reference
- this.currentStreamController = null;
- }
-
- async chat(messages: OllamaMessage[], tools: OllamaTool[] = []): Promise<OllamaMessage> {
- return this.chatWithRetry(messages, tools, 0);
- }
-
- private async chatWithRetry(
- messages: OllamaMessage[],
- tools: OllamaTool[] = [],
- attempt: number = 0
- ): Promise<OllamaMessage> {
- // Create a local controller for this request instead of using the instance variable
- const controller = new AbortController();
- 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) {
- if (response.status >= 500 && attempt < this.maxRetries) {
- const retryDelay = Math.pow(2, attempt) * 100;
- Logger.warn(
- `Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
- 'ollama-client'
- );
- if (attempt < this.maxRetries - 1) {
- const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay));
- const abortListener = () => {
- Logger.info('Retry aborted by user', 'ollama-client');
- };
- const signal = controller.signal;
- if (signal) {
- signal.addEventListener('abort', abortListener);
- try {
- await Promise.race([
- retryTimeout,
- new Promise<void>((resolve) => {
- signal.addEventListener('abort', () => resolve(), {
- once: true,
- });
- }),
- ]);
- } finally {
- signal.removeEventListener('abort', abortListener);
- }
- } else E{
- await retryTimeout;
- }
- }
- return this.chatWithRetry(messages, tools, attempt + 1);
- }
- throw new ApiError(`Ollama API error: ${response.status}`, response.status);
- }
-
- const data = (await response.json()) as OllamaChatResponse;
- return (
- this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] }
- );
- } finally {
- // Abort the local controller
- controller.abort();
- }
- }
-
- private throwIfOllamaError(parsed: Record<string, unknown>): void {
- if (parsed.error) {
- throw new Error(`Ollama error: ${String(parsed.error)}`);
- }
- }
-
- private toOllamaMessage(value: unknown): OllamaMessage | null {
- if (!value || typeof value !== 'object') {
- return null;
- }
-
- const record = value as Partial<OllamaMessage>;
- return {
- role: record.role ?? 'assistant',
- content: typeof record.content === 'string' ? record.content : '',
- tool_calls: record.tool_calls ?? [],
- };
- }
-}
- |