semantic titles ai assist

This commit is contained in:
2026-06-23 17:46:33 -05:00
parent 2b27428849
commit 33eb0f745e
14 changed files with 1518 additions and 17 deletions
+107 -2
View File
@@ -135,6 +135,7 @@ class GuideEditor {
this.descriptionDirty = false;
this.titleDirty = false;
this.active = true;
this.settings = {};
this.saveStepDebounced = debounce(() => this.flushStep(), 180);
this.saveGuideDebounced = debounce(() => this.flushGuide(), 180);
@@ -152,6 +153,80 @@ class GuideEditor {
this.active = Boolean(active);
}
setSettings(settings) {
this.settings = settings || {};
this.updateAiButtonState();
}
isAiEnabled() {
return Boolean(this.settings?.ai?.enabled);
}
updateAiButtonState() {
const enabled = this.isAiEnabled();
const buttons = [
this.dom?.titleAiBtn,
this.dom?.descAiBtn,
...(this.dom?.blocksList ? [...this.dom.blocksList.querySelectorAll('button[data-ai-action]')] : []),
].filter(Boolean);
for (const button of buttons) {
button.disabled = !enabled;
button.title = enabled
? button.dataset.aiTitle || 'Generate with AI'
: 'Enable AI in Settings first.';
}
}
async runAiGeneration(target, { blockId = null, button = null } = {}) {
if (!this.currentStep) {
this.onToast('Select a step first.', { error: true });
return null;
}
if (!this.isAiEnabled()) {
this.onToast('Enable AI in Settings first.', { error: true });
return null;
}
if (this.pendingSave) await this.flushStep();
if (button) setButtonLoading(button, true, 'Generating…');
try {
const result = await api.ai.fillStep({
guideId: this.guideId,
stepId: this.currentStep.stepId,
target,
blockId,
});
if (!result || !result.ok) {
this.onToast(result?.reason || 'AI generation failed.', { error: true });
return null;
}
await this.reload(result.step.stepId);
this.onToast('AI text filled.');
return result.step;
} catch (err) {
this.onToast(err.message || 'AI generation failed.', { error: true });
return null;
} finally {
if (button) setButtonLoading(button, false);
}
}
async generateTitleWithAi(button = null) {
return this.runAiGeneration('title', { button });
}
async generateDescriptionWithAi(button = null) {
return this.runAiGeneration('description', { button });
}
async generateAllTextFieldsWithAi(button = null) {
return this.runAiGeneration('all', { button });
}
async generateBlockWithAi(kind, block, button = null) {
if (!block) return null;
return this.runAiGeneration('block', { blockId: block.id, button });
}
get currentStep() {
return this.stepMap.get(this.selectedStepId) || null;
}
@@ -271,7 +346,14 @@ class GuideEditor {
el('aside.pane-props', {},
el('section', {},
el('h3', {}, 'Step'),
this.dom.titleInput = el('input', { type: 'text', placeholder: 'Step title' }),
el('div.row', {},
this.dom.titleInput = el('input', { type: 'text', placeholder: 'Step title', style: { flex: 1 } }),
this.dom.titleAiBtn = el('button.ai', {
type: 'button',
title: 'Generate the step title with AI',
dataset: { aiAction: 'title', aiTitle: 'Generate the step title with AI' },
}, 'AI'),
),
this.dom.statusSelect = makeSelect('todo', [
{ value: 'todo', label: 'Todo' },
{ value: 'in-progress', label: 'In progress' },
@@ -296,7 +378,14 @@ class GuideEditor {
),
),
el('section', {},
el('h3', {}, 'Description'),
el('div.row', { style: { justifyContent: 'space-between', alignItems: 'center' } },
el('h3', { style: { margin: 0 } }, 'Description'),
this.dom.descAiBtn = el('button.ai', {
type: 'button',
title: 'Generate the description with AI',
dataset: { aiAction: 'description', aiTitle: 'Generate the description with AI' },
}, 'AI'),
),
this.dom.richToolbar = el('div.rich-toolbar', {},
this.toolbarBtn('bold', 'Bold'),
this.toolbarBtn('italic', 'Italic'),
@@ -416,6 +505,7 @@ class GuideEditor {
this.renderStepList();
this.emitMeta();
});
this.dom.titleAiBtn.addEventListener('click', () => this.generateTitleWithAi(this.dom.titleAiBtn));
this.dom.statusSelect.addEventListener('change', () => {
if (!this.currentStep) return;
@@ -491,6 +581,7 @@ class GuideEditor {
document.addEventListener('selectionchange', () => {
if (document.activeElement === this.dom.descEditor) this.updateToolbarState();
});
this.dom.descAiBtn.addEventListener('click', () => this.generateDescriptionWithAi(this.dom.descAiBtn));
this.dom.descEditor.addEventListener('paste', (e) => {
// Keep pasted text simple; backend sanitization will handle the rest.
@@ -504,6 +595,8 @@ class GuideEditor {
if (!item) return;
this.canvas.select(item.dataset.annId);
});
this.updateAiButtonState();
}
renderAll() {
@@ -513,6 +606,7 @@ class GuideEditor {
this.renderCanvas();
this.renderAnnotationPanel();
this.renderBlocksPanel();
this.updateAiButtonState();
this.emitMeta();
}
@@ -670,6 +764,15 @@ class GuideEditor {
el('span.spacer'),
el('button.icon', { type: 'button', title: 'Move block up', disabled: !canMoveUp, onClick: moveUp, dataset: { blockMove: 'up' } }, '↑'),
el('button.icon', { type: 'button', title: 'Move block down', disabled: !canMoveDown, onClick: moveDown, dataset: { blockMove: 'down' } }, '↓'),
(() => {
const aiBtn = el('button.ai', {
type: 'button',
title: 'Generate this block with AI',
dataset: { aiAction: 'block', aiTitle: 'Generate this block with AI' },
onClick: () => this.generateBlockWithAi(kind, block, aiBtn),
}, 'AI');
return aiBtn;
})(),
removeBtn(() => {
if (kind === 'text') step.textBlocks = (step.textBlocks || []).filter((b) => b !== block);
else if (kind === 'code') step.codeBlocks = (step.codeBlocks || []).filter((b) => b !== block);
@@ -761,6 +864,7 @@ class GuideEditor {
if (!blocks.length) {
this.dom.blocksList.append(el('div.muted', {}, 'Informational text, code, and table blocks can be reordered with drag handles or arrows.'));
}
this.updateAiButtonState();
}
renderStepList() {
@@ -1658,6 +1762,7 @@ class GuideEditor {
await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck });
await api.settings.set({ keyPath: 'capture', value: next.capture });
await api.settings.set({ keyPath: 'editor', value: next.editor });
await api.settings.set({ keyPath: 'ai', value: next.ai });
await api.settings.set({ keyPath: 'exports', value: next.exports });
await api.settings.set({ keyPath: 'backups', value: next.backups });
await api.settings.setGlobalPlaceholders(next.placeholders || {});