mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
feat(ai-assist): pure prompt builder + result applier
buildAssistPrompt(action, selection) returns the {system, messages}
payload for rewrite/shorten/expand. Enforces 8KB selection cap and
typed errors with codes (unknown_action, bad_selection,
empty_selection, selection_too_large) so the IPC layer can map them
to user-friendly messages.
applyAssistResult(original, newText) trims the response and rejects
empty/unchanged payloads so a no-op streaming chunk doesn't churn
the editor.
15 new tests cover action coverage, prompt content, all four error
codes, cap-edge (exactly 8KB OK, 8KB+1 rejected), and trim semantics.
Amit Haridas
This commit is contained in:
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Inline AI assist — prompt builder + result applier.
|
||||||
|
*
|
||||||
|
* Pure module. The actual network call goes through AiProviders.js; this
|
||||||
|
* module shapes the request (system + user messages) and validates the
|
||||||
|
* response before it reaches the editor.
|
||||||
|
*
|
||||||
|
* Actions (v4.13.0): rewrite / shorten / expand.
|
||||||
|
*
|
||||||
|
* Safety: MAX_SELECTION_CHARS caps the selection so a runaway or hostile
|
||||||
|
* caller can't push an 8KB+ blob at an API. The cap is enforced BEFORE
|
||||||
|
* the IPC hop so the main process sees only safe payloads.
|
||||||
|
*
|
||||||
|
* @module inline-assist
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_SELECTION_CHARS = 8 * 1024;
|
||||||
|
|
||||||
|
const ACTIONS = {
|
||||||
|
rewrite: {
|
||||||
|
label: 'Rewrite',
|
||||||
|
systemPrompt:
|
||||||
|
'You are a precise writing assistant. Rewrite the selected text to improve clarity, ' +
|
||||||
|
'flow, and word choice while preserving the original meaning, tone, and length. ' +
|
||||||
|
'Return ONLY the rewritten text — no preamble, no explanation, no surrounding quotes.',
|
||||||
|
},
|
||||||
|
shorten: {
|
||||||
|
label: 'Shorten',
|
||||||
|
systemPrompt:
|
||||||
|
'You are a precise writing assistant. Shorten the selected text while preserving all ' +
|
||||||
|
'key information. Aim for 30–50% reduction in length. Drop filler, redundancy, and ' +
|
||||||
|
'verbose phrasing. Return ONLY the shortened text — no preamble, no explanation.',
|
||||||
|
},
|
||||||
|
expand: {
|
||||||
|
label: 'Expand',
|
||||||
|
systemPrompt:
|
||||||
|
'You are a precise writing assistant. Expand the selected text by adding relevant ' +
|
||||||
|
'detail, examples, or explanation. Aim for 50–100% expansion. Stay on-topic. ' +
|
||||||
|
'Return ONLY the expanded text — no preamble, no explanation.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIONS_LIST = Object.keys(ACTIONS);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the {system, messages} payload for `action`.
|
||||||
|
*
|
||||||
|
* @param {'rewrite'|'shorten'|'expand'} action
|
||||||
|
* @param {string} selection
|
||||||
|
* @returns {{system:string, messages:Array<{role:'user', content:string}>}}
|
||||||
|
* @throws when action is unknown or selection is empty / too large
|
||||||
|
*/
|
||||||
|
function buildAssistPrompt(action, selection) {
|
||||||
|
const def = ACTIONS[action];
|
||||||
|
if (!def) {
|
||||||
|
const err = new Error(`Unknown AI assist action: ${action}`);
|
||||||
|
err.code = 'unknown_action';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (typeof selection !== 'string') {
|
||||||
|
const err = new Error('Selection must be a string');
|
||||||
|
err.code = 'bad_selection';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (selection.length === 0) {
|
||||||
|
const err = new Error('Selection is empty');
|
||||||
|
err.code = 'empty_selection';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (selection.length > MAX_SELECTION_CHARS) {
|
||||||
|
const err = new Error(
|
||||||
|
`Selection is too large (${selection.length} chars; max ${MAX_SELECTION_CHARS})`
|
||||||
|
);
|
||||||
|
err.code = 'selection_too_large';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
system: def.systemPrompt,
|
||||||
|
messages: [{ role: 'user', content: selection }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the provider's response before applying it to the editor.
|
||||||
|
*
|
||||||
|
* - Empty / whitespace-only → return null (don't replace selection with nothing)
|
||||||
|
* - Unchanged → return null (no-op, don't churn the editor)
|
||||||
|
*
|
||||||
|
* @param {string} originalText - The original selection
|
||||||
|
* @param {string} newText - The provider's response
|
||||||
|
* @returns {string|null} text to apply, or null to skip
|
||||||
|
*/
|
||||||
|
function applyAssistResult(originalText, newText) {
|
||||||
|
if (typeof newText !== 'string') return null;
|
||||||
|
const trimmed = newText.trim();
|
||||||
|
if (trimmed.length === 0) return null;
|
||||||
|
if (trimmed === String(originalText || '').trim()) return null;
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildAssistPrompt,
|
||||||
|
applyAssistResult,
|
||||||
|
ACTIONS,
|
||||||
|
ACTIONS_LIST,
|
||||||
|
MAX_SELECTION_CHARS,
|
||||||
|
};
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment node
|
||||||
|
*
|
||||||
|
* Inline AI assist pure module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildAssistPrompt,
|
||||||
|
applyAssistResult,
|
||||||
|
ACTIONS,
|
||||||
|
ACTIONS_LIST,
|
||||||
|
MAX_SELECTION_CHARS,
|
||||||
|
} = require('../src/ai-assist/inline-assist');
|
||||||
|
|
||||||
|
describe('ACTIONS', () => {
|
||||||
|
test('the three actions are exposed', () => {
|
||||||
|
expect(ACTIONS_LIST).toEqual(['rewrite', 'shorten', 'expand']);
|
||||||
|
expect(ACTIONS.rewrite.label).toBe('Rewrite');
|
||||||
|
expect(ACTIONS.shorten.label).toBe('Shorten');
|
||||||
|
expect(ACTIONS.expand.label).toBe('Expand');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every action has a non-empty system prompt', () => {
|
||||||
|
for (const a of ACTIONS_LIST) {
|
||||||
|
expect(typeof ACTIONS[a].systemPrompt).toBe('string');
|
||||||
|
expect(ACTIONS[a].systemPrompt.length).toBeGreaterThan(20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildAssistPrompt', () => {
|
||||||
|
test('returns {system, messages} for each action', () => {
|
||||||
|
for (const action of ACTIONS_LIST) {
|
||||||
|
const out = buildAssistPrompt(action, 'hello world');
|
||||||
|
expect(out.system).toBe(ACTIONS[action].systemPrompt);
|
||||||
|
expect(out.messages).toHaveLength(1);
|
||||||
|
expect(out.messages[0].role).toBe('user');
|
||||||
|
expect(out.messages[0].content).toBe('hello world');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves the exact selection content', () => {
|
||||||
|
const selection = 'A\nmultiline\nselection with unicode éàü ✓ and trailing spaces ';
|
||||||
|
const out = buildAssistPrompt('rewrite', selection);
|
||||||
|
expect(out.messages[0].content).toBe(selection);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on unknown action with unknown_action code', () => {
|
||||||
|
try {
|
||||||
|
buildAssistPrompt('polish', 'hi');
|
||||||
|
throw new Error('expected to throw');
|
||||||
|
} catch (err) {
|
||||||
|
expect(err.code).toBe('unknown_action');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on non-string selection with bad_selection code', () => {
|
||||||
|
try {
|
||||||
|
buildAssistPrompt('rewrite', null);
|
||||||
|
throw new Error('expected to throw');
|
||||||
|
} catch (err) {
|
||||||
|
expect(err.code).toBe('bad_selection');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
buildAssistPrompt('rewrite', 42);
|
||||||
|
throw new Error('expected to throw');
|
||||||
|
} catch (err) {
|
||||||
|
expect(err.code).toBe('bad_selection');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on empty selection with empty_selection code', () => {
|
||||||
|
try {
|
||||||
|
buildAssistPrompt('rewrite', '');
|
||||||
|
throw new Error('expected to throw');
|
||||||
|
} catch (err) {
|
||||||
|
expect(err.code).toBe('empty_selection');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on oversized selection with selection_too_large code', () => {
|
||||||
|
const huge = 'a'.repeat(MAX_SELECTION_CHARS + 1);
|
||||||
|
try {
|
||||||
|
buildAssistPrompt('rewrite', huge);
|
||||||
|
throw new Error('expected to throw');
|
||||||
|
} catch (err) {
|
||||||
|
expect(err.code).toBe('selection_too_large');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a selection at exactly the cap', () => {
|
||||||
|
const exact = 'a'.repeat(MAX_SELECTION_CHARS);
|
||||||
|
expect(() => buildAssistPrompt('rewrite', exact)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MAX_SELECTION_CHARS is 8KB', () => {
|
||||||
|
expect(MAX_SELECTION_CHARS).toBe(8 * 1024);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyAssistResult', () => {
|
||||||
|
test('returns the trimmed new text when different', () => {
|
||||||
|
expect(applyAssistResult('hello', 'HELLO')).toBe('HELLO');
|
||||||
|
expect(applyAssistResult('hello', ' goodbye ')).toBe('goodbye');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when newText is unchanged from original (after trim)', () => {
|
||||||
|
expect(applyAssistResult('hello', 'hello')).toBeNull();
|
||||||
|
expect(applyAssistResult('hello', ' hello ')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when newText is empty or whitespace-only', () => {
|
||||||
|
expect(applyAssistResult('hello', '')).toBeNull();
|
||||||
|
expect(applyAssistResult('hello', ' ')).toBeNull();
|
||||||
|
expect(applyAssistResult('hello', '\n\t ')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when newText is not a string', () => {
|
||||||
|
expect(applyAssistResult('hello', null)).toBeNull();
|
||||||
|
expect(applyAssistResult('hello', undefined)).toBeNull();
|
||||||
|
expect(applyAssistResult('hello', 42)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('trims surrounding whitespace before comparing/applying', () => {
|
||||||
|
// Even if the LLM adds stray newlines, we strip them before writing
|
||||||
|
expect(applyAssistResult('x', '\n\nresult\n\n')).toBe('result');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user