/* eslint-disable multiline-comment-style */
import AiService from '../../ai/AiService';
import EmbeddingIndexer from '../../ai/EmbeddingIndexer';
import SearchService from '../../ai/SearchService';
import { ChatRole } from '../../ai/types';
import { AiIndexStatus, ChatMessage, ChatOptions, ChatResult, EmbeddingsPage, GetEmbeddingsOptions, SearchOptions, SearchResult } from './types';
/**
* Provides access to AI models configured by the user. The active provider
* (Joplin Cloud AI, OpenAI-compatible, or Anthropic) and the model are picked
* by the user in the Joplin settings — plugins inherit whichever is active.
*
* AI is disabled by default. The user must enable it in the settings, and
* separately grant permission to use a remote (cloud-hosted) provider before
* any plugin call will succeed.
*
* If the user is signed into Joplin Cloud, AI works zero-config — they only
* need to flip the master toggle on.
*
* desktop
*/
export default class JoplinAi {
/**
* Sends a chat completion request to the active AI provider and returns
* the assistant's response as an object with a `text` property. The object
* shape is stable so future fields (e.g. token usage, finish reason) can be
* added without breaking existing plugins.
*
* The active provider and model are controlled by the user in Settings →
* AI. Plugins should not assume any particular provider or model.
*
* This call throws when:
*
* - AI features are disabled (`AI features are disabled`).
* - The active provider is remote and the user has not allowed remote
* providers (`Remote AI access is not allowed`).
* - The provider is misconfigured, e.g. missing API key or model name
* (`*provider* has no API key configured`).
* - The provider returns an HTTP error (the message includes the status
* and any detail returned by the provider).
*
* Plugins should catch these errors and present a user-friendly message
* pointing the user at the Joplin settings.
*
* @example
* ```typescript
* const reply = await joplin.ai.chat([
* { role: 'system', content: 'You are a concise assistant.' },
* { role: 'user', content: 'Summarise this note: ...' },
* ]);
* console.log(reply.text);
* ```
*/
public async chat(messages: ChatMessage[], options?: ChatOptions): Promise {
const result = await AiService.instance().chat(
messages.map(message => ({
role: message.role as ChatRole.System | ChatRole.User | ChatRole.Assistant,
content: message.content,
})),
options,
);
return { text: result.text };
}
/**
* Runs a semantic search against the locally-indexed embeddings and
* returns matching chunks ranked by similarity.
*
* The `query` is either plain text (which gets embedded internally) or
* `{ noteId }`, which reuses the note's already-indexed chunks as the
* query — useful for "find related notes" / tag suggestion / semantic
* graph use cases without spending another embedding pass.
*
* The `scope` restricts the search: `'all'` (default), `'note'`,
* `'folder'` (by folder id), or `'tag'` (by tag id).
* Trashed and conflict notes are excluded from results.
*
* The `relevance` preset controls how strict the match is:
* `'strict' | 'normal' | 'loose'`. Joplin owns the mapping from preset
* to model-specific (k, minScore) — plugins write against the preset
* and stay compatible when the bundled model changes.
*
* Throws when AI features are disabled or no embedding provider is
* active (e.g. ONNX failed to load on this platform).
*
* @example
* ```typescript
* const results = await joplin.ai.search({
* query: { text: 'pizza dough hydration' },
* relevance: 'normal',
* });
* for (const r of results) {
* console.log(r.score, r.noteId, r.chunkText.slice(0, 80));
* }
* ```
*/
public async search(options: SearchOptions): Promise {
return SearchService.instance().search(options);
}
/**
* Returns raw embedding vectors for indexed note chunks, paginated.
*
* Unlike {@link search}, this exposes the underlying vectors rather than
* similarity scores — intended for plugins that need to run their own
* clustering, dimensionality reduction, or distance computations over the
* full set.
*
* Vectors are model-specific: the response includes the `modelId` that
* produced them. Plugins that cache vectors must invalidate on `modelId`
* change. If the model swaps mid-pagination, the cursor stops returning
* rows; the plugin should restart with no cursor and the new `modelId`.
*
* Pagination uses an opaque cursor. Pass `nextCursor` from one call back
* as `cursor` on the next. A missing `nextCursor` signals end-of-stream.
*
* The cursor is stable under concurrent writes: chunks inserted behind
* the cursor are guaranteed to have been returned; chunks inserted ahead
* of the cursor will be returned in a later page.
*
* Throws when AI features are disabled or no embedding provider is
* active.
*
* @example
* ```typescript
* let cursor: string | undefined;
* const all: EmbeddingChunk[] = [];
* let modelId: string | null = null;
* do {
* const page = await joplin.ai.getEmbeddings({ cursor, limit: 1000 });
* if (modelId && page.modelId !== modelId) throw new Error('model changed mid-fetch');
* modelId = page.modelId;
* all.push(...page.chunks);
* cursor = page.nextCursor;
* } while (cursor);
* ```
*/
public async getEmbeddings(options?: GetEmbeddingsOptions): Promise {
return SearchService.instance().getEmbeddings(options);
}
/**
* Returns the current state of the on-device embedding index. Useful for
* hybrid pipelines that prefer {@link search} when ready and fall back
* otherwise. Cheap enough to call on a UI tick.
*
* @example
* ```typescript
* const status = await joplin.ai.getIndexStatus();
* if (status.ready) {
* await runOnJoplinAi();
* } else {
* await runOnLocalFallback();
* }
* ```
*/
public async getIndexStatus(): Promise {
return EmbeddingIndexer.instance().getPluginStatus();
}
}