diff --git a/packages/ai-assistant/src/aiCommandTypes.test.ts b/packages/ai-assistant/src/aiCommandTypes.test.ts new file mode 100644 index 00000000..0ca7443e --- /dev/null +++ b/packages/ai-assistant/src/aiCommandTypes.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect } from 'vitest'; +import { + resolveTemplate, + validateAiCommandDefinition, + validateAiCommandPreset, + serializePreset, + parsePreset, +} from './aiCommandTypes'; +import type { AiCommandDefinition, AiCommandPreset } from './aiCommandTypes'; + +// --------------------------------------------------------------------------- +// resolveTemplate +// --------------------------------------------------------------------------- + +describe('resolveTemplate', () => { + it('replaces {{selection}} placeholder', () => { + expect(resolveTemplate('Fix: {{selection}}', { selection: 'hello world' })).toBe( + 'Fix: hello world' + ); + }); + + it('replaces {{note}} placeholder', () => { + expect(resolveTemplate('Note: {{note}}', { note: 'full note content' })).toBe( + 'Note: full note content' + ); + }); + + it('replaces {{title}} placeholder', () => { + expect(resolveTemplate('Title: {{title}}', { title: 'My Note' })).toBe('Title: My Note'); + }); + + it('replaces multiple placeholders', () => { + const result = resolveTemplate('In "{{title}}", rewrite: {{selection}}', { + title: 'Draft', + selection: 'some text', + }); + expect(result).toBe('In "Draft", rewrite: some text'); + }); + + it('replaces missing context with empty string', () => { + expect(resolveTemplate('Selection: {{selection}}', {})).toBe('Selection: '); + }); + + it('leaves unknown placeholders as-is', () => { + expect(resolveTemplate('{{unknown}} text', {})).toBe('{{unknown}} text'); + }); + + it('handles template with no placeholders', () => { + expect(resolveTemplate('plain text', { selection: 'ignored' })).toBe('plain text'); + }); +}); + +// --------------------------------------------------------------------------- +// validateAiCommandDefinition +// --------------------------------------------------------------------------- + +describe('validateAiCommandDefinition', () => { + const validCommand: AiCommandDefinition = { + id: 'fix-grammar', + name: 'Fix Grammar', + systemPrompt: 'You are a grammar expert.', + userPromptTemplate: 'Fix the grammar: {{selection}}', + }; + + it('returns no errors for a valid command', () => { + expect(validateAiCommandDefinition(validCommand)).toEqual([]); + }); + + it('requires id', () => { + const errors = validateAiCommandDefinition({ ...validCommand, id: '' }); + expect(errors.some(e => e.field === 'id')).toBe(true); + }); + + it('rejects invalid id characters', () => { + const errors = validateAiCommandDefinition({ ...validCommand, id: 'has spaces!' }); + expect(errors.some(e => e.field === 'id')).toBe(true); + }); + + it('allows colons, dots, and hyphens in id', () => { + const errors = validateAiCommandDefinition({ ...validCommand, id: 'plugin:fix-grammar.v2' }); + expect(errors).toEqual([]); + }); + + it('requires name', () => { + const errors = validateAiCommandDefinition({ ...validCommand, name: '' }); + expect(errors.some(e => e.field === 'name')).toBe(true); + }); + + it('requires systemPrompt', () => { + const errors = validateAiCommandDefinition({ ...validCommand, systemPrompt: '' }); + expect(errors.some(e => e.field === 'systemPrompt')).toBe(true); + }); + + it('requires userPromptTemplate', () => { + const errors = validateAiCommandDefinition({ ...validCommand, userPromptTemplate: '' }); + expect(errors.some(e => e.field === 'userPromptTemplate')).toBe(true); + }); + + it('validates outputTarget enum', () => { + const errors = validateAiCommandDefinition({ ...validCommand, outputTarget: 'invalid' }); + expect(errors.some(e => e.field === 'outputTarget')).toBe(true); + }); + + it('accepts valid outputTarget values', () => { + for (const target of ['replace', 'insert', 'panel']) { + const errors = validateAiCommandDefinition({ ...validCommand, outputTarget: target }); + expect(errors).toEqual([]); + } + }); + + it('returns error for non-object input', () => { + const errors = validateAiCommandDefinition(null); + expect(errors).toHaveLength(1); + expect(errors[0]!.field).toBe('root'); + }); +}); + +// --------------------------------------------------------------------------- +// validateAiCommandPreset +// --------------------------------------------------------------------------- + +describe('validateAiCommandPreset', () => { + const validPreset: AiCommandPreset = { + name: 'Writing Tools', + version: '1.0.0', + commands: [ + { + id: 'fix-grammar', + name: 'Fix Grammar', + systemPrompt: 'You are a grammar expert.', + userPromptTemplate: 'Fix: {{selection}}', + }, + ], + }; + + it('returns no errors for a valid preset', () => { + expect(validateAiCommandPreset(validPreset)).toEqual([]); + }); + + it('requires name', () => { + const errors = validateAiCommandPreset({ ...validPreset, name: '' }); + expect(errors.some(e => e.field === 'name')).toBe(true); + }); + + it('requires version', () => { + const errors = validateAiCommandPreset({ ...validPreset, version: '' }); + expect(errors.some(e => e.field === 'version')).toBe(true); + }); + + it('requires commands array', () => { + const errors = validateAiCommandPreset({ ...validPreset, commands: 'not an array' }); + expect(errors.some(e => e.field === 'commands')).toBe(true); + }); + + it('rejects empty commands array', () => { + const errors = validateAiCommandPreset({ ...validPreset, commands: [] }); + expect(errors.some(e => e.message.includes('must not be empty'))).toBe(true); + }); + + it('validates individual commands', () => { + const errors = validateAiCommandPreset({ + ...validPreset, + commands: [{ id: '', name: '', systemPrompt: '', userPromptTemplate: '' }], + }); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!.field).toMatch(/^commands\[0\]/); + }); + + it('detects duplicate command ids', () => { + const errors = validateAiCommandPreset({ + ...validPreset, + commands: [validPreset.commands[0]!, validPreset.commands[0]!], + }); + expect(errors.some(e => e.message.includes('Duplicate'))).toBe(true); + }); + + it('returns error for non-object input', () => { + const errors = validateAiCommandPreset('string'); + expect(errors).toHaveLength(1); + expect(errors[0]!.field).toBe('root'); + }); +}); + +// --------------------------------------------------------------------------- +// serializePreset / parsePreset +// --------------------------------------------------------------------------- + +describe('serializePreset / parsePreset', () => { + const preset: AiCommandPreset = { + name: 'Test', + version: '1.0.0', + commands: [ + { + id: 'test-cmd', + name: 'Test Command', + systemPrompt: 'system', + userPromptTemplate: '{{selection}}', + }, + ], + }; + + it('round-trips a preset through serialize/parse', () => { + const json = serializePreset(preset); + const parsed = parsePreset(json); + expect(parsed).toEqual(preset); + }); + + it('produces valid JSON', () => { + const json = serializePreset(preset); + expect(() => JSON.parse(json)).not.toThrow(); + }); + + it('parsePreset throws on invalid JSON', () => { + expect(() => parsePreset('not json')).toThrow(); + }); +}); diff --git a/packages/ai-assistant/src/aiCommandTypes.ts b/packages/ai-assistant/src/aiCommandTypes.ts new file mode 100644 index 00000000..cb374a75 --- /dev/null +++ b/packages/ai-assistant/src/aiCommandTypes.ts @@ -0,0 +1,228 @@ +/** + * AI Command Definition — the core type for custom AI commands. + * + * Plugins and users can define custom AI commands that take the current + * selection and/or note context and send a templated prompt to the AI. + * + * Templates support these placeholders: + * {{selection}} — the currently selected text in the editor + * {{note}} — the full content of the current note + * {{title}} — the title of the current note + */ + +/** A single AI command that can be registered by plugins or imported from presets */ +export interface AiCommandDefinition { + /** Unique identifier (e.g. "my-plugin:fix-grammar"). Must be alphanumeric + hyphens/colons. */ + id: string; + + /** Human-readable name shown in the command palette (e.g. "Fix Grammar") */ + name: string; + + /** Optional description for tooltips / help text */ + description?: string; + + /** System prompt that sets the AI's behavior for this command */ + systemPrompt: string; + + /** + * User prompt template. Supports placeholders: + * - {{selection}} — replaced with editor selection + * - {{note}} — replaced with full note content + * - {{title}} — replaced with note title + */ + userPromptTemplate: string; + + /** Lucide icon name (e.g. "Wand2", "CheckCircle"). Optional. */ + icon?: string; + + /** Where to put the AI response: 'replace' selection, 'insert' at cursor, or 'panel' (chat). Default: 'panel' */ + outputTarget?: 'replace' | 'insert' | 'panel'; + + /** Optional category tag for organizing commands (e.g. "writing", "coding", "research") */ + category?: string; +} + +/** A shareable collection of AI commands */ +export interface AiCommandPreset { + /** Preset display name */ + name: string; + + /** Preset description */ + description?: string; + + /** Author name */ + author?: string; + + /** Semantic version of the preset (e.g. "1.0.0") */ + version: string; + + /** The commands in this preset */ + commands: AiCommandDefinition[]; +} + +/** Valid placeholder names for AI command templates */ +export const AI_TEMPLATE_PLACEHOLDERS = ['selection', 'note', 'title'] as const; +export type AiTemplatePlaceholder = (typeof AI_TEMPLATE_PLACEHOLDERS)[number]; + +/** Regex to match template placeholders */ +const PLACEHOLDER_REGEX = /\{\{(\w+)\}\}/g; + +/** + * Resolve a user prompt template by replacing placeholders with actual values. + */ +export function resolveTemplate( + template: string, + context: { + selection?: string; + note?: string; + title?: string; + } +): string { + return template.replace(PLACEHOLDER_REGEX, (match, key: string) => { + switch (key) { + case 'selection': + return context.selection ?? ''; + case 'note': + return context.note ?? ''; + case 'title': + return context.title ?? ''; + default: + return match; // Leave unknown placeholders as-is + } + }); +} + +/** Validation errors for AI command definitions */ +export interface AiCommandValidationError { + field: string; + message: string; +} + +/** Validate a single AI command definition. Returns an array of errors (empty = valid). */ +export function validateAiCommandDefinition(cmd: unknown): AiCommandValidationError[] { + const errors: AiCommandValidationError[] = []; + + if (!cmd || typeof cmd !== 'object') { + return [{ field: 'root', message: 'AI command definition must be an object' }]; + } + + const def = cmd as Record; + + if (typeof def.id !== 'string' || def.id.trim().length === 0) { + errors.push({ field: 'id', message: 'id is required and must be a non-empty string' }); + } else if (!/^[\w:.-]+$/.test(def.id)) { + errors.push({ + field: 'id', + message: 'id must contain only alphanumeric characters, hyphens, dots, and colons', + }); + } + + if (typeof def.name !== 'string' || def.name.trim().length === 0) { + errors.push({ field: 'name', message: 'name is required and must be a non-empty string' }); + } + + if (typeof def.systemPrompt !== 'string' || def.systemPrompt.trim().length === 0) { + errors.push({ + field: 'systemPrompt', + message: 'systemPrompt is required and must be a non-empty string', + }); + } + + if (typeof def.userPromptTemplate !== 'string' || def.userPromptTemplate.trim().length === 0) { + errors.push({ + field: 'userPromptTemplate', + message: 'userPromptTemplate is required and must be a non-empty string', + }); + } + + if (def.icon !== undefined && typeof def.icon !== 'string') { + errors.push({ field: 'icon', message: 'icon must be a string if provided' }); + } + + if ( + def.outputTarget !== undefined && + !['replace', 'insert', 'panel'].includes(def.outputTarget as string) + ) { + errors.push({ + field: 'outputTarget', + message: 'outputTarget must be "replace", "insert", or "panel"', + }); + } + + if (def.description !== undefined && typeof def.description !== 'string') { + errors.push({ field: 'description', message: 'description must be a string if provided' }); + } + + if (def.category !== undefined && typeof def.category !== 'string') { + errors.push({ field: 'category', message: 'category must be a string if provided' }); + } + + return errors; +} + +/** Validate a full AI command preset file. Returns an array of errors (empty = valid). */ +export function validateAiCommandPreset(data: unknown): AiCommandValidationError[] { + const errors: AiCommandValidationError[] = []; + + if (!data || typeof data !== 'object') { + return [{ field: 'root', message: 'Preset must be an object' }]; + } + + const preset = data as Record; + + if (typeof preset.name !== 'string' || preset.name.trim().length === 0) { + errors.push({ field: 'name', message: 'Preset name is required' }); + } + + if (typeof preset.version !== 'string' || preset.version.trim().length === 0) { + errors.push({ field: 'version', message: 'Preset version is required' }); + } + + if (!Array.isArray(preset.commands)) { + errors.push({ field: 'commands', message: 'commands must be an array' }); + return errors; + } + + if (preset.commands.length === 0) { + errors.push({ field: 'commands', message: 'commands array must not be empty' }); + } + + for (let i = 0; i < preset.commands.length; i++) { + const cmdErrors = validateAiCommandDefinition(preset.commands[i]); + for (const err of cmdErrors) { + errors.push({ + field: `commands[${i}].${err.field}`, + message: err.message, + }); + } + } + + // Check for duplicate IDs + const ids = new Set(); + for (const cmd of preset.commands) { + if (cmd && typeof cmd === 'object' && typeof (cmd as Record).id === 'string') { + const id = (cmd as Record).id as string; + if (ids.has(id)) { + errors.push({ field: 'commands', message: `Duplicate command id: "${id}"` }); + } + ids.add(id); + } + } + + return errors; +} + +/** + * Serialize an array of AI command definitions into a preset JSON string. + */ +export function serializePreset(preset: AiCommandPreset): string { + return JSON.stringify(preset, null, 2); +} + +/** + * Parse a preset from a JSON string. Returns the preset or throws on invalid JSON. + * Use validateAiCommandPreset() to validate the parsed object. + */ +export function parsePreset(json: string): unknown { + return JSON.parse(json); +}