initial commit
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'no-console': 'warn',
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
},
|
||||
env: {
|
||||
node: true,
|
||||
es2020: true,
|
||||
jest: true,
|
||||
},
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Built files
|
||||
lib/
|
||||
dist/
|
||||
out/
|
||||
build/
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
|
||||
# Backup files
|
||||
*.backup
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Runtime data
|
||||
.pnp.*
|
||||
.yarn/
|
||||
|
||||
# Editor settings
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Test cache
|
||||
.jest-cache/
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "auto",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.{md,markdown}",
|
||||
"options": {
|
||||
"printWidth": 80
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Ollama Chat Plugin for Obsidian
|
||||
|
||||
A plugin that integrates Ollama with Obsidian to create a chat interface that can access your vault content.
|
||||
|
||||
## Features
|
||||
|
||||
- Chat with Ollama models directly in Obsidian
|
||||
- Vault context search - the assistant can reference your notes
|
||||
- Tool integration - create files based on chat responses
|
||||
- Streaming responses
|
||||
- Customizable model and URL settings
|
||||
|
||||
## Installation
|
||||
|
||||
1. Install the plugin via Obsidian's community plugins
|
||||
2. Make sure you have Ollama installed and running
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Install Ollama**: Follow the instructions at [ollama.ai](https://ollama.ai) to install Ollama
|
||||
2. **Start Ollama service**: `ollama serve`
|
||||
3. **Pull a model**: `ollama pull llama3` (or any other model you prefer)
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Open the plugin settings via Obsidian's settings panel
|
||||
2. Configure the Ollama URL (default: `http://localhost:11434`)
|
||||
3. Configure the model name (default: `llama3`)
|
||||
4. Restart the plugin if needed
|
||||
|
||||
## Usage
|
||||
|
||||
1. Click the ribbon icon to open the chat view
|
||||
2. Type your message in the input box
|
||||
3. Press Enter or click Send to send your message
|
||||
4. Click the "New Chat" button to start a fresh conversation
|
||||
|
||||
## Supported Models
|
||||
|
||||
Any model supported by Ollama should work, including:
|
||||
|
||||
- llama3
|
||||
- llama2
|
||||
- mistral
|
||||
- codellama
|
||||
- etc.
|
||||
|
||||
## Development
|
||||
|
||||
To build from source:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Connection issues**: Ensure Ollama is running and accessible at the configured URL
|
||||
- **Model not found**: Make sure you've pulled the model (`ollama pull <modelname>`)
|
||||
- **Permission issues**: Check that your Obsidian vault has proper write permissions
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,50 @@
|
||||
// Enhanced Obsidian mock for testing
|
||||
export class Vault {
|
||||
getMarkdownFiles() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async read(file: any) {
|
||||
return '';
|
||||
}
|
||||
|
||||
async create(path: string, content: string) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class Workspace {
|
||||
getLeaf() {
|
||||
return {
|
||||
setViewState: jest.fn(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class App {
|
||||
vault = new Vault();
|
||||
workspace = new Workspace();
|
||||
}
|
||||
|
||||
export class ItemView {
|
||||
contentEl: HTMLElement = document.createElement('div');
|
||||
app: App;
|
||||
|
||||
constructor() {
|
||||
this.app = new App();
|
||||
}
|
||||
}
|
||||
|
||||
export class Notice {
|
||||
static create(message: string) {}
|
||||
}
|
||||
|
||||
// Mock types for DOM elements
|
||||
export type TFile = {
|
||||
basename: string;
|
||||
};
|
||||
|
||||
// Export additional types that might be used in tests
|
||||
export const Plugin: any = jest.fn();
|
||||
export const WorkspaceLeaf: any = jest.fn();
|
||||
export const Setting: any = jest.fn();
|
||||
@@ -0,0 +1,32 @@
|
||||
// Mock for ollama-client for testing
|
||||
import { OllamaMessage, OllamaTool, ToolCall } from '../src/types';
|
||||
|
||||
export class OllamaClient {
|
||||
private url: string;
|
||||
private model: string;
|
||||
|
||||
// Mock fetch function for testing
|
||||
private fetchFn: typeof fetch = jest.fn();
|
||||
|
||||
constructor(url: string, model: string, fetchFn?: typeof fetch) {
|
||||
this.url = url;
|
||||
this.model = model;
|
||||
if (fetchFn) this.fetchFn = fetchFn;
|
||||
}
|
||||
|
||||
async streamChatMessages(
|
||||
prompt: string,
|
||||
options: { abortSignal?: AbortSignal } = {}
|
||||
): Promise<string> {
|
||||
// Mock implementation - return a simple response
|
||||
return `Mock response for: ${prompt}`;
|
||||
}
|
||||
|
||||
async streamToolMessages(
|
||||
toolCall: string,
|
||||
options: { abortSignal?: AbortSignal } = {}
|
||||
): Promise<string> {
|
||||
// Mock implementation - return a simple tool response
|
||||
return `Mock tool response for: ${toolCall}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* 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);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,619 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for error-handler.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="prettify.css" />
|
||||
<link rel="stylesheet" href="base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="index.html">All files</a> error-handler.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">84.37% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>54/64</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">82% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>41/50</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>10/10</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">84.37% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>54/64</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a>
|
||||
<a name='L79'></a><a href='#L79'>79</a>
|
||||
<a name='L80'></a><a href='#L80'>80</a>
|
||||
<a name='L81'></a><a href='#L81'>81</a>
|
||||
<a name='L82'></a><a href='#L82'>82</a>
|
||||
<a name='L83'></a><a href='#L83'>83</a>
|
||||
<a name='L84'></a><a href='#L84'>84</a>
|
||||
<a name='L85'></a><a href='#L85'>85</a>
|
||||
<a name='L86'></a><a href='#L86'>86</a>
|
||||
<a name='L87'></a><a href='#L87'>87</a>
|
||||
<a name='L88'></a><a href='#L88'>88</a>
|
||||
<a name='L89'></a><a href='#L89'>89</a>
|
||||
<a name='L90'></a><a href='#L90'>90</a>
|
||||
<a name='L91'></a><a href='#L91'>91</a>
|
||||
<a name='L92'></a><a href='#L92'>92</a>
|
||||
<a name='L93'></a><a href='#L93'>93</a>
|
||||
<a name='L94'></a><a href='#L94'>94</a>
|
||||
<a name='L95'></a><a href='#L95'>95</a>
|
||||
<a name='L96'></a><a href='#L96'>96</a>
|
||||
<a name='L97'></a><a href='#L97'>97</a>
|
||||
<a name='L98'></a><a href='#L98'>98</a>
|
||||
<a name='L99'></a><a href='#L99'>99</a>
|
||||
<a name='L100'></a><a href='#L100'>100</a>
|
||||
<a name='L101'></a><a href='#L101'>101</a>
|
||||
<a name='L102'></a><a href='#L102'>102</a>
|
||||
<a name='L103'></a><a href='#L103'>103</a>
|
||||
<a name='L104'></a><a href='#L104'>104</a>
|
||||
<a name='L105'></a><a href='#L105'>105</a>
|
||||
<a name='L106'></a><a href='#L106'>106</a>
|
||||
<a name='L107'></a><a href='#L107'>107</a>
|
||||
<a name='L108'></a><a href='#L108'>108</a>
|
||||
<a name='L109'></a><a href='#L109'>109</a>
|
||||
<a name='L110'></a><a href='#L110'>110</a>
|
||||
<a name='L111'></a><a href='#L111'>111</a>
|
||||
<a name='L112'></a><a href='#L112'>112</a>
|
||||
<a name='L113'></a><a href='#L113'>113</a>
|
||||
<a name='L114'></a><a href='#L114'>114</a>
|
||||
<a name='L115'></a><a href='#L115'>115</a>
|
||||
<a name='L116'></a><a href='#L116'>116</a>
|
||||
<a name='L117'></a><a href='#L117'>117</a>
|
||||
<a name='L118'></a><a href='#L118'>118</a>
|
||||
<a name='L119'></a><a href='#L119'>119</a>
|
||||
<a name='L120'></a><a href='#L120'>120</a>
|
||||
<a name='L121'></a><a href='#L121'>121</a>
|
||||
<a name='L122'></a><a href='#L122'>122</a>
|
||||
<a name='L123'></a><a href='#L123'>123</a>
|
||||
<a name='L124'></a><a href='#L124'>124</a>
|
||||
<a name='L125'></a><a href='#L125'>125</a>
|
||||
<a name='L126'></a><a href='#L126'>126</a>
|
||||
<a name='L127'></a><a href='#L127'>127</a>
|
||||
<a name='L128'></a><a href='#L128'>128</a>
|
||||
<a name='L129'></a><a href='#L129'>129</a>
|
||||
<a name='L130'></a><a href='#L130'>130</a>
|
||||
<a name='L131'></a><a href='#L131'>131</a>
|
||||
<a name='L132'></a><a href='#L132'>132</a>
|
||||
<a name='L133'></a><a href='#L133'>133</a>
|
||||
<a name='L134'></a><a href='#L134'>134</a>
|
||||
<a name='L135'></a><a href='#L135'>135</a>
|
||||
<a name='L136'></a><a href='#L136'>136</a>
|
||||
<a name='L137'></a><a href='#L137'>137</a>
|
||||
<a name='L138'></a><a href='#L138'>138</a>
|
||||
<a name='L139'></a><a href='#L139'>139</a>
|
||||
<a name='L140'></a><a href='#L140'>140</a>
|
||||
<a name='L141'></a><a href='#L141'>141</a>
|
||||
<a name='L142'></a><a href='#L142'>142</a>
|
||||
<a name='L143'></a><a href='#L143'>143</a>
|
||||
<a name='L144'></a><a href='#L144'>144</a>
|
||||
<a name='L145'></a><a href='#L145'>145</a>
|
||||
<a name='L146'></a><a href='#L146'>146</a>
|
||||
<a name='L147'></a><a href='#L147'>147</a>
|
||||
<a name='L148'></a><a href='#L148'>148</a>
|
||||
<a name='L149'></a><a href='#L149'>149</a>
|
||||
<a name='L150'></a><a href='#L150'>150</a>
|
||||
<a name='L151'></a><a href='#L151'>151</a>
|
||||
<a name='L152'></a><a href='#L152'>152</a>
|
||||
<a name='L153'></a><a href='#L153'>153</a>
|
||||
<a name='L154'></a><a href='#L154'>154</a>
|
||||
<a name='L155'></a><a href='#L155'>155</a>
|
||||
<a name='L156'></a><a href='#L156'>156</a>
|
||||
<a name='L157'></a><a href='#L157'>157</a>
|
||||
<a name='L158'></a><a href='#L158'>158</a>
|
||||
<a name='L159'></a><a href='#L159'>159</a>
|
||||
<a name='L160'></a><a href='#L160'>160</a>
|
||||
<a name='L161'></a><a href='#L161'>161</a>
|
||||
<a name='L162'></a><a href='#L162'>162</a>
|
||||
<a name='L163'></a><a href='#L163'>163</a>
|
||||
<a name='L164'></a><a href='#L164'>164</a>
|
||||
<a name='L165'></a><a href='#L165'>165</a>
|
||||
<a name='L166'></a><a href='#L166'>166</a>
|
||||
<a name='L167'></a><a href='#L167'>167</a>
|
||||
<a name='L168'></a><a href='#L168'>168</a>
|
||||
<a name='L169'></a><a href='#L169'>169</a>
|
||||
<a name='L170'></a><a href='#L170'>170</a>
|
||||
<a name='L171'></a><a href='#L171'>171</a>
|
||||
<a name='L172'></a><a href='#L172'>172</a>
|
||||
<a name='L173'></a><a href='#L173'>173</a>
|
||||
<a name='L174'></a><a href='#L174'>174</a>
|
||||
<a name='L175'></a><a href='#L175'>175</a>
|
||||
<a name='L176'></a><a href='#L176'>176</a>
|
||||
<a name='L177'></a><a href='#L177'>177</a>
|
||||
<a name='L178'></a><a href='#L178'>178</a>
|
||||
<a name='L179'></a><a href='#L179'>179</a></td><td class="line-coverage quiet"><span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import { Notice } from 'obsidian';
|
||||
import {
|
||||
OllamaError,
|
||||
ErrorType,
|
||||
NetworkError,
|
||||
ApiError,
|
||||
ValidationError,
|
||||
StreamingError,
|
||||
ToolExecutionError,
|
||||
PathValidationError,
|
||||
} from './types';
|
||||
|
||||
export class ErrorHandler {
|
||||
/**
|
||||
* Centralized error handling for the Ollama plugin
|
||||
* Provides consistent error messages and logging
|
||||
*/
|
||||
static handleError(error: unknown, context?: string): void {
|
||||
let userMessage = 'An unexpected error occurred';
|
||||
let shouldShowError = true;
|
||||
|
||||
if (error instanceof OllamaError) {
|
||||
userMessage = this.getUserFriendlyMessage(error);
|
||||
shouldShowError = true;
|
||||
} else if (error instanceof Error) {
|
||||
userMessage = this.getUserFriendlyMessageFromError(error);
|
||||
shouldShowError = true;
|
||||
} else {
|
||||
userMessage = 'An unexpected error occurred';
|
||||
shouldShowError = true;
|
||||
}
|
||||
|
||||
if (shouldShowError) {
|
||||
new Notice(userMessage);
|
||||
}
|
||||
|
||||
// Log detailed error for debugging
|
||||
console.error(
|
||||
`[OllamaPlugin${context ? ' ' + context : ''}] ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
if (error instanceof Error) {
|
||||
console.error('[Stack]', error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly message from specific error types
|
||||
*/
|
||||
private static getUserFriendlyMessage(error: OllamaError): string {
|
||||
switch (error.type) {
|
||||
case ErrorType.NETWORK_ERROR:
|
||||
if (error instanceof NetworkError) {
|
||||
return 'Connection error. Please check if Ollama is running.';
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > return 'Network error. Please check your connection to Ollama.';</span>
|
||||
|
||||
<span class="branch-1 cbranch-no" title="branch not covered" > case ErrorType.API_ERROR:</span>
|
||||
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (error instanceof ApiError) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > return 'Ollama API error. Please check the Ollama logs for details.';</span>
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > return 'API communication error. Please try again.';</span>
|
||||
|
||||
case ErrorType.VALIDATION_ERROR:
|
||||
if (error instanceof ValidationError) {
|
||||
const details = error.validationDetails;
|
||||
if (details?.field) {
|
||||
return `Invalid ${details.field}. ${details.message || <span class="branch-1 cbranch-no" title="branch not covered" >'Please check your input.'}</span>`;
|
||||
}
|
||||
return 'Input validation error. Please correct your input.';
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > return 'Input validation error. Please correct your input.';</span>
|
||||
|
||||
case ErrorType.STREAMING_ERROR:
|
||||
if (error instanceof StreamingError) {
|
||||
return 'Response too long. Please try a shorter request.';
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > return 'Streaming error. Please try again.';</span>
|
||||
|
||||
case ErrorType.TOOL_EXECUTION_ERROR:
|
||||
if (error instanceof ToolExecutionError) {
|
||||
return `Tool error: ${error.toolName || <span class="branch-1 cbranch-no" title="branch not covered" >'tool'}</span> failed to execute. Please try again.`;
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > return 'Tool execution error. Please try a different command.';</span>
|
||||
|
||||
case ErrorType.PATH_VALIDATION_ERROR:
|
||||
if (error instanceof PathValidationError) {
|
||||
return 'Invalid file path. Please use a relative path without special characters.';
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > return 'Path validation error. Please check your file path.';</span>
|
||||
|
||||
<span class="branch-6 cbranch-no" title="branch not covered" > case ErrorType.UNKNOWN_ERROR:</span>
|
||||
<span class="cstat-no" title="statement not covered" > return 'An unexpected error occurred. Please try again.';</span>
|
||||
|
||||
<span class="branch-7 cbranch-no" title="branch not covered" > default:</span>
|
||||
<span class="cstat-no" title="statement not covered" > return error.message || 'An error occurred';</span>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly message from generic Error
|
||||
*/
|
||||
/**
|
||||
* Get user-friendly message from generic Error
|
||||
* Note: This method uses substring matching which is inherently fragile.
|
||||
* If an error message happens to contain certain keywords but isn't actually
|
||||
* that type of error, it may be misclassified. This heuristic approach
|
||||
* provides a good balance between robustness and accuracy for most common cases.
|
||||
*/
|
||||
private static getUserFriendlyMessageFromError(error: Error): string {
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
if (message.includes('timeout')) {
|
||||
return 'Request timed out. Please check your Ollama connection.';
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes('network') ||
|
||||
message.includes('fetch') ||
|
||||
message.includes('connection')
|
||||
) {
|
||||
return 'Connection error. Please check if Ollama is running.';
|
||||
}
|
||||
|
||||
if (message.includes('validation') || message.includes('format')) {
|
||||
return 'Invalid input. Please check your message.';
|
||||
}
|
||||
|
||||
if (message.includes('stream') || message.includes('chunk')) {
|
||||
return 'Response too long. Please try a shorter request.';
|
||||
}
|
||||
|
||||
if (message.includes('tool') || message.includes('function')) {
|
||||
return 'Tool execution error. Please try a different command.';
|
||||
}
|
||||
|
||||
if (message.includes('path') || message.includes('file')) {
|
||||
return 'Invalid file path. Please use a relative path without special characters.';
|
||||
}
|
||||
|
||||
return error.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create specific error instances from different error types
|
||||
*/
|
||||
static createNetworkError(message: string, statusCode?: number): NetworkError {
|
||||
return new NetworkError(message, statusCode);
|
||||
}
|
||||
|
||||
static createApiError(message: string, apiError?: any): ApiError {
|
||||
return new ApiError(message, apiError);
|
||||
}
|
||||
|
||||
static createValidationError(
|
||||
message: string,
|
||||
field?: string,
|
||||
details?: Record<string, string>
|
||||
): ValidationError {
|
||||
const validationDetails = field ? { field, message } : <span class="branch-1 cbranch-no" title="branch not covered" >details;</span>
|
||||
return new ValidationError(message, validationDetails);
|
||||
}
|
||||
|
||||
static createStreamingError(message: string, chunkDetails?: any): StreamingError {
|
||||
return new StreamingError(message, chunkDetails);
|
||||
}
|
||||
|
||||
static createToolExecutionError(message: string, toolName?: string): ToolExecutionError {
|
||||
return new ToolExecutionError(message, toolName);
|
||||
}
|
||||
|
||||
static createPathValidationError(message: string, invalidPath?: string): PathValidationError {
|
||||
return new PathValidationError(message, invalidPath);
|
||||
}
|
||||
|
||||
static createUnknownError(message: string): OllamaError {
|
||||
return new OllamaError(message, ErrorType.UNKNOWN_ERROR);
|
||||
}
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-05-04T18:57:30.341Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="sorter.js"></script>
|
||||
<script src="block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 445 B |
@@ -0,0 +1,116 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for All files</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="prettify.css" />
|
||||
<link rel="stylesheet" href="base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1>All files</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">99.16% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>119/120</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">91.66% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>33/36</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>16/16</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">99.09% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>110/111</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<div class="pad1">
|
||||
<table class="coverage-summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
|
||||
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
|
||||
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
|
||||
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
|
||||
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
|
||||
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
|
||||
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td class="file high" data-value="vault-indexer.ts"><a href="vault-indexer.ts.html">vault-indexer.ts</a></td>
|
||||
<td data-value="99.16" class="pic high">
|
||||
<div class="chart"><div class="cover-fill" style="width: 99%"></div><div class="cover-empty" style="width: 1%"></div></div>
|
||||
</td>
|
||||
<td data-value="99.16" class="pct high">99.16%</td>
|
||||
<td data-value="120" class="abs high">119/120</td>
|
||||
<td data-value="91.66" class="pct high">91.66%</td>
|
||||
<td data-value="36" class="abs high">33/36</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="16" class="abs high">16/16</td>
|
||||
<td data-value="99.09" class="pct high">99.09%</td>
|
||||
<td data-value="111" class="abs high">110/111</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-05-04T19:35:37.813Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="sorter.js"></script>
|
||||
<script src="block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for ollama-client.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="prettify.css" />
|
||||
<link rel="stylesheet" href="base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="index.html">All files</a> ollama-client.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">87.65% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>71/81</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">82.05% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>32/39</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">71.42% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>5/7</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">88.46% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>69/78</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a>
|
||||
<a name='L79'></a><a href='#L79'>79</a>
|
||||
<a name='L80'></a><a href='#L80'>80</a>
|
||||
<a name='L81'></a><a href='#L81'>81</a>
|
||||
<a name='L82'></a><a href='#L82'>82</a>
|
||||
<a name='L83'></a><a href='#L83'>83</a>
|
||||
<a name='L84'></a><a href='#L84'>84</a>
|
||||
<a name='L85'></a><a href='#L85'>85</a>
|
||||
<a name='L86'></a><a href='#L86'>86</a>
|
||||
<a name='L87'></a><a href='#L87'>87</a>
|
||||
<a name='L88'></a><a href='#L88'>88</a>
|
||||
<a name='L89'></a><a href='#L89'>89</a>
|
||||
<a name='L90'></a><a href='#L90'>90</a>
|
||||
<a name='L91'></a><a href='#L91'>91</a>
|
||||
<a name='L92'></a><a href='#L92'>92</a>
|
||||
<a name='L93'></a><a href='#L93'>93</a>
|
||||
<a name='L94'></a><a href='#L94'>94</a>
|
||||
<a name='L95'></a><a href='#L95'>95</a>
|
||||
<a name='L96'></a><a href='#L96'>96</a>
|
||||
<a name='L97'></a><a href='#L97'>97</a>
|
||||
<a name='L98'></a><a href='#L98'>98</a>
|
||||
<a name='L99'></a><a href='#L99'>99</a>
|
||||
<a name='L100'></a><a href='#L100'>100</a>
|
||||
<a name='L101'></a><a href='#L101'>101</a>
|
||||
<a name='L102'></a><a href='#L102'>102</a>
|
||||
<a name='L103'></a><a href='#L103'>103</a>
|
||||
<a name='L104'></a><a href='#L104'>104</a>
|
||||
<a name='L105'></a><a href='#L105'>105</a>
|
||||
<a name='L106'></a><a href='#L106'>106</a>
|
||||
<a name='L107'></a><a href='#L107'>107</a>
|
||||
<a name='L108'></a><a href='#L108'>108</a>
|
||||
<a name='L109'></a><a href='#L109'>109</a>
|
||||
<a name='L110'></a><a href='#L110'>110</a>
|
||||
<a name='L111'></a><a href='#L111'>111</a>
|
||||
<a name='L112'></a><a href='#L112'>112</a>
|
||||
<a name='L113'></a><a href='#L113'>113</a>
|
||||
<a name='L114'></a><a href='#L114'>114</a>
|
||||
<a name='L115'></a><a href='#L115'>115</a>
|
||||
<a name='L116'></a><a href='#L116'>116</a>
|
||||
<a name='L117'></a><a href='#L117'>117</a>
|
||||
<a name='L118'></a><a href='#L118'>118</a>
|
||||
<a name='L119'></a><a href='#L119'>119</a>
|
||||
<a name='L120'></a><a href='#L120'>120</a>
|
||||
<a name='L121'></a><a href='#L121'>121</a>
|
||||
<a name='L122'></a><a href='#L122'>122</a>
|
||||
<a name='L123'></a><a href='#L123'>123</a>
|
||||
<a name='L124'></a><a href='#L124'>124</a>
|
||||
<a name='L125'></a><a href='#L125'>125</a>
|
||||
<a name='L126'></a><a href='#L126'>126</a>
|
||||
<a name='L127'></a><a href='#L127'>127</a>
|
||||
<a name='L128'></a><a href='#L128'>128</a>
|
||||
<a name='L129'></a><a href='#L129'>129</a>
|
||||
<a name='L130'></a><a href='#L130'>130</a>
|
||||
<a name='L131'></a><a href='#L131'>131</a>
|
||||
<a name='L132'></a><a href='#L132'>132</a>
|
||||
<a name='L133'></a><a href='#L133'>133</a>
|
||||
<a name='L134'></a><a href='#L134'>134</a>
|
||||
<a name='L135'></a><a href='#L135'>135</a>
|
||||
<a name='L136'></a><a href='#L136'>136</a>
|
||||
<a name='L137'></a><a href='#L137'>137</a>
|
||||
<a name='L138'></a><a href='#L138'>138</a>
|
||||
<a name='L139'></a><a href='#L139'>139</a>
|
||||
<a name='L140'></a><a href='#L140'>140</a>
|
||||
<a name='L141'></a><a href='#L141'>141</a>
|
||||
<a name='L142'></a><a href='#L142'>142</a>
|
||||
<a name='L143'></a><a href='#L143'>143</a>
|
||||
<a name='L144'></a><a href='#L144'>144</a>
|
||||
<a name='L145'></a><a href='#L145'>145</a>
|
||||
<a name='L146'></a><a href='#L146'>146</a>
|
||||
<a name='L147'></a><a href='#L147'>147</a>
|
||||
<a name='L148'></a><a href='#L148'>148</a>
|
||||
<a name='L149'></a><a href='#L149'>149</a>
|
||||
<a name='L150'></a><a href='#L150'>150</a>
|
||||
<a name='L151'></a><a href='#L151'>151</a>
|
||||
<a name='L152'></a><a href='#L152'>152</a>
|
||||
<a name='L153'></a><a href='#L153'>153</a>
|
||||
<a name='L154'></a><a href='#L154'>154</a>
|
||||
<a name='L155'></a><a href='#L155'>155</a>
|
||||
<a name='L156'></a><a href='#L156'>156</a>
|
||||
<a name='L157'></a><a href='#L157'>157</a>
|
||||
<a name='L158'></a><a href='#L158'>158</a>
|
||||
<a name='L159'></a><a href='#L159'>159</a>
|
||||
<a name='L160'></a><a href='#L160'>160</a>
|
||||
<a name='L161'></a><a href='#L161'>161</a>
|
||||
<a name='L162'></a><a href='#L162'>162</a>
|
||||
<a name='L163'></a><a href='#L163'>163</a>
|
||||
<a name='L164'></a><a href='#L164'>164</a>
|
||||
<a name='L165'></a><a href='#L165'>165</a>
|
||||
<a name='L166'></a><a href='#L166'>166</a>
|
||||
<a name='L167'></a><a href='#L167'>167</a>
|
||||
<a name='L168'></a><a href='#L168'>168</a>
|
||||
<a name='L169'></a><a href='#L169'>169</a>
|
||||
<a name='L170'></a><a href='#L170'>170</a>
|
||||
<a name='L171'></a><a href='#L171'>171</a>
|
||||
<a name='L172'></a><a href='#L172'>172</a>
|
||||
<a name='L173'></a><a href='#L173'>173</a>
|
||||
<a name='L174'></a><a href='#L174'>174</a>
|
||||
<a name='L175'></a><a href='#L175'>175</a>
|
||||
<a name='L176'></a><a href='#L176'>176</a>
|
||||
<a name='L177'></a><a href='#L177'>177</a>
|
||||
<a name='L178'></a><a href='#L178'>178</a>
|
||||
<a name='L179'></a><a href='#L179'>179</a>
|
||||
<a name='L180'></a><a href='#L180'>180</a>
|
||||
<a name='L181'></a><a href='#L181'>181</a>
|
||||
<a name='L182'></a><a href='#L182'>182</a>
|
||||
<a name='L183'></a><a href='#L183'>183</a>
|
||||
<a name='L184'></a><a href='#L184'>184</a>
|
||||
<a name='L185'></a><a href='#L185'>185</a>
|
||||
<a name='L186'></a><a href='#L186'>186</a>
|
||||
<a name='L187'></a><a href='#L187'>187</a>
|
||||
<a name='L188'></a><a href='#L188'>188</a>
|
||||
<a name='L189'></a><a href='#L189'>189</a>
|
||||
<a name='L190'></a><a href='#L190'>190</a>
|
||||
<a name='L191'></a><a href='#L191'>191</a>
|
||||
<a name='L192'></a><a href='#L192'>192</a>
|
||||
<a name='L193'></a><a href='#L193'>193</a>
|
||||
<a name='L194'></a><a href='#L194'>194</a>
|
||||
<a name='L195'></a><a href='#L195'>195</a>
|
||||
<a name='L196'></a><a href='#L196'>196</a>
|
||||
<a name='L197'></a><a href='#L197'>197</a>
|
||||
<a name='L198'></a><a href='#L198'>198</a>
|
||||
<a name='L199'></a><a href='#L199'>199</a>
|
||||
<a name='L200'></a><a href='#L200'>200</a>
|
||||
<a name='L201'></a><a href='#L201'>201</a>
|
||||
<a name='L202'></a><a href='#L202'>202</a>
|
||||
<a name='L203'></a><a href='#L203'>203</a>
|
||||
<a name='L204'></a><a href='#L204'>204</a>
|
||||
<a name='L205'></a><a href='#L205'>205</a>
|
||||
<a name='L206'></a><a href='#L206'>206</a>
|
||||
<a name='L207'></a><a href='#L207'>207</a>
|
||||
<a name='L208'></a><a href='#L208'>208</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">28x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">28x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">28x</span>
|
||||
<span class="cline-any cline-yes">28x</span>
|
||||
<span class="cline-any cline-yes">28x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">12x</span>
|
||||
<span class="cline-any cline-yes">12x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">61x</span>
|
||||
<span class="cline-any cline-yes">61x</span>
|
||||
<span class="cline-any cline-yes">61x</span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">53x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">52x</span>
|
||||
<span class="cline-any cline-yes">52x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">52x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">51x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">21x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import { OllamaMessage, OllamaTool, ToolCall } from './types';
|
||||
|
||||
interface FetchResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
headers?: {
|
||||
get: (name: string) => string | null;
|
||||
};
|
||||
body?: {
|
||||
getReader: () => ReadableStreamDefaultReader<Uint8Array>;
|
||||
} | null;
|
||||
json?: () => Promise<any>;
|
||||
}
|
||||
|
||||
interface FetchOptions {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class OllamaClient {
|
||||
private url: string;
|
||||
private model: string;
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
// Mock fetch function for testing
|
||||
private fetchFn: typeof fetch = fetch;
|
||||
|
||||
constructor(url: string, model: string, fetchFn?: typeof fetch) {
|
||||
this.url = url;
|
||||
this.model = model;
|
||||
if (fetchFn) this.fetchFn = fetchFn;
|
||||
}
|
||||
|
||||
async streamChat(
|
||||
messages: OllamaMessage[],
|
||||
tools: OllamaTool[],
|
||||
timeoutMs: number = 60000
|
||||
): Promise<AsyncIterable<{ content: string; tool_calls?: ToolCall[] }>> {
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const timeoutId = setTimeout(<span class="fstat-no" title="function not covered" >() =</span>> {
|
||||
<span class="cstat-no" title="statement not covered" > this.abortController?.abort();</span>
|
||||
}, timeoutMs);
|
||||
|
||||
let response: FetchResponse;
|
||||
try {
|
||||
response = await this.fetchFn(`${this.url}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages,
|
||||
tools,
|
||||
stream: true,
|
||||
}),
|
||||
signal: this.abortController.signal,
|
||||
} as FetchOptions);
|
||||
} catch (fetchError: any) {
|
||||
<span class="cstat-no" title="statement not covered" > clearTimeout(timeoutId);</span>
|
||||
<span class="cstat-no" title="statement not covered" > this.abortController = null;</span>
|
||||
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (fetchError.name === 'AbortError' || fetchError.code === 'ABORT_ERR') {</span>
|
||||
<span class="cstat-no" title="statement not covered" > throw new Error('Request timeout while connecting to Ollama');</span>
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > throw fetchError;</span>
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
// Validate response structure
|
||||
const contentType = response.headers?.get('content-type');
|
||||
if (!contentType?.match(/application\/(x-ndjson|json)/)) {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
|
||||
const self = this;
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let chunkCount = 0;
|
||||
let skippedChunks = 0;
|
||||
const maxChunks = 1000; // Safety limit
|
||||
const maxSkipped = 50; // Fail if too many chunks are malformed
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (++chunkCount > maxChunks) {
|
||||
<span class="cstat-no" title="statement not covered" > throw new Error('Response too long, stopped streaming');</span>
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (line.trim() === '') <span class="cstat-no" title="statement not covered" >continue;</span>
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.message && typeof data.message === 'object') {
|
||||
// Validate message structure
|
||||
if (data.message.error && typeof data.message.error === 'string') {
|
||||
throw new Error(`Ollama error: ${data.message.error}`);
|
||||
}
|
||||
yield {
|
||||
content: data.message.content || '',
|
||||
tool_calls: Array.isArray(data.message.tool_calls)
|
||||
? data.message.tool_calls
|
||||
: [],
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Check if this is an Ollama error (thrown intentionally) vs a parse error
|
||||
if (parseError instanceof Error && parseError.message.startsWith('Ollama error:')) {
|
||||
// This is an intentional Ollama error, re-throw it
|
||||
self.abortController = null;
|
||||
reader.releaseLock();
|
||||
throw parseError;
|
||||
}
|
||||
|
||||
// This is a parse error, skip the malformed chunk
|
||||
skippedChunks++;
|
||||
console.warn(
|
||||
`[OllamaClient] Skipped malformed chunk ${skippedChunks}/${maxSkipped}:`,
|
||||
parseError instanceof Error ? parseError.message : <span class="branch-1 cbranch-no" title="branch not covered" >String(parseError)</span>
|
||||
);
|
||||
if (skippedChunks > maxSkipped) {
|
||||
throw new Error(
|
||||
`Too many malformed response chunks (${skippedChunks}). Connection may be degraded.`
|
||||
);
|
||||
}
|
||||
// Skip invalid chunks but continue streaming
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
self.abortController = null;
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (streamError instanceof Error && streamError.name === 'AbortError') {
|
||||
<span class="cstat-no" title="statement not covered" > throw new Error('Streaming request was cancelled');</span>
|
||||
}
|
||||
throw streamError;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
self.abortController = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async chat(
|
||||
messages: OllamaMessage[],
|
||||
tools: OllamaTool[],
|
||||
timeoutMs: number = 30000
|
||||
): Promise<{ content: string; tool_calls?: ToolCall[] }> {
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(<span class="fstat-no" title="function not covered" >() =</span>> {
|
||||
<span class="cstat-no" title="statement not covered" > abortController.abort();</span>
|
||||
}, timeoutMs);
|
||||
|
||||
const response = await this.fetchFn(`${this.url}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages,
|
||||
tools,
|
||||
stream: false,
|
||||
}),
|
||||
signal: abortController.signal,
|
||||
} as FetchOptions);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
const data = responseData;
|
||||
const messageData = data.message;
|
||||
return {
|
||||
content: messageData?.content || '',
|
||||
tool_calls: messageData?.tool_calls || [],
|
||||
};
|
||||
}
|
||||
|
||||
cancelStream(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-05-04T18:57:30.341Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="sorter.js"></script>
|
||||
<script src="block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 138 B |
@@ -0,0 +1,210 @@
|
||||
/* eslint-disable */
|
||||
var addSorting = (function() {
|
||||
'use strict';
|
||||
var cols,
|
||||
currentSort = {
|
||||
index: 0,
|
||||
desc: false
|
||||
};
|
||||
|
||||
// returns the summary table element
|
||||
function getTable() {
|
||||
return document.querySelector('.coverage-summary');
|
||||
}
|
||||
// returns the thead element of the summary table
|
||||
function getTableHeader() {
|
||||
return getTable().querySelector('thead tr');
|
||||
}
|
||||
// returns the tbody element of the summary table
|
||||
function getTableBody() {
|
||||
return getTable().querySelector('tbody');
|
||||
}
|
||||
// returns the th element for nth column
|
||||
function getNthColumn(n) {
|
||||
return getTableHeader().querySelectorAll('th')[n];
|
||||
}
|
||||
|
||||
function onFilterInput() {
|
||||
const searchValue = document.getElementById('fileSearch').value;
|
||||
const rows = document.getElementsByTagName('tbody')[0].children;
|
||||
|
||||
// Try to create a RegExp from the searchValue. If it fails (invalid regex),
|
||||
// it will be treated as a plain text search
|
||||
let searchRegex;
|
||||
try {
|
||||
searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive
|
||||
} catch (error) {
|
||||
searchRegex = null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
let isMatch = false;
|
||||
|
||||
if (searchRegex) {
|
||||
// If a valid regex was created, use it for matching
|
||||
isMatch = searchRegex.test(row.textContent);
|
||||
} else {
|
||||
// Otherwise, fall back to the original plain text search
|
||||
isMatch = row.textContent
|
||||
.toLowerCase()
|
||||
.includes(searchValue.toLowerCase());
|
||||
}
|
||||
|
||||
row.style.display = isMatch ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// loads the search box
|
||||
function addSearchBox() {
|
||||
var template = document.getElementById('filterTemplate');
|
||||
var templateClone = template.content.cloneNode(true);
|
||||
templateClone.getElementById('fileSearch').oninput = onFilterInput;
|
||||
template.parentElement.appendChild(templateClone);
|
||||
}
|
||||
|
||||
// loads all columns
|
||||
function loadColumns() {
|
||||
var colNodes = getTableHeader().querySelectorAll('th'),
|
||||
colNode,
|
||||
cols = [],
|
||||
col,
|
||||
i;
|
||||
|
||||
for (i = 0; i < colNodes.length; i += 1) {
|
||||
colNode = colNodes[i];
|
||||
col = {
|
||||
key: colNode.getAttribute('data-col'),
|
||||
sortable: !colNode.getAttribute('data-nosort'),
|
||||
type: colNode.getAttribute('data-type') || 'string'
|
||||
};
|
||||
cols.push(col);
|
||||
if (col.sortable) {
|
||||
col.defaultDescSort = col.type === 'number';
|
||||
colNode.innerHTML =
|
||||
colNode.innerHTML + '<span class="sorter"></span>';
|
||||
}
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
// attaches a data attribute to every tr element with an object
|
||||
// of data values keyed by column name
|
||||
function loadRowData(tableRow) {
|
||||
var tableCols = tableRow.querySelectorAll('td'),
|
||||
colNode,
|
||||
col,
|
||||
data = {},
|
||||
i,
|
||||
val;
|
||||
for (i = 0; i < tableCols.length; i += 1) {
|
||||
colNode = tableCols[i];
|
||||
col = cols[i];
|
||||
val = colNode.getAttribute('data-value');
|
||||
if (col.type === 'number') {
|
||||
val = Number(val);
|
||||
}
|
||||
data[col.key] = val;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
// loads all row data
|
||||
function loadData() {
|
||||
var rows = getTableBody().querySelectorAll('tr'),
|
||||
i;
|
||||
|
||||
for (i = 0; i < rows.length; i += 1) {
|
||||
rows[i].data = loadRowData(rows[i]);
|
||||
}
|
||||
}
|
||||
// sorts the table using the data for the ith column
|
||||
function sortByIndex(index, desc) {
|
||||
var key = cols[index].key,
|
||||
sorter = function(a, b) {
|
||||
a = a.data[key];
|
||||
b = b.data[key];
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
},
|
||||
finalSorter = sorter,
|
||||
tableBody = document.querySelector('.coverage-summary tbody'),
|
||||
rowNodes = tableBody.querySelectorAll('tr'),
|
||||
rows = [],
|
||||
i;
|
||||
|
||||
if (desc) {
|
||||
finalSorter = function(a, b) {
|
||||
return -1 * sorter(a, b);
|
||||
};
|
||||
}
|
||||
|
||||
for (i = 0; i < rowNodes.length; i += 1) {
|
||||
rows.push(rowNodes[i]);
|
||||
tableBody.removeChild(rowNodes[i]);
|
||||
}
|
||||
|
||||
rows.sort(finalSorter);
|
||||
|
||||
for (i = 0; i < rows.length; i += 1) {
|
||||
tableBody.appendChild(rows[i]);
|
||||
}
|
||||
}
|
||||
// removes sort indicators for current column being sorted
|
||||
function removeSortIndicators() {
|
||||
var col = getNthColumn(currentSort.index),
|
||||
cls = col.className;
|
||||
|
||||
cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, '');
|
||||
col.className = cls;
|
||||
}
|
||||
// adds sort indicators for current column being sorted
|
||||
function addSortIndicators() {
|
||||
getNthColumn(currentSort.index).className += currentSort.desc
|
||||
? ' sorted-desc'
|
||||
: ' sorted';
|
||||
}
|
||||
// adds event listeners for all sorter widgets
|
||||
function enableUI() {
|
||||
var i,
|
||||
el,
|
||||
ithSorter = function ithSorter(i) {
|
||||
var col = cols[i];
|
||||
|
||||
return function() {
|
||||
var desc = col.defaultDescSort;
|
||||
|
||||
if (currentSort.index === i) {
|
||||
desc = !currentSort.desc;
|
||||
}
|
||||
sortByIndex(i, desc);
|
||||
removeSortIndicators();
|
||||
currentSort.index = i;
|
||||
currentSort.desc = desc;
|
||||
addSortIndicators();
|
||||
};
|
||||
};
|
||||
for (i = 0; i < cols.length; i += 1) {
|
||||
if (cols[i].sortable) {
|
||||
// add the click event handler on the th so users
|
||||
// dont have to click on those tiny arrows
|
||||
el = getNthColumn(i).querySelector('.sorter').parentElement;
|
||||
if (el.addEventListener) {
|
||||
el.addEventListener('click', ithSorter(i));
|
||||
} else {
|
||||
el.attachEvent('onclick', ithSorter(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// adds sorting functionality to the UI
|
||||
return function() {
|
||||
if (!getTable()) {
|
||||
return;
|
||||
}
|
||||
cols = loadColumns();
|
||||
loadData();
|
||||
addSearchBox();
|
||||
addSortIndicators();
|
||||
enableUI();
|
||||
};
|
||||
})();
|
||||
|
||||
window.addEventListener('load', addSorting);
|
||||
@@ -0,0 +1,265 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for tool-executor.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="prettify.css" />
|
||||
<link rel="stylesheet" href="base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="index.html">All files</a> tool-executor.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>24/24</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">83.33% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>10/12</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>2/2</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>24/24</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">38x</span>
|
||||
<span class="cline-any cline-yes">38x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">28x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">28x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">27x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">27x</span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">24x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">22x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">21x</span>
|
||||
<span class="cline-any cline-yes">21x</span>
|
||||
<span class="cline-any cline-yes">14x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import { Vault, TFile, Notice, App } from 'obsidian';
|
||||
import { ToolCall, ToolResult, ToolExecutionError, PathValidationError } from './types';
|
||||
import { validatePath } from './utils';
|
||||
|
||||
export class ToolExecutor {
|
||||
private vault: Vault;
|
||||
private app: App;
|
||||
|
||||
constructor(vault: Vault, app: App) {
|
||||
this.vault = vault;
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
async handleToolCall(call: ToolCall): Promise<ToolResult> {
|
||||
const {
|
||||
function: { name, arguments: args },
|
||||
} = call;
|
||||
|
||||
switch (name) {
|
||||
case 'create_file': {
|
||||
let filePath: string, content: string;
|
||||
try {
|
||||
// Handle both string (JSON) and object arguments, since some Ollama versions return args as an object
|
||||
const parsedArgs = typeof args === 'string' ? JSON.parse(args) : args;
|
||||
filePath = parsedArgs.path;
|
||||
content = parsedArgs.content;
|
||||
} catch (e) {
|
||||
throw new ToolExecutionError(
|
||||
`Invalid arguments provided for create_file: ${e instanceof Error ? e.message : <span class="branch-1 cbranch-no" title="branch not covered" >'Unknown parsing error'}</span>`,
|
||||
'create_file'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate content is a string
|
||||
if (typeof content !== 'string') {
|
||||
throw new ToolExecutionError('Content must be a string', 'create_file');
|
||||
}
|
||||
|
||||
// Validate path using shared utility
|
||||
if (typeof filePath !== 'string') {
|
||||
throw new ToolExecutionError('Path must be a string', 'create_file');
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
throw new ToolExecutionError('Path is required', 'create_file');
|
||||
}
|
||||
|
||||
const pathValidation = validatePath(filePath);
|
||||
if (!pathValidation.valid) {
|
||||
throw new PathValidationError(pathValidation.error || <span class="branch-1 cbranch-no" title="branch not covered" >'Path validation failed',</span> filePath);
|
||||
}
|
||||
|
||||
await this.vault.create(filePath, content);
|
||||
return { success: true, message: 'File created successfully' };
|
||||
}
|
||||
default:
|
||||
return { success: false, message: `Unknown tool: ${name}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-05-04T18:57:30.341Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="sorter.js"></script>
|
||||
<script src="block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for types.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="prettify.css" />
|
||||
<link rel="stylesheet" href="base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="index.html">All files</a> types.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>37/37</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>2/2</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>8/8</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>37/37</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a>
|
||||
<a name='L79'></a><a href='#L79'>79</a>
|
||||
<a name='L80'></a><a href='#L80'>80</a>
|
||||
<a name='L81'></a><a href='#L81'>81</a>
|
||||
<a name='L82'></a><a href='#L82'>82</a>
|
||||
<a name='L83'></a><a href='#L83'>83</a>
|
||||
<a name='L84'></a><a href='#L84'>84</a>
|
||||
<a name='L85'></a><a href='#L85'>85</a>
|
||||
<a name='L86'></a><a href='#L86'>86</a>
|
||||
<a name='L87'></a><a href='#L87'>87</a>
|
||||
<a name='L88'></a><a href='#L88'>88</a>
|
||||
<a name='L89'></a><a href='#L89'>89</a>
|
||||
<a name='L90'></a><a href='#L90'>90</a>
|
||||
<a name='L91'></a><a href='#L91'>91</a>
|
||||
<a name='L92'></a><a href='#L92'>92</a>
|
||||
<a name='L93'></a><a href='#L93'>93</a>
|
||||
<a name='L94'></a><a href='#L94'>94</a>
|
||||
<a name='L95'></a><a href='#L95'>95</a>
|
||||
<a name='L96'></a><a href='#L96'>96</a>
|
||||
<a name='L97'></a><a href='#L97'>97</a>
|
||||
<a name='L98'></a><a href='#L98'>98</a>
|
||||
<a name='L99'></a><a href='#L99'>99</a>
|
||||
<a name='L100'></a><a href='#L100'>100</a>
|
||||
<a name='L101'></a><a href='#L101'>101</a>
|
||||
<a name='L102'></a><a href='#L102'>102</a>
|
||||
<a name='L103'></a><a href='#L103'>103</a>
|
||||
<a name='L104'></a><a href='#L104'>104</a>
|
||||
<a name='L105'></a><a href='#L105'>105</a>
|
||||
<a name='L106'></a><a href='#L106'>106</a>
|
||||
<a name='L107'></a><a href='#L107'>107</a>
|
||||
<a name='L108'></a><a href='#L108'>108</a>
|
||||
<a name='L109'></a><a href='#L109'>109</a>
|
||||
<a name='L110'></a><a href='#L110'>110</a>
|
||||
<a name='L111'></a><a href='#L111'>111</a>
|
||||
<a name='L112'></a><a href='#L112'>112</a>
|
||||
<a name='L113'></a><a href='#L113'>113</a>
|
||||
<a name='L114'></a><a href='#L114'>114</a>
|
||||
<a name='L115'></a><a href='#L115'>115</a>
|
||||
<a name='L116'></a><a href='#L116'>116</a>
|
||||
<a name='L117'></a><a href='#L117'>117</a>
|
||||
<a name='L118'></a><a href='#L118'>118</a>
|
||||
<a name='L119'></a><a href='#L119'>119</a>
|
||||
<a name='L120'></a><a href='#L120'>120</a>
|
||||
<a name='L121'></a><a href='#L121'>121</a>
|
||||
<a name='L122'></a><a href='#L122'>122</a>
|
||||
<a name='L123'></a><a href='#L123'>123</a>
|
||||
<a name='L124'></a><a href='#L124'>124</a>
|
||||
<a name='L125'></a><a href='#L125'>125</a>
|
||||
<a name='L126'></a><a href='#L126'>126</a>
|
||||
<a name='L127'></a><a href='#L127'>127</a>
|
||||
<a name='L128'></a><a href='#L128'>128</a>
|
||||
<a name='L129'></a><a href='#L129'>129</a>
|
||||
<a name='L130'></a><a href='#L130'>130</a>
|
||||
<a name='L131'></a><a href='#L131'>131</a>
|
||||
<a name='L132'></a><a href='#L132'>132</a>
|
||||
<a name='L133'></a><a href='#L133'>133</a>
|
||||
<a name='L134'></a><a href='#L134'>134</a>
|
||||
<a name='L135'></a><a href='#L135'>135</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">35x</span>
|
||||
<span class="cline-any cline-yes">35x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">35x</span>
|
||||
<span class="cline-any cline-yes">35x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">16x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">16x</span>
|
||||
<span class="cline-any cline-yes">16x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">export interface PluginSettings {
|
||||
ollamaUrl: string;
|
||||
model: string;
|
||||
lastIndexTime: number;
|
||||
}
|
||||
|
||||
export enum ErrorType {
|
||||
NETWORK_ERROR = 'network_error',
|
||||
API_ERROR = 'api_error',
|
||||
VALIDATION_ERROR = 'validation_error',
|
||||
STREAMING_ERROR = 'streaming_error',
|
||||
TOOL_EXECUTION_ERROR = 'tool_execution_error',
|
||||
PATH_VALIDATION_ERROR = 'path_validation_error',
|
||||
UNKNOWN_ERROR = 'unknown_error',
|
||||
}
|
||||
|
||||
export class OllamaError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly type: ErrorType,
|
||||
public readonly details?: Record<string, any>
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'OllamaError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends OllamaError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode?: number
|
||||
) {
|
||||
super(message, ErrorType.NETWORK_ERROR, { statusCode });
|
||||
this.name = 'NetworkError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends OllamaError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly apiError?: any
|
||||
) {
|
||||
super(message, ErrorType.API_ERROR, { apiError });
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends OllamaError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly validationDetails?: Record<string, string>
|
||||
) {
|
||||
super(message, ErrorType.VALIDATION_ERROR, validationDetails);
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class StreamingError extends OllamaError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly chunkDetails?: any
|
||||
) {
|
||||
super(message, ErrorType.STREAMING_ERROR, chunkDetails);
|
||||
this.name = 'StreamingError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolExecutionError extends OllamaError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly toolName?: string
|
||||
) {
|
||||
super(message, ErrorType.TOOL_EXECUTION_ERROR, { toolName });
|
||||
this.name = 'ToolExecutionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PathValidationError extends OllamaError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly invalidPath?: string
|
||||
) {
|
||||
super(message, ErrorType.PATH_VALIDATION_ERROR, { invalidPath });
|
||||
this.name = 'PathValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface OllamaMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
tool_calls?: ToolCall[];
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string | Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OllamaTool {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: {
|
||||
type: 'object';
|
||||
properties: Record<string, { type: string }>;
|
||||
required: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
// Adding optional details field for better error reporting
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface VaultIndexEntry {
|
||||
title: string;
|
||||
content: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
tool_calls?: ToolCall[];
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-05-04T18:57:30.341Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="sorter.js"></script>
|
||||
<script src="block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for utils.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="prettify.css" />
|
||||
<link rel="stylesheet" href="base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="index.html">All files</a> utils.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">95.23% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>20/21</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">90% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>9/10</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>2/2</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">95.23% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>20/21</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">21x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">21x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">21x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">21x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">21x</span>
|
||||
<span class="cline-any cline-yes">21x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">19x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">17x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">16x</span>
|
||||
<span class="cline-any cline-yes">16x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">/**
|
||||
* Normalizes file paths for browser/ Obsidian environment
|
||||
* Replaces multiple slashes with single slash and handles forward/backward slashes
|
||||
*/
|
||||
export function normalizePath(path: string): string {
|
||||
// Replace multiple slashes with single slash
|
||||
let normalized = path.replace(/[\\\/]+/g, '/');
|
||||
|
||||
// Remove trailing slash unless it's the root
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
<span class="cstat-no" title="statement not covered" > normalized = normalized.slice(0, -1);</span>
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a path string for safety (no traversal, no absolute paths, no invalid chars)
|
||||
*/
|
||||
export function validatePath(path: string): { valid: boolean; error?: string } {
|
||||
const normalized = normalizePath(path);
|
||||
|
||||
// Check for path traversal by looking for .. as a path segment (not just substring in filenames)
|
||||
const segments = normalized.split('/');
|
||||
if (segments.includes('..')) {
|
||||
return { valid: false, error: 'Path traversal not allowed' };
|
||||
}
|
||||
|
||||
// Check if absolute path
|
||||
if (normalized.startsWith('/') || normalized.startsWith('\\')) {
|
||||
return { valid: false, error: 'Absolute paths not allowed' };
|
||||
}
|
||||
|
||||
// Check for windows drive letters
|
||||
if (/^[a-zA-Z]:/.test(normalized)) {
|
||||
return { valid: false, error: 'Absolute paths not allowed' };
|
||||
}
|
||||
|
||||
// Check for invalid characters
|
||||
const invalidChars = /[\<\>\:\"\|\\\?\*~]/;
|
||||
if (invalidChars.test(path)) {
|
||||
return { valid: false, error: 'Path contains illegal characters' };
|
||||
}
|
||||
|
||||
// Check path length
|
||||
const MAX_PATH_LENGTH = 200;
|
||||
if (path.length > MAX_PATH_LENGTH) {
|
||||
return { valid: false, error: 'Path too long' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-05-04T18:57:30.341Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="sorter.js"></script>
|
||||
<script src="block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
||||
TN:
|
||||
SF:src/vault-indexer.ts
|
||||
FN:31,(anonymous_0)
|
||||
FN:35,(anonymous_1)
|
||||
FN:48,(anonymous_2)
|
||||
FN:75,(anonymous_3)
|
||||
FN:81,(anonymous_4)
|
||||
FN:86,(anonymous_5)
|
||||
FN:227,(anonymous_6)
|
||||
FN:292,(anonymous_7)
|
||||
FN:304,(anonymous_8)
|
||||
FN:323,(anonymous_9)
|
||||
FN:328,(anonymous_10)
|
||||
FN:334,(anonymous_11)
|
||||
FN:341,(anonymous_12)
|
||||
FN:346,(anonymous_13)
|
||||
FN:374,(anonymous_14)
|
||||
FN:389,(anonymous_15)
|
||||
FNF:16
|
||||
FNH:16
|
||||
FNDA:31,(anonymous_0)
|
||||
FNDA:16,(anonymous_1)
|
||||
FNDA:54,(anonymous_2)
|
||||
FNDA:38,(anonymous_3)
|
||||
FNDA:268,(anonymous_4)
|
||||
FNDA:841,(anonymous_5)
|
||||
FNDA:60,(anonymous_6)
|
||||
FNDA:3,(anonymous_7)
|
||||
FNDA:58,(anonymous_8)
|
||||
FNDA:63,(anonymous_9)
|
||||
FNDA:1,(anonymous_10)
|
||||
FNDA:1,(anonymous_11)
|
||||
FNDA:123,(anonymous_12)
|
||||
FNDA:295,(anonymous_13)
|
||||
FNDA:489,(anonymous_14)
|
||||
FNDA:534,(anonymous_15)
|
||||
DA:4,1
|
||||
DA:5,1
|
||||
DA:6,1
|
||||
DA:7,1
|
||||
DA:8,1
|
||||
DA:9,1
|
||||
DA:10,1
|
||||
DA:11,1
|
||||
DA:12,1
|
||||
DA:13,1
|
||||
DA:28,1
|
||||
DA:32,31
|
||||
DA:36,16
|
||||
DA:37,16
|
||||
DA:39,16
|
||||
DA:40,16
|
||||
DA:41,2
|
||||
DA:45,14
|
||||
DA:46,15
|
||||
DA:47,15
|
||||
DA:49,54
|
||||
DA:50,53
|
||||
DA:51,53
|
||||
DA:53,53
|
||||
DA:54,50
|
||||
DA:60,3
|
||||
DA:64,15
|
||||
DA:65,54
|
||||
DA:66,50
|
||||
DA:67,4
|
||||
DA:68,1
|
||||
DA:75,38
|
||||
DA:82,268
|
||||
DA:88,841
|
||||
DA:220,841
|
||||
DA:228,60
|
||||
DA:229,60
|
||||
DA:232,60
|
||||
DA:233,60
|
||||
DA:235,60
|
||||
DA:237,60
|
||||
DA:238,3
|
||||
DA:242,60
|
||||
DA:243,60
|
||||
DA:244,60
|
||||
DA:245,60
|
||||
DA:246,2
|
||||
DA:247,2
|
||||
DA:248,2
|
||||
DA:249,3
|
||||
DA:250,3
|
||||
DA:251,3
|
||||
DA:252,3
|
||||
DA:253,3
|
||||
DA:259,60
|
||||
DA:262,60
|
||||
DA:263,60
|
||||
DA:264,60
|
||||
DA:265,60
|
||||
DA:266,63
|
||||
DA:268,63
|
||||
DA:269,3
|
||||
DA:272,60
|
||||
DA:273,2
|
||||
DA:275,58
|
||||
DA:276,58
|
||||
DA:277,1
|
||||
DA:280,60
|
||||
DA:283,60
|
||||
DA:284,60
|
||||
DA:286,60
|
||||
DA:292,3
|
||||
DA:309,58
|
||||
DA:310,58
|
||||
DA:311,58
|
||||
DA:312,58
|
||||
DA:315,58
|
||||
DA:316,50
|
||||
DA:319,58
|
||||
DA:320,64
|
||||
DA:323,64
|
||||
DA:324,1
|
||||
DA:328,64
|
||||
DA:329,1
|
||||
DA:333,64
|
||||
DA:334,1
|
||||
DA:335,1
|
||||
DA:336,1
|
||||
DA:341,123
|
||||
DA:342,57
|
||||
DA:346,295
|
||||
DA:347,64
|
||||
DA:349,58
|
||||
DA:353,64
|
||||
DA:354,58
|
||||
DA:357,64
|
||||
DA:362,58
|
||||
DA:363,58
|
||||
DA:364,0
|
||||
DA:367,58
|
||||
DA:376,489
|
||||
DA:377,220
|
||||
DA:381,269
|
||||
DA:382,1
|
||||
DA:384,268
|
||||
DA:385,1
|
||||
DA:389,267
|
||||
DA:390,534
|
||||
DA:391,520
|
||||
DA:392,519
|
||||
DA:395,267
|
||||
LF:111
|
||||
LH:110
|
||||
BRDA:35,0,0,0
|
||||
BRDA:40,1,0,2
|
||||
BRDA:53,2,0,50
|
||||
BRDA:65,3,0,50
|
||||
BRDA:65,3,1,4
|
||||
BRDA:65,4,0,54
|
||||
BRDA:65,4,1,53
|
||||
BRDA:67,5,0,1
|
||||
BRDA:69,6,0,1
|
||||
BRDA:69,6,1,0
|
||||
BRDA:220,7,0,841
|
||||
BRDA:220,7,1,710
|
||||
BRDA:245,8,0,2
|
||||
BRDA:250,9,0,3
|
||||
BRDA:268,10,0,3
|
||||
BRDA:272,11,0,2
|
||||
BRDA:276,12,0,1
|
||||
BRDA:315,13,0,50
|
||||
BRDA:315,14,0,58
|
||||
BRDA:315,14,1,57
|
||||
BRDA:323,15,0,1
|
||||
BRDA:328,16,0,1
|
||||
BRDA:334,17,0,1
|
||||
BRDA:341,18,0,57
|
||||
BRDA:347,19,0,58
|
||||
BRDA:353,20,0,58
|
||||
BRDA:363,21,0,0
|
||||
BRDA:376,22,0,220
|
||||
BRDA:381,23,0,1
|
||||
BRDA:381,24,0,269
|
||||
BRDA:381,24,1,1
|
||||
BRDA:384,25,0,1
|
||||
BRDA:384,26,0,268
|
||||
BRDA:384,26,1,24
|
||||
BRDA:390,27,0,14
|
||||
BRDA:391,28,0,1
|
||||
BRF:36
|
||||
BRH:33
|
||||
end_of_record
|
||||
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'jsdom',
|
||||
testMatch: ['**/tests/**/*.test.ts'],
|
||||
moduleFileExtensions: ['ts', 'js', 'json', 'node'],
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest',
|
||||
},
|
||||
coverageDirectory: 'coverage',
|
||||
collectCoverage: true,
|
||||
coverageReporters: ['text', 'lcov'],
|
||||
setupFiles: ['./jest.setup.js'],
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: 'tsconfig.test.json',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Plugin, WorkspaceLeaf, App, Setting, Notice, PluginSettingTab } from 'obsidian';
|
||||
import { ChatView } from './src/chat-view';
|
||||
import { PluginSettings } from './src/types';
|
||||
|
||||
export default class OllamaPlugin extends Plugin {
|
||||
settings: PluginSettings = {
|
||||
ollamaUrl: 'http://localhost:11434',
|
||||
model: 'llama3',
|
||||
lastIndexTime: 0,
|
||||
};
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
||||
this.registerView(
|
||||
'ollama-chat-view',
|
||||
(leaf: WorkspaceLeaf) => new ChatView(leaf, this.settings)
|
||||
);
|
||||
|
||||
this.addRibbonIcon('message-square', 'Ollama Chat', async () => {
|
||||
const leaf = this.app.workspace.getLeaf();
|
||||
await leaf.setViewState({
|
||||
type: 'ollama-chat-view',
|
||||
active: true,
|
||||
});
|
||||
this.app.workspace.revealLeaf(leaf);
|
||||
});
|
||||
|
||||
this.addSettingTab(new OllamaSettingTab(this.app, this));
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, this.settings, await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
|
||||
class OllamaSettingTab extends PluginSettingTab {
|
||||
private plugin: OllamaPlugin;
|
||||
|
||||
constructor(app: App, plugin: OllamaPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const container = this.containerEl.createDiv() as HTMLElement;
|
||||
container.empty();
|
||||
|
||||
new Setting(container)
|
||||
.setName('Ollama URL')
|
||||
.setDesc('URL of your Ollama instance')
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.ollamaUrl).onChange(async (value) => {
|
||||
this.plugin.settings.ollamaUrl = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(container)
|
||||
.setName('Model')
|
||||
.setDesc('Model to use for chat')
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.model).onChange(async (value) => {
|
||||
this.plugin.settings.model = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.containerEl.empty();
|
||||
}
|
||||
}
|
||||
Generated
+6274
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "ollama-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Ollama integration plugin for Obsidian",
|
||||
"main": "main.ts",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"keywords": [
|
||||
"obsidian",
|
||||
"ollama",
|
||||
"chat",
|
||||
"ai",
|
||||
"plugin"
|
||||
],
|
||||
"author": "Anonymous",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20.11.19",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
||||
"@typescript-eslint/parser": "^6.19.1",
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.2.5",
|
||||
"ts-jest": "^29.1.2",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"jest-environment-jsdom": "^30.3.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"obsidian": "^1.4.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
# Code Review Agent Rules
|
||||
**Models:** devstral-small-2:24b · gemma4:31b · qwen3:27b
|
||||
**Role:** Senior Code Reviewer
|
||||
|
||||
---
|
||||
|
||||
## Identity & Mindset
|
||||
|
||||
You are a senior engineer conducting a thorough code review. Be direct, specific, and constructive. Every comment must reference the exact file and line. Never praise for the sake of it — only flag what genuinely matters. Prioritize correctness and maintainability over style.
|
||||
|
||||
---
|
||||
|
||||
## Review Workflow
|
||||
|
||||
1. **Understand intent** — Before reviewing, state in one sentence what the code is trying to do.
|
||||
2. **Read fully first** — Scan all changed files before commenting on any single one.
|
||||
3. **Categorize findings** — Label every issue with a severity (see below).
|
||||
4. **Cite precisely** — Every finding must include: file path, line number(s), and a concrete suggestion.
|
||||
5. **Summarize** — End with an overall verdict and a prioritized list of must-fix items.
|
||||
|
||||
---
|
||||
|
||||
## Severity Labels
|
||||
|
||||
Use exactly these labels — no others:
|
||||
|
||||
| Label | Meaning |
|
||||
|---|---|
|
||||
| `[CRITICAL]` | Bug, security flaw, data loss risk — must fix before merge |
|
||||
| `[MAJOR]` | Logic error, bad abstraction, serious performance issue |
|
||||
| `[MINOR]` | Code smell, unnecessary complexity, poor naming |
|
||||
| `[NIT]` | Style, formatting, trivial rename — fix or ignore, your call |
|
||||
| `[QUESTION]` | Reviewer is uncertain — needs clarification from the author |
|
||||
|
||||
---
|
||||
|
||||
## What to Check
|
||||
|
||||
### Correctness
|
||||
- Off-by-one errors, null/undefined handling, edge cases not covered
|
||||
- Incorrect assumptions about input ranges or types
|
||||
- Race conditions, mutation of shared state
|
||||
|
||||
### Security
|
||||
- Unsanitized inputs, injection vectors (SQL, shell, XSS)
|
||||
- Secrets or credentials hardcoded or logged
|
||||
- Overly permissive access control
|
||||
|
||||
### Performance
|
||||
- N+1 queries, unnecessary re-renders, blocking calls in hot paths
|
||||
- Unbounded loops or allocations
|
||||
|
||||
### Maintainability
|
||||
- Functions doing more than one thing
|
||||
- Magic numbers or strings without named constants
|
||||
- Deeply nested logic that can be flattened
|
||||
- Missing or misleading comments on non-obvious logic
|
||||
|
||||
### Tests
|
||||
- Are new code paths covered?
|
||||
- Are edge cases and failure modes tested?
|
||||
- Are tests actually asserting meaningful behavior?
|
||||
|
||||
---
|
||||
|
||||
## Model-Specific Guidance
|
||||
|
||||
| Model | Strength | Best For |
|
||||
|---|---|---|
|
||||
| `devstral-small-2:24b` | Code reasoning, diff analysis | Line-level bugs, logic errors |
|
||||
| `gemma4:31b` | Broad reasoning | Architecture-level feedback, abstractions |
|
||||
| `qwen3:27b` | Structured output | Generating formatted review summaries |
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
Structure your review exactly like this:
|
||||
|
||||
```
|
||||
## Intent
|
||||
[One sentence describing what the code does]
|
||||
|
||||
## Findings
|
||||
|
||||
### `path/to/file.ext`
|
||||
- [SEVERITY] Line X: <issue>. Suggestion: <concrete fix>
|
||||
|
||||
### `path/to/other.ext`
|
||||
- [SEVERITY] Lines X–Y: <issue>. Suggestion: <concrete fix>
|
||||
|
||||
## Summary
|
||||
**Verdict:** Approve / Request Changes / Needs Discussion
|
||||
|
||||
**Must fix before merge:**
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
**Nice to have:**
|
||||
- ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Do not rewrite the code** unless asked — suggest, don't replace.
|
||||
- **Do not invent bugs** — only flag what you can verify from the actual code shown.
|
||||
- **Do not nitpick everything** — if there are `[CRITICAL]` or `[MAJOR]` issues, lead with those; don't bury them in `[NIT]`s.
|
||||
- If you lack context (e.g., external dependencies, DB schema), say so with a `[QUESTION]` rather than guessing.
|
||||
- Keep each finding to 2–3 lines max. Be dense, not verbose.
|
||||
@@ -0,0 +1,432 @@
|
||||
import { ItemView, WorkspaceLeaf, Notice, TFile } 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;
|
||||
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: HTMLElement | null = null;
|
||||
private sendButton: HTMLElement | null = null;
|
||||
private inputEl: HTMLElement | 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 listenersAttached = false;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
getViewType(): string {
|
||||
return 'ollama-chat-view';
|
||||
}
|
||||
|
||||
getDisplayText(): string {
|
||||
return 'Ollama Chat';
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
await this.render();
|
||||
this.removeEventListeners(); // Clean up any existing listeners before reattaching
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
this.ollamaClient.cancelStream();
|
||||
this.removeEventListeners();
|
||||
this.cleanupStreamingResources();
|
||||
this.lastMessageEl = null;
|
||||
this.sendButton = null;
|
||||
this.inputEl = null;
|
||||
this.chatContainer = null;
|
||||
}
|
||||
|
||||
private cleanupStreamingResources(): void {
|
||||
// Ensure any ongoing streaming is properly cleaned up
|
||||
if (this.lastMessageEl && this.lastMessageEl.parentElement) {
|
||||
this.lastMessageEl.parentElement.removeChild(this.lastMessageEl);
|
||||
this.lastMessageEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
async 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',
|
||||
}) as HTMLButtonElement;
|
||||
(this.sendButton as HTMLButtonElement).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',
|
||||
}) as HTMLButtonElement;
|
||||
(this.newChatButton as HTMLButtonElement).textContent = '🔄 New Chat';
|
||||
(this.newChatButton as HTMLButtonElement).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');
|
||||
const existingIds = Array.from(existingMessages).map((el) => el.getAttribute('data-msg-id'));
|
||||
|
||||
for (const msg of nonStreamingMessages) {
|
||||
const existingEl = container.querySelector(
|
||||
`.ollama-message[data-msg-id="${msg.id}"]`
|
||||
) as HTMLElement | null;
|
||||
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 {
|
||||
if (!this.sendButton || !this.inputEl || this.listenersAttached) return;
|
||||
|
||||
// Create handlers if they don't exist
|
||||
if (!this.sendButtonClickHandler) {
|
||||
this.sendButtonClickHandler = async () => {
|
||||
if (!this.inputEl) return;
|
||||
await this.handleUserInput((this.inputEl as HTMLTextAreaElement).value);
|
||||
(this.inputEl as HTMLTextAreaElement).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 as HTMLTextAreaElement).value);
|
||||
(this.inputEl as HTMLTextAreaElement).value = '';
|
||||
};
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
(this.sendButton as HTMLButtonElement).addEventListener('click', this.sendButtonClickHandler!);
|
||||
(this.inputEl as HTMLTextAreaElement).addEventListener('keydown', this.inputKeyDownHandler!);
|
||||
if (this.newChatButton) {
|
||||
if (!this.newChatButtonClickHandler) {
|
||||
this.newChatButtonClickHandler = () => this.clearConversation();
|
||||
}
|
||||
(this.newChatButton as HTMLButtonElement).addEventListener(
|
||||
'click',
|
||||
this.newChatButtonClickHandler!
|
||||
);
|
||||
}
|
||||
this.listenersAttached = true;
|
||||
}
|
||||
|
||||
private removeEventListeners(): void {
|
||||
if (this.sendButton && this.sendButtonClickHandler) {
|
||||
(this.sendButton as HTMLButtonElement).removeEventListener(
|
||||
'click',
|
||||
this.sendButtonClickHandler!
|
||||
);
|
||||
}
|
||||
if (this.inputEl && this.inputKeyDownHandler) {
|
||||
(this.inputEl as HTMLTextAreaElement).removeEventListener(
|
||||
'keydown',
|
||||
this.inputKeyDownHandler!
|
||||
);
|
||||
}
|
||||
if (this.newChatButton && this.newChatButtonClickHandler) {
|
||||
(this.newChatButton as HTMLButtonElement).removeEventListener(
|
||||
'click',
|
||||
this.newChatButtonClickHandler!
|
||||
);
|
||||
}
|
||||
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);
|
||||
if (index < 0) return false;
|
||||
this.messages = [
|
||||
...this.messages.slice(0, index),
|
||||
{ ...this.messages[index], ...partial },
|
||||
...this.messages.slice(index + 1),
|
||||
];
|
||||
return true;
|
||||
}
|
||||
|
||||
private async 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) {
|
||||
if (!this.sendButton || !this.inputEl) return;
|
||||
(this.sendButton as HTMLButtonElement).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((e) => `### ${e.title}\n${e.content}`).join('\n\n');
|
||||
|
||||
// Cap context size to prevent prompt bloat with large vaults
|
||||
const MAX_CONTEXT_LENGTH = 4000;
|
||||
if (context.length > MAX_CONTEXT_LENGTH) {
|
||||
context = context.substring(0, MAX_CONTEXT_LENGTH) + '\n\n... (truncated)';
|
||||
}
|
||||
|
||||
const systemMessage: OllamaMessage = {
|
||||
role: 'system',
|
||||
content: 'You are a helpful assistant.',
|
||||
};
|
||||
const userMessageWithContext: OllamaMessage = {
|
||||
role: 'user',
|
||||
content: `${context}\n\n${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 },
|
||||
content: { type: 'string' as const },
|
||||
},
|
||||
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];
|
||||
|
||||
await this.render();
|
||||
|
||||
const stream = await this.ollamaClient.streamChat(messages, tools);
|
||||
let fullResponse = '';
|
||||
let toolCalls: ToolCall[] = [];
|
||||
let chunkCount = 0;
|
||||
const MAX_STREAM_CHUNKS = 1000;
|
||||
const maxChunks = MAX_STREAM_CHUNKS;
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
chunkCount++;
|
||||
if (chunkCount > maxChunks) {
|
||||
throw new Error('Response too long, stopped streaming');
|
||||
}
|
||||
|
||||
if (chunk.content) {
|
||||
fullResponse += chunk.content;
|
||||
}
|
||||
|
||||
if (chunk.tool_calls) {
|
||||
toolCalls = toolCalls.concat(chunk.tool_calls);
|
||||
}
|
||||
|
||||
await this.updateLastMessage(fullResponse);
|
||||
}
|
||||
} finally {
|
||||
// Clean up streaming resources regardless of outcome
|
||||
this.cleanupStreamingResources();
|
||||
}
|
||||
|
||||
// Update the assistant message with the full response immutably
|
||||
if (
|
||||
!this.updateMessageById(assistantMessageId, {
|
||||
content: fullResponse,
|
||||
tool_calls: toolCalls,
|
||||
})
|
||||
) {
|
||||
throw new Error('Assistant message not found');
|
||||
}
|
||||
|
||||
// Process tool calls with proper follow-up context
|
||||
if (toolCalls.length > 0) {
|
||||
// Validate tool calls before processing
|
||||
const MAX_TOOL_CALLS = 10;
|
||||
if (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))
|
||||
);
|
||||
|
||||
let 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');
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
await this.updateLastMessage(fullResponse);
|
||||
|
||||
// Update the assistant message with the final response immutably
|
||||
this.updateMessageById(assistantMessageId, { content: fullResponse, isStreaming: false });
|
||||
}
|
||||
|
||||
// Update last message immutably — only if no tool calls were processed
|
||||
if (toolCalls.length === 0) {
|
||||
const lastMessageIndex = this.messages.length - 1;
|
||||
if (lastMessageIndex >= 0) {
|
||||
const lastMessage = { ...this.messages[lastMessageIndex], isStreaming: false };
|
||||
this.messages = [...this.messages.slice(0, lastMessageIndex), lastMessage];
|
||||
}
|
||||
}
|
||||
|
||||
// Limit conversation history to prevent memory issues
|
||||
if (this.messages.length > MAX_MESSAGE_HISTORY) {
|
||||
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
|
||||
}
|
||||
await this.render();
|
||||
} catch (error) {
|
||||
// Use centralized error handler
|
||||
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
|
||||
this.cleanupStreamingResources();
|
||||
} finally {
|
||||
if (this.sendButton) {
|
||||
(this.sendButton as HTMLButtonElement).disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Notice } from 'obsidian';
|
||||
import {
|
||||
OllamaError,
|
||||
ErrorType,
|
||||
NetworkError,
|
||||
ApiError,
|
||||
ValidationError,
|
||||
StreamingError,
|
||||
ToolExecutionError,
|
||||
PathValidationError,
|
||||
} from './types';
|
||||
|
||||
export class ErrorHandler {
|
||||
/**
|
||||
* Centralized error handling for the Ollama plugin
|
||||
* Provides consistent error messages and logging
|
||||
*/
|
||||
static handleError(error: unknown, context?: string): void {
|
||||
let userMessage = 'An unexpected error occurred';
|
||||
let shouldShowError = true;
|
||||
|
||||
if (error instanceof OllamaError) {
|
||||
userMessage = this.getUserFriendlyMessage(error);
|
||||
shouldShowError = true;
|
||||
} else if (error instanceof Error) {
|
||||
userMessage = this.getUserFriendlyMessageFromError(error);
|
||||
shouldShowError = true;
|
||||
} else {
|
||||
userMessage = 'An unexpected error occurred';
|
||||
shouldShowError = true;
|
||||
}
|
||||
|
||||
if (shouldShowError) {
|
||||
new Notice(userMessage);
|
||||
}
|
||||
|
||||
// Log detailed error for debugging
|
||||
console.error(
|
||||
`[OllamaPlugin${context ? ' ' + context : ''}] ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
if (error instanceof Error) {
|
||||
console.error('[Stack]', error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly message from specific error types
|
||||
*/
|
||||
private static getUserFriendlyMessage(error: OllamaError): string {
|
||||
switch (error.type) {
|
||||
case ErrorType.NETWORK_ERROR:
|
||||
if (error instanceof NetworkError) {
|
||||
return 'Connection error. Please check if Ollama is running.';
|
||||
}
|
||||
return 'Network error. Please check your connection to Ollama.';
|
||||
|
||||
case ErrorType.API_ERROR:
|
||||
if (error instanceof ApiError) {
|
||||
return 'Ollama API error. Please check the Ollama logs for details.';
|
||||
}
|
||||
return 'API communication error. Please try again.';
|
||||
|
||||
case ErrorType.VALIDATION_ERROR:
|
||||
if (error instanceof ValidationError) {
|
||||
const details = error.validationDetails;
|
||||
if (details?.field) {
|
||||
return `Invalid ${details.field}. ${details.message || 'Please check your input.'}`;
|
||||
}
|
||||
return 'Input validation error. Please correct your input.';
|
||||
}
|
||||
return 'Input validation error. Please correct your input.';
|
||||
|
||||
case ErrorType.STREAMING_ERROR:
|
||||
if (error instanceof StreamingError) {
|
||||
return 'Response too long. Please try a shorter request.';
|
||||
}
|
||||
return 'Streaming error. Please try again.';
|
||||
|
||||
case ErrorType.TOOL_EXECUTION_ERROR:
|
||||
if (error instanceof ToolExecutionError) {
|
||||
return `Tool error: ${error.toolName || 'tool'} failed to execute. Please try again.`;
|
||||
}
|
||||
return 'Tool execution error. Please try a different command.';
|
||||
|
||||
case ErrorType.PATH_VALIDATION_ERROR:
|
||||
if (error instanceof PathValidationError) {
|
||||
return 'Invalid file path. Please use a relative path without special characters.';
|
||||
}
|
||||
return 'Path validation error. Please check your file path.';
|
||||
|
||||
case ErrorType.UNKNOWN_ERROR:
|
||||
return 'An unexpected error occurred. Please try again.';
|
||||
|
||||
default:
|
||||
return error.message || 'An error occurred';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly message from generic Error
|
||||
*/
|
||||
/**
|
||||
* Get user-friendly message from generic Error
|
||||
* Note: This method uses substring matching which is inherently fragile.
|
||||
* If an error message happens to contain certain keywords but isn't actually
|
||||
* that type of error, it may be misclassified. This heuristic approach
|
||||
* provides a good balance between robustness and accuracy for most common cases.
|
||||
*/
|
||||
private static getUserFriendlyMessageFromError(error: Error): string {
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
if (message.includes('timeout')) {
|
||||
return 'Request timed out. Please check your Ollama connection.';
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes('network') ||
|
||||
message.includes('fetch') ||
|
||||
message.includes('connection')
|
||||
) {
|
||||
return 'Connection error. Please check if Ollama is running.';
|
||||
}
|
||||
|
||||
if (message.includes('validation') || message.includes('format')) {
|
||||
return 'Invalid input. Please check your message.';
|
||||
}
|
||||
|
||||
if (message.includes('stream') || message.includes('chunk')) {
|
||||
return 'Response too long. Please try a shorter request.';
|
||||
}
|
||||
|
||||
if (message.includes('tool') || message.includes('function')) {
|
||||
return 'Tool execution error. Please try a different command.';
|
||||
}
|
||||
|
||||
if (message.includes('path') || message.includes('file')) {
|
||||
return 'Invalid file path. Please use a relative path without special characters.';
|
||||
}
|
||||
|
||||
return error.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create specific error instances from different error types
|
||||
*/
|
||||
static createNetworkError(message: string, statusCode?: number): NetworkError {
|
||||
return new NetworkError(message, statusCode);
|
||||
}
|
||||
|
||||
static createApiError(message: string, apiError?: any): ApiError {
|
||||
return new ApiError(message, apiError);
|
||||
}
|
||||
|
||||
static createValidationError(
|
||||
message: string,
|
||||
field?: string,
|
||||
details?: Record<string, string>
|
||||
): ValidationError {
|
||||
const validationDetails = field ? { field, message } : details;
|
||||
return new ValidationError(message, validationDetails);
|
||||
}
|
||||
|
||||
static createStreamingError(message: string, chunkDetails?: any): StreamingError {
|
||||
return new StreamingError(message, chunkDetails);
|
||||
}
|
||||
|
||||
static createToolExecutionError(message: string, toolName?: string): ToolExecutionError {
|
||||
return new ToolExecutionError(message, toolName);
|
||||
}
|
||||
|
||||
static createPathValidationError(message: string, invalidPath?: string): PathValidationError {
|
||||
return new PathValidationError(message, invalidPath);
|
||||
}
|
||||
|
||||
static createUnknownError(message: string): OllamaError {
|
||||
return new OllamaError(message, ErrorType.UNKNOWN_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { OllamaMessage, OllamaTool, ToolCall } from './types';
|
||||
|
||||
interface FetchResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
headers?: {
|
||||
get: (name: string) => string | null;
|
||||
};
|
||||
body?: {
|
||||
getReader: () => ReadableStreamDefaultReader<Uint8Array>;
|
||||
} | null;
|
||||
json?: () => Promise<any>;
|
||||
}
|
||||
|
||||
interface FetchOptions {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class OllamaClient {
|
||||
private url: string;
|
||||
private model: string;
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
// Mock fetch function for testing
|
||||
private fetchFn: typeof fetch = fetch;
|
||||
|
||||
constructor(url: string, model: string, fetchFn?: typeof fetch) {
|
||||
this.url = url;
|
||||
this.model = model;
|
||||
if (fetchFn) this.fetchFn = fetchFn;
|
||||
}
|
||||
|
||||
async streamChat(
|
||||
messages: OllamaMessage[],
|
||||
tools: OllamaTool[],
|
||||
timeoutMs: number = 60000
|
||||
): Promise<AsyncIterable<{ content: string; tool_calls?: ToolCall[] }>> {
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.abortController?.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
let response: FetchResponse;
|
||||
try {
|
||||
response = await this.fetchFn(`${this.url}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages,
|
||||
tools,
|
||||
stream: true,
|
||||
}),
|
||||
signal: this.abortController.signal,
|
||||
} as FetchOptions);
|
||||
} catch (fetchError: any) {
|
||||
clearTimeout(timeoutId);
|
||||
this.abortController = null;
|
||||
if (fetchError.name === 'AbortError' || fetchError.code === 'ABORT_ERR') {
|
||||
throw new Error('Request timeout while connecting to Ollama');
|
||||
}
|
||||
throw fetchError;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
// Validate response structure
|
||||
const contentType = response.headers?.get('content-type');
|
||||
if (!contentType?.match(/application\/(x-ndjson|json)/)) {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
|
||||
const self = this;
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let chunkCount = 0;
|
||||
let skippedChunks = 0;
|
||||
const maxChunks = 1000; // Safety limit
|
||||
const maxSkipped = 50; // Fail if too many chunks are malformed
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (++chunkCount > maxChunks) {
|
||||
throw new Error('Response too long, stopped streaming');
|
||||
}
|
||||
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 data = JSON.parse(line);
|
||||
if (data.message && typeof data.message === 'object') {
|
||||
// Validate message structure
|
||||
if (data.message.error && typeof data.message.error === 'string') {
|
||||
throw new Error(`Ollama error: ${data.message.error}`);
|
||||
}
|
||||
yield {
|
||||
content: data.message.content || '',
|
||||
tool_calls: Array.isArray(data.message.tool_calls)
|
||||
? data.message.tool_calls
|
||||
: [],
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Check if this is an Ollama error (thrown intentionally) vs a parse error
|
||||
if (parseError instanceof Error && parseError.message.startsWith('Ollama error:')) {
|
||||
// This is an intentional Ollama error, re-throw it
|
||||
self.abortController = null;
|
||||
reader.releaseLock();
|
||||
throw parseError;
|
||||
}
|
||||
|
||||
// This is a parse error, skip the malformed chunk
|
||||
skippedChunks++;
|
||||
console.warn(
|
||||
`[OllamaClient] Skipped malformed chunk ${skippedChunks}/${maxSkipped}:`,
|
||||
parseError instanceof Error ? parseError.message : String(parseError)
|
||||
);
|
||||
if (skippedChunks > maxSkipped) {
|
||||
throw new Error(
|
||||
`Too many malformed response chunks (${skippedChunks}). Connection may be degraded.`
|
||||
);
|
||||
}
|
||||
// Skip invalid chunks but continue streaming
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
self.abortController = null;
|
||||
if (streamError instanceof Error && streamError.name === 'AbortError') {
|
||||
throw new Error('Streaming request was cancelled');
|
||||
}
|
||||
throw streamError;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
self.abortController = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async chat(
|
||||
messages: OllamaMessage[],
|
||||
tools: OllamaTool[],
|
||||
timeoutMs: number = 30000
|
||||
): Promise<{ content: string; tool_calls?: ToolCall[] }> {
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
const response = await this.fetchFn(`${this.url}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages,
|
||||
tools,
|
||||
stream: false,
|
||||
}),
|
||||
signal: abortController.signal,
|
||||
} as FetchOptions);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
const data = responseData;
|
||||
const messageData = data.message;
|
||||
return {
|
||||
content: messageData?.content || '',
|
||||
tool_calls: messageData?.tool_calls || [],
|
||||
};
|
||||
}
|
||||
|
||||
cancelStream(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Vault, TFile, Notice, App } from 'obsidian';
|
||||
import { ToolCall, ToolResult, ToolExecutionError, PathValidationError } from './types';
|
||||
import { validatePath } from './utils';
|
||||
|
||||
export class ToolExecutor {
|
||||
private vault: Vault;
|
||||
private app: App;
|
||||
|
||||
constructor(vault: Vault, app: App) {
|
||||
this.vault = vault;
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
async handleToolCall(call: ToolCall): Promise<ToolResult> {
|
||||
const {
|
||||
function: { name, arguments: args },
|
||||
} = call;
|
||||
|
||||
switch (name) {
|
||||
case 'create_file': {
|
||||
let filePath: string, content: string;
|
||||
try {
|
||||
// Handle both string (JSON) and object arguments, since some Ollama versions return args as an object
|
||||
const parsedArgs = typeof args === 'string' ? JSON.parse(args) : args;
|
||||
filePath = parsedArgs.path;
|
||||
content = parsedArgs.content;
|
||||
} catch (e) {
|
||||
throw new ToolExecutionError(
|
||||
`Invalid arguments provided for create_file: ${e instanceof Error ? e.message : 'Unknown parsing error'}`,
|
||||
'create_file'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate content is a string
|
||||
if (typeof content !== 'string') {
|
||||
throw new ToolExecutionError('Content must be a string', 'create_file');
|
||||
}
|
||||
|
||||
// Validate path using shared utility
|
||||
if (typeof filePath !== 'string') {
|
||||
throw new ToolExecutionError('Path must be a string', 'create_file');
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
throw new ToolExecutionError('Path is required', 'create_file');
|
||||
}
|
||||
|
||||
const pathValidation = validatePath(filePath);
|
||||
if (!pathValidation.valid) {
|
||||
throw new PathValidationError(pathValidation.error || 'Path validation failed', filePath);
|
||||
}
|
||||
|
||||
await this.vault.create(filePath, content);
|
||||
return { success: true, message: 'File created successfully' };
|
||||
}
|
||||
default:
|
||||
return { success: false, message: `Unknown tool: ${name}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
export interface PluginSettings {
|
||||
ollamaUrl: string;
|
||||
model: string;
|
||||
lastIndexTime: number;
|
||||
}
|
||||
|
||||
export enum ErrorType {
|
||||
NETWORK_ERROR = 'network_error',
|
||||
API_ERROR = 'api_error',
|
||||
VALIDATION_ERROR = 'validation_error',
|
||||
STREAMING_ERROR = 'streaming_error',
|
||||
TOOL_EXECUTION_ERROR = 'tool_execution_error',
|
||||
PATH_VALIDATION_ERROR = 'path_validation_error',
|
||||
UNKNOWN_ERROR = 'unknown_error',
|
||||
}
|
||||
|
||||
export class OllamaError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly type: ErrorType,
|
||||
public readonly details?: Record<string, any>
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'OllamaError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends OllamaError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode?: number
|
||||
) {
|
||||
super(message, ErrorType.NETWORK_ERROR, { statusCode });
|
||||
this.name = 'NetworkError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends OllamaError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly apiError?: any
|
||||
) {
|
||||
super(message, ErrorType.API_ERROR, { apiError });
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends OllamaError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly validationDetails?: Record<string, string>
|
||||
) {
|
||||
super(message, ErrorType.VALIDATION_ERROR, validationDetails);
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class StreamingError extends OllamaError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly chunkDetails?: any
|
||||
) {
|
||||
super(message, ErrorType.STREAMING_ERROR, chunkDetails);
|
||||
this.name = 'StreamingError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolExecutionError extends OllamaError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly toolName?: string
|
||||
) {
|
||||
super(message, ErrorType.TOOL_EXECUTION_ERROR, { toolName });
|
||||
this.name = 'ToolExecutionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PathValidationError extends OllamaError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly invalidPath?: string
|
||||
) {
|
||||
super(message, ErrorType.PATH_VALIDATION_ERROR, { invalidPath });
|
||||
this.name = 'PathValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface OllamaMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
tool_calls?: ToolCall[];
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string | Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OllamaTool {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: {
|
||||
type: 'object';
|
||||
properties: Record<string, { type: string }>;
|
||||
required: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
// Adding optional details field for better error reporting
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface VaultIndexEntry {
|
||||
title: string;
|
||||
content: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
tool_calls?: ToolCall[];
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Normalizes file paths for browser/ Obsidian environment
|
||||
* Replaces multiple slashes with single slash and handles forward/backward slashes
|
||||
*/
|
||||
export function normalizePath(path: string): string {
|
||||
// Replace multiple slashes with single slash
|
||||
let normalized = path.replace(/[\\\/]+/g, '/');
|
||||
|
||||
// Remove trailing slash unless it's the root
|
||||
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a path string for safety (no traversal, no absolute paths, no invalid chars)
|
||||
*/
|
||||
export function validatePath(path: string): { valid: boolean; error?: string } {
|
||||
const normalized = normalizePath(path);
|
||||
|
||||
// Check for path traversal by looking for .. as a path segment (not just substring in filenames)
|
||||
const segments = normalized.split('/');
|
||||
if (segments.includes('..')) {
|
||||
return { valid: false, error: 'Path traversal not allowed' };
|
||||
}
|
||||
|
||||
// Check if absolute path
|
||||
if (normalized.startsWith('/') || normalized.startsWith('\\')) {
|
||||
return { valid: false, error: 'Absolute paths not allowed' };
|
||||
}
|
||||
|
||||
// Check for windows drive letters
|
||||
if (/^[a-zA-Z]:/.test(normalized)) {
|
||||
return { valid: false, error: 'Absolute paths not allowed' };
|
||||
}
|
||||
|
||||
// Check for invalid characters
|
||||
const invalidChars = /[\<\>\:\"\|\\\?\*~]/;
|
||||
if (invalidChars.test(path)) {
|
||||
return { valid: false, error: 'Path contains illegal characters' };
|
||||
}
|
||||
|
||||
// Check path length
|
||||
const MAX_PATH_LENGTH = 200;
|
||||
if (path.length > MAX_PATH_LENGTH) {
|
||||
return { valid: false, error: 'Path too long' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { Vault, TFile } from 'obsidian';
|
||||
import { VaultIndexEntry } from './types';
|
||||
|
||||
const MAX_CONTENT_PREVIEW_LENGTH = 500;
|
||||
const BATCH_SIZE = 10;
|
||||
const MAX_TOKENS = 10000;
|
||||
const TITLE_WEIGHT = 10;
|
||||
const HEADING_WEIGHT = 5;
|
||||
const FRONTMATTER_WEIGHT = 8;
|
||||
const FIRST_PARAGRAPH_WEIGHT = 3;
|
||||
const BODY_WEIGHT = 1;
|
||||
const PHRASE_MATCH_BONUS = 2;
|
||||
const EXACT_WORD_MATCH_BONUS = 1.5;
|
||||
|
||||
interface TokenizedContent {
|
||||
text: string;
|
||||
tokens: string[];
|
||||
title: string;
|
||||
titleTokens: string[];
|
||||
headings: string[];
|
||||
headingTokens: string[][];
|
||||
frontmatter: Record<string, string>;
|
||||
frontmatterTokens: string[];
|
||||
firstParagraph: string;
|
||||
firstParagraphTokens: string[];
|
||||
}
|
||||
|
||||
export class VaultIndexer {
|
||||
private vault: Vault;
|
||||
|
||||
constructor(vault: Vault) {
|
||||
this.vault = vault;
|
||||
}
|
||||
|
||||
async searchVault(query: string, limit: number = 5): Promise<VaultIndexEntry[]> {
|
||||
const files = this.vault.getMarkdownFiles();
|
||||
const results: VaultIndexEntry[] = [];
|
||||
|
||||
const queryTokens = this.tokenize(query);
|
||||
if (queryTokens.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Process files in batches with concurrency limit
|
||||
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
||||
const batch = files.slice(i, i + BATCH_SIZE);
|
||||
const batchResults = await Promise.allSettled(
|
||||
batch.map(async (file) => {
|
||||
const fullContent = await this.vault.read(file);
|
||||
const tokenized = this.tokenizeContent(fullContent, file);
|
||||
const score = this.calculateWeightedScore(tokenized, query, queryTokens);
|
||||
|
||||
if (score > 0) {
|
||||
return {
|
||||
title: file.basename,
|
||||
content: fullContent.substring(0, MAX_CONTENT_PREVIEW_LENGTH),
|
||||
score,
|
||||
} as VaultIndexEntry;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of batchResults) {
|
||||
if (result.status === 'fulfilled' && result.value !== null) {
|
||||
results.push(result.value);
|
||||
} else if (result.status === 'rejected') {
|
||||
console.warn(
|
||||
`[VaultIndexer] Failed to read file: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.score - a.score).slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize a string into lowercase words, filtering out stop words and very short tokens
|
||||
*/
|
||||
private tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter((token) => {
|
||||
// Filter out common stop words and very short tokens
|
||||
const stopWords = new Set([
|
||||
'a',
|
||||
'an',
|
||||
'the',
|
||||
'and',
|
||||
'or',
|
||||
'but',
|
||||
'in',
|
||||
'on',
|
||||
'at',
|
||||
'to',
|
||||
'for',
|
||||
'of',
|
||||
'with',
|
||||
'by',
|
||||
'is',
|
||||
'are',
|
||||
'was',
|
||||
'were',
|
||||
'be',
|
||||
'been',
|
||||
'have',
|
||||
'has',
|
||||
'had',
|
||||
'do',
|
||||
'does',
|
||||
'did',
|
||||
'will',
|
||||
'would',
|
||||
'could',
|
||||
'should',
|
||||
'may',
|
||||
'might',
|
||||
'must',
|
||||
'shall',
|
||||
'can',
|
||||
'need',
|
||||
'dare',
|
||||
'ought',
|
||||
'used',
|
||||
'it',
|
||||
'its',
|
||||
'this',
|
||||
'that',
|
||||
'these',
|
||||
'those',
|
||||
'i',
|
||||
'you',
|
||||
'he',
|
||||
'she',
|
||||
'we',
|
||||
'they',
|
||||
'me',
|
||||
'him',
|
||||
'her',
|
||||
'us',
|
||||
'them',
|
||||
'my',
|
||||
'your',
|
||||
'his',
|
||||
'our',
|
||||
'their',
|
||||
'mine',
|
||||
'yours',
|
||||
'hers',
|
||||
'ours',
|
||||
'theirs',
|
||||
'what',
|
||||
'which',
|
||||
'who',
|
||||
'whom',
|
||||
'whose',
|
||||
'where',
|
||||
'when',
|
||||
'why',
|
||||
'how',
|
||||
'not',
|
||||
'no',
|
||||
'nor',
|
||||
'so',
|
||||
'if',
|
||||
'then',
|
||||
'than',
|
||||
'too',
|
||||
'very',
|
||||
'just',
|
||||
'about',
|
||||
'above',
|
||||
'after',
|
||||
'again',
|
||||
'all',
|
||||
'am',
|
||||
'any',
|
||||
'as',
|
||||
'because',
|
||||
'before',
|
||||
'being',
|
||||
'below',
|
||||
'between',
|
||||
'both',
|
||||
'during',
|
||||
'each',
|
||||
'few',
|
||||
'further',
|
||||
'get',
|
||||
'got',
|
||||
'here',
|
||||
'into',
|
||||
'more',
|
||||
'most',
|
||||
'much',
|
||||
'myself',
|
||||
'nothing',
|
||||
'only',
|
||||
'other',
|
||||
'out',
|
||||
'over',
|
||||
'own',
|
||||
'same',
|
||||
'some',
|
||||
'such',
|
||||
'there',
|
||||
'through',
|
||||
'under',
|
||||
'until',
|
||||
'up',
|
||||
'while',
|
||||
'why',
|
||||
'yes',
|
||||
'also',
|
||||
'from',
|
||||
]);
|
||||
return token.length > 1 && !stopWords.has(token);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize markdown content into structured components
|
||||
*/
|
||||
private tokenizeContent(content: string, file: TFile): TokenizedContent {
|
||||
const title = file.basename;
|
||||
const titleTokens = this.tokenize(title);
|
||||
|
||||
// Extract headings
|
||||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||||
const headings: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
const headingRegexState = /^(#{1,6})\s+(.+)$/gm;
|
||||
|
||||
while ((match = headingRegexState.exec(content)) !== null) {
|
||||
headings.push(match[2]);
|
||||
}
|
||||
|
||||
// Extract frontmatter (YAML between --- markers)
|
||||
const frontmatter: Record<string, string> = {};
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---/;
|
||||
const frontmatterMatch = frontmatterRegex.exec(content);
|
||||
if (frontmatterMatch) {
|
||||
const frontmatterContent = frontmatterMatch[1];
|
||||
const lines = frontmatterContent.split('\n');
|
||||
for (const line of lines) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const key = line.substring(0, colonIndex).trim();
|
||||
const value = line.substring(colonIndex + 1).trim();
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get frontmatter tokens from values
|
||||
const frontmatterTokens = this.tokenize(Object.values(frontmatter).join(' '));
|
||||
|
||||
// Extract first paragraph (non-empty lines after frontmatter, stop at paragraph break)
|
||||
const cleanContent = content.replace(/^---\n[\s\S]*?\n---/, '').trim();
|
||||
const lines = cleanContent.split('\n');
|
||||
let firstParagraph = '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Stop at empty line (paragraph break)
|
||||
if (!trimmed) {
|
||||
break;
|
||||
}
|
||||
// Skip headings
|
||||
if (trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
firstParagraph += trimmed + ' ';
|
||||
if (firstParagraph.length > 200) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const firstParagraphTokens = this.tokenize(firstParagraph);
|
||||
|
||||
// Get body tokens (limit to prevent memory issues with huge files)
|
||||
const bodyText = cleanContent.substring(0, MAX_TOKENS);
|
||||
const tokens = this.tokenize(bodyText);
|
||||
|
||||
return {
|
||||
text: bodyText,
|
||||
tokens,
|
||||
title,
|
||||
titleTokens,
|
||||
headings,
|
||||
headingTokens: headings.map((h) => this.tokenize(h)),
|
||||
frontmatter,
|
||||
frontmatterTokens,
|
||||
firstParagraph,
|
||||
firstParagraphTokens,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a weighted score based on where query tokens appear
|
||||
* Uses a combination of position weighting, exact matching, and phrase matching
|
||||
*/
|
||||
private calculateWeightedScore(
|
||||
tokenized: TokenizedContent,
|
||||
query: string,
|
||||
queryTokens: string[]
|
||||
): number {
|
||||
let score = 0;
|
||||
const queryLower = query.toLowerCase();
|
||||
const contentLower = tokenized.text.toLowerCase();
|
||||
const contentWithBoundaries = '\\b' + contentLower + '\\b';
|
||||
|
||||
// Check for exact phrase match (bonus)
|
||||
if (queryLower.length > 0 && contentLower.includes(queryLower)) {
|
||||
score += PHRASE_MATCH_BONUS * queryTokens.length;
|
||||
}
|
||||
|
||||
for (const queryToken of queryTokens) {
|
||||
let tokenScore = 0;
|
||||
|
||||
// Title match (highest priority)
|
||||
if (tokenized.titleTokens.some((t) => this.exactMatch(t, queryToken))) {
|
||||
tokenScore += TITLE_WEIGHT;
|
||||
}
|
||||
|
||||
// Frontmatter match (high priority - often contains tags/categories)
|
||||
if (tokenized.frontmatterTokens.some((t) => this.exactMatch(t, queryToken))) {
|
||||
tokenScore += FRONTMATTER_WEIGHT;
|
||||
}
|
||||
|
||||
// Heading match (high priority)
|
||||
for (const headingTokens of tokenized.headingTokens) {
|
||||
if (headingTokens.some((t) => this.exactMatch(t, queryToken))) {
|
||||
tokenScore += HEADING_WEIGHT;
|
||||
break; // Only count once per query token
|
||||
}
|
||||
}
|
||||
|
||||
// First paragraph match (medium priority - likely contains topic summary)
|
||||
if (tokenized.firstParagraphTokens.some((t) => this.exactMatch(t, queryToken))) {
|
||||
tokenScore += FIRST_PARAGRAPH_WEIGHT;
|
||||
}
|
||||
|
||||
// Body match (lowest priority)
|
||||
const bodyMatchCount = tokenized.tokens.filter((t) => this.exactMatch(t, queryToken)).length;
|
||||
if (bodyMatchCount > 0) {
|
||||
// Use logarithmic scaling to prevent very frequent words from dominating
|
||||
tokenScore += BODY_WEIGHT * Math.log(1 + bodyMatchCount);
|
||||
}
|
||||
|
||||
// Exact word boundary bonus
|
||||
if (new RegExp(`\\b${queryToken}\\b`).test(contentLower)) {
|
||||
tokenScore *= EXACT_WORD_MATCH_BONUS;
|
||||
}
|
||||
|
||||
score += tokenScore;
|
||||
}
|
||||
|
||||
// Normalize by document length to prevent bias toward longer documents
|
||||
// Use a gentle normalization: divide by log of token count + 1
|
||||
const lengthNorm = Math.log(1 + tokenized.tokens.length / 100);
|
||||
if (lengthNorm > 1) {
|
||||
score /= lengthNorm;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for exact or stemmed word match
|
||||
* Handles plurals and common suffixes
|
||||
*/
|
||||
private exactMatch(textToken: string, queryToken: string): boolean {
|
||||
// Exact match
|
||||
if (textToken === queryToken) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle plurals
|
||||
if (queryToken.endsWith('s') && textToken === queryToken.slice(0, -1)) {
|
||||
return true;
|
||||
}
|
||||
if (textToken.endsWith('s') && textToken.slice(0, -1) === queryToken) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle -ed and -ing suffixes (simple stemmer)
|
||||
const stem = (word: string): string => {
|
||||
if (word.endsWith('ing')) return word.slice(0, -3);
|
||||
if (word.endsWith('ed')) return word.slice(0, -2);
|
||||
return word;
|
||||
};
|
||||
|
||||
return stem(textToken) === stem(queryToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { ChatView } from '../src/chat-view';
|
||||
import { PluginSettings, OllamaMessage, ChatMessage, OllamaTool, ToolCall } from '../src/types';
|
||||
|
||||
// Mock Obsidian types
|
||||
interface MockVault {
|
||||
getMarkdownFiles: () => any[];
|
||||
read: () => Promise<string>;
|
||||
create: () => Promise<any>;
|
||||
}
|
||||
interface MockWorkspace {
|
||||
getLeaf: () => any;
|
||||
revealLeaf: () => void;
|
||||
}
|
||||
interface MockApp {
|
||||
vault: MockVault;
|
||||
workspace: MockWorkspace;
|
||||
}
|
||||
|
||||
// Mock Obsidian module - ItemView must set this.app from the leaf
|
||||
jest.mock('obsidian', () => ({
|
||||
ItemView: jest.fn().mockImplementation(function (this: any, leaf: any) {
|
||||
this.app = leaf.app;
|
||||
}),
|
||||
WorkspaceLeaf: jest.fn(),
|
||||
Notice: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockSettings: PluginSettings = {
|
||||
ollamaUrl: 'http://localhost:11434',
|
||||
model: 'llama3',
|
||||
lastIndexTime: 0,
|
||||
};
|
||||
|
||||
describe('ChatView', () => {
|
||||
let view: ChatView;
|
||||
let mockLeaf: any;
|
||||
let mockApp: MockApp;
|
||||
|
||||
beforeEach(() => {
|
||||
mockApp = {
|
||||
vault: {
|
||||
getMarkdownFiles: jest.fn().mockReturnValue([]),
|
||||
read: jest.fn().mockResolvedValue(''),
|
||||
create: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
workspace: {
|
||||
getLeaf: jest.fn(),
|
||||
revealLeaf: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
mockLeaf = {
|
||||
view: null,
|
||||
setViewState: jest.fn(),
|
||||
app: mockApp,
|
||||
};
|
||||
|
||||
view = new ChatView(mockLeaf as unknown as any, mockSettings);
|
||||
// Obsidian's contentEl has a createEl helper that standard DOM lacks
|
||||
// Unlike standard DOM, Obsidian elements can create nested elements with createEl
|
||||
const contentDiv = document.createElement('div') as any;
|
||||
|
||||
// Create a factory function that captures the parent element
|
||||
const createElementWithCreateEl = function (parent: any) {
|
||||
return function (tag: string, options?: { cls?: string }) {
|
||||
const el = document.createElement(tag);
|
||||
if (options?.cls) {
|
||||
el.classList.add(...options.cls.split(' '));
|
||||
}
|
||||
parent.appendChild(el);
|
||||
// Add createEl to the new element so it can create nested elements
|
||||
(el as any).createEl = createElementWithCreateEl(el);
|
||||
return el;
|
||||
};
|
||||
};
|
||||
|
||||
contentDiv.createEl = createElementWithCreateEl(contentDiv);
|
||||
view.contentEl = contentDiv;
|
||||
});
|
||||
|
||||
describe('getViewType', () => {
|
||||
it('should return the correct view type', () => {
|
||||
expect(view.getViewType()).toBe('ollama-chat-view');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDisplayText', () => {
|
||||
it('should return the correct display text', () => {
|
||||
expect(view.getDisplayText()).toBe('Ollama Chat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onOpen', () => {
|
||||
it('should call render and setup event listeners', async () => {
|
||||
const renderSpy = jest.spyOn(view, 'render');
|
||||
const setupSpy = jest.spyOn(view, 'setupEventListeners' as any);
|
||||
await view.onOpen();
|
||||
expect(renderSpy).toHaveBeenCalled();
|
||||
expect(setupSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onClose', () => {
|
||||
it('should clean up resources and remove event listeners', async () => {
|
||||
view['lastMessageEl'] = document.createElement('div');
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
const cancelSpy = jest.spyOn(view['ollamaClient'], 'cancelStream');
|
||||
const removeSpy = jest.spyOn(view, 'removeEventListeners' as any);
|
||||
await view.onClose();
|
||||
expect(view['lastMessageEl']).toBeNull();
|
||||
expect(view['sendButton']).toBeNull();
|
||||
expect(view['inputEl']).toBeNull();
|
||||
expect(cancelSpy).toHaveBeenCalled();
|
||||
expect(removeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('render', () => {
|
||||
it('should render the chat interface', async () => {
|
||||
await view.render();
|
||||
expect(view.contentEl.querySelector('.ollama-chat-container')).not.toBeNull();
|
||||
expect(view.contentEl.querySelector('.ollama-input-container')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should not duplicate elements on re-render', async () => {
|
||||
// First render
|
||||
await view.render();
|
||||
const firstRenderCount = view.contentEl.querySelectorAll('.ollama-message').length;
|
||||
|
||||
// Second render with no changes
|
||||
await view.render();
|
||||
const secondRenderCount = view.contentEl.querySelectorAll('.ollama-message').length;
|
||||
|
||||
expect(secondRenderCount).toBe(firstRenderCount);
|
||||
});
|
||||
|
||||
it('should only render non-streaming messages', async () => {
|
||||
view['messages'] = [
|
||||
{ id: '1', role: 'user', content: 'test', timestamp: Date.now() },
|
||||
{
|
||||
id: '2',
|
||||
role: 'assistant',
|
||||
content: 'response',
|
||||
timestamp: Date.now(),
|
||||
isStreaming: true,
|
||||
},
|
||||
];
|
||||
await view.render();
|
||||
const messages = view.contentEl.querySelectorAll('.ollama-message');
|
||||
expect(messages.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleUserInput', () => {
|
||||
it('should handle user input and call ollamaClient', async () => {
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
||||
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield { content: 'test' };
|
||||
},
|
||||
} as any);
|
||||
await (view as any).handleUserInput('test');
|
||||
expect(chatSpy).toHaveBeenCalled();
|
||||
// Verify that messages were added to conversation history
|
||||
expect((view as any).messages.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should process tool calls with follow-up context', async () => {
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
||||
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
content: 'test',
|
||||
tool_calls: [{ function: { name: 'create_file', arguments: '{}' } }],
|
||||
};
|
||||
},
|
||||
} as any);
|
||||
const followUpSpy = jest
|
||||
.spyOn(view['ollamaClient'], 'chat')
|
||||
.mockResolvedValue({ content: ' follow-up' });
|
||||
await (view as any).handleUserInput('test');
|
||||
expect(followUpSpy).toHaveBeenCalled();
|
||||
// Verify that tool calls resulted in follow-up messages
|
||||
expect((view as any).messages.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('event listeners', () => {
|
||||
it('should setup event listeners on open', async () => {
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
await view.onOpen();
|
||||
expect(view['sendButtonClickHandler']).not.toBeNull();
|
||||
expect(view['inputKeyDownHandler']).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should remove event listeners on close', async () => {
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
await view.onOpen();
|
||||
const removeSpy = jest.spyOn(view, 'removeEventListeners' as any);
|
||||
await view.onClose();
|
||||
expect(removeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { ErrorHandler } from '../src/error-handler';
|
||||
import {
|
||||
OllamaError,
|
||||
ErrorType,
|
||||
NetworkError,
|
||||
ApiError,
|
||||
ValidationError,
|
||||
StreamingError,
|
||||
ToolExecutionError,
|
||||
PathValidationError,
|
||||
} from '../src/types';
|
||||
|
||||
// Mock Notice from Obsidian
|
||||
jest.mock('obsidian', () => ({
|
||||
Notice: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('ErrorHandler', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('handleError', () => {
|
||||
it('should handle OllamaError with user-friendly message', () => {
|
||||
const error = new NetworkError('Connection failed');
|
||||
ErrorHandler.handleError(error);
|
||||
expect(require('obsidian').Notice).toHaveBeenCalledWith(
|
||||
'Connection error. Please check if Ollama is running.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle generic Error with user-friendly message', () => {
|
||||
const error = new Error('Network timeout occurred');
|
||||
ErrorHandler.handleError(error);
|
||||
expect(require('obsidian').Notice).toHaveBeenCalledWith(
|
||||
'Request timed out. Please check your Ollama connection.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle unknown error type with generic message', () => {
|
||||
const error = 'some string error';
|
||||
ErrorHandler.handleError(error);
|
||||
expect(require('obsidian').Notice).toHaveBeenCalledWith('An unexpected error occurred');
|
||||
});
|
||||
|
||||
it('should log detailed error information to console', () => {
|
||||
const error = new Error('Test error');
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
ErrorHandler.handleError(error);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Test error'));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NetworkError handling', () => {
|
||||
it('should create user-friendly message for network errors', () => {
|
||||
const error = new NetworkError('Connection failed');
|
||||
const message = ErrorHandler['getUserFriendlyMessage'](error);
|
||||
expect(message).toBe('Connection error. Please check if Ollama is running.');
|
||||
});
|
||||
|
||||
it('should include status code in error details', () => {
|
||||
const error = new NetworkError('Connection failed', 500);
|
||||
expect(error.statusCode).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ValidationError handling', () => {
|
||||
it('should create user-friendly message for validation errors', () => {
|
||||
const error = new ValidationError('Invalid input', {
|
||||
field: 'path',
|
||||
message: 'Path is required',
|
||||
});
|
||||
const message = ErrorHandler['getUserFriendlyMessage'](error);
|
||||
expect(message).toContain('Invalid path');
|
||||
});
|
||||
|
||||
it('should handle validation errors without field details', () => {
|
||||
const error = new ValidationError('Invalid input');
|
||||
const message = ErrorHandler['getUserFriendlyMessage'](error);
|
||||
expect(message).toBe('Input validation error. Please correct your input.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StreamingError handling', () => {
|
||||
it('should create user-friendly message for streaming errors', () => {
|
||||
const error = new StreamingError('Response too long');
|
||||
const message = ErrorHandler['getUserFriendlyMessage'](error);
|
||||
expect(message).toBe('Response too long. Please try a shorter request.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolExecutionError handling', () => {
|
||||
it('should create user-friendly message for tool errors', () => {
|
||||
const error = new ToolExecutionError('Tool failed', 'create_file');
|
||||
const message = ErrorHandler['getUserFriendlyMessage'](error);
|
||||
expect(message).toContain('Tool error');
|
||||
expect(message).toContain('create_file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PathValidationError handling', () => {
|
||||
it('should create user-friendly message for path errors', () => {
|
||||
const error = new PathValidationError('Invalid path', '/../test.md');
|
||||
const message = ErrorHandler['getUserFriendlyMessage'](error);
|
||||
expect(message).toContain('Invalid file path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error creation methods', () => {
|
||||
it('should create NetworkError with proper type', () => {
|
||||
const error = ErrorHandler.createNetworkError('Connection failed');
|
||||
expect(error).toBeInstanceOf(NetworkError);
|
||||
expect(error.type).toBe(ErrorType.NETWORK_ERROR);
|
||||
});
|
||||
|
||||
it('should create ApiError with proper type', () => {
|
||||
const error = ErrorHandler.createApiError('API error');
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect(error.type).toBe(ErrorType.API_ERROR);
|
||||
});
|
||||
|
||||
it('should create ValidationError with proper type', () => {
|
||||
const error = ErrorHandler.createValidationError('Validation failed', 'path');
|
||||
expect(error).toBeInstanceOf(ValidationError);
|
||||
expect(error.type).toBe(ErrorType.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it('should create StreamingError with proper type', () => {
|
||||
const error = ErrorHandler.createStreamingError('Streaming failed');
|
||||
expect(error).toBeInstanceOf(StreamingError);
|
||||
expect(error.type).toBe(ErrorType.STREAMING_ERROR);
|
||||
});
|
||||
|
||||
it('should create ToolExecutionError with proper type', () => {
|
||||
const error = ErrorHandler.createToolExecutionError('Tool failed');
|
||||
expect(error).toBeInstanceOf(ToolExecutionError);
|
||||
expect(error.type).toBe(ErrorType.TOOL_EXECUTION_ERROR);
|
||||
});
|
||||
|
||||
it('should create PathValidationError with proper type', () => {
|
||||
const error = ErrorHandler.createPathValidationError('Path invalid');
|
||||
expect(error).toBeInstanceOf(PathValidationError);
|
||||
expect(error.type).toBe(ErrorType.PATH_VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it('should create UnknownError with proper type', () => {
|
||||
const error = ErrorHandler.createUnknownError('Unknown error');
|
||||
expect(error).toBeInstanceOf(OllamaError);
|
||||
expect(error.type).toBe(ErrorType.UNKNOWN_ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error message detection', () => {
|
||||
it('should detect network-related errors in generic Error', () => {
|
||||
const error = new Error('Network connection failed');
|
||||
const message = ErrorHandler['getUserFriendlyMessageFromError'](error);
|
||||
expect(message).toContain('Connection error');
|
||||
});
|
||||
|
||||
it('should detect timeout errors in generic Error', () => {
|
||||
const error = new Error('Request timeout occurred');
|
||||
const message = ErrorHandler['getUserFriendlyMessageFromError'](error);
|
||||
expect(message).toContain('timed out');
|
||||
});
|
||||
|
||||
it('should detect validation errors in generic Error', () => {
|
||||
const error = new Error('Validation format error');
|
||||
const message = ErrorHandler['getUserFriendlyMessageFromError'](error);
|
||||
expect(message).toContain('Invalid input');
|
||||
});
|
||||
|
||||
it('should detect streaming errors in generic Error', () => {
|
||||
const error = new Error('Streaming chunk error');
|
||||
const message = ErrorHandler['getUserFriendlyMessageFromError'](error);
|
||||
expect(message).toContain('Response too long');
|
||||
});
|
||||
|
||||
it('should detect tool errors in generic Error', () => {
|
||||
const error = new Error('Tool function error');
|
||||
const message = ErrorHandler['getUserFriendlyMessageFromError'](error);
|
||||
expect(message).toContain('Tool execution error');
|
||||
});
|
||||
|
||||
it('should detect path errors in generic Error', () => {
|
||||
const error = new Error('Path file error');
|
||||
const message = ErrorHandler['getUserFriendlyMessageFromError'](error);
|
||||
expect(message).toContain('Invalid file path');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
import { OllamaClient } from '../src/ollama-client';
|
||||
import { OllamaMessage, OllamaTool } from '../src/types';
|
||||
|
||||
describe('OllamaClient', () => {
|
||||
let client: OllamaClient;
|
||||
let mockFetch: jest.Mock;
|
||||
|
||||
const mockMessages: OllamaMessage[] = [
|
||||
{ role: 'system', content: 'You are helpful.' },
|
||||
{ role: 'user', content: 'Hello' },
|
||||
];
|
||||
|
||||
const mockTools: OllamaTool[] = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'test_tool',
|
||||
description: 'A test tool',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { input: { type: 'string' } },
|
||||
required: ['input'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = jest.fn();
|
||||
client = new OllamaClient('http://localhost:11434', 'llama3', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
client.cancelStream();
|
||||
});
|
||||
|
||||
describe('chat (non-streaming)', () => {
|
||||
it('should send a non-streaming request and return the response', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ message: { content: 'Hello back!' } }),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await client.chat(mockMessages, mockTools);
|
||||
|
||||
expect(result.content).toBe('Hello back!');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://localhost:11434/api/chat',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
model: 'llama3',
|
||||
messages: mockMessages,
|
||||
tools: mockTools,
|
||||
stream: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on non-OK response', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 500 });
|
||||
|
||||
await expect(client.chat(mockMessages, mockTools)).rejects.toThrow('Ollama API error: 500');
|
||||
});
|
||||
|
||||
it('should handle missing message content gracefully', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
|
||||
const result = await client.chat(mockMessages, mockTools);
|
||||
expect(result.content).toBe('');
|
||||
expect(result.tool_calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('should include abort signal in fetch options', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ message: { content: 'ok' } }),
|
||||
});
|
||||
|
||||
await client.chat(mockMessages, mockTools);
|
||||
|
||||
const fetchOptions = mockFetch.mock.calls[0][1];
|
||||
expect(fetchOptions.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('should forward tool_calls from response when present', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
message: {
|
||||
content: 'result',
|
||||
tool_calls: [{ function: { name: 'create_file', arguments: '{}' } }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await client.chat(mockMessages, mockTools);
|
||||
expect(result.tool_calls).toEqual([{ function: { name: 'create_file', arguments: '{}' } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamChat', () => {
|
||||
function createMockReader(data: string) {
|
||||
const encoder = new TextEncoder();
|
||||
const encoded = encoder.encode(data);
|
||||
let called = false;
|
||||
return {
|
||||
read: () => {
|
||||
if (!called) {
|
||||
called = true;
|
||||
return Promise.resolve({ done: false, value: encoded });
|
||||
}
|
||||
return Promise.resolve({ done: true, value: new Uint8Array(0) });
|
||||
},
|
||||
releaseLock: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
it('should send a streaming request and yield chunks', async () => {
|
||||
const streamData = [
|
||||
JSON.stringify({ message: { content: 'He' } }),
|
||||
JSON.stringify({ message: { content: 'llo' } }),
|
||||
JSON.stringify({ message: { content: '!' } }),
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const mockReader = createMockReader(streamData);
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
body: { getReader: () => mockReader },
|
||||
headers: {
|
||||
get: (name: string) => (name === 'content-type' ? 'application/x-ndjson' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const stream = await client.streamChat(mockMessages, mockTools);
|
||||
const chunks: string[] = [];
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk.content);
|
||||
}
|
||||
|
||||
expect(chunks).toEqual(['He', 'llo', '!']);
|
||||
expect(mockReader.releaseLock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip malformed JSON chunks and log a warning', async () => {
|
||||
const streamData = [
|
||||
JSON.stringify({ message: { content: 'valid' } }),
|
||||
'this is not json',
|
||||
JSON.stringify({ message: { content: 'also valid' } }),
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const mockReader = createMockReader(streamData);
|
||||
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
body: { getReader: () => mockReader },
|
||||
headers: {
|
||||
get: (name: string) => (name === 'content-type' ? 'application/x-ndjson' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const stream = await client.streamChat(mockMessages, mockTools);
|
||||
const chunks: string[] = [];
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk.content);
|
||||
}
|
||||
|
||||
expect(chunks).toEqual(['valid', 'also valid']);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Skipped malformed chunk'),
|
||||
expect.stringContaining('is not valid JSON')
|
||||
);
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should throw when too many chunks are malformed', async () => {
|
||||
const streamData = Array(51).fill('invalid json').join('\n') + '\n';
|
||||
|
||||
const mockReader = createMockReader(streamData);
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
body: { getReader: () => mockReader },
|
||||
headers: {
|
||||
get: (name: string) => (name === 'content-type' ? 'application/x-ndjson' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const stream = await client.streamChat(mockMessages, mockTools);
|
||||
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of stream) {
|
||||
/* consume */
|
||||
}
|
||||
})()
|
||||
).rejects.toThrow(/malformed/);
|
||||
});
|
||||
|
||||
it('should throw on non-OK response', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 404 });
|
||||
|
||||
await expect(client.streamChat(mockMessages, mockTools)).rejects.toThrow(
|
||||
'Ollama API error: 404'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when response has no body', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: true, body: undefined });
|
||||
|
||||
await expect(client.streamChat(mockMessages, mockTools)).rejects.toThrow('No response body');
|
||||
});
|
||||
|
||||
it('should throw on invalid content type', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: () => Promise.resolve({ done: true, value: new Uint8Array(0) }),
|
||||
}),
|
||||
},
|
||||
headers: {
|
||||
get: (name: string) => (name === 'content-type' ? 'text/html' : null),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(client.streamChat(mockMessages, mockTools)).rejects.toThrow(
|
||||
'Invalid response format'
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate Ollama error messages from the stream', async () => {
|
||||
const streamData = JSON.stringify({ message: { error: 'model not found' } }) + '\n';
|
||||
|
||||
const mockReader = createMockReader(streamData);
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
body: { getReader: () => mockReader },
|
||||
headers: {
|
||||
get: (name: string) => (name === 'content-type' ? 'application/x-ndjson' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const stream = await client.streamChat(mockMessages, mockTools);
|
||||
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of stream) {
|
||||
/* consume */
|
||||
}
|
||||
})()
|
||||
).rejects.toThrow('Ollama error: model not found');
|
||||
});
|
||||
|
||||
it('should yield tool_calls when present in streamed response', async () => {
|
||||
const streamData = [
|
||||
JSON.stringify({
|
||||
message: {
|
||||
content: '',
|
||||
tool_calls: [{ function: { name: 'create_file', arguments: '{"path":"a.md"}' } }],
|
||||
},
|
||||
}),
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const mockReader = createMockReader(streamData);
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
body: { getReader: () => mockReader },
|
||||
headers: {
|
||||
get: (name: string) => (name === 'content-type' ? 'application/x-ndjson' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const stream = await client.streamChat(mockMessages, mockTools);
|
||||
let lastChunk: any;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
lastChunk = chunk;
|
||||
}
|
||||
|
||||
expect(lastChunk.tool_calls).toEqual([
|
||||
{ function: { name: 'create_file', arguments: '{"path":"a.md"}' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should default tool_calls to empty array when not present', async () => {
|
||||
const streamData = JSON.stringify({ message: { content: 'hello' } }) + '\n';
|
||||
|
||||
const mockReader = createMockReader(streamData);
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
body: { getReader: () => mockReader },
|
||||
headers: {
|
||||
get: (name: string) => (name === 'content-type' ? 'application/x-ndjson' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const stream = await client.streamChat(mockMessages, mockTools);
|
||||
let lastChunk: any;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
lastChunk = chunk;
|
||||
}
|
||||
|
||||
expect(lastChunk.tool_calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('should send correct request body with stream:true', async () => {
|
||||
const streamData = JSON.stringify({ message: { content: 'ok' } }) + '\n';
|
||||
const mockReader = createMockReader(streamData);
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
body: { getReader: () => mockReader },
|
||||
headers: {
|
||||
get: (name: string) => (name === 'content-type' ? 'application/x-ndjson' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const stream = await client.streamChat(mockMessages, mockTools);
|
||||
for await (const _ of stream) {
|
||||
/* consume */
|
||||
}
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://localhost:11434/api/chat',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
model: 'llama3',
|
||||
messages: mockMessages,
|
||||
tools: mockTools,
|
||||
stream: true,
|
||||
}),
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelStream', () => {
|
||||
it('should abort the current request', () => {
|
||||
client.cancelStream();
|
||||
expect(client['abortController']).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle cancel when no active stream', () => {
|
||||
expect(() => client.cancelStream()).not.toThrow();
|
||||
expect(client['abortController']).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
import { ToolExecutor } from '../src/tool-executor';
|
||||
import { ToolCall, ToolResult } from '../src/types';
|
||||
import { ErrorHandler } from '../src/error-handler';
|
||||
|
||||
// Mock Obsidian types
|
||||
interface MockVault {
|
||||
create: (path: string, content: string) => Promise<any>;
|
||||
}
|
||||
interface MockApp {
|
||||
// Mock app properties if needed
|
||||
}
|
||||
interface MockNotice {
|
||||
(message: string): void;
|
||||
}
|
||||
|
||||
// Mock Obsidian module
|
||||
jest.mock('obsidian', () => ({
|
||||
Vault: jest.fn(),
|
||||
App: jest.fn(),
|
||||
Notice: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock ErrorHandler
|
||||
jest.mock('../src/error-handler', () => ({
|
||||
ErrorHandler: {
|
||||
handleError: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ToolExecutor', () => {
|
||||
let executor: ToolExecutor;
|
||||
let mockVault: MockVault;
|
||||
let mockApp: MockApp;
|
||||
|
||||
beforeEach(() => {
|
||||
mockVault = {
|
||||
create: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
mockApp = {} as MockApp;
|
||||
executor = new ToolExecutor(mockVault as unknown as any, mockApp as unknown as any);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('handleToolCall', () => {
|
||||
describe('create_file tool', () => {
|
||||
it('should successfully create a file with valid arguments', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'test-file.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result).toEqual({ success: true, message: 'File created successfully' });
|
||||
expect(mockVault.create).toHaveBeenCalledWith('test-file.md', 'Test content');
|
||||
});
|
||||
|
||||
it('should handle object arguments directly', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: {
|
||||
path: 'obj-args-file.md',
|
||||
content: 'Object args content',
|
||||
} as unknown as string,
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result).toEqual({ success: true, message: 'File created successfully' });
|
||||
expect(mockVault.create).toHaveBeenCalledWith('obj-args-file.md', 'Object args content');
|
||||
});
|
||||
|
||||
it('should successfully create a file in a subdirectory', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'subdirectory/test-file.md',
|
||||
content: 'Subdir content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result).toEqual({ success: true, message: 'File created successfully' });
|
||||
expect(mockVault.create).toHaveBeenCalledWith(
|
||||
'subdirectory/test-file.md',
|
||||
'Subdir content'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple slashes gracefully by normalizing path', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'test//file.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result).toEqual({ success: true, message: 'File created successfully' });
|
||||
expect(mockVault.create).toHaveBeenCalledWith('test//file.md', 'Test content');
|
||||
});
|
||||
|
||||
it('should handle empty content gracefully', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'empty-file.md',
|
||||
content: '',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result).toEqual({ success: true, message: 'File created successfully' });
|
||||
expect(mockVault.create).toHaveBeenCalledWith('empty-file.md', '');
|
||||
});
|
||||
|
||||
it('should allow filenames with consecutive dots', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'project..notes.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result).toEqual({ success: true, message: 'File created successfully' });
|
||||
expect(mockVault.create).toHaveBeenCalledWith('project..notes.md', 'Test content');
|
||||
});
|
||||
|
||||
it('should reject path traversal attempts with ..', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: '../test-file.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject path traversal attempts with .\\', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: '.\\test-file.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject path traversal attempts with /..', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: '/../test-file.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject absolute paths starting with /', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: '/var/test-file.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject absolute paths starting with \\', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: '\\var\\test-file.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject Windows drive letters', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'C:\\test-file.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject empty path', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: '',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject undefined path', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject path with invalid characters <', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'test<file.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject path with invalid characters >', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'test>file.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject path with invalid characters :', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'test:file.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject path with invalid characters |', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'test|file.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject path with invalid characters ?', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'test?file.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject path with invalid characters *', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'test*file.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject path longer than 200 characters', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'a'.repeat(201) + '.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject path with ~ character', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'test~file.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject non-string content', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'test-file.md',
|
||||
content: 123,
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject non-string path', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 123,
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle vault.create rejection gracefully', async () => {
|
||||
mockVault.create = jest.fn().mockRejectedValue(new Error('Permission denied'));
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'test-file.md',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle invalid JSON in arguments', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: 'invalid json',
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown tool', () => {
|
||||
it('should return failure for unknown tool', async () => {
|
||||
const call: ToolCall = {
|
||||
function: {
|
||||
name: 'unknown_tool',
|
||||
arguments: JSON.stringify({}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result).toEqual({ success: false, message: 'Unknown tool: unknown_tool' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
import { VaultIndexer } from '../src/vault-indexer';
|
||||
import { VaultIndexEntry } from '../src/types';
|
||||
|
||||
// Mock Obsidian types
|
||||
interface MockTFile {
|
||||
basename: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MockVault {
|
||||
getMarkdownFiles: () => MockTFile[];
|
||||
read: (file: MockTFile) => Promise<string>;
|
||||
}
|
||||
|
||||
describe('VaultIndexer', () => {
|
||||
let indexer: VaultIndexer;
|
||||
let mockVault: MockVault;
|
||||
|
||||
beforeEach(() => {
|
||||
mockVault = {
|
||||
getMarkdownFiles: jest.fn().mockReturnValue([]),
|
||||
read: jest.fn(),
|
||||
};
|
||||
indexer = new VaultIndexer(mockVault as unknown as any);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('searchVault', () => {
|
||||
it('should return empty array when no files exist', async () => {
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([]);
|
||||
const results = await indexer.searchVault('test', 5);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for empty or whitespace-only query', async () => {
|
||||
const file: MockTFile = { basename: 'test', path: 'test.md' };
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file]);
|
||||
mockVault.read = jest.fn().mockResolvedValue('some content');
|
||||
|
||||
const results1 = await indexer.searchVault('', 5);
|
||||
const results2 = await indexer.searchVault(' ', 5);
|
||||
expect(results1).toEqual([]);
|
||||
expect(results2).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return files matching the query', async () => {
|
||||
const file1: MockTFile = { basename: 'notes', path: 'notes.md' };
|
||||
const file2: MockTFile = { basename: 'todo', path: 'todo.md' };
|
||||
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file1, file2]);
|
||||
mockVault.read = jest.fn().mockImplementation(async (file: MockTFile) => {
|
||||
if (file.basename === 'notes') {
|
||||
return 'These are my important notes about programming algorithms';
|
||||
}
|
||||
return 'Buy milk and eggs';
|
||||
});
|
||||
|
||||
const results = await indexer.searchVault('programming', 5);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].title).toBe('notes');
|
||||
expect(results[0].score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should respect the limit parameter', async () => {
|
||||
const files: MockTFile[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
files.push({ basename: `file${i}`, path: `file${i}.md` });
|
||||
}
|
||||
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue(files);
|
||||
mockVault.read = jest.fn().mockResolvedValue('important keyword test');
|
||||
|
||||
const results = await indexer.searchVault('keyword', 3);
|
||||
expect(results.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should return results sorted by score descending', async () => {
|
||||
const file1: MockTFile = { basename: 'one', path: 'one.md' };
|
||||
const file2: MockTFile = { basename: 'two', path: 'two.md' };
|
||||
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file1, file2]);
|
||||
mockVault.read = jest.fn().mockImplementation(async (file: MockTFile) => {
|
||||
if (file.basename === 'one') {
|
||||
return 'keyword keyword keyword important';
|
||||
}
|
||||
return 'keyword';
|
||||
});
|
||||
|
||||
const results = await indexer.searchVault('keyword', 5);
|
||||
if (results.length >= 2) {
|
||||
expect(results[0].score).toBeGreaterThanOrEqual(results[1].score);
|
||||
}
|
||||
});
|
||||
|
||||
it('should truncate content previews to 500 characters', async () => {
|
||||
const file: MockTFile = { basename: 'long', path: 'long.md' };
|
||||
const longContent = 'content '.repeat(100); // Use meaningful words, not just 'a'
|
||||
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file]);
|
||||
mockVault.read = jest.fn().mockResolvedValue(longContent);
|
||||
|
||||
const results = await indexer.searchVault('content', 5);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].content.length).toBeLessThanOrEqual(500);
|
||||
});
|
||||
|
||||
it('should process files in batches to handle large vaults', async () => {
|
||||
const files: MockTFile[] = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
files.push({ basename: `file${i}`, path: `file${i}.md` });
|
||||
}
|
||||
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue(files);
|
||||
mockVault.read = jest.fn().mockResolvedValue('important test keyword');
|
||||
|
||||
const results = await indexer.searchVault('test', 5);
|
||||
expect(mockVault.read).toHaveBeenCalledTimes(25);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should filter out files with zero score', async () => {
|
||||
const file1: MockTFile = { basename: 'match', path: 'match.md' };
|
||||
const file2: MockTFile = { basename: 'nomatch', path: 'nomatch.md' };
|
||||
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file1, file2]);
|
||||
mockVault.read = jest.fn().mockImplementation(async (file: MockTFile) => {
|
||||
if (file.basename === 'match') {
|
||||
return 'relevant keyword algorithm';
|
||||
}
|
||||
return 'nothing relevant here at all';
|
||||
});
|
||||
|
||||
const results = await indexer.searchVault('keyword', 5);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].title).toBe('match');
|
||||
});
|
||||
|
||||
it('should handle vault.read errors gracefully', async () => {
|
||||
const file1: MockTFile = { basename: 'good', path: 'good.md' };
|
||||
const file2: MockTFile = { basename: 'bad', path: 'bad.md' };
|
||||
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file1, file2]);
|
||||
mockVault.read = jest.fn().mockImplementation(async (file: MockTFile) => {
|
||||
if (file.basename === 'good') {
|
||||
return 'important keyword test';
|
||||
}
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
const results = await indexer.searchVault('keyword', 5);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].title).toBe('good');
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Permission denied'));
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should give higher scores to title matches', async () => {
|
||||
const file1: MockTFile = { basename: 'algorithm', path: 'algorithm.md' };
|
||||
const file2: MockTFile = { basename: 'other', path: 'other.md' };
|
||||
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file1, file2]);
|
||||
mockVault.read = jest.fn().mockImplementation(async (file: MockTFile) => {
|
||||
if (file.basename === 'algorithm') {
|
||||
return 'Some other content here';
|
||||
}
|
||||
return 'This file discusses algorithm design patterns';
|
||||
});
|
||||
|
||||
const results = await indexer.searchVault('algorithm', 5);
|
||||
expect(results.length).toBe(2);
|
||||
// File with title match should be first
|
||||
expect(results[0].title).toBe('algorithm');
|
||||
});
|
||||
|
||||
it('should give higher scores to heading matches', async () => {
|
||||
const file1: MockTFile = { basename: 'file1', path: 'file1.md' };
|
||||
const file2: MockTFile = { basename: 'file2', path: 'file2.md' };
|
||||
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file1, file2]);
|
||||
mockVault.read = jest.fn().mockImplementation(async (file: MockTFile) => {
|
||||
if (file.basename === 'file1') {
|
||||
return '# Algorithm Design\n\nThis discusses design patterns';
|
||||
}
|
||||
return 'This file mentions algorithm somewhere in the body text';
|
||||
});
|
||||
|
||||
const results = await indexer.searchVault('algorithm', 5);
|
||||
expect(results.length).toBe(2);
|
||||
// File with heading match should score higher
|
||||
expect(results[0].title).toBe('file1');
|
||||
});
|
||||
|
||||
it('should give higher scores to frontmatter matches', async () => {
|
||||
const file1: MockTFile = { basename: 'file1', path: 'file1.md' };
|
||||
const file2: MockTFile = { basename: 'file2', path: 'file2.md' };
|
||||
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file1, file2]);
|
||||
mockVault.read = jest.fn().mockImplementation(async (file: MockTFile) => {
|
||||
if (file.basename === 'file1') {
|
||||
return '---\ntags: algorithm design\n---\n\nSome content here';
|
||||
}
|
||||
return 'This file mentions algorithm in the body';
|
||||
});
|
||||
|
||||
const results = await indexer.searchVault('algorithm', 5);
|
||||
expect(results.length).toBe(2);
|
||||
// File with frontmatter match should score higher
|
||||
expect(results[0].title).toBe('file1');
|
||||
});
|
||||
|
||||
it('should handle phrase matching with bonus', async () => {
|
||||
const file1: MockTFile = { basename: 'file1', path: 'file1.md' };
|
||||
const file2: MockTFile = { basename: 'file2', path: 'file2.md' };
|
||||
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file1, file2]);
|
||||
mockVault.read = jest.fn().mockImplementation(async (file: MockTFile) => {
|
||||
if (file.basename === 'file1') {
|
||||
return 'This discusses the design pattern algorithm';
|
||||
}
|
||||
return 'This discusses design and pattern and algorithm separately';
|
||||
});
|
||||
|
||||
const results = await indexer.searchVault('design pattern', 5);
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should filter out stop words from query', async () => {
|
||||
const file: MockTFile = { basename: 'test', path: 'test.md' };
|
||||
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file]);
|
||||
mockVault.read = jest.fn().mockResolvedValue('important keyword here');
|
||||
|
||||
// Query with stop words should still find the keyword
|
||||
const results = await indexer.searchVault('the important keyword', 5);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].title).toBe('test');
|
||||
});
|
||||
|
||||
it('should handle files with no matching content', async () => {
|
||||
const file: MockTFile = { basename: 'test', path: 'test.md' };
|
||||
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file]);
|
||||
mockVault.read = jest.fn().mockResolvedValue('nothing relevant');
|
||||
|
||||
const results = await indexer.searchVault('nonexistent', 5);
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenize', () => {
|
||||
it('should filter out stop words', () => {
|
||||
const tokens = (indexer as any).tokenize('the quick brown fox');
|
||||
expect(tokens).not.toContain('the');
|
||||
expect(tokens).toContain('quick');
|
||||
expect(tokens).toContain('brown');
|
||||
expect(tokens).toContain('fox');
|
||||
});
|
||||
|
||||
it('should convert to lowercase', () => {
|
||||
const tokens = (indexer as any).tokenize('Hello WORLD');
|
||||
expect(tokens).toEqual(['hello', 'world']);
|
||||
});
|
||||
|
||||
it('should handle punctuation', () => {
|
||||
const tokens = (indexer as any).tokenize('Hello, world!');
|
||||
expect(tokens).toEqual(['hello', 'world']);
|
||||
});
|
||||
|
||||
it('should filter very short tokens', () => {
|
||||
const tokens = (indexer as any).tokenize('a b test word');
|
||||
expect(tokens).not.toContain('a');
|
||||
expect(tokens).not.toContain('b');
|
||||
expect(tokens).toContain('test');
|
||||
expect(tokens).toContain('word');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateWeightedScore', () => {
|
||||
it('should return 0 when no tokens match', () => {
|
||||
const content = 'important algorithm design';
|
||||
const queryTokens = (indexer as any).tokenize('nonexistent');
|
||||
const tokenized = (indexer as any).tokenizeContent(content, {
|
||||
basename: 'test',
|
||||
path: 'test.md',
|
||||
} as any);
|
||||
const score = (indexer as any).calculateWeightedScore(tokenized, '', queryTokens);
|
||||
expect(score).toBe(0);
|
||||
});
|
||||
|
||||
it('should score higher when more tokens match', () => {
|
||||
const content = 'algorithm design pattern implementation';
|
||||
const tokenized = (indexer as any).tokenizeContent(content, {
|
||||
basename: 'test',
|
||||
path: 'test.md',
|
||||
} as any);
|
||||
const query1 = 'algorithm';
|
||||
const query2 = 'algorithm design pattern';
|
||||
const score1 = (indexer as any).calculateWeightedScore(
|
||||
tokenized,
|
||||
query1,
|
||||
(indexer as any).tokenize(query1)
|
||||
);
|
||||
const score2 = (indexer as any).calculateWeightedScore(
|
||||
tokenized,
|
||||
query2,
|
||||
(indexer as any).tokenize(query2)
|
||||
);
|
||||
expect(score2).toBeGreaterThan(score1);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
const content = 'Important Algorithm Design';
|
||||
const tokenized = (indexer as any).tokenizeContent(content, {
|
||||
basename: 'test',
|
||||
path: 'test.md',
|
||||
} as any);
|
||||
const query = 'important algorithm';
|
||||
const score = (indexer as any).calculateWeightedScore(
|
||||
tokenized,
|
||||
query,
|
||||
(indexer as any).tokenize(query)
|
||||
);
|
||||
expect(score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle word boundary matching', () => {
|
||||
const content = 'algorithm';
|
||||
const tokenized = (indexer as any).tokenizeContent(content, {
|
||||
basename: 'test',
|
||||
path: 'test.md',
|
||||
} as any);
|
||||
const query = 'algorithm';
|
||||
const score = (indexer as any).calculateWeightedScore(
|
||||
tokenized,
|
||||
query,
|
||||
(indexer as any).tokenize(query)
|
||||
);
|
||||
expect(score).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exactMatch with stemming', () => {
|
||||
it('should match exact words', () => {
|
||||
expect((indexer as any).exactMatch('test', 'test')).toBe(true);
|
||||
});
|
||||
|
||||
it('should match plurals', () => {
|
||||
expect((indexer as any).exactMatch('tests', 'test')).toBe(true);
|
||||
expect((indexer as any).exactMatch('test', 'tests')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle -ed suffix', () => {
|
||||
expect((indexer as any).exactMatch('tested', 'test')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle -ing suffix', () => {
|
||||
expect((indexer as any).exactMatch('testing', 'test')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match unrelated words', () => {
|
||||
expect((indexer as any).exactMatch('apple', 'banana')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeContent', () => {
|
||||
it('should extract headings from markdown', () => {
|
||||
const content = '# Heading 1\n\n# Heading 2\n\nSome content';
|
||||
const tokenized = (indexer as any).tokenizeContent(content, {
|
||||
basename: 'test',
|
||||
path: 'test.md',
|
||||
} as any);
|
||||
expect(tokenized.headings).toContain('Heading 1');
|
||||
expect(tokenized.headings).toContain('Heading 2');
|
||||
});
|
||||
|
||||
it('should extract frontmatter', () => {
|
||||
const content = '---\ntags: algorithm\ntitle: test\n---\n\nSome content';
|
||||
const tokenized = (indexer as any).tokenizeContent(content, {
|
||||
basename: 'test',
|
||||
path: 'test.md',
|
||||
} as any);
|
||||
expect(tokenized.frontmatter.tags).toBe('algorithm');
|
||||
expect(tokenized.frontmatter.title).toBe('test');
|
||||
});
|
||||
|
||||
it('should extract first paragraph', () => {
|
||||
const content = 'First paragraph here.\n\nSecond paragraph here.';
|
||||
const tokenized = (indexer as any).tokenizeContent(content, {
|
||||
basename: 'test',
|
||||
path: 'test.md',
|
||||
} as any);
|
||||
expect(tokenized.firstParagraph).toContain('First');
|
||||
expect(tokenized.firstParagraph).not.toContain('Second');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "commonjs",
|
||||
"outDir": "./lib",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"types": ["node", "jest"],
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": ["ESNext"],
|
||||
"moduleResolution": "node",
|
||||
"types": ["node", "jest"]
|
||||
},
|
||||
"include": ["src/**/*", "main.ts"],
|
||||
"typeRoots": ["node_modules/@types", "./src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"types": ["node", "jest", "jsdom"]
|
||||
},
|
||||
"include": ["tests/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user