semantic titles ai assist
This commit is contained in:
@@ -117,6 +117,7 @@ class StepForgeApp {
|
||||
guideFolders: library.folders?.guideFolders || {},
|
||||
};
|
||||
this.state.trash = trash;
|
||||
this.editor.setSettings(settings);
|
||||
}
|
||||
|
||||
async refreshLibrary({ keepFilter = true } = {}) {
|
||||
@@ -320,6 +321,7 @@ class StepForgeApp {
|
||||
{ label: 'Guide information…', action: () => this.editor.openGuideInfo() },
|
||||
{ label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() },
|
||||
{ label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() },
|
||||
{ label: 'Generate all text fields with AI', action: () => this.editor.generateAllTextFieldsWithAi() },
|
||||
{ label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() },
|
||||
'sep',
|
||||
{ label: 'Keyboard shortcuts…', action: () => this.editor.openShortcutsHelp() },
|
||||
@@ -866,6 +868,7 @@ class StepForgeApp {
|
||||
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 || {});
|
||||
|
||||
@@ -313,6 +313,42 @@ function showSettingsDialog({
|
||||
const captureOutside = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.captureOutsideClicks) });
|
||||
const confirmSimple = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.confirmSimpleCapture) });
|
||||
const keepLast = makeInput(settings.backups?.keepLast ?? 10, 'number', { min: 0, step: 1 });
|
||||
const aiEnabled = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.enabled) });
|
||||
const ollamaHost = makeInput(settings.ai?.ollama?.host || 'http://127.0.0.1:11434');
|
||||
const ollamaModel = makeInput(settings.ai?.ollama?.model || 'llama3.2:1b');
|
||||
const aiStatus = el('div', { className: 'muted ai-status' }, 'AI stays local through Ollama. Nothing is sent to the cloud.');
|
||||
const testAiBtn = el('button', { type: 'button' }, 'Test connection');
|
||||
|
||||
const updateAiStatus = (message, { error = false } = {}) => {
|
||||
aiStatus.textContent = message;
|
||||
aiStatus.classList.toggle('error', Boolean(error));
|
||||
};
|
||||
|
||||
const testAiConnection = async () => {
|
||||
setButtonLoading(testAiBtn, true, 'Testing…');
|
||||
updateAiStatus('Checking Ollama at the configured host…');
|
||||
try {
|
||||
const result = await api.ai.test({
|
||||
ollama: {
|
||||
host: ollamaHost.value.trim(),
|
||||
model: ollamaModel.value.trim(),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
updateAiStatus(result.reason || 'Could not connect to Ollama.', { error: true });
|
||||
return;
|
||||
}
|
||||
if (result.installed) {
|
||||
updateAiStatus(`Connected to ${result.host} with ${result.model}.`);
|
||||
} else {
|
||||
updateAiStatus(`Connected to ${result.host}. Model ${result.model} is not installed yet.`, { error: true });
|
||||
}
|
||||
} catch (err) {
|
||||
updateAiStatus(err.message || 'Could not connect to Ollama.', { error: true });
|
||||
} finally {
|
||||
setButtonLoading(testAiBtn, false);
|
||||
}
|
||||
};
|
||||
|
||||
const placeholderRows = el('div', { className: 'placeholder-rows' });
|
||||
const rows = [];
|
||||
@@ -369,6 +405,19 @@ function showSettingsDialog({
|
||||
el('legend', {}, 'Backups'),
|
||||
labeledRow('Keep last snapshots', keepLast),
|
||||
),
|
||||
el('fieldset', {},
|
||||
el('legend', {}, 'AI'),
|
||||
labeledRow('Enable AI text filling', aiEnabled),
|
||||
labeledRow('Ollama host', ollamaHost),
|
||||
labeledRow('Ollama model', ollamaModel),
|
||||
el('div.row', { style: { justifyContent: 'space-between' } },
|
||||
aiStatus,
|
||||
testAiBtn,
|
||||
),
|
||||
el('div.muted', {},
|
||||
'AI generation is manual only. Captures stay deterministic until you click a generate button.',
|
||||
),
|
||||
),
|
||||
el('fieldset', {},
|
||||
el('legend', {}, 'Global placeholders'),
|
||||
placeholderRows,
|
||||
@@ -412,6 +461,15 @@ function showSettingsDialog({
|
||||
...settings.backups,
|
||||
keepLast: Number(keepLast.value || 0),
|
||||
},
|
||||
ai: {
|
||||
...settings.ai,
|
||||
enabled: aiEnabled.checked,
|
||||
ollama: {
|
||||
...(settings.ai?.ollama || {}),
|
||||
host: ollamaHost.value.trim(),
|
||||
model: ollamaModel.value.trim(),
|
||||
},
|
||||
},
|
||||
placeholders: rows.reduce((acc, row) => {
|
||||
const inputs = row.querySelectorAll('input');
|
||||
const key = inputs[0].value.trim();
|
||||
@@ -430,6 +488,14 @@ function showSettingsDialog({
|
||||
});
|
||||
|
||||
form.addEventListener('submit', (e) => e.preventDefault());
|
||||
testAiBtn.addEventListener('click', testAiConnection);
|
||||
aiEnabled.addEventListener('change', () => {
|
||||
updateAiStatus(
|
||||
aiEnabled.checked
|
||||
? 'AI generation will be available once Ollama is reachable.'
|
||||
: 'AI generation is disabled. The settings are still saved for later.',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+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 || {});
|
||||
|
||||
@@ -90,6 +90,16 @@ button.tool.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
button.ai {
|
||||
padding: 5px 10px;
|
||||
border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--panel-solid));
|
||||
color: var(--accent-strong);
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
button.ai:hover { background: color-mix(in srgb, var(--accent) 16%, var(--panel-2)); }
|
||||
button.ai:disabled { opacity: 0.6; }
|
||||
button:disabled { opacity: 0.6; cursor: default; }
|
||||
button.loading {
|
||||
display: inline-flex;
|
||||
@@ -751,6 +761,7 @@ fieldset legend {
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ai-status.error { color: var(--danger); }
|
||||
|
||||
#modal-root:not(:empty) {
|
||||
position: fixed;
|
||||
|
||||
Reference in New Issue
Block a user