mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
feat(ai-assist): first-use confirmation + wire Check-for-Updates to updater
Inline AI first-use confirmation (v4.13.0):
- inline-ai-controller accepts optional confirmFirstUse + getProviderLabel
deps. On the first action of the session, calls confirmFirstUse with
{providerLabel, selectionLength, action}; if it returns false, aborts
and shows error state. Once confirmed, never re-prompts for the
session.
- new 'ai-assistant:confirm-info' invoke handler returns
{provider, model} (no key material) so the renderer can label the
confirm dialog.
- electronAPI.aiAssistant.confirmInfo() exposed via preload.
- renderer.js wires window.confirm() with the provider label as the
v1 prompt UX; can be upgraded to a custom modal later without
touching the controller.
Check-for-Updates menu (v4.13.0):
- Help > Check for Updates now calls ensureAutoUpdater().check()
instead of opening the Releases page in the browser.
- Falls back to the browser page if the check throws (dev runs with
no app-update.yml, network failures, etc.) so the user always has
a way to update manually.
4 new controller tests cover: confirm called once with provider label
and selection length; declining aborts + surfaces error state; confirm
not asked twice in a session; missing confirmFirstUse dep skips the
prompt and streams normally.
Amit Haridas
This commit is contained in:
+22
-1
@@ -1475,8 +1475,19 @@ function createMenu() {
|
|||||||
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/issues'),
|
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/issues'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// v4.13.0 — trigger the in-app updater (electron-updater).
|
||||||
|
// If the check throws (no app-update.yml in dev, no network),
|
||||||
|
// fall back to opening the Releases page in the browser so the
|
||||||
|
// user still has a way to check manually.
|
||||||
label: 'Check for Updates',
|
label: 'Check for Updates',
|
||||||
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/releases'),
|
click: async () => {
|
||||||
|
try {
|
||||||
|
await ensureAutoUpdater().check();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Check-for-updates failed; falling back to releases page:', err.message);
|
||||||
|
shell.openExternal('https://github.com/amitwh/markdown-converter/releases');
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -5153,6 +5164,16 @@ ipcMain.handle('ai-assistant:status', async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v4.13.0 — Inline AI first-use confirmation. Returns a small payload
|
||||||
|
// (provider + model name; never the key) for the renderer's confirm
|
||||||
|
// dialog. The renderer is responsible for asking the user.
|
||||||
|
ipcMain.handle('ai-assistant:confirm-info', async () => {
|
||||||
|
const settings = getAiAssistantSettings();
|
||||||
|
const provider = settings.provider || 'unknown';
|
||||||
|
const model = settings.model || AiProviders.PROVIDER_DEFAULTS[provider]?.defaultModel || '';
|
||||||
|
return { provider, model };
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a chat completion. The payload is {system?, messages:[{role,content}]}
|
* Run a chat completion. The payload is {system?, messages:[{role,content}]}
|
||||||
* with provider settings taken from the store, never from the caller — a
|
* with provider settings taken from the store, never from the caller — a
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'ai-assistant:complete',
|
'ai-assistant:complete',
|
||||||
'ai-assistant:status',
|
'ai-assistant:status',
|
||||||
|
|
||||||
|
// Inline AI assist confirm helpers
|
||||||
|
'ai-assistant:confirm-info',
|
||||||
|
|
||||||
// v4.13.0 — Inline AI assist streaming (Cmd+K in editor)
|
// v4.13.0 — Inline AI assist streaming (Cmd+K in editor)
|
||||||
'ai-assist-stream:start',
|
'ai-assist-stream:start',
|
||||||
'ai-assist-stream:cancel',
|
'ai-assist-stream:cancel',
|
||||||
@@ -597,6 +600,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
onStatus: (cb) => ipcRenderer.on('updates:status', (_e, p) => cb(p)),
|
onStatus: (cb) => ipcRenderer.on('updates:status', (_e, p) => cb(p)),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// v4.13.0 — Inline AI first-use confirmation. Returns a small
|
||||||
|
// user-safe payload (no keys) describing what the AI request will do,
|
||||||
|
// for the renderer to surface in a confirm dialog.
|
||||||
|
aiAssistant: {
|
||||||
|
confirmInfo: () => ipcRenderer.invoke('ai-assistant:confirm-info'),
|
||||||
|
},
|
||||||
|
|
||||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2604,9 +2604,20 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// CodeMirror view is sourced via tabManager each time Cmd+K fires, so
|
// CodeMirror view is sourced via tabManager each time Cmd+K fires, so
|
||||||
// tab switches automatically pick up the right editor.
|
// tab switches automatically pick up the right editor.
|
||||||
let inlineAiControllerInstance = null;
|
let inlineAiControllerInstance = null;
|
||||||
|
let inlineAiProviderLabel = 'your AI provider';
|
||||||
function ensureInlineAiController() {
|
function ensureInlineAiController() {
|
||||||
if (inlineAiControllerInstance) return inlineAiControllerInstance;
|
if (inlineAiControllerInstance) return inlineAiControllerInstance;
|
||||||
const create = getCreateInlineAiController();
|
const create = getCreateInlineAiController();
|
||||||
|
// Fetch provider label once (used in the first-use confirm dialog).
|
||||||
|
window.electronAPI.aiAssistant
|
||||||
|
.confirmInfo()
|
||||||
|
.then((info) => {
|
||||||
|
if (info && info.provider) {
|
||||||
|
inlineAiProviderLabel = `${info.provider}${info.model ? ` (${info.model})` : ''}`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
inlineAiControllerInstance = create({
|
inlineAiControllerInstance = create({
|
||||||
getEditorView: () => {
|
getEditorView: () => {
|
||||||
const active =
|
const active =
|
||||||
@@ -2615,6 +2626,18 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
return active ? active.editorView : null;
|
return active ? active.editorView : null;
|
||||||
},
|
},
|
||||||
electronAPI: window.electronAPI,
|
electronAPI: window.electronAPI,
|
||||||
|
// v4.13.0 — first-use confirmation. Once per session, then never
|
||||||
|
// re-prompts. Uses native window.confirm for v1; can be upgraded
|
||||||
|
// to a custom modal later without changing the controller.
|
||||||
|
getProviderLabel: () => inlineAiProviderLabel,
|
||||||
|
confirmFirstUse: ({ providerLabel, selectionLength, action }) => {
|
||||||
|
const verb = action === 'rewrite' ? 'Rewrite' : action === 'shorten' ? 'Shorten' : 'Expand';
|
||||||
|
return window.confirm(
|
||||||
|
`${verb} ${selectionLength} characters using ${providerLabel}?\n\n` +
|
||||||
|
'This sends your selected text to the AI provider. API charges may apply ' +
|
||||||
|
'according to your provider plan. You will not be prompted again this session.'
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return inlineAiControllerInstance;
|
return inlineAiControllerInstance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const {
|
|||||||
* Esc-cancel from firing when the popover isn't open.
|
* Esc-cancel from firing when the popover isn't open.
|
||||||
*/
|
*/
|
||||||
function createInlineAiController(deps) {
|
function createInlineAiController(deps) {
|
||||||
const { getEditorView, electronAPI, onShortcut } = deps;
|
const { getEditorView, electronAPI, onShortcut, confirmFirstUse, getProviderLabel } = deps;
|
||||||
if (typeof getEditorView !== 'function') {
|
if (typeof getEditorView !== 'function') {
|
||||||
throw new Error('createInlineAiController: getEditorView is required');
|
throw new Error('createInlineAiController: getEditorView is required');
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,7 @@ function createInlineAiController(deps) {
|
|||||||
let activeSelection = null; // { from, to, original }
|
let activeSelection = null; // { from, to, original }
|
||||||
let activeAction = null; // 'rewrite' | 'shorten' | 'expand'
|
let activeAction = null; // 'rewrite' | 'shorten' | 'expand'
|
||||||
let unsubscribers = []; // [{ off }]
|
let unsubscribers = []; // [{ off }]
|
||||||
|
let firstUseConfirmed = false; // session-scoped — never re-prompts after OK
|
||||||
|
|
||||||
function ensurePopover() {
|
function ensurePopover() {
|
||||||
if (popover) return popover;
|
if (popover) return popover;
|
||||||
@@ -113,6 +114,25 @@ function createInlineAiController(deps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// First-use confirmation — only on the first action of the session,
|
||||||
|
// and only when the renderer supplied a confirmFirstUse callback.
|
||||||
|
if (!firstUseConfirmed && typeof confirmFirstUse === 'function') {
|
||||||
|
const providerLabel =
|
||||||
|
typeof getProviderLabel === 'function' ? getProviderLabel() : 'your configured AI provider';
|
||||||
|
const ok = confirmFirstUse({
|
||||||
|
providerLabel,
|
||||||
|
selectionLength: selectionText.length,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
ensurePopover().setState('error', {
|
||||||
|
message: 'Cancelled — first-use confirmation declined.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
firstUseConfirmed = true;
|
||||||
|
}
|
||||||
|
|
||||||
activeAction = action;
|
activeAction = action;
|
||||||
ensurePopover().setState('loading');
|
ensurePopover().setState('loading');
|
||||||
|
|
||||||
|
|||||||
@@ -269,6 +269,75 @@ describe('createInlineAiController — detach', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createInlineAiController — first-use confirmation', () => {
|
||||||
|
test('confirmFirstUse is called with provider label and selection length', () => {
|
||||||
|
const confirmFirstUse = jest.fn().mockReturnValue(true);
|
||||||
|
const editor = makeEditor('hello world longer');
|
||||||
|
editor._setSelection(0, 5);
|
||||||
|
const { api } = makeApi();
|
||||||
|
const controller = require('../src/renderer/inline-ai-controller').createInlineAiController({
|
||||||
|
getEditorView: () => editor,
|
||||||
|
electronAPI: api,
|
||||||
|
getProviderLabel: () => 'openai (gpt-4o)',
|
||||||
|
confirmFirstUse,
|
||||||
|
});
|
||||||
|
controller.showForSelection();
|
||||||
|
getPopoverButton('rewrite').click();
|
||||||
|
expect(confirmFirstUse).toHaveBeenCalledTimes(1);
|
||||||
|
expect(confirmFirstUse).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
providerLabel: 'openai (gpt-4o)',
|
||||||
|
selectionLength: 5, // selection is 'hello' (5 chars)
|
||||||
|
action: 'rewrite',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declining the confirm aborts and surfaces an error state', () => {
|
||||||
|
const confirmFirstUse = jest.fn().mockReturnValue(false);
|
||||||
|
const editor = makeEditor('hello world');
|
||||||
|
editor._setSelection(0, 5);
|
||||||
|
const { api, _state } = makeApi();
|
||||||
|
const controller = require('../src/renderer/inline-ai-controller').createInlineAiController({
|
||||||
|
getEditorView: () => editor,
|
||||||
|
electronAPI: api,
|
||||||
|
confirmFirstUse,
|
||||||
|
});
|
||||||
|
controller.showForSelection();
|
||||||
|
getPopoverButton('rewrite').click();
|
||||||
|
expect(_state.started).toHaveLength(0);
|
||||||
|
const popovers = document.querySelectorAll('.inline-ai-popover');
|
||||||
|
expect(popovers[popovers.length - 1].dataset.state).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirm is only asked once per session', () => {
|
||||||
|
const confirmFirstUse = jest.fn().mockReturnValue(true);
|
||||||
|
const editor = makeEditor('hello world');
|
||||||
|
editor._setSelection(0, 5);
|
||||||
|
const { api } = makeApi();
|
||||||
|
const controller = require('../src/renderer/inline-ai-controller').createInlineAiController({
|
||||||
|
getEditorView: () => editor,
|
||||||
|
electronAPI: api,
|
||||||
|
confirmFirstUse,
|
||||||
|
});
|
||||||
|
controller.showForSelection();
|
||||||
|
getPopoverButton('rewrite').click();
|
||||||
|
// Reset selection for a second action — confirm should NOT be asked again
|
||||||
|
editor._setSelection(0, 5);
|
||||||
|
getPopoverButton('shorten').click();
|
||||||
|
expect(confirmFirstUse).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no confirmFirstUse dep means no prompt (still streams)', () => {
|
||||||
|
const editor = makeEditor('hello world');
|
||||||
|
editor._setSelection(0, 5);
|
||||||
|
const { controller, _state } = mount({ editor });
|
||||||
|
controller.showForSelection();
|
||||||
|
getPopoverButton('rewrite').click();
|
||||||
|
expect(_state.started).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('createInlineAiController — validation', () => {
|
describe('createInlineAiController — validation', () => {
|
||||||
test('throws when getEditorView is missing', () => {
|
test('throws when getEditorView is missing', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user