Release OChat 0.1.0

This commit is contained in:
Raniendu Singh 2026-06-13 20:05:51 -07:00
commit dc9d2d1573
50 changed files with 10236 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
main.js
*.map
coverage/
.DS_Store

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Raniendu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

126
README.md Normal file
View file

@ -0,0 +1,126 @@
# OChat
OChat is an Obsidian plugin for chatting with local models using the active Markdown note and relevant vault context. It supports Ollama by default and OpenAI-compatible local servers such as LM Studio or llama.cpp server.
Current release: `0.1.0`.
## Features
- Right sidebar chat for active-note and vault-aware questions.
- First-run setup that checks the default Ollama endpoint and shows a model picker when local models are found.
- Ollama provider using `POST /api/chat` and `GET /api/tags`.
- OpenAI-compatible provider using `/v1/chat/completions` and `/v1/models`.
- Active note, selected text, explicit `@note` attachments, and ranked Markdown vault snippets in context.
- Compact assistant composer with context chips, `@` note suggestions, Ask/Edit toggle, and icon controls.
- Rendered Markdown chat responses using Obsidian's Markdown renderer.
- Collapsed model thinking blocks for models that emit `<think>...</think>`, plus a live `thinking...` indicator while a request is running.
- Endpoint testing in the sidebar settings panel with visible success, warning, and failure states.
- Markdown edit proposals with review mode enabled by default.
- Markdown-only file writes through Obsidian APIs.
## Privacy and network use
OChat sends prompt text, active-note content, selected text, and selected vault snippets to the configured model endpoint. The default endpoint is `http://localhost:11434`.
Localhost and private LAN endpoints are allowed without extra acknowledgement. Public endpoints require an explicit acknowledgement in settings because note and vault context may leave your machine.
OChat does not include telemetry, ads, remote assets, or an auto-update mechanism.
## Requirements
- Obsidian 1.12.7 or newer.
- Node.js 18 or newer for development.
- Ollama or another compatible local model server.
## Ollama quick start
1. Install Ollama from [ollama.com](https://ollama.com).
2. Pull a model:
```bash
ollama pull llama3.2
```
3. Confirm the local API is running:
```bash
curl http://localhost:11434/api/tags
```
4. In OChat settings, use provider `Ollama`, base URL `http://localhost:11434`, and model `llama3.2`.
When OChat opens for the first time, it also checks `http://localhost:11434` automatically. If models are found, choose one from the model picker. If Ollama is running somewhere else, enter that endpoint in the setup panel and test it.
## Context workflow
OChat always includes the active Markdown note and current selection when you send a prompt. The composer shows the active note as a context chip so you can see what is grounded by default.
To attach more Markdown notes, type `@` in the composer and choose a note from the suggestions, or click the plus button and search the vault. You can also type references directly, such as `@README.md`, `@"Projects/Move Plan.md"`, or `@[[Areas/Moving/00 Moving Dashboard.md]]`.
If you do not attach every relevant note, OChat still runs capped lexical search over the Markdown vault and includes ranked snippets that match your prompt, while respecting excluded folders.
## Local development
Install dependencies:
```bash
npm install
```
Run tests:
```bash
npm test
```
Build the plugin:
```bash
npm run build
```
Install into a local vault:
```bash
npm run install-local -- "/path/to/Your Vault"
```
Then open Obsidian, enable community plugins, and enable OChat from Settings.
## Installing from a GitHub release
Until OChat is approved in the Obsidian Community directory, install it manually from a GitHub release:
1. Download `main.js`, `manifest.json`, and `styles.css` from the latest release.
2. Create this folder in your vault: `.obsidian/plugins/ochat`.
3. Copy the downloaded files into `.obsidian/plugins/ochat`.
4. Reload Obsidian and enable OChat in Settings > Community plugins.
## Editing notes
When you ask OChat to edit notes, the model must return structured patch JSON with `path`, `original`, `replacement`, and `rationale`. OChat validates that each target is a Markdown file and that the original text appears exactly once before applying a replacement.
Review mode is on by default. With review mode enabled, proposed edits are shown in the OChat sidebar and require approval. If you turn review mode off, valid Markdown patches are applied automatically.
## Release checklist
- Update `manifest.json` and `versions.json` for the release version.
- Run `npm test`, `npm run lint`, and `npm run build`.
- Create a GitHub release whose tag exactly matches the manifest version, for example `0.1.0`.
- Upload `main.js`, `manifest.json`, and `styles.css` as release assets.
- Submit the repository URL through `community.obsidian.md` for the first community-plugin review. The community directory reads `manifest.json` from the default branch and installs assets from the matching GitHub release.
- Before public release, add `fundingUrl` to `manifest.json` only after the real GitHub Sponsors URL is available.
Example funding metadata:
```json
{
"fundingUrl": {
"GitHub Sponsors": "https://github.com/sponsors/YOUR_HANDLE"
}
}
```
## License
MIT

12
RELEASE_NOTES.md Normal file
View file

@ -0,0 +1,12 @@
# Release Notes
## 0.1.0
- Add right-sidebar chat for local Ollama and OpenAI-compatible model servers.
- Add active-note, selected-text, explicit `@note` attachments, and lexical vault-snippet context.
- Add compact assistant composer with context chips, note suggestions, Ask/Edit toggle, icon controls, and model dropdowns.
- Render assistant Markdown with Obsidian's Markdown renderer.
- Add collapsed thinking sections for `<think>...</think>` output and a live `thinking...` indicator while requests run.
- Add visible endpoint-test success, warning, and failure states in the sidebar settings panel.
- Add Markdown-only patch review and apply workflow.
- Add local install script, MIT license, and release metadata.

48
esbuild.config.mjs Normal file
View file

@ -0,0 +1,48 @@
import esbuild from 'esbuild';
import process from 'process';
import { builtinModules } from 'node:module';
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD.
View source in the plugin repository.
*/
`;
const prod = process.argv[2] === 'production';
const context = await esbuild.context({
banner: {
js: banner
},
entryPoints: ['src/main.ts'],
bundle: true,
external: [
'obsidian',
'electron',
'@codemirror/autocomplete',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/search',
'@codemirror/state',
'@codemirror/view',
'@lezer/common',
'@lezer/highlight',
'@lezer/lr',
...builtinModules
],
format: 'cjs',
target: 'es2021',
logLevel: 'info',
sourcemap: prod ? false : 'inline',
treeShaking: true,
outfile: 'main.js',
minify: prod
});
if (prod) {
await context.rebuild();
process.exit(0);
}
await context.watch();

46
eslint.config.mjs Normal file
View file

@ -0,0 +1,46 @@
import js from '@eslint/js';
import { globalIgnores } from 'eslint/config';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import obsidian from 'eslint-plugin-obsidianmd';
export default tseslint.config(
globalIgnores([
'node_modules',
'coverage',
'main.js',
'eslint.config.mjs',
'esbuild.config.mjs',
'vitest.config.ts',
'version-bump.mjs',
'scripts/install-local.mjs',
'*.json'
]),
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.ts', 'tests/**/*.ts'],
languageOptions: {
ecmaVersion: 2021,
sourceType: 'module',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname
},
globals: {
...globals.browser,
...globals.node
}
},
rules: {
'no-console': ['error', { allow: ['warn', 'error'] }]
}
},
...obsidian.configs.recommended,
{
files: ['src/**/*.ts', 'tests/**/*.ts'],
rules: {
'@typescript-eslint/no-deprecated': 'off'
}
}
);

9
manifest.json Normal file
View file

@ -0,0 +1,9 @@
{
"id": "ochat",
"name": "OChat",
"version": "0.1.0",
"minAppVersion": "1.12.7",
"description": "Chat with local models using active note and vault context.",
"author": "Raniendu",
"isDesktopOnly": false
}

6558
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "ochat",
"version": "0.1.0",
"description": "Chat with local models using active note and vault context.",
"main": "main.js",
"type": "module",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc --noEmit --skipLibCheck && node esbuild.config.mjs production",
"install-local": "node scripts/install-local.mjs",
"test": "vitest run",
"lint": "eslint .",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [
"obsidian",
"ollama",
"local-ai"
],
"author": "Raniendu",
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^22.15.17",
"esbuild": "0.25.5",
"eslint": "^9.39.4",
"eslint-plugin-obsidianmd": "^0.3.0",
"globals": "^17.6.0",
"jiti": "^2.6.1",
"obsidian": "latest",
"typescript": "^5.8.3",
"typescript-eslint": "^8.59.1",
"vitest": "^3.2.4"
}
}

23
scripts/install-local.mjs Normal file
View file

@ -0,0 +1,23 @@
import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
const vaultPath = process.argv[2] ?? process.env.OBSIDIAN_VAULT;
if (!vaultPath) {
throw new Error('Pass a vault path or set OBSIDIAN_VAULT. Example: npm run install-local -- "/path/to/Vault"');
}
for (const file of ['main.js', 'manifest.json', 'styles.css']) {
if (!existsSync(file)) {
throw new Error(`Missing ${file}. Run npm run build before installing locally.`);
}
}
const pluginDir = join(resolve(vaultPath), '.obsidian', 'plugins', 'ochat');
mkdirSync(pluginDir, { recursive: true });
for (const file of ['main.js', 'manifest.json', 'styles.css']) {
copyFileSync(file, join(pluginDir, file));
}
console.log(`Installed OChat to ${pluginDir}`);

22
src/assistant-response.ts Normal file
View file

@ -0,0 +1,22 @@
export interface ParsedAssistantResponse {
content: string;
thinking?: string;
}
export function parseAssistantResponse(response: string): ParsedAssistantResponse {
const thinkingBlocks: string[] = [];
const content = response
.replace(/<think>([\s\S]*?)<\/think>/gi, (_match, thinking: string) => {
const trimmed = thinking.trim();
if (trimmed.length > 0) {
thinkingBlocks.push(trimmed);
}
return '';
})
.trim();
return {
content,
...(thinkingBlocks.length > 0 ? { thinking: thinkingBlocks.join('\n\n') } : {})
};
}

36
src/composer-tools.ts Normal file
View file

@ -0,0 +1,36 @@
export interface ToolsPanelState {
toolsOpen: boolean;
onboardingRequired: boolean;
}
export interface ComposerPanelState {
onboardingRequired: boolean;
settingsOpen: boolean;
contextOpen: boolean;
}
export type ComposerPanel = 'onboarding' | 'settings' | 'context' | null;
export function isToolsPanelVisible(state: ToolsPanelState): boolean {
return state.onboardingRequired || state.toolsOpen;
}
export function toggleToolsPanel(current: boolean): boolean {
return !current;
}
export function getComposerPanel(state: ComposerPanelState): ComposerPanel {
if (state.settingsOpen) {
return 'settings';
}
if (state.contextOpen) {
return 'context';
}
if (state.onboardingRequired) {
return 'onboarding';
}
return null;
}

2
src/constants.ts Normal file
View file

@ -0,0 +1,2 @@
export const OCHAT_VIEW_TYPE = 'ochat-chat-view';
export const OCHAT_DISPLAY_NAME = 'OChat';

178
src/context-mentions.ts Normal file
View file

@ -0,0 +1,178 @@
const MAX_FUZZY_MENTION_MATCHES = 3;
export function extractContextMentions(input: string): string[] {
const mentions: string[] = [];
let index = 0;
while (index < input.length) {
const atIndex = input.indexOf('@', index);
if (atIndex === -1) {
break;
}
if (!isMentionBoundary(input, atIndex)) {
index = atIndex + 1;
continue;
}
const parsed = parseMentionAt(input, atIndex);
if (parsed) {
mentions.push(parsed.query);
index = parsed.end;
continue;
}
index = atIndex + 1;
}
return [...new Set(mentions.map(cleanMentionQuery).filter((mention) => mention.length > 0))];
}
export function resolveMentionedMarkdownPaths(input: string, markdownPaths: string[]): string[] {
const paths: string[] = [];
for (const mention of extractContextMentions(input)) {
const exact = findExactMarkdownPath(mention, markdownPaths);
const matches = exact ? [exact] : findMarkdownPathMatches(mention, markdownPaths, MAX_FUZZY_MENTION_MATCHES);
for (const match of matches) {
if (!paths.includes(match)) {
paths.push(match);
}
}
}
return paths;
}
export function findMarkdownPathMatches(query: string, markdownPaths: string[], limit: number): string[] {
const normalizedQuery = normalizePathish(cleanMentionQuery(query));
const candidates = markdownPaths.filter((path) => path.toLowerCase().endsWith('.md'));
if (normalizedQuery.length === 0) {
return candidates.sort((a, b) => a.localeCompare(b)).slice(0, limit);
}
return candidates
.map((path) => ({
path,
score: scoreMarkdownPath(path, normalizedQuery)
}))
.filter((candidate) => candidate.score > 0)
.sort((a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path))
.slice(0, limit)
.map((candidate) => candidate.path);
}
function parseMentionAt(input: string, atIndex: number): { query: string; end: number } | null {
if (input.startsWith('@[[', atIndex)) {
const end = input.indexOf(']]', atIndex + 3);
return end === -1 ? null : { query: input.slice(atIndex + 3, end), end: end + 2 };
}
if (input.startsWith('@"', atIndex)) {
const end = input.indexOf('"', atIndex + 2);
return end === -1 ? null : { query: input.slice(atIndex + 2, end), end: end + 1 };
}
let end = atIndex + 1;
while (end < input.length && !/\s/.test(input[end])) {
end++;
}
return end === atIndex + 1 ? null : { query: input.slice(atIndex + 1, end), end };
}
function findExactMarkdownPath(query: string, markdownPaths: string[]): string | null {
const normalizedQuery = normalizePathish(cleanMentionQuery(query));
return (
markdownPaths.find((path) => {
const normalizedPath = normalizePathish(path);
const normalizedBase = normalizePathish(baseName(path));
return (
normalizedPath === normalizedQuery ||
stripMarkdownExtension(normalizedPath) === stripMarkdownExtension(normalizedQuery) ||
normalizedBase === normalizedQuery ||
stripMarkdownExtension(normalizedBase) === stripMarkdownExtension(normalizedQuery)
);
}) ?? null
);
}
function scoreMarkdownPath(path: string, normalizedQuery: string): number {
const normalizedPath = normalizePathish(path);
const normalizedBase = normalizePathish(baseName(path));
const pathWithoutExtension = stripMarkdownExtension(normalizedPath);
const baseWithoutExtension = stripMarkdownExtension(normalizedBase);
const pathSegments = pathWithoutExtension.split('/');
const baseWords = baseWithoutExtension.split(/[^a-z0-9]+/).filter(Boolean);
const queryVariants = getQueryVariants(normalizedQuery);
if (normalizedPath === normalizedQuery || pathWithoutExtension === stripMarkdownExtension(normalizedQuery)) {
return 1000;
}
if (normalizedBase === normalizedQuery || baseWithoutExtension === stripMarkdownExtension(normalizedQuery)) {
return 950;
}
if (baseWords.some((word) => queryVariants.includes(word))) {
return 900;
}
if (pathSegments.some((segment) => queryVariants.includes(segment))) {
return 850;
}
if (baseWords.some((word) => queryVariants.some((query) => word.startsWith(query)))) {
return 800;
}
if (pathSegments.some((segment) => queryVariants.some((query) => segment.startsWith(query)))) {
return 700;
}
if (queryVariants.some((query) => baseWithoutExtension.includes(query))) {
return 600;
}
if (queryVariants.some((query) => pathWithoutExtension.includes(query))) {
return 500;
}
return 0;
}
function getQueryVariants(query: string): string[] {
const variants = [query];
if (query.endsWith('e') && query.length > 3) {
variants.push(query.slice(0, -1));
}
return [...new Set(variants)];
}
function isMentionBoundary(input: string, atIndex: number): boolean {
if (atIndex === 0) {
return true;
}
return /[\s([{:]/.test(input[atIndex - 1]);
}
function cleanMentionQuery(query: string): string {
return query.trim().replace(/[.,;:!?)]$/g, '');
}
function normalizePathish(value: string): string {
return value.trim().replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase();
}
function stripMarkdownExtension(value: string): string {
return value.endsWith('.md') ? value.slice(0, -3) : value;
}
function baseName(path: string): string {
return path.split('/').pop() ?? path;
}

80
src/context.ts Normal file
View file

@ -0,0 +1,80 @@
import type { ChatMessage, NoteSource, VaultSnippet } from './types';
export interface ContextBundleInput {
activeNote: NoteSource | null;
selectedText: string;
contextNotes?: NoteSource[];
vaultSnippets: VaultSnippet[];
maxContextCharacters: number;
}
export interface ContextBundle {
messages: ChatMessage[];
contextText: string;
}
const SYSTEM_PROMPT = [
'You are OChat, a local-first Obsidian assistant.',
'Use the supplied Markdown context when it is relevant.',
'When asked to edit notes, respond with JSON containing a patches array.',
'Each patch must include path, original, replacement, and rationale.'
].join(' ');
export function buildContextBundle(input: ContextBundleInput): ContextBundle {
const sections: string[] = [];
if (input.activeNote) {
sections.push(formatSection(`Active note: ${input.activeNote.path}`, input.activeNote.content));
}
if (input.selectedText.trim().length > 0) {
sections.push(formatSection('Selected text', input.selectedText));
}
for (const note of input.contextNotes ?? []) {
sections.push(formatSection(`Context file: ${note.path}`, note.content));
}
if (input.vaultSnippets.length > 0) {
for (const snippet of input.vaultSnippets) {
sections.push(formatSection(`Vault match: ${snippet.path}`, snippet.snippet));
}
}
const contextText = truncateText(sections.join('\n\n'), input.maxContextCharacters);
return {
contextText,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: contextText }
]
};
}
export function buildUserMessage(question: string, contextText: string): ChatMessage {
return {
role: 'user',
content: `${contextText}\n\nUser request:\n${question}`.trim()
};
}
function formatSection(title: string, body: string): string {
return `## ${title}\n${body.trim()}`;
}
function truncateText(text: string, maxCharacters: number): string {
if (maxCharacters <= 0) {
return '';
}
if (text.length <= maxCharacters) {
return text;
}
if (maxCharacters <= 3) {
return text.slice(0, maxCharacters);
}
return `${text.slice(0, maxCharacters - 3)}...`;
}

9
src/keyboard.ts Normal file
View file

@ -0,0 +1,9 @@
export interface PromptKeyEvent {
key: string;
shiftKey: boolean;
isComposing: boolean;
}
export function shouldSubmitPromptKey(event: PromptKeyEvent): boolean {
return event.key === 'Enter' && !event.shiftKey && !event.isComposing;
}

325
src/main.ts Normal file
View file

@ -0,0 +1,325 @@
import {
MarkdownView,
Plugin,
requestUrl,
TFile
} from 'obsidian';
import { findMarkdownPathMatches, resolveMentionedMarkdownPaths } from './context-mentions';
import { OCHAT_VIEW_TYPE } from './constants';
import { buildContextBundle, buildUserMessage } from './context';
import { applyModelDiscovery, isOnboardingRequired } from './onboarding';
import { applyPatchProposals } from './patch-applier';
import { createModelProvider } from './providers/provider-client';
import { classifyEndpoint } from './providers/url-policy';
import { searchVaultSnippets } from './search';
import { OChatSettingTab } from './settings';
import { DEFAULT_SETTINGS, normalizeSettings } from './settings-data';
import type {
ChatMessage,
EndpointClassification,
NoteSource,
OChatSettings,
PatchProposal,
RequestDescriptor
} from './types';
import { OChatView } from './view';
export default class OChatPlugin extends Plugin {
settings: OChatSettings = DEFAULT_SETTINGS;
async onload(): Promise<void> {
await this.loadSettings();
this.registerView(OCHAT_VIEW_TYPE, (leaf) => new OChatView(leaf, this));
this.addSettingTab(new OChatSettingTab(this.app, this));
this.addRibbonIcon('bot', 'Open chat', () => {
void this.activateView();
});
this.addCommand({
id: 'open-chat',
name: 'Open chat',
callback: () => {
void this.activateView();
}
});
this.addCommand({
id: 'ask-active-note',
name: 'Ask about active note',
editorCallback: () => {
void this.withView((view) => view.askActiveNote());
}
});
this.addCommand({
id: 'edit-active-note',
name: 'Edit active note',
editorCallback: () => {
void this.withView((view) => view.editActiveNote());
}
});
this.addCommand({
id: 'refresh-models',
name: 'Refresh models',
callback: () => {
void this.withView((view) => view.refreshModels());
}
});
this.addCommand({
id: 'clear-conversation',
name: 'Clear conversation',
callback: () => {
void this.withView((view) => view.clearConversation());
}
});
}
async loadSettings(): Promise<void> {
this.settings = normalizeSettings((await this.loadData()) as Partial<OChatSettings> | null);
}
async saveSettings(): Promise<void> {
this.settings = normalizeSettings(this.settings);
await this.saveData(this.settings);
}
async activateView(): Promise<OChatView | null> {
const leaf = await this.app.workspace.ensureSideLeaf(OCHAT_VIEW_TYPE, 'right', {
active: true,
reveal: true
});
return leaf.view instanceof OChatView ? leaf.view : null;
}
async withView(callback: (view: OChatView) => void | Promise<void>): Promise<void> {
const view = await this.activateView();
if (view) {
await callback(view);
}
}
getEndpointClassification(): EndpointClassification {
return classifyEndpoint(this.settings.baseUrl);
}
needsOnboarding(): boolean {
return isOnboardingRequired(this.settings);
}
async updateBaseUrl(baseUrl: string): Promise<void> {
this.settings.baseUrl = baseUrl.trim();
this.settings.availableModels = [];
this.settings.setupComplete = false;
const endpoint = classifyEndpoint(this.settings.baseUrl);
if (!endpoint.requiresAcknowledgement) {
this.settings.remoteEndpointAcknowledged = false;
}
await this.saveSettings();
}
async selectModel(model: string): Promise<void> {
this.settings.model = model.trim();
this.settings.setupComplete = this.settings.model.length > 0;
await this.saveSettings();
}
async selectComposerMode(mode: OChatSettings['composerMode']): Promise<void> {
this.settings.composerMode = mode;
await this.saveSettings();
}
async refreshAvailableModels(): Promise<string[]> {
const models = await this.listModels();
this.settings = applyModelDiscovery(this.settings, models);
await this.saveSettings();
return models;
}
async buildMessages(prompt: string, history: ChatMessage[], contextFilePaths: string[] = []): Promise<ChatMessage[]> {
const activeNote = await this.getActiveNoteSource();
const selectedText = this.app.workspace.activeEditor?.editor?.getSelection() ?? '';
const mentionedContextPaths = this.resolvePromptContextPaths(prompt);
const contextPaths = [...new Set([...contextFilePaths, ...mentionedContextPaths])].filter(
(path) => path !== activeNote?.path
);
const contextNotes = await this.getContextNoteSources(contextPaths);
const vaultNotes = await this.getVaultNoteSources();
const vaultSnippets = searchVaultSnippets(vaultNotes, prompt, {
excludedFolders: this.settings.excludedFolders,
maxResults: this.settings.maxVaultResults,
maxSnippetCharacters: Math.min(1200, this.settings.maxContextCharacters)
});
const bundle = buildContextBundle({
activeNote,
selectedText,
contextNotes,
vaultSnippets,
maxContextCharacters: this.settings.maxContextCharacters
});
return [bundle.messages[0], ...history.slice(-8), buildUserMessage(prompt, bundle.contextText)];
}
async chat(messages: ChatMessage[]): Promise<string> {
this.assertEndpointAllowed();
const provider = createModelProvider(this.settings.provider, (request) => this.executeRequest(request));
return provider.chat({
baseUrl: this.settings.baseUrl,
model: this.settings.model,
messages,
temperature: this.settings.temperature
});
}
async listModels(): Promise<string[]> {
this.assertEndpointAllowed();
const provider = createModelProvider(this.settings.provider, (request) => this.executeRequest(request));
return provider.listModels(this.settings.baseUrl);
}
getMarkdownFilePaths(): string[] {
return this.app.vault
.getMarkdownFiles()
.filter((file) => !this.isExcludedPath(file.path))
.map((file) => file.path)
.sort((a, b) => a.localeCompare(b));
}
getMarkdownFileMatches(query: string, limit: number): string[] {
return findMarkdownPathMatches(query, this.getMarkdownFilePaths(), limit);
}
resolvePromptContextPaths(prompt: string): string[] {
return resolveMentionedMarkdownPaths(prompt, this.getMarkdownFilePaths());
}
getActiveMarkdownPath(): string | null {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
const file = activeView?.file ?? this.app.workspace.getActiveFile();
if (!file || file.extension !== 'md' || this.isExcludedPath(file.path)) {
return null;
}
return file.path;
}
async applyPatches(patches: PatchProposal[], approved: boolean) {
return applyPatchProposals(patches, {
reviewMode: this.settings.reviewMode,
approved,
readFile: (path) => this.readMarkdownFile(path),
writeFile: (path, content) => this.writeMarkdownFile(path, content)
});
}
private async executeRequest(request: RequestDescriptor): Promise<unknown> {
const response = await requestUrl({
url: request.url,
method: request.method,
headers: request.headers,
contentType: request.method === 'POST' ? 'application/json' : undefined,
body: request.body === undefined ? undefined : JSON.stringify(request.body)
});
return response.json;
}
private assertEndpointAllowed(): void {
const endpoint = this.getEndpointClassification();
if (endpoint.requiresAcknowledgement && !this.settings.remoteEndpointAcknowledged) {
throw new Error(`${endpoint.reason} Enable acknowledgement in OChat settings to continue.`);
}
}
private async getActiveNoteSource(): Promise<NoteSource | null> {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
const file = activeView?.file ?? this.app.workspace.getActiveFile();
if (!file || file.extension !== 'md') {
return null;
}
const activeEditor = this.app.workspace.activeEditor;
const content =
activeEditor?.file?.path === file.path && activeEditor.editor
? activeEditor.editor.getValue()
: await this.app.vault.cachedRead(file);
return {
path: file.path,
content
};
}
private async getVaultNoteSources(): Promise<NoteSource[]> {
const files = this.app.vault.getMarkdownFiles().filter((file) => !this.isExcludedPath(file.path));
const notes: NoteSource[] = [];
for (const file of files) {
notes.push({
path: file.path,
content: await this.app.vault.cachedRead(file)
});
}
return notes;
}
private async getContextNoteSources(paths: string[]): Promise<NoteSource[]> {
const notes: NoteSource[] = [];
for (const path of [...new Set(paths)]) {
const file = this.getMarkdownFileByPath(path);
notes.push({
path: file.path,
content: await this.app.vault.cachedRead(file)
});
}
return notes;
}
private async readMarkdownFile(path: string): Promise<string> {
const file = this.getMarkdownFileByPath(path);
return this.app.vault.cachedRead(file);
}
private async writeMarkdownFile(path: string, content: string): Promise<void> {
const file = this.getMarkdownFileByPath(path);
const activeEditor = this.app.workspace.activeEditor;
if (activeEditor?.file?.path === file.path && activeEditor.editor) {
activeEditor.editor.setValue(content);
return;
}
await this.app.vault.process(file, () => content);
}
private getMarkdownFileByPath(path: string): TFile {
const file = this.app.vault.getFileByPath(path);
if (!(file instanceof TFile) || file.extension !== 'md') {
throw new Error(`OChat can only edit Markdown files in the vault: ${path}`);
}
return file;
}
private isExcludedPath(path: string): boolean {
return [this.app.vault.configDir, ...this.settings.excludedFolders].some((folder) => {
const normalized = folder.trim().replace(/^\/+|\/+$/g, '');
return normalized.length > 0 && (path === normalized || path.startsWith(`${normalized}/`));
});
}
}

19
src/model-options.ts Normal file
View file

@ -0,0 +1,19 @@
export interface ModelSelectOption {
value: string;
label: string;
}
export function getModelSelectOptions(currentModel: string, availableModels: string[]): ModelSelectOption[] {
const current = currentModel.trim();
const discovered = [...new Set(availableModels.map((model) => model.trim()).filter((model) => model.length > 0))];
const options = discovered.map((model) => ({
value: model,
label: model
}));
if (current.length > 0 && !discovered.includes(current)) {
return [{ value: current, label: `${current} (saved)` }, ...options];
}
return options;
}

11
src/modes.ts Normal file
View file

@ -0,0 +1,11 @@
import type { ComposerMode } from './types';
export type SubmitMode = 'chat' | 'edit';
export function resolveSubmitMode(mode: ComposerMode, _prompt: string): SubmitMode {
if (mode === 'ask') {
return 'chat';
}
return 'edit';
}

24
src/onboarding.ts Normal file
View file

@ -0,0 +1,24 @@
import type { OChatSettings } from './types';
export function isOnboardingRequired(settings: OChatSettings): boolean {
return !settings.setupComplete || settings.model.trim().length === 0 || settings.availableModels.length === 0;
}
export function applyModelDiscovery(settings: OChatSettings, models: string[]): OChatSettings {
const uniqueModels = [...new Set(models.map((model) => model.trim()).filter((model) => model.length > 0))];
if (uniqueModels.length === 0) {
return {
...settings,
availableModels: [],
setupComplete: false
};
}
return {
...settings,
availableModels: uniqueModels,
model: uniqueModels.includes(settings.model) ? settings.model : uniqueModels[0],
setupComplete: true
};
}

54
src/patch-applier.ts Normal file
View file

@ -0,0 +1,54 @@
import { applyPatchToContent, validatePatchProposal } from './patches';
import type { PatchProposal } from './types';
export interface PatchApplicationOptions {
reviewMode: boolean;
approved: boolean;
readFile(path: string): Promise<string>;
writeFile(path: string, content: string): Promise<void>;
}
export type PatchApplicationResult =
| { path: string; status: 'pending-review'; rationale: string }
| { path: string; status: 'applied'; rationale: string }
| { path: string; status: 'rejected'; reason: string; rationale: string };
export async function applyPatchProposals(
patches: PatchProposal[],
options: PatchApplicationOptions
): Promise<PatchApplicationResult[]> {
const results: PatchApplicationResult[] = [];
for (const patch of patches) {
const currentContent = await options.readFile(patch.path);
const validation = validatePatchProposal(patch, currentContent);
if (!validation.ok) {
results.push({
path: patch.path,
status: 'rejected',
reason: validation.reason,
rationale: patch.rationale
});
continue;
}
if (options.reviewMode && !options.approved) {
results.push({
path: patch.path,
status: 'pending-review',
rationale: patch.rationale
});
continue;
}
await options.writeFile(patch.path, applyPatchToContent(patch, currentContent));
results.push({
path: patch.path,
status: 'applied',
rationale: patch.rationale
});
}
return results;
}

80
src/patches.ts Normal file
View file

@ -0,0 +1,80 @@
import type { PatchProposal, PatchValidationResult } from './types';
export function parsePatchProposals(output: string): PatchProposal[] {
const jsonText = stripJsonFence(output.trim());
try {
const parsed = JSON.parse(jsonText) as { patches?: unknown };
if (!Array.isArray(parsed.patches)) {
return [];
}
return parsed.patches.filter(isPatchProposal);
} catch {
return [];
}
}
export function validatePatchProposal(
patch: PatchProposal,
currentContent: string
): PatchValidationResult {
if (!patch.path.toLowerCase().endsWith('.md')) {
return { ok: false, reason: 'Only Markdown files can be edited.' };
}
if (patch.original.length === 0) {
return { ok: false, reason: 'Original text must not be empty.' };
}
const matches = countExactMatches(currentContent, patch.original);
if (matches === 0) {
return { ok: false, reason: 'Original text was not found.' };
}
if (matches > 1) {
return { ok: false, reason: 'Original text matched more than once.' };
}
return { ok: true };
}
export function applyPatchToContent(patch: PatchProposal, currentContent: string): string {
const validation = validatePatchProposal(patch, currentContent);
if (!validation.ok) {
throw new Error(validation.reason);
}
return currentContent.replace(patch.original, patch.replacement);
}
function stripJsonFence(output: string): string {
const fenced = output.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
return fenced ? fenced[1].trim() : output;
}
function isPatchProposal(value: unknown): value is PatchProposal {
const candidate = value as PatchProposal;
return (
typeof candidate?.path === 'string' &&
typeof candidate.original === 'string' &&
typeof candidate.replacement === 'string' &&
typeof candidate.rationale === 'string'
);
}
function countExactMatches(content: string, needle: string): number {
let count = 0;
let index = content.indexOf(needle);
while (index !== -1) {
count++;
index = content.indexOf(needle, index + needle.length);
}
return count;
}

25
src/providers/common.ts Normal file
View file

@ -0,0 +1,25 @@
import type { RequestDescriptor } from '../types';
export function joinUrl(baseUrl: string, path: string): string {
const base = baseUrl.trim().replace(/\/+$/, '');
const suffix = path.startsWith('/') ? path : `/${path}`;
return `${base}${suffix}`;
}
export function postJson(url: string, body: unknown): RequestDescriptor {
return {
url,
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body
};
}
export function getJson(url: string): RequestDescriptor {
return {
url,
method: 'GET'
};
}

53
src/providers/ollama.ts Normal file
View file

@ -0,0 +1,53 @@
import { getJson, joinUrl, postJson } from './common';
import type { ChatRequestOptions, RequestDescriptor } from '../types';
interface OllamaChatResponse {
message?: {
role?: string;
content?: string;
};
}
interface OllamaModelResponse {
models?: Array<{
name?: string;
}>;
}
export function buildOllamaChatRequest(options: ChatRequestOptions): RequestDescriptor {
return postJson(joinUrl(options.baseUrl, '/api/chat'), {
model: options.model,
messages: options.messages,
stream: false,
options: {
temperature: options.temperature
}
});
}
export function buildOllamaModelsRequest(baseUrl: string): RequestDescriptor {
return getJson(joinUrl(baseUrl, '/api/tags'));
}
export function parseOllamaChatResponse(response: unknown): string {
const typed = response as OllamaChatResponse;
const content = typed.message?.content;
if (typeof content !== 'string') {
throw new Error('Ollama response did not include assistant content.');
}
return content;
}
export function parseOllamaModelList(response: unknown): string[] {
const typed = response as OllamaModelResponse;
if (!Array.isArray(typed.models)) {
return [];
}
return typed.models
.map((model) => model.name)
.filter((name): name is string => typeof name === 'string' && name.length > 0);
}

View file

@ -0,0 +1,53 @@
import { getJson, joinUrl, postJson } from './common';
import type { ChatRequestOptions, RequestDescriptor } from '../types';
interface OpenAICompatibleChatResponse {
choices?: Array<{
message?: {
role?: string;
content?: string | null;
};
}>;
}
interface OpenAICompatibleModelResponse {
data?: Array<{
id?: string;
}>;
}
export function buildOpenAICompatibleChatRequest(options: ChatRequestOptions): RequestDescriptor {
return postJson(joinUrl(options.baseUrl, '/chat/completions'), {
model: options.model,
messages: options.messages,
stream: false,
temperature: options.temperature
});
}
export function buildOpenAICompatibleModelsRequest(baseUrl: string): RequestDescriptor {
return getJson(joinUrl(baseUrl, '/models'));
}
export function parseOpenAICompatibleChatResponse(response: unknown): string {
const typed = response as OpenAICompatibleChatResponse;
const content = typed.choices?.[0]?.message?.content;
if (typeof content !== 'string') {
throw new Error('OpenAI-compatible response did not include assistant content.');
}
return content;
}
export function parseOpenAICompatibleModelList(response: unknown): string[] {
const typed = response as OpenAICompatibleModelResponse;
if (!Array.isArray(typed.data)) {
return [];
}
return typed.data
.map((model) => model.id)
.filter((id): id is string => typeof id === 'string' && id.length > 0);
}

View file

@ -0,0 +1,42 @@
import {
buildOllamaChatRequest,
buildOllamaModelsRequest,
parseOllamaChatResponse,
parseOllamaModelList
} from './ollama';
import {
buildOpenAICompatibleChatRequest,
buildOpenAICompatibleModelsRequest,
parseOpenAICompatibleChatResponse,
parseOpenAICompatibleModelList
} from './openai-compatible';
import type { ChatRequestOptions, ProviderKind, RequestDescriptor } from '../types';
export type RequestExecutor = (request: RequestDescriptor) => Promise<unknown>;
export interface ModelProvider {
chat(options: ChatRequestOptions): Promise<string>;
listModels(baseUrl: string): Promise<string[]>;
}
export function createModelProvider(kind: ProviderKind, execute: RequestExecutor): ModelProvider {
if (kind === 'ollama') {
return {
async chat(options) {
return parseOllamaChatResponse(await execute(buildOllamaChatRequest(options)));
},
async listModels(baseUrl) {
return parseOllamaModelList(await execute(buildOllamaModelsRequest(baseUrl)));
}
};
}
return {
async chat(options) {
return parseOpenAICompatibleChatResponse(await execute(buildOpenAICompatibleChatRequest(options)));
},
async listModels(baseUrl) {
return parseOpenAICompatibleModelList(await execute(buildOpenAICompatibleModelsRequest(baseUrl)));
}
};
}

View file

@ -0,0 +1,52 @@
import type { EndpointClassification } from '../types';
export function classifyEndpoint(rawUrl: string): EndpointClassification {
try {
const url = new URL(rawUrl);
const hostname = url.hostname.toLowerCase();
if (isLocalhost(hostname)) {
return {
kind: 'localhost',
requiresAcknowledgement: false,
reason: 'Endpoint is local to this device.'
};
}
if (isPrivateLanHost(hostname)) {
return {
kind: 'private-lan',
requiresAcknowledgement: false,
reason: 'Endpoint is on a private network.'
};
}
return {
kind: 'public',
requiresAcknowledgement: true,
reason: 'Public endpoints may receive note and vault context.'
};
} catch {
return {
kind: 'invalid',
requiresAcknowledgement: true,
reason: 'Endpoint URL is invalid.'
};
}
}
function isLocalhost(hostname: string): boolean {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
}
function isPrivateLanHost(hostname: string): boolean {
const parts = hostname.split('.').map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return hostname.endsWith('.local');
}
const [first, second] = parts;
return first === 10 || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168);
}

92
src/search.ts Normal file
View file

@ -0,0 +1,92 @@
import type { NoteSource, VaultSnippet } from './types';
export interface VaultSearchOptions {
excludedFolders: string[];
maxResults: number;
maxSnippetCharacters: number;
}
export function searchVaultSnippets(
notes: NoteSource[],
query: string,
options: VaultSearchOptions
): VaultSnippet[] {
const terms = tokenize(query);
if (terms.length === 0 || options.maxResults <= 0) {
return [];
}
return notes
.filter((note) => note.path.toLowerCase().endsWith('.md'))
.filter((note) => !isExcluded(note.path, options.excludedFolders))
.map((note) => scoreNote(note, terms, options.maxSnippetCharacters))
.filter((snippet): snippet is VaultSnippet => snippet !== null)
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
.slice(0, options.maxResults);
}
function scoreNote(note: NoteSource, terms: string[], maxSnippetCharacters: number): VaultSnippet | null {
const haystack = `${note.path}\n${note.content}`.toLowerCase();
let score = 0;
let firstIndex = -1;
for (const term of terms) {
const pathMatches = countOccurrences(note.path.toLowerCase(), term);
const contentMatches = countOccurrences(note.content.toLowerCase(), term);
if (pathMatches + contentMatches > 0 && firstIndex === -1) {
firstIndex = Math.max(0, note.content.toLowerCase().indexOf(term));
}
score += pathMatches * 3 + contentMatches;
}
if (score === 0 || !haystack) {
return null;
}
return {
path: note.path,
snippet: makeSnippet(note.content, firstIndex, maxSnippetCharacters),
score
};
}
function tokenize(input: string): string[] {
return [...new Set(input.toLowerCase().match(/[a-z0-9][a-z0-9_-]*/g) ?? [])];
}
function countOccurrences(input: string, term: string): number {
let count = 0;
let index = input.indexOf(term);
while (index !== -1) {
count++;
index = input.indexOf(term, index + term.length);
}
return count;
}
function isExcluded(path: string, excludedFolders: string[]): boolean {
return excludedFolders.some((folder) => {
const normalized = folder.trim().replace(/^\/+|\/+$/g, '');
return normalized.length > 0 && (path === normalized || path.startsWith(`${normalized}/`));
});
}
function makeSnippet(content: string, firstIndex: number, maxCharacters: number): string {
const normalized = content.replace(/\s+/g, ' ').trim();
if (normalized.length <= maxCharacters) {
return normalized;
}
const start = Math.max(0, firstIndex - Math.floor(maxCharacters / 3));
const snippet = normalized.slice(start, start + maxCharacters);
const prefix = start > 0 ? '...' : '';
const suffix = start + maxCharacters < normalized.length ? '...' : '';
return `${prefix}${snippet}${suffix}`.slice(0, maxCharacters);
}

56
src/settings-data.ts Normal file
View file

@ -0,0 +1,56 @@
import type { OChatSettings } from './types';
export const DEFAULT_SETTINGS: OChatSettings = {
provider: 'ollama',
baseUrl: 'http://localhost:11434',
model: 'llama3.2',
availableModels: [],
setupComplete: false,
composerMode: 'ask',
temperature: 0.2,
maxContextCharacters: 12000,
maxVaultResults: 5,
excludedFolders: [],
reviewMode: true,
remoteEndpointAcknowledged: false
};
export function normalizeSettings(data: Partial<OChatSettings> | null | undefined): OChatSettings {
const settings = {
...DEFAULT_SETTINGS,
...(data ?? {})
};
return {
...settings,
provider: settings.provider === 'openai-compatible' ? 'openai-compatible' : 'ollama',
model:
typeof settings.model === 'string' && settings.model.trim().length > 0
? settings.model.trim()
: DEFAULT_SETTINGS.model,
availableModels: Array.isArray(settings.availableModels)
? settings.availableModels.filter((model) => typeof model === 'string' && model.trim().length > 0)
: DEFAULT_SETTINGS.availableModels,
setupComplete: settings.setupComplete === true,
composerMode: settings.composerMode === 'edit' ? 'edit' : 'ask',
temperature: clampNumber(settings.temperature, 0, 2, DEFAULT_SETTINGS.temperature),
maxContextCharacters: clampNumber(
settings.maxContextCharacters,
1000,
100000,
DEFAULT_SETTINGS.maxContextCharacters
),
maxVaultResults: clampNumber(settings.maxVaultResults, 0, 20, DEFAULT_SETTINGS.maxVaultResults),
excludedFolders: Array.isArray(settings.excludedFolders)
? settings.excludedFolders.filter((folder) => typeof folder === 'string')
: DEFAULT_SETTINGS.excludedFolders
};
}
function clampNumber(value: number, min: number, max: number, fallback: number): number {
if (!Number.isFinite(value)) {
return fallback;
}
return Math.max(min, Math.min(max, value));
}

141
src/settings.ts Normal file
View file

@ -0,0 +1,141 @@
import { App, PluginSettingTab, Setting } from 'obsidian';
import type OChatPlugin from './main';
import { getModelSelectOptions } from './model-options';
import { classifyEndpoint } from './providers/url-policy';
import type { ProviderKind } from './types';
export class OChatSettingTab extends PluginSettingTab {
private readonly plugin: OChatPlugin;
constructor(app: App, plugin: OChatPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl).setName('Connection').setHeading();
new Setting(containerEl)
.setName('Provider')
.setDesc('Choose the local model API shape.')
.addDropdown((dropdown) => {
dropdown
.addOption('ollama', 'Ollama')
.addOption('openai-compatible', 'OpenAI-compatible')
.setValue(this.plugin.settings.provider)
.onChange(async (value) => {
this.plugin.settings.provider = value as ProviderKind;
await this.plugin.saveSettings();
this.display();
});
});
new Setting(containerEl)
.setName('Base URL')
.setDesc('Use localhost or a private network address unless you intentionally trust a remote endpoint.')
.addText((text) => {
text
.setValue(this.plugin.settings.baseUrl)
.onChange(async (value) => {
await this.plugin.updateBaseUrl(value);
});
});
const endpoint = classifyEndpoint(this.plugin.settings.baseUrl);
if (endpoint.requiresAcknowledgement) {
new Setting(containerEl)
.setName('Acknowledge remote endpoint')
.setDesc(endpoint.reason)
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.remoteEndpointAcknowledged).onChange(async (value) => {
this.plugin.settings.remoteEndpointAcknowledged = value;
await this.plugin.saveSettings();
});
});
}
new Setting(containerEl)
.setName('Model')
.setDesc('Model name to use for chat and edit requests.')
.addDropdown((dropdown) => {
for (const option of getModelSelectOptions(this.plugin.settings.model, this.plugin.settings.availableModels)) {
dropdown.addOption(option.value, option.label);
}
dropdown.setValue(this.plugin.settings.model).onChange(async (value) => {
await this.plugin.selectModel(value);
});
});
new Setting(containerEl)
.setName('Temperature')
.setDesc('Lower values are more deterministic.')
.addSlider((slider) => {
slider
.setLimits(0, 2, 0.1)
.setValue(this.plugin.settings.temperature)
.onChange(async (value) => {
this.plugin.settings.temperature = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl).setName('Context').setHeading();
new Setting(containerEl)
.setName('Maximum context characters')
.setDesc('Caps active note and vault snippets sent to the model.')
.addText((text) => {
text.setValue(String(this.plugin.settings.maxContextCharacters)).onChange(async (value) => {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
this.plugin.settings.maxContextCharacters = Math.round(parsed);
await this.plugin.saveSettings();
}
});
});
new Setting(containerEl)
.setName('Maximum vault results')
.setDesc('Number of lexical vault matches included with each request.')
.addSlider((slider) => {
slider
.setLimits(0, 20, 1)
.setValue(this.plugin.settings.maxVaultResults)
.onChange(async (value) => {
this.plugin.settings.maxVaultResults = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName('Excluded folders')
.setDesc('One vault-relative folder per line.')
.addTextArea((textArea) => {
textArea
.setPlaceholder(`${this.app.vault.configDir}\narchive`)
.setValue(this.plugin.settings.excludedFolders.join('\n'))
.onChange(async (value) => {
this.plugin.settings.excludedFolders = value
.split('\n')
.map((folder) => folder.trim())
.filter((folder) => folder.length > 0);
await this.plugin.saveSettings();
});
});
new Setting(containerEl).setName('Editing').setHeading();
new Setting(containerEl)
.setName('Review mode')
.setDesc('Preview model-generated Markdown edits before writing files.')
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.reviewMode).onChange(async (value) => {
this.plugin.settings.reviewMode = value;
await this.plugin.saveSettings();
});
});
}
}

67
src/types.ts Normal file
View file

@ -0,0 +1,67 @@
export type ChatRole = 'system' | 'user' | 'assistant';
export interface ChatMessage {
role: ChatRole;
content: string;
thinking?: string;
}
export interface ChatRequestOptions {
baseUrl: string;
model: string;
messages: ChatMessage[];
temperature: number;
}
export interface RequestDescriptor {
url: string;
method: 'GET' | 'POST';
headers?: Record<string, string>;
body?: unknown;
}
export interface NoteSource {
path: string;
content: string;
}
export interface VaultSnippet {
path: string;
snippet: string;
score: number;
}
export interface PatchProposal {
path: string;
original: string;
replacement: string;
rationale: string;
}
export type PatchValidationResult =
| { ok: true }
| { ok: false; reason: string };
export type ProviderKind = 'ollama' | 'openai-compatible';
export type ComposerMode = 'edit' | 'ask';
export interface OChatSettings {
provider: ProviderKind;
baseUrl: string;
model: string;
availableModels: string[];
setupComplete: boolean;
composerMode: ComposerMode;
temperature: number;
maxContextCharacters: number;
maxVaultResults: number;
excludedFolders: string[];
reviewMode: boolean;
remoteEndpointAcknowledged: boolean;
}
export interface EndpointClassification {
kind: 'localhost' | 'private-lan' | 'public' | 'invalid';
requiresAcknowledgement: boolean;
reason: string;
}

861
src/view.ts Normal file
View file

@ -0,0 +1,861 @@
import { ItemView, MarkdownRenderer, Notice, setIcon, WorkspaceLeaf } from 'obsidian';
import { parseAssistantResponse } from './assistant-response';
import { getComposerPanel, toggleToolsPanel } from './composer-tools';
import { OCHAT_DISPLAY_NAME, OCHAT_VIEW_TYPE } from './constants';
import { shouldSubmitPromptKey } from './keyboard';
import { getModelSelectOptions } from './model-options';
import type OChatPlugin from './main';
import { resolveSubmitMode } from './modes';
import { parsePatchProposals } from './patches';
import type { ChatMessage, ComposerMode, PatchProposal } from './types';
import type { SubmitMode } from './modes';
export class OChatView extends ItemView {
private readonly plugin: OChatPlugin;
private history: ChatMessage[] = [];
private pendingPatches: PatchProposal[] = [];
private transcriptEl: HTMLElement | null = null;
private promptEl: HTMLTextAreaElement | null = null;
private statusEl: HTMLElement | null = null;
private setupEl: HTMLElement | null = null;
private contextStripEl: HTMLElement | null = null;
private mentionSuggestionsEl: HTMLElement | null = null;
private testResultEl: HTMLElement | null = null;
private isAwaitingResponse = false;
private onboardingProbeStarted = false;
private contextOpen = false;
private settingsOpen = false;
private contextFilePaths: string[] = [];
private contextSearchQuery = '';
constructor(leaf: WorkspaceLeaf, plugin: OChatPlugin) {
super(leaf);
this.plugin = plugin;
this.icon = 'bot';
}
getViewType(): string {
return OCHAT_VIEW_TYPE;
}
getDisplayText(): string {
return OCHAT_DISPLAY_NAME;
}
async onOpen(): Promise<void> {
this.registerEvent(
this.app.workspace.on('active-leaf-change', () => {
this.renderContextStrip();
})
);
this.render();
if (this.plugin.needsOnboarding()) {
void this.runOnboardingProbe();
}
}
async onClose(): Promise<void> {
this.pendingPatches = [];
}
clearConversation(): void {
this.history = [];
this.pendingPatches = [];
this.render();
}
async askActiveNote(): Promise<void> {
await this.submitPrompt('Summarize the active note and identify useful follow-up questions.', 'chat');
}
async editActiveNote(): Promise<void> {
await this.submitPrompt(
[
'Review the active Markdown note and propose useful edits.',
'Return only JSON in this exact shape:',
'{"patches":[{"path":"path/to/note.md","original":"exact text","replacement":"new text","rationale":"why"}]}'
].join(' '),
'edit'
);
}
private render(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('ochat-view');
this.transcriptEl = contentEl.createDiv({ cls: 'ochat-transcript' });
this.renderTranscript();
const composer = contentEl.createDiv({ cls: 'ochat-composer' });
this.statusEl = composer.createDiv({ cls: 'ochat-status' });
this.renderEndpointStatus();
this.setupEl = composer.createDiv({ cls: 'ochat-setup' });
this.renderComposerSetup();
this.contextStripEl = composer.createDiv({ cls: 'ochat-context-strip' });
this.renderContextStrip();
this.promptEl = composer.createEl('textarea', {
cls: 'ochat-prompt',
attr: {
placeholder: 'Ask about this note, or type @ to attach more context'
}
});
this.promptEl.addEventListener('keydown', (event) => {
if (!shouldSubmitPromptKey(event)) {
return;
}
event.preventDefault();
void this.submitFromComposer();
});
this.promptEl.addEventListener('input', () => {
this.renderMentionSuggestions();
});
this.promptEl.addEventListener('keyup', () => {
this.renderMentionSuggestions();
});
this.promptEl.addEventListener('click', () => {
this.renderMentionSuggestions();
});
this.mentionSuggestionsEl = composer.createDiv({ cls: 'ochat-mention-suggestions' });
const footer = composer.createDiv({ cls: 'ochat-composer-footer' });
const controls = footer.createDiv({ cls: 'ochat-composer-controls' });
this.createIconButton(controls, 'plus', 'Add context files', () => {
this.contextOpen = toggleToolsPanel(this.contextOpen);
this.settingsOpen = false;
this.renderComposerSetup();
});
this.renderModelPicker(controls);
this.renderModeToggle(controls, this.plugin.settings.composerMode, async (mode) => {
await this.plugin.selectComposerMode(mode);
});
const actions = footer.createDiv({ cls: 'ochat-composer-actions' });
this.createIconButton(actions, 'settings', 'Settings', () => {
this.settingsOpen = !this.settingsOpen;
this.contextOpen = false;
this.renderComposerSetup();
});
this.createIconButton(actions, 'trash-2', 'Clear conversation', () => {
this.clearConversation();
});
this.createIconButton(actions, 'send', 'Send', () => {
void this.submitFromComposer();
}, 'ochat-send-button mod-cta');
}
private createIconButton(
containerEl: HTMLElement,
icon: string,
title: string,
onClick: () => void,
cls = 'ochat-icon-button'
): HTMLButtonElement {
const button = containerEl.createEl('button', {
cls,
attr: {
title,
'aria-label': title
}
});
setIcon(button, icon);
button.addEventListener('click', onClick);
return button;
}
private renderTranscript(): void {
if (!this.transcriptEl) {
return;
}
this.transcriptEl.empty();
if (this.history.length === 0 && this.pendingPatches.length === 0) {
this.transcriptEl.createDiv({
cls: 'ochat-empty',
text: 'Ask a question, summarize the active note, or request Markdown edits.'
});
return;
}
for (const message of this.history) {
const messageEl = this.transcriptEl.createDiv({
cls: `ochat-message ochat-message-${message.role}`
});
messageEl.createDiv({ cls: 'ochat-message-role', text: message.role });
this.renderMessageContent(messageEl, message);
}
if (this.isAwaitingResponse) {
const thinkingEl = this.transcriptEl.createDiv({
cls: 'ochat-message ochat-message-assistant ochat-message-thinking'
});
thinkingEl.createDiv({ cls: 'ochat-message-role', text: 'assistant' });
thinkingEl.createDiv({ cls: 'ochat-thinking-live', text: 'thinking...' });
}
if (this.pendingPatches.length > 0) {
this.renderPatchReview(this.pendingPatches);
}
}
private renderComposerSetup(): void {
if (!this.setupEl) {
return;
}
this.setupEl.empty();
const panel = getComposerPanel({
onboardingRequired: this.plugin.needsOnboarding(),
settingsOpen: this.settingsOpen,
contextOpen: this.contextOpen
});
if (panel === 'onboarding') {
this.renderOnboardingPanel(this.setupEl);
return;
}
if (panel === 'settings') {
this.renderSettingsPanel(this.setupEl);
return;
}
if (panel === 'context') {
this.renderContextPanel(this.setupEl);
}
}
private renderContextStrip(): void {
if (!this.contextStripEl) {
return;
}
this.contextStripEl.empty();
const activePath = this.plugin.getActiveMarkdownPath();
if (activePath) {
this.contextStripEl.createSpan({
cls: 'ochat-context-chip ochat-context-chip-active',
text: `Active: ${activePath}`
});
}
for (const path of this.getAttachedContextPaths()) {
const chip = this.contextStripEl.createEl('button', {
cls: 'ochat-context-chip ochat-context-chip-removable',
attr: { title: `Remove ${path}` }
});
chip.createSpan({ text: `@${path}` });
const close = chip.createSpan({ cls: 'ochat-context-chip-x' });
setIcon(close, 'x');
chip.addEventListener('click', () => {
this.removeContextFile(path);
});
}
if (this.plugin.settings.maxVaultResults > 0) {
this.contextStripEl.createSpan({
cls: 'ochat-context-chip ochat-context-chip-muted',
text: 'Vault search'
});
}
}
private renderSettingsPanel(containerEl: HTMLElement): void {
containerEl.createDiv({ cls: 'ochat-connection-title', text: 'Settings' });
const endpointRow = containerEl.createDiv({ cls: 'ochat-setting-row' });
endpointRow.createDiv({ cls: 'ochat-setting-label', text: 'Ollama endpoint' });
const endpointInput = endpointRow.createEl('input', {
type: 'text',
value: this.plugin.settings.baseUrl,
cls: 'ochat-endpoint-input'
});
const modelRow = containerEl.createDiv({ cls: 'ochat-setting-row' });
modelRow.createDiv({ cls: 'ochat-setting-label', text: 'Default model' });
const modelSelect = modelRow.createEl('select', {
cls: 'ochat-model-picker',
attr: {
title: 'Default model'
}
});
for (const option of getModelSelectOptions(this.plugin.settings.model, this.plugin.settings.availableModels)) {
modelSelect.createEl('option', {
text: option.label,
value: option.value
});
}
modelSelect.value = this.plugin.settings.model;
const behaviorRow = containerEl.createDiv({ cls: 'ochat-setting-row' });
behaviorRow.createDiv({ cls: 'ochat-setting-label', text: 'Default behavior' });
let selectedMode = this.plugin.settings.composerMode;
this.renderModeToggle(behaviorRow, selectedMode, (mode) => {
selectedMode = mode;
});
const actions = containerEl.createDiv({ cls: 'ochat-actions' });
actions.createEl('button', { text: 'Save', cls: 'mod-cta' }, (button) => {
button.addEventListener('click', () => {
void this.saveComposerSettings(endpointInput.value, modelSelect.value, selectedMode);
});
});
actions.createEl('button', { text: 'Refresh models' }, (button) => {
button.addEventListener('click', () => {
void this.refreshModels();
});
});
actions.createEl('button', { text: 'Test endpoint' }, (button) => {
button.addEventListener('click', () => {
void this.testEndpoint(endpointInput.value);
});
});
actions.createEl('button', { text: 'Close' }, (button) => {
button.addEventListener('click', () => {
this.settingsOpen = false;
this.renderComposerSetup();
});
});
this.testResultEl = containerEl.createDiv({ cls: 'ochat-test-result' });
}
private renderMessageContent(messageEl: HTMLElement, message: ChatMessage): void {
if (message.thinking?.trim()) {
const details = messageEl.createEl('details', { cls: 'ochat-thinking' });
details.createEl('summary', { text: 'Thinking' });
details.createEl('pre', { text: message.thinking.trim() });
}
if (message.role === 'assistant') {
const markdownEl = messageEl.createDiv({ cls: 'ochat-markdown markdown-rendered' });
void MarkdownRenderer.render(this.app, message.content, markdownEl, '', this);
return;
}
messageEl.createDiv({ cls: 'ochat-user-content', text: message.content });
}
private renderContextPanel(containerEl: HTMLElement): void {
containerEl.createDiv({ cls: 'ochat-connection-title', text: 'Attach context' });
if (this.contextFilePaths.length === 0) {
containerEl.createDiv({ cls: 'ochat-muted', text: 'No extra files attached.' });
} else {
const list = containerEl.createDiv({ cls: 'ochat-context-list' });
for (const path of this.contextFilePaths) {
const row = list.createDiv({ cls: 'ochat-context-file' });
row.createSpan({ text: path });
row.createEl('button', { text: 'Remove', cls: 'ochat-ghost-button' }, (button) => {
button.addEventListener('click', () => {
this.contextFilePaths = this.contextFilePaths.filter((item) => item !== path);
this.renderComposerSetup();
});
});
}
}
const searchInput = containerEl.createEl('input', {
type: 'text',
value: this.contextSearchQuery,
cls: 'ochat-context-search',
attr: {
placeholder: 'Search Markdown notes'
}
});
const results = containerEl.createDiv({ cls: 'ochat-context-results' });
const renderResults = () => {
results.empty();
const activePath = this.plugin.getActiveMarkdownPath();
const matches = this.plugin
.getMarkdownFileMatches(this.contextSearchQuery, 12)
.filter((path) => path !== activePath && !this.contextFilePaths.includes(path));
if (matches.length === 0) {
results.createDiv({ cls: 'ochat-muted', text: 'No matching notes' });
return;
}
for (const path of matches) {
const button = results.createEl('button', {
text: path,
cls: 'ochat-context-result'
});
button.addEventListener('click', () => {
this.attachContextFile(path);
});
}
};
searchInput.addEventListener('input', () => {
this.contextSearchQuery = searchInput.value;
renderResults();
});
renderResults();
const actions = containerEl.createDiv({ cls: 'ochat-actions' });
actions.createEl('button', { text: 'Clear files' }, (button) => {
button.addEventListener('click', () => {
this.contextFilePaths = [];
this.renderComposerSetup();
this.renderContextStrip();
});
});
actions.createEl('button', { text: 'Close' }, (button) => {
button.addEventListener('click', () => {
this.contextOpen = false;
this.renderComposerSetup();
});
});
}
private renderOnboardingPanel(containerEl: HTMLElement): void {
containerEl.createDiv({ cls: 'ochat-connection-title', text: 'Set up local model' });
containerEl.createDiv({
cls: 'ochat-muted',
text: `Default endpoint: ${this.plugin.settings.baseUrl}`
});
if (this.plugin.settings.availableModels.length > 0) {
this.renderModelPicker(containerEl);
containerEl.createEl('button', { text: 'Use selected model', cls: 'mod-cta' }, (button) => {
button.addEventListener('click', () => {
void this.completeOnboarding();
});
});
return;
}
const endpointInput = containerEl.createEl('input', {
type: 'text',
value: this.plugin.settings.baseUrl,
cls: 'ochat-endpoint-input'
});
const actions = containerEl.createDiv({ cls: 'ochat-actions' });
actions.createEl('button', { text: 'Test endpoint', cls: 'mod-cta' }, (button) => {
button.addEventListener('click', () => {
void this.testEndpoint(endpointInput.value);
});
});
actions.createEl('button', { text: 'Retry default' }, (button) => {
button.addEventListener('click', () => {
void this.testEndpoint('http://localhost:11434');
});
});
}
private renderModelPicker(containerEl: HTMLElement): void {
const models = this.plugin.settings.availableModels;
if (models.length === 0) {
const empty = containerEl.createEl('select', {
cls: 'ochat-model-picker',
attr: { title: 'No models discovered yet' }
});
empty.createEl('option', { text: 'No models' });
empty.disabled = true;
return;
}
const select = containerEl.createEl('select', {
cls: 'ochat-model-picker',
attr: { title: 'Model' }
});
for (const model of models) {
select.createEl('option', {
text: model,
value: model
});
}
select.value = this.plugin.settings.model;
select.addEventListener('change', () => {
void this.plugin.selectModel(select.value).then(() => {
this.renderEndpointStatus();
this.renderComposerSetup();
});
});
}
private renderModeToggle(
containerEl: HTMLElement,
selectedMode: ComposerMode,
onChange: (mode: ComposerMode) => void | Promise<void>
): void {
const group = containerEl.createDiv({
cls: 'ochat-mode-toggle',
attr: {
role: 'group',
'aria-label': 'Behavior'
}
});
const modes: Array<{ value: ComposerMode; label: string }> = [
{ value: 'ask', label: 'Ask' },
{ value: 'edit', label: 'Edit' }
];
const buttons = new Map<ComposerMode, HTMLButtonElement>();
for (const mode of modes) {
const button = group.createEl('button', {
text: mode.label,
cls: 'ochat-mode-toggle-button',
attr: {
type: 'button',
'aria-pressed': String(mode.value === selectedMode)
}
});
buttons.set(mode.value, button);
button.addEventListener('click', () => {
selectedMode = mode.value;
syncButtons();
void onChange(mode.value);
});
}
const syncButtons = () => {
for (const [mode, button] of buttons.entries()) {
button.toggleClass('is-active', mode === selectedMode);
button.setAttribute('aria-pressed', String(mode === selectedMode));
}
};
syncButtons();
}
private async saveComposerSettings(baseUrl: string, model: string, mode: ComposerMode): Promise<void> {
try {
if (baseUrl.trim() !== this.plugin.settings.baseUrl) {
await this.plugin.updateBaseUrl(baseUrl);
}
await this.plugin.selectModel(model);
await this.plugin.selectComposerMode(mode);
this.setStatus(`Ready. Default ${mode}. Model ${this.plugin.settings.model}.`);
this.renderEndpointStatus();
this.render();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.setStatus(message);
new Notice(message);
}
}
private renderPatchReview(patches: PatchProposal[]): void {
if (!this.transcriptEl) {
return;
}
const reviewEl = this.transcriptEl.createDiv({ cls: 'ochat-review' });
reviewEl.createDiv({ cls: 'ochat-review-title', text: 'Pending Markdown edits' });
for (const patch of patches) {
const patchEl = reviewEl.createDiv({ cls: 'ochat-patch' });
patchEl.createDiv({ cls: 'ochat-patch-path', text: patch.path });
patchEl.createDiv({ cls: 'ochat-patch-rationale', text: patch.rationale });
patchEl.createEl('pre', { cls: 'ochat-patch-original', text: patch.original });
patchEl.createEl('pre', { cls: 'ochat-patch-replacement', text: patch.replacement });
}
const actions = reviewEl.createDiv({ cls: 'ochat-actions' });
actions.createEl('button', { text: 'Apply edits', cls: 'mod-cta' }, (button) => {
button.addEventListener('click', () => {
void this.applyPendingPatches();
});
});
actions.createEl('button', { text: 'Discard' }, (button) => {
button.addEventListener('click', () => {
this.pendingPatches = [];
this.renderTranscript();
});
});
}
private renderEndpointStatus(): void {
if (!this.statusEl) {
return;
}
this.statusEl.empty();
const endpoint = this.plugin.getEndpointClassification();
this.statusEl.setText(`${this.plugin.settings.provider} - ${this.plugin.settings.baseUrl}`);
if (endpoint.requiresAcknowledgement) {
this.statusEl.createDiv({
cls: 'ochat-warning',
text: endpoint.reason
});
}
}
private async submitFromComposer(): Promise<void> {
const prompt = this.promptEl?.value.trim() ?? '';
if (prompt.length === 0) {
new Notice('Enter a prompt first.');
return;
}
if (this.promptEl) {
this.promptEl.value = '';
}
const mode = resolveSubmitMode(this.plugin.settings.composerMode, prompt);
await this.submitPrompt(prompt, mode);
}
private async submitPrompt(prompt: string, mode: SubmitMode): Promise<void> {
try {
this.setStatus('Building context...');
this.attachContextFiles(this.plugin.resolvePromptContextPaths(prompt));
const requestPrompt =
mode === 'edit'
? `${prompt}\n\nReturn only JSON with a patches array. Edit only Markdown files.`
: prompt;
const messages = await this.plugin.buildMessages(requestPrompt, this.history, this.contextFilePaths);
this.history.push({ role: 'user', content: prompt });
this.isAwaitingResponse = true;
this.renderTranscript();
this.setStatus('Waiting for model...');
const answer = await this.plugin.chat(messages);
this.isAwaitingResponse = false;
if (mode === 'edit') {
await this.handleEditAnswer(answer);
} else {
this.history.push({ role: 'assistant', ...parseAssistantResponse(answer) });
}
this.setStatus('Ready.');
this.renderTranscript();
} catch (error) {
this.isAwaitingResponse = false;
const message = error instanceof Error ? error.message : String(error);
this.setStatus(message);
this.renderTranscript();
new Notice(message);
}
}
private async handleEditAnswer(answer: string): Promise<void> {
const parsed = parseAssistantResponse(answer);
const patches = parsePatchProposals(parsed.content);
if (patches.length === 0) {
this.history.push({ role: 'assistant', ...parsed });
new Notice('The model did not return valid patch JSON.');
return;
}
if (this.plugin.settings.reviewMode) {
this.pendingPatches = patches;
this.history.push({
role: 'assistant',
content: `Prepared ${patches.length} Markdown edit${patches.length === 1 ? '' : 's'} for review.`,
thinking: parsed.thinking
});
return;
}
const results = await this.plugin.applyPatches(patches, true);
this.history.push({
role: 'assistant',
content: results.map((result) => JSON.stringify(result)).join('\n')
});
}
private async applyPendingPatches(): Promise<void> {
if (this.pendingPatches.length === 0) {
return;
}
try {
const results = await this.plugin.applyPatches(this.pendingPatches, true);
this.pendingPatches = [];
this.history.push({
role: 'assistant',
content: results.map((result) => JSON.stringify(result)).join('\n')
});
this.renderTranscript();
new Notice('Applied reviewed edits.');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
new Notice(message);
this.setStatus(message);
}
}
async refreshModels(): Promise<void> {
try {
this.setStatus('Refreshing models...');
const models = await this.plugin.refreshAvailableModels();
this.setStatus(models.length > 0 ? `Ready. Selected ${this.plugin.settings.model}.` : 'No models were returned.');
this.renderComposerSetup();
this.render();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.setStatus(message);
new Notice(message);
}
}
private async runOnboardingProbe(): Promise<void> {
if (this.onboardingProbeStarted) {
return;
}
this.onboardingProbeStarted = true;
await this.refreshModels();
}
private async testEndpoint(baseUrl: string): Promise<void> {
try {
this.setStatus('Testing endpoint...');
this.setTestResult('Testing endpoint...', 'pending');
await this.plugin.updateBaseUrl(baseUrl);
const models = await this.plugin.refreshAvailableModels();
const message =
models.length > 0
? `Success. Found ${models.length} model${models.length === 1 ? '' : 's'}. Selected ${this.plugin.settings.model}.`
: 'Connected, but no models were returned.';
this.renderEndpointStatus();
this.renderComposerSetup();
this.setStatus(message);
this.setTestResult(message, models.length > 0 ? 'success' : 'warning');
new Notice(message);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.setStatus(message);
this.renderComposerSetup();
this.setTestResult(`Failed. ${message}`, 'error');
new Notice(message);
}
}
private async completeOnboarding(): Promise<void> {
await this.plugin.selectModel(this.plugin.settings.model);
this.setStatus(`Ready. Selected ${this.plugin.settings.model}.`);
this.renderComposerSetup();
this.render();
}
private setStatus(message: string): void {
this.statusEl?.setText(message);
}
private setTestResult(message: string, kind: 'pending' | 'success' | 'warning' | 'error'): void {
if (!this.testResultEl) {
return;
}
this.testResultEl.empty();
this.testResultEl.setText(message);
this.testResultEl.className = `ochat-test-result ochat-test-result-${kind}`;
}
private renderMentionSuggestions(): void {
if (!this.mentionSuggestionsEl) {
return;
}
this.mentionSuggestionsEl.empty();
this.mentionSuggestionsEl.removeClass('is-visible');
const mention = this.getActiveMentionAtCursor();
if (!mention) {
return;
}
const matches = this.plugin
.getMarkdownFileMatches(mention.query, 8)
.filter((path) => path !== this.plugin.getActiveMarkdownPath() && !this.contextFilePaths.includes(path));
if (matches.length === 0) {
this.mentionSuggestionsEl.createDiv({ cls: 'ochat-muted', text: 'No matching notes' });
this.mentionSuggestionsEl.addClass('is-visible');
return;
}
for (const path of matches) {
const button = this.mentionSuggestionsEl.createEl('button', {
text: path,
cls: 'ochat-mention-suggestion'
});
button.addEventListener('click', () => {
this.attachContextFile(path);
this.removeActiveMentionToken(mention);
this.renderMentionSuggestions();
});
}
this.mentionSuggestionsEl.addClass('is-visible');
}
private getActiveMentionAtCursor(): { start: number; end: number; query: string } | null {
if (!this.promptEl) {
return null;
}
const cursor = this.promptEl.selectionStart;
const beforeCursor = this.promptEl.value.slice(0, cursor);
const match = /(^|\s)@([^\s@"[\]]*)$/.exec(beforeCursor);
if (!match) {
return null;
}
return {
start: beforeCursor.length - match[2].length - 1,
end: cursor,
query: match[2]
};
}
private removeActiveMentionToken(mention: { start: number; end: number }): void {
if (!this.promptEl) {
return;
}
const value = this.promptEl.value;
const before = value.slice(0, mention.start).trimEnd();
const after = value.slice(mention.end).trimStart();
const spacer = before.length > 0 && after.length > 0 ? ' ' : '';
const nextValue = `${before}${spacer}${after}`;
this.promptEl.value = nextValue;
const cursor = before.length + spacer.length;
this.promptEl.setSelectionRange(cursor, cursor);
this.promptEl.focus();
}
private attachContextFiles(paths: string[]): void {
for (const path of paths) {
this.attachContextFile(path);
}
}
private attachContextFile(path: string): void {
if (path === this.plugin.getActiveMarkdownPath() || this.contextFilePaths.includes(path)) {
return;
}
this.contextFilePaths.push(path);
this.renderComposerSetup();
this.renderContextStrip();
}
private removeContextFile(path: string): void {
this.contextFilePaths = this.contextFilePaths.filter((item) => item !== path);
this.renderComposerSetup();
this.renderContextStrip();
}
private getAttachedContextPaths(): string[] {
const activePath = this.plugin.getActiveMarkdownPath();
return this.contextFilePaths.filter((path) => path !== activePath);
}
}

415
styles.css Normal file
View file

@ -0,0 +1,415 @@
.ochat-view {
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
padding: 10px;
}
.ochat-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ochat-status {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
min-height: 18px;
}
.ochat-setup {
display: flex;
flex-direction: column;
gap: 8px;
padding-bottom: 4px;
}
.ochat-connection-title {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
font-weight: var(--font-semibold);
text-transform: uppercase;
}
.ochat-muted {
color: var(--text-muted);
font-size: var(--font-ui-small);
}
.ochat-setting-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.ochat-setting-label {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
font-weight: var(--font-medium);
}
.ochat-model-picker,
.ochat-context-picker,
.ochat-context-search,
.ochat-endpoint-input {
max-width: 100%;
}
.ochat-warning {
color: var(--text-warning);
margin-top: 6px;
}
.ochat-transcript {
display: flex;
flex: 1;
flex-direction: column;
gap: 10px;
min-height: 0;
overflow: auto;
}
.ochat-empty {
color: var(--text-muted);
font-size: var(--font-ui-small);
padding: 12px 0;
}
.ochat-message,
.ochat-review {
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
padding: 10px;
width: 100%;
}
.ochat-message-role,
.ochat-review-title,
.ochat-patch-path {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
font-weight: var(--font-semibold);
margin-bottom: 6px;
text-transform: uppercase;
}
.ochat-user-content {
white-space: pre-wrap;
}
.ochat-markdown {
font-family: var(--font-text);
font-size: var(--font-ui-medium);
line-height: var(--line-height-normal);
max-width: 100%;
overflow-wrap: anywhere;
width: 100%;
}
.ochat-markdown > :first-child {
margin-top: 0;
}
.ochat-markdown > :last-child {
margin-bottom: 0;
}
.ochat-thinking {
border-bottom: 1px solid var(--background-modifier-border);
color: var(--text-muted);
margin-bottom: 8px;
padding-bottom: 8px;
}
.ochat-thinking summary {
cursor: pointer;
font-size: var(--font-ui-smaller);
font-weight: var(--font-medium);
}
.ochat-thinking-live {
color: var(--text-muted);
font-size: var(--font-ui-small);
font-style: italic;
}
.ochat-thinking pre,
.ochat-patch pre {
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
margin: 6px 0;
max-width: 100%;
overflow: auto;
padding: 8px;
white-space: pre-wrap;
}
.ochat-test-result {
border-radius: 4px;
font-size: var(--font-ui-small);
min-height: 24px;
padding: 4px 6px;
}
.ochat-test-result:empty {
display: none;
}
.ochat-test-result-pending,
.ochat-test-result-warning {
background: var(--background-secondary);
color: var(--text-muted);
}
.ochat-test-result-success {
background: var(--background-modifier-success);
color: var(--text-success);
}
.ochat-test-result-error {
background: var(--background-modifier-error);
color: var(--text-error);
}
.ochat-message-user {
border-color: var(--interactive-accent);
}
.ochat-patch {
border-top: 1px solid var(--background-modifier-border);
margin-top: 10px;
padding-top: 10px;
}
.ochat-patch-rationale {
color: var(--text-normal);
font-size: var(--font-ui-small);
margin-bottom: 8px;
}
.ochat-patch-original {
color: var(--text-error);
}
.ochat-patch-replacement {
color: var(--text-success);
}
.ochat-context-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.ochat-context-file {
align-items: center;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
display: flex;
gap: 8px;
justify-content: space-between;
padding: 6px 8px;
}
.ochat-context-file span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ochat-context-search {
width: 100%;
}
.ochat-context-results {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 180px;
overflow: auto;
}
.ochat-context-result {
background: transparent;
border: 0;
box-shadow: none;
justify-content: flex-start;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.ochat-context-strip {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 28px;
}
.ochat-context-chip {
align-items: center;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 999px;
color: var(--text-muted);
display: inline-flex;
font-size: var(--font-ui-smaller);
gap: 4px;
line-height: 1.2;
max-width: 100%;
min-height: 24px;
padding: 3px 8px;
}
.ochat-context-chip span:first-child,
.ochat-context-chip-active {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ochat-context-chip-active {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.ochat-context-chip-muted {
border-style: dashed;
}
.ochat-context-chip-removable {
cursor: pointer;
}
.ochat-context-chip-x {
align-items: center;
display: inline-flex;
height: 14px;
width: 14px;
}
.ochat-mention-suggestions {
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
box-shadow: 0 4px 14px rgb(0 0 0 / 12%);
display: none;
flex-direction: column;
gap: 2px;
max-height: 160px;
overflow: auto;
padding: 4px;
}
.ochat-mention-suggestions.is-visible {
display: flex;
}
.ochat-mention-suggestion {
background: transparent;
border: 0;
box-shadow: none;
justify-content: flex-start;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.ochat-composer {
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
}
.ochat-prompt {
background: transparent;
border: 0;
box-shadow: none;
font-size: var(--font-ui-medium);
min-height: 96px;
outline: none;
resize: vertical;
width: 100%;
}
.ochat-prompt:focus {
box-shadow: none;
}
.ochat-composer-footer {
align-items: center;
border-top: 1px solid var(--background-modifier-border);
display: flex;
gap: 8px;
justify-content: space-between;
padding-top: 8px;
}
.ochat-composer-controls,
.ochat-composer-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.ochat-model-picker {
max-width: 170px;
}
.ochat-mode-toggle {
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
display: inline-flex;
overflow: hidden;
}
.ochat-mode-toggle-button {
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
min-width: 44px;
padding: 4px 8px;
}
.ochat-mode-toggle-button.is-active {
background: var(--interactive-accent);
color: var(--text-on-accent);
}
.ochat-icon-button,
.ochat-ghost-button,
.ochat-send-button {
min-width: 0;
}
.ochat-icon-button,
.ochat-send-button {
align-items: center;
display: inline-flex;
justify-content: center;
min-height: 28px;
min-width: 28px;
padding: 4px;
}

View file

@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest';
import { parseAssistantResponse } from '../src/assistant-response';
describe('assistant response parsing', () => {
test('separates think tags from visible Markdown', () => {
expect(parseAssistantResponse('<think>Reviewing the note.</think>\n\n**Next step:** Book movers.')).toEqual({
content: '**Next step:** Book movers.',
thinking: 'Reviewing the note.'
});
});
test('combines multiple thinking blocks and removes them from content', () => {
expect(parseAssistantResponse('<think>A</think>\nAnswer\n<think>B</think>')).toEqual({
content: 'Answer',
thinking: 'A\n\nB'
});
});
test('returns plain content when there is no thinking block', () => {
expect(parseAssistantResponse('## Summary\n\n- Item')).toEqual({
content: '## Summary\n\n- Item'
});
});
});

View file

@ -0,0 +1,46 @@
import { describe, expect, test } from 'vitest';
import { getComposerPanel, isToolsPanelVisible, toggleToolsPanel } from '../src/composer-tools';
describe('composer tools panel', () => {
test('shows tools while onboarding is required', () => {
expect(isToolsPanelVisible({ toolsOpen: false, onboardingRequired: true })).toBe(true);
});
test('hides tools when setup is complete and the panel is closed', () => {
expect(isToolsPanelVisible({ toolsOpen: false, onboardingRequired: false })).toBe(false);
});
test('shows tools when setup is complete and the panel is open', () => {
expect(isToolsPanelVisible({ toolsOpen: true, onboardingRequired: false })).toBe(true);
});
test('toggles the tools panel state', () => {
expect(toggleToolsPanel(false)).toBe(true);
expect(toggleToolsPanel(true)).toBe(false);
});
test('routes onboarding when setup is required and no panel is explicitly open', () => {
expect(getComposerPanel({ onboardingRequired: true, settingsOpen: false, contextOpen: false })).toBe('onboarding');
});
test('routes settings even when onboarding is required', () => {
expect(getComposerPanel({ onboardingRequired: true, settingsOpen: true, contextOpen: false })).toBe('settings');
});
test('routes plus button state to context panel even when onboarding is required', () => {
expect(getComposerPanel({ onboardingRequired: true, settingsOpen: false, contextOpen: true })).toBe('context');
});
test('routes settings independently from context tools', () => {
expect(getComposerPanel({ onboardingRequired: false, settingsOpen: true, contextOpen: false })).toBe('settings');
});
test('routes plus button state to context panel', () => {
expect(getComposerPanel({ onboardingRequired: false, settingsOpen: false, contextOpen: true })).toBe('context');
});
test('returns no panel when all panels are closed', () => {
expect(getComposerPanel({ onboardingRequired: false, settingsOpen: false, contextOpen: false })).toBe(null);
});
});

View file

@ -0,0 +1,36 @@
import { describe, expect, test } from 'vitest';
import {
extractContextMentions,
findMarkdownPathMatches,
resolveMentionedMarkdownPaths
} from '../src/context-mentions';
const paths = [
'README.md',
'Areas/Moving/00 Moving Dashboard.md',
'Projects/Move Plan.md',
'Projects/Package Notes.md',
'Templates/Travel Template.md'
];
describe('context mentions', () => {
test('extracts bare, quoted, and wiki-style @ mentions', () => {
expect(
extractContextMentions('Summarize @README.md with @"Projects/Move Plan.md" and @[[Areas/Moving/00 Moving Dashboard.md]]')
).toEqual(['README.md', 'Projects/Move Plan.md', 'Areas/Moving/00 Moving Dashboard.md']);
});
test('resolves exact and fuzzy @ mentions to Markdown paths without duplicates', () => {
expect(resolveMentionedMarkdownPaths('Use @README.md and @move and @[[README.md]]', paths)).toEqual([
'README.md',
'Projects/Move Plan.md',
'Areas/Moving/00 Moving Dashboard.md'
]);
});
test('finds picker suggestions by filename and path', () => {
expect(findMarkdownPathMatches('pack', paths, 5)).toEqual(['Projects/Package Notes.md']);
expect(findMarkdownPathMatches('moving', paths, 5)).toEqual(['Areas/Moving/00 Moving Dashboard.md']);
});
});

81
tests/context.test.ts Normal file
View file

@ -0,0 +1,81 @@
import { describe, expect, test } from 'vitest';
import { buildContextBundle } from '../src/context';
import { searchVaultSnippets } from '../src/search';
import type { NoteSource } from '../src/types';
const notes: NoteSource[] = [
{
path: 'Projects/Alpha.md',
content: 'Alpha launch notes mention Ollama, local models, and private workflows.'
},
{
path: 'Projects/Beta.md',
content: 'Beta planning covers unrelated budgeting and timelines.'
},
{
path: 'Archive/Alpha old.md',
content: 'Archived Alpha notes should not be searched.'
}
];
describe('vault search', () => {
test('returns ranked Markdown snippets while respecting excluded folders', () => {
const snippets = searchVaultSnippets(notes, 'Alpha local models', {
excludedFolders: ['Archive'],
maxResults: 2,
maxSnippetCharacters: 80
});
expect(snippets).toHaveLength(1);
expect(snippets[0]).toMatchObject({
path: 'Projects/Alpha.md'
});
expect(snippets[0].snippet).toContain('local models');
});
});
describe('context bundle', () => {
test('includes active note, selected text, and capped vault snippets', () => {
const bundle = buildContextBundle({
activeNote: {
path: 'Daily.md',
content: 'Today I worked on OChat implementation details for a local Obsidian assistant.'
},
selectedText: 'selected implementation details',
contextNotes: [],
vaultSnippets: [
{
path: 'Projects/Alpha.md',
snippet: 'Alpha launch notes mention Ollama, local models, and private workflows.',
score: 4
}
],
maxContextCharacters: 260
});
expect(bundle.messages[0].role).toBe('system');
expect(bundle.messages[1].content).toContain('Daily.md');
expect(bundle.messages[1].content).toContain('selected implementation details');
expect(bundle.messages[1].content).toContain('Projects/Alpha.md');
expect(bundle.messages[1].content.length).toBeLessThanOrEqual(260);
});
test('includes explicitly attached context files', () => {
const bundle = buildContextBundle({
activeNote: null,
selectedText: '',
contextNotes: [
{
path: 'Projects/Move Plan.md',
content: 'Hire movers and reserve elevator.'
}
],
vaultSnippets: [],
maxContextCharacters: 500
});
expect(bundle.messages[1].content).toContain('Context file: Projects/Move Plan.md');
expect(bundle.messages[1].content).toContain('Hire movers and reserve elevator.');
});
});

21
tests/keyboard.test.ts Normal file
View file

@ -0,0 +1,21 @@
import { describe, expect, test } from 'vitest';
import { shouldSubmitPromptKey } from '../src/keyboard';
describe('composer keyboard behavior', () => {
test('submits on plain Enter', () => {
expect(shouldSubmitPromptKey({ key: 'Enter', shiftKey: false, isComposing: false })).toBe(true);
});
test('keeps Shift+Enter available for multiline prompts', () => {
expect(shouldSubmitPromptKey({ key: 'Enter', shiftKey: true, isComposing: false })).toBe(false);
});
test('does not submit while IME composition is active', () => {
expect(shouldSubmitPromptKey({ key: 'Enter', shiftKey: false, isComposing: true })).toBe(false);
});
test('ignores non-Enter keys', () => {
expect(shouldSubmitPromptKey({ key: 'a', shiftKey: false, isComposing: false })).toBe(false);
});
});

View file

@ -0,0 +1,23 @@
import { describe, expect, test } from 'vitest';
import { getModelSelectOptions } from '../src/model-options';
describe('model select options', () => {
test('uses discovered models for the dropdown', () => {
expect(getModelSelectOptions('gemma4:12b-it-qat', ['qwen3.6:35b-a3b', 'gemma4:12b-it-qat'])).toEqual([
{ value: 'qwen3.6:35b-a3b', label: 'qwen3.6:35b-a3b' },
{ value: 'gemma4:12b-it-qat', label: 'gemma4:12b-it-qat' }
]);
});
test('keeps the saved model as a dropdown option when discovery is stale', () => {
expect(getModelSelectOptions('custom-model', ['qwen3.6:35b-a3b'])).toEqual([
{ value: 'custom-model', label: 'custom-model (saved)' },
{ value: 'qwen3.6:35b-a3b', label: 'qwen3.6:35b-a3b' }
]);
});
test('still returns a dropdown option before models are discovered', () => {
expect(getModelSelectOptions('llama3.2', [])).toEqual([{ value: 'llama3.2', label: 'llama3.2 (saved)' }]);
});
});

13
tests/modes.test.ts Normal file
View file

@ -0,0 +1,13 @@
import { describe, expect, test } from 'vitest';
import { resolveSubmitMode } from '../src/modes';
describe('composer mode routing', () => {
test('keeps ask mode as chat', () => {
expect(resolveSubmitMode('ask', 'rewrite this note')).toBe('chat');
});
test('keeps edit mode as edit', () => {
expect(resolveSubmitMode('edit', 'what is this note about?')).toBe('edit');
});
});

55
tests/onboarding.test.ts Normal file
View file

@ -0,0 +1,55 @@
import { describe, expect, test } from 'vitest';
import { applyModelDiscovery, isOnboardingRequired } from '../src/onboarding';
import { normalizeSettings } from '../src/settings-data';
describe('onboarding model discovery', () => {
test('requires onboarding for fresh settings', () => {
const settings = normalizeSettings(null);
expect(isOnboardingRequired(settings)).toBe(true);
});
test('selects the first discovered model when current model is unavailable', () => {
const settings = normalizeSettings({
model: 'missing-model',
setupComplete: false,
availableModels: []
});
const updated = applyModelDiscovery(settings, ['qwen3.6:35b-a3b', 'gemma4:12b-it-qat']);
expect(updated.model).toBe('qwen3.6:35b-a3b');
expect(updated.availableModels).toEqual(['qwen3.6:35b-a3b', 'gemma4:12b-it-qat']);
expect(updated.setupComplete).toBe(true);
expect(isOnboardingRequired(updated)).toBe(false);
});
test('preserves the selected model when it is still available', () => {
const settings = normalizeSettings({
model: 'gemma4:12b-it-qat',
setupComplete: true,
availableModels: ['gemma4:12b-it-qat']
});
const updated = applyModelDiscovery(settings, ['qwen3.6:35b-a3b', 'gemma4:12b-it-qat']);
expect(updated.model).toBe('gemma4:12b-it-qat');
expect(updated.setupComplete).toBe(true);
});
test('keeps setup incomplete when no models are discovered', () => {
const settings = normalizeSettings({
model: 'llama3.2',
setupComplete: false,
availableModels: []
});
const updated = applyModelDiscovery(settings, []);
expect(updated.model).toBe('llama3.2');
expect(updated.availableModels).toEqual([]);
expect(updated.setupComplete).toBe(false);
expect(isOnboardingRequired(updated)).toBe(true);
});
});

View file

@ -0,0 +1,80 @@
import { describe, expect, test } from 'vitest';
import { applyPatchProposals } from '../src/patch-applier';
import type { PatchProposal } from '../src/types';
const patch: PatchProposal = {
path: 'Notes/Plan.md',
original: 'old text',
replacement: 'new text',
rationale: 'Improve wording'
};
describe('patch applier', () => {
test('holds valid patches when review mode is enabled and approval is missing', async () => {
const writes: Array<{ path: string; content: string }> = [];
const results = await applyPatchProposals([patch], {
reviewMode: true,
approved: false,
readFile: async () => 'old text',
writeFile: async (path, content) => {
writes.push({ path, content });
}
});
expect(results).toEqual([
{
path: 'Notes/Plan.md',
status: 'pending-review',
rationale: 'Improve wording'
}
]);
expect(writes).toEqual([]);
});
test('applies valid patches when review mode is disabled', async () => {
const writes: Array<{ path: string; content: string }> = [];
const results = await applyPatchProposals([patch], {
reviewMode: false,
approved: false,
readFile: async () => 'Before\nold text\nAfter',
writeFile: async (path, content) => {
writes.push({ path, content });
}
});
expect(results).toEqual([
{
path: 'Notes/Plan.md',
status: 'applied',
rationale: 'Improve wording'
}
]);
expect(writes).toEqual([{ path: 'Notes/Plan.md', content: 'Before\nnew text\nAfter' }]);
});
test('reports invalid patches without writing', async () => {
const writes: Array<{ path: string; content: string }> = [];
const results = await applyPatchProposals([{ ...patch, path: 'Notes/Plan.canvas' }], {
reviewMode: false,
approved: false,
readFile: async () => 'old text',
writeFile: async (path, content) => {
writes.push({ path, content });
}
});
expect(results).toEqual([
{
path: 'Notes/Plan.canvas',
status: 'rejected',
reason: 'Only Markdown files can be edited.',
rationale: 'Improve wording'
}
]);
expect(writes).toEqual([]);
});
});

61
tests/patches.test.ts Normal file
View file

@ -0,0 +1,61 @@
import { describe, expect, test } from 'vitest';
import {
applyPatchToContent,
parsePatchProposals,
validatePatchProposal
} from '../src/patches';
describe('patch proposals', () => {
test('parses patch JSON from plain or fenced model output', () => {
const plain = parsePatchProposals(
'{"patches":[{"path":"Note.md","original":"old","replacement":"new","rationale":"cleanup"}]}'
);
const fenced = parsePatchProposals(
'```json\n{"patches":[{"path":"Note.md","original":"old","replacement":"new","rationale":"cleanup"}]}\n```'
);
expect(plain[0]).toEqual({
path: 'Note.md',
original: 'old',
replacement: 'new',
rationale: 'cleanup'
});
expect(fenced).toEqual(plain);
});
test('applies a valid Markdown patch with an exact single match', () => {
const patch = {
path: 'Folder/Note.md',
original: 'old paragraph',
replacement: 'new paragraph',
rationale: 'clarify'
};
const currentContent = 'Title\n\nold paragraph\n';
expect(validatePatchProposal(patch, currentContent)).toEqual({ ok: true });
expect(applyPatchToContent(patch, currentContent)).toBe('Title\n\nnew paragraph\n');
});
test('rejects non-Markdown, missing, and ambiguous patch targets', () => {
const basePatch = {
path: 'Note.md',
original: 'target',
replacement: 'replacement',
rationale: 'test'
};
expect(validatePatchProposal({ ...basePatch, path: 'canvas.json' }, 'target')).toMatchObject({
ok: false,
reason: 'Only Markdown files can be edited.'
});
expect(validatePatchProposal(basePatch, 'missing')).toMatchObject({
ok: false,
reason: 'Original text was not found.'
});
expect(validatePatchProposal(basePatch, 'target and target')).toMatchObject({
ok: false,
reason: 'Original text matched more than once.'
});
});
});

View file

@ -0,0 +1,56 @@
import { describe, expect, test } from 'vitest';
import { createModelProvider } from '../src/providers/provider-client';
import type { RequestDescriptor } from '../src/types';
describe('model provider clients', () => {
test('uses the injected executor for Ollama chat and model listing', async () => {
const seen: RequestDescriptor[] = [];
const provider = createModelProvider('ollama', async (request) => {
seen.push(request);
if (request.url.endsWith('/api/tags')) {
return { models: [{ name: 'llama3.2' }] };
}
return { message: { role: 'assistant', content: 'Ollama answer' } };
});
await expect(
provider.chat({
baseUrl: 'http://localhost:11434',
model: 'llama3.2',
messages: [{ role: 'user', content: 'Hello' }],
temperature: 0.3
})
).resolves.toBe('Ollama answer');
await expect(provider.listModels('http://localhost:11434')).resolves.toEqual(['llama3.2']);
expect(seen.map((request) => request.url)).toEqual([
'http://localhost:11434/api/chat',
'http://localhost:11434/api/tags'
]);
});
test('uses the injected executor for OpenAI-compatible chat and model listing', async () => {
const seen: RequestDescriptor[] = [];
const provider = createModelProvider('openai-compatible', async (request) => {
seen.push(request);
if (request.url.endsWith('/models')) {
return { data: [{ id: 'local-model' }] };
}
return { choices: [{ message: { role: 'assistant', content: 'Compatible answer' } }] };
});
await expect(
provider.chat({
baseUrl: 'http://localhost:1234/v1',
model: 'local-model',
messages: [{ role: 'user', content: 'Hello' }],
temperature: 0.3
})
).resolves.toBe('Compatible answer');
await expect(provider.listModels('http://localhost:1234/v1')).resolves.toEqual(['local-model']);
expect(seen.map((request) => request.url)).toEqual([
'http://localhost:1234/v1/chat/completions',
'http://localhost:1234/v1/models'
]);
});
});

97
tests/providers.test.ts Normal file
View file

@ -0,0 +1,97 @@
import { describe, expect, test } from 'vitest';
import {
buildOllamaChatRequest,
parseOllamaChatResponse,
parseOllamaModelList
} from '../src/providers/ollama';
import {
buildOpenAICompatibleChatRequest,
parseOpenAICompatibleChatResponse,
parseOpenAICompatibleModelList
} from '../src/providers/openai-compatible';
import { classifyEndpoint } from '../src/providers/url-policy';
import type { ChatMessage } from '../src/types';
const messages: ChatMessage[] = [
{ role: 'system', content: 'Use Markdown.' },
{ role: 'user', content: 'Summarize this note.' }
];
describe('provider request parsing', () => {
test('builds non-streaming Ollama chat requests', () => {
const request = buildOllamaChatRequest({
baseUrl: 'http://localhost:11434',
model: 'llama3.2',
messages,
temperature: 0.2
});
expect(request.url).toBe('http://localhost:11434/api/chat');
expect(request.method).toBe('POST');
expect(request.body).toEqual({
model: 'llama3.2',
messages,
stream: false,
options: { temperature: 0.2 }
});
});
test('parses Ollama chat and model responses', () => {
expect(parseOllamaChatResponse({ message: { role: 'assistant', content: 'Done' } })).toBe('Done');
expect(parseOllamaModelList({ models: [{ name: 'llama3.2' }, { name: 'qwen2.5:7b' }] })).toEqual([
'llama3.2',
'qwen2.5:7b'
]);
});
test('builds non-streaming OpenAI-compatible chat requests', () => {
const request = buildOpenAICompatibleChatRequest({
baseUrl: 'http://localhost:1234/v1',
model: 'local-model',
messages,
temperature: 0.1
});
expect(request.url).toBe('http://localhost:1234/v1/chat/completions');
expect(request.method).toBe('POST');
expect(request.body).toEqual({
model: 'local-model',
messages,
stream: false,
temperature: 0.1
});
});
test('parses OpenAI-compatible chat and model responses', () => {
expect(
parseOpenAICompatibleChatResponse({
choices: [{ message: { role: 'assistant', content: 'Answer' } }]
})
).toBe('Answer');
expect(parseOpenAICompatibleModelList({ data: [{ id: 'model-a' }, { id: 'model-b' }] })).toEqual([
'model-a',
'model-b'
]);
});
});
describe('endpoint classification', () => {
test('accepts localhost and private LAN endpoints without acknowledgement', () => {
expect(classifyEndpoint('http://localhost:11434')).toMatchObject({
kind: 'localhost',
requiresAcknowledgement: false
});
expect(classifyEndpoint('http://192.168.1.10:11434')).toMatchObject({
kind: 'private-lan',
requiresAcknowledgement: false
});
});
test('requires acknowledgement for public endpoints', () => {
expect(classifyEndpoint('https://api.example.com')).toMatchObject({
kind: 'public',
requiresAcknowledgement: true
});
});
});

View file

@ -0,0 +1,17 @@
import { describe, expect, test } from 'vitest';
import { normalizeSettings } from '../src/settings-data';
describe('settings normalization', () => {
test('defaults composer behavior to ask', () => {
expect(normalizeSettings(null).composerMode).toBe('ask');
});
test('migrates legacy auto mode to ask', () => {
expect(normalizeSettings({ composerMode: 'auto' as never }).composerMode).toBe('ask');
});
test('preserves explicit edit mode', () => {
expect(normalizeSettings({ composerMode: 'edit' }).composerMode).toBe('edit');
});
});

23
tsconfig.json Normal file
View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2021",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "Bundler",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES2021"
]
},
"include": [
"src/**/*.ts",
"tests/**/*.ts"
]
}

11
version-bump.mjs Normal file
View file

@ -0,0 +1,11 @@
import { readFileSync, writeFileSync } from 'node:fs';
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
const manifest = JSON.parse(readFileSync('manifest.json', 'utf8'));
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
manifest.version = packageJson.version;
versions[packageJson.version] = manifest.minAppVersion;
writeFileSync('manifest.json', `${JSON.stringify(manifest, null, '\t')}\n`);
writeFileSync('versions.json', `${JSON.stringify(versions, null, '\t')}\n`);

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"0.1.0": "1.12.7"
}

8
vitest.config.ts Normal file
View file

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['tests/**/*.test.ts']
}
});