semantic titles ai assist
This commit is contained in:
+107
-2
@@ -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 || {});
|
||||
|
||||
Reference in New Issue
Block a user