Author SHA1 Message Date
Tyler 3915865029 Keep ai model saved when app closes. 2026-06-26 08:48:38 -05:00
Tyler 79edb6bb56 Change model
Ran into an issue with the previous model for having mllama archetecture so I changed it
2026-06-26 08:36:45 -05:00
Tyler a971ff870f Added feature to where local ai models can see the image in each step. 2026-06-26 08:05:18 -05:00
TylerandGitHub 9bd4a959c4 Semantic capture titles, AI documentation assist, and keyboard tracking (#3)
Template tests / tests (push) Successful in 1m58s
Semantic capture titles, AI documentation assist, and keyboard tracking
2026-06-24 12:11:11 -05:00
TylerandGitHub cb5e0c6837 Merge pull request #2 from Twest2/text_locations
Honor text block positions in exports
2026-06-23 16:57:06 -05:00
Tyler ffaa123893 Honor text block positions in exports 2026-06-23 16:44:28 -05:00
15 changed files with 490 additions and 70 deletions
+1 -1
View File
@@ -1 +1 @@
In the future, planned updates include a re-vamp of the settings panel, text box's (important, tip, warning, and note) are not properly moving to the intended areas and I will look into locking the size (or being able to control the size) of images throughout varias export formats. In the future, planned updates include a re-vamp of the settings panel, text box's (important, tip, warning, and note) are not properly moving to the intended areas and I will look into locking the size (or being able to control the size) of images throughout varias export formats. OCR or local ai would be pretty cool....
+11 -2
View File
@@ -318,8 +318,13 @@ function showSettingsDialog({
const aiAutoDoc = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.autoDoc) }); const aiAutoDoc = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.autoDoc) });
const ollamaHost = makeInput(settings.ai?.ollama?.host || 'http://127.0.0.1:11434'); const ollamaHost = makeInput(settings.ai?.ollama?.host || 'http://127.0.0.1:11434');
const ollamaModel = makeInput(settings.ai?.ollama?.model || 'llama3.2:1b'); 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 aiStatus = el('div', { className: 'muted ai-status' }, 'AI stays local through Ollama. Vision-capable models can also inspect the screenshot attached to each step.');
const testAiBtn = el('button', { type: 'button' }, 'Test connection'); const testAiBtn = el('button', { type: 'button' }, 'Test connection');
const persistOllamaModel = debounce(() => {
const model = ollamaModel.value.trim();
// Keep the last model choice even if the dialog is dismissed without a full save.
void api.settings.set({ keyPath: 'ai.ollama.model', value: model }).catch(() => {});
}, 250);
const updateAiStatus = (message, { error = false } = {}) => { const updateAiStatus = (message, { error = false } = {}) => {
aiStatus.textContent = message; aiStatus.textContent = message;
@@ -341,7 +346,9 @@ function showSettingsDialog({
return; return;
} }
if (result.installed) { if (result.installed) {
updateAiStatus(`Connected to ${result.host} with ${result.model}.`); updateAiStatus(result.vision
? `Connected to ${result.host} with ${result.model}. It can inspect screenshots.`
: `Connected to ${result.host} with ${result.model}. This model is text-only, so StepForge will use OCR and metadata only.`);
} else { } else {
updateAiStatus(`Connected to ${result.host}. Model ${result.model} is not installed yet.`, { error: true }); updateAiStatus(`Connected to ${result.host}. Model ${result.model} is not installed yet.`, { error: true });
} }
@@ -351,6 +358,8 @@ function showSettingsDialog({
setButtonLoading(testAiBtn, false); setButtonLoading(testAiBtn, false);
} }
}; };
ollamaModel.addEventListener('input', () => persistOllamaModel());
ollamaModel.addEventListener('blur', () => persistOllamaModel.flush());
const placeholderRows = el('div', { className: 'placeholder-rows' }); const placeholderRows = el('div', { className: 'placeholder-rows' });
const rows = []; const rows = [];
+74 -2
View File
@@ -35,6 +35,15 @@ function clamp(v, min, max) {
return Math.min(max, Math.max(min, v)); return Math.min(max, Math.max(min, v));
} }
function modelLooksVisionCapable(model) {
const clean = normalizeWhitespace(model).toLowerCase();
if (!clean) return false;
return clean.includes('vision')
|| clean.includes('llava')
|| clean.includes('gemma4')
|| /(^|[^a-z0-9])qwen[23](?:\.[0-9]+)?vl([^a-z0-9]|$)/.test(clean);
}
let createWorkerImpl = null; let createWorkerImpl = null;
function loadCreateWorker() { function loadCreateWorker() {
if (createWorkerImpl) return createWorkerImpl; if (createWorkerImpl) return createWorkerImpl;
@@ -64,6 +73,7 @@ class TextIntelService {
this.workerPromise = null; this.workerPromise = null;
this.workerQueue = Promise.resolve(); this.workerQueue = Promise.resolve();
this.ocrDataDir = path.join(dataDir, 'ocr', 'eng'); this.ocrDataDir = path.join(dataDir, 'ocr', 'eng');
this.modelCapabilityCache = new Map();
} }
async shutdown() { async shutdown() {
@@ -372,15 +382,63 @@ public static class Win32 {
const data = await res.json(); const data = await res.json();
const models = Array.isArray(data?.models) ? data.models.map((model) => model.name).filter(Boolean) : []; const models = Array.isArray(data?.models) ? data.models.map((model) => model.name).filter(Boolean) : [];
const installed = config.ollama.model ? models.includes(config.ollama.model) : false; const installed = config.ollama.model ? models.includes(config.ollama.model) : false;
const vision = installed ? await this.modelSupportsVision({
host: config.ollama.host,
model: config.ollama.model,
}) : false;
return { return {
ok: true, ok: true,
installed, installed,
vision,
models, models,
host: config.ollama.host, host: config.ollama.host,
model: config.ollama.model, model: config.ollama.model,
}; };
} }
async modelCapabilities({ host, model }) {
const normalizedHost = normalizeOllamaHost(host);
const normalizedModel = normalizeWhitespace(model);
if (!normalizedHost || !normalizedModel) return [];
const cacheKey = `${normalizedHost}::${normalizedModel}`;
if (this.modelCapabilityCache.has(cacheKey)) {
return this.modelCapabilityCache.get(cacheKey);
}
const url = new URL('/api/show', `${normalizedHost.replace(/\/+$/, '')}/`);
let capabilities = [];
try {
const response = await this.fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: normalizedModel }),
});
if (response.ok) {
const payload = await response.json();
capabilities = Array.isArray(payload?.capabilities)
? payload.capabilities.map((cap) => normalizeWhitespace(cap).toLowerCase()).filter(Boolean)
: [];
}
} catch {
capabilities = [];
}
if (!capabilities.includes('vision') && modelLooksVisionCapable(normalizedModel)) {
capabilities = [...capabilities, 'vision'];
}
this.modelCapabilityCache.set(cacheKey, capabilities);
return capabilities;
}
async modelSupportsVision({ host, model }) {
const capabilities = await this.modelCapabilities({ host, model });
return capabilities.includes('vision');
}
readStepImageBase64(guideId, stepId) {
const imagePath = this.store.stepImagePath(guideId, stepId, 'working') || this.store.stepImagePath(guideId, stepId, 'original');
if (!imagePath || !fs.existsSync(imagePath)) return '';
return fs.readFileSync(imagePath).toString('base64');
}
async callOllamaText({ host, model, prompt, systemPrompt }) { async callOllamaText({ host, model, prompt, systemPrompt }) {
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`); const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
const response = await this.fetch(url, { const response = await this.fetch(url, {
@@ -403,8 +461,12 @@ public static class Win32 {
return content.trim(); return content.trim();
} }
async callOllama({ host, model, prompt, systemPrompt }) { async callOllama({ host, model, prompt, systemPrompt, images = [] }) {
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`); const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
const userMessage = { role: 'user', content: prompt };
if (Array.isArray(images) && images.length) {
userMessage.images = images;
}
const response = await this.fetch(url, { const response = await this.fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -414,7 +476,7 @@ public static class Win32 {
format: 'json', format: 'json',
messages: [ messages: [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
{ role: 'user', content: prompt }, userMessage,
], ],
options: { options: {
temperature: 0.2, temperature: 0.2,
@@ -460,6 +522,14 @@ public static class Win32 {
return { ok: false, reason: 'Block not found.' }; return { ok: false, reason: 'Block not found.' };
} }
const screenshotBase64 = step.image ? this.readStepImageBase64(guideId, stepId) : '';
const screenshotAttached = Boolean(screenshotBase64)
? await this.modelSupportsVision({
host: config.ollama.host,
model: config.ollama.model,
})
: false;
let captureContext = null; let captureContext = null;
// Use stored capture metadata when available (best context, from capture time). // Use stored capture metadata when available (best context, from capture time).
// Fall back to re-running OCR on the stored image only when metadata is absent. // Fall back to re-running OCR on the stored image only when metadata is absent.
@@ -514,6 +584,7 @@ public static class Win32 {
step, step,
captureContext, captureContext,
block: currentBlock, block: currentBlock,
screenshotAttached,
}); });
const raw = await this.callOllama({ const raw = await this.callOllama({
@@ -521,6 +592,7 @@ public static class Win32 {
model: config.ollama.model, model: config.ollama.model,
prompt, prompt,
systemPrompt, systemPrompt,
images: screenshotAttached ? [screenshotBase64] : [],
}); });
const patch = normalizeAiPatch(raw); const patch = normalizeAiPatch(raw);
const updated = applyAiPatchToStep(step, patch, { target, blockId }); const updated = applyAiPatchToStep(step, patch, { target, blockId });
+5
View File
@@ -673,6 +673,7 @@ function buildAiPrompt({
step = null, step = null,
captureContext = null, captureContext = null,
block = null, block = null,
screenshotAttached = false,
} = {}) { } = {}) {
const hasDraftTitle = step && !isPlaceholderTitle(step.title); const hasDraftTitle = step && !isPlaceholderTitle(step.title);
const hasDraftDesc = step && Boolean(htmlToText(step.descriptionHtml || '')); const hasDraftDesc = step && Boolean(htmlToText(step.descriptionHtml || ''));
@@ -717,6 +718,7 @@ function buildAiPrompt({
(!hasDraftTitle || target === 'description') && captureContext.titleCandidate (!hasDraftTitle || target === 'description') && captureContext.titleCandidate
? `Suggested title: ${captureContext.titleCandidate}` : null, ? `Suggested title: ${captureContext.titleCandidate}` : null,
] : []), ] : []),
screenshotAttached ? 'Screenshot: attached to this request.' : null,
draftTitleLine, draftTitleLine,
draftDescLine, draftDescLine,
].filter(Boolean); ].filter(Boolean);
@@ -770,6 +772,9 @@ function buildAiPrompt({
richContext richContext
? '- Use the OCR text, window title, app name, and element info to make the documentation specific.' ? '- Use the OCR text, window title, app name, and element info to make the documentation specific.'
: '- Context is limited. Use the app name or window title if available; generate a reasonable action title.', : '- Context is limited. Use the app name or window title if available; generate a reasonable action title.',
screenshotAttached
? '- A screenshot is attached. Use it together with the OCR and metadata to resolve visual details, but do not mention the screenshot in the output.'
: '- No screenshot is attached. Rely on OCR, the window title, app name, and element info.',
'- Do NOT generate blocks that describe the technical capture process or mention OCR.', '- Do NOT generate blocks that describe the technical capture process or mention OCR.',
'- Do NOT invent details not supported by the capture context.', '- Do NOT invent details not supported by the capture context.',
'- If the target is one block, only rewrite that block.', '- If the target is one block, only rewrite that block.',
+10 -1
View File
@@ -22,6 +22,14 @@ ollama pull llama3.2:1b
That model is small enough to feel responsive on modest hardware, but still good enough for human-sounding titles and short text blocks. That model is small enough to feel responsive on modest hardware, but still good enough for human-sounding titles and short text blocks.
If you want StepForge to send the screenshot itself to the model, pull a vision-capable model instead:
```bash
ollama pull gemma3
```
That model can inspect pictures as well as text, so it is better when you want the AI to read the UI directly from the screenshot.
If you need something even smaller, try: If you need something even smaller, try:
```bash ```bash
@@ -44,7 +52,7 @@ Set:
* `Enable AI text filling` to on * `Enable AI text filling` to on
* `Ollama host` to your local Ollama server * `Ollama host` to your local Ollama server
* `Ollama model` to `llama3.2:1b` or the smaller model you pulled * `Ollama model` to `llama3.2:1b` for text-only mode, or `llama3.2-vision` if you want screenshot-aware AI
The default host is: The default host is:
@@ -73,3 +81,4 @@ You can also use `More -> Generate all text fields with AI` to fill the whole st
* Capture titles are still generated automatically without AI. * Capture titles are still generated automatically without AI.
* AI generation only works when `Enable AI text filling` is turned on. * AI generation only works when `Enable AI text filling` is turned on.
* The app always uses local OCR around the click area first, then local AI only when you ask for it. * The app always uses local OCR around the click area first, then local AI only when you ask for it.
* When the selected Ollama model supports vision, StepForge also sends the screenshot to the model so it can cross-check OCR and visual context.
+43 -8
View File
@@ -56,17 +56,52 @@ function stepBlocks(step) {
} }
/** /**
* Split a step's blocks into the groups exporters lay out around the * Split a step's blocks into the layout buckets exporters use around the
* description/image: text blocks pinned to 'before-description', and * step title, description, and image. Text blocks keep their block-list
* everything else (code/table blocks plus 'after-description' and * ordering inside each bucket; code/table blocks stay in `rest`.
* 'after-image' text blocks) in the same relative order they appear in the
* editor's Blocks list.
*/ */
function stepContentGroups(step) { function stepContentGroups(step) {
const all = stepBlocks(step); const all = stepBlocks(step);
const before = all.filter((b) => b.kind === 'text' && b.position === 'before-description'); const groups = {
const rest = all.filter((b) => !(b.kind === 'text' && b.position === 'before-description')); beforeTitle: [],
return { before, rest }; afterTitle: [],
beforeDescription: [],
afterDescription: [],
beforeImage: [],
afterImage: [],
rest: [],
};
for (const block of all) {
if (block.kind !== 'text') {
groups.rest.push(block);
continue;
}
switch (block.position) {
case 'before-title':
groups.beforeTitle.push(block);
break;
case 'after-title':
groups.afterTitle.push(block);
break;
case 'before-image':
groups.beforeImage.push(block);
break;
case 'after-image':
groups.afterImage.push(block);
break;
case 'before-description':
groups.beforeDescription.push(block);
break;
case 'after-description':
default:
groups.afterDescription.push(block);
break;
}
}
return groups;
} }
function codeBlockText(block) { function codeBlockText(block) {
+29 -4
View File
@@ -62,11 +62,25 @@ function exportConfluence(ast, outDir, template = {}) {
} }
const stepXml = ast.steps.map((step) => { const stepXml = ast.steps.map((step) => {
const parts = [`<a id="${anchorFor(step)}"></a>`, `<h2>${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}</h2>`]; const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
const parts = [`<a id="${anchorFor(step)}"></a>`];
for (const tb of beforeTitle) {
parts.push(blockMacro(tb, ast));
}
parts.push(`<h2>${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}</h2>`);
if (step.skipped) parts.push('<p><em>(skipped)</em></p>'); if (step.skipped) parts.push('<p><em>(skipped)</em></p>');
for (const tb of afterTitle) {
const { before, rest } = stepContentGroups(step); parts.push(blockMacro(tb, ast));
for (const tb of before) { }
for (const tb of beforeDescription) {
parts.push(blockMacro(tb, ast)); parts.push(blockMacro(tb, ast));
} }
@@ -74,11 +88,22 @@ function exportConfluence(ast, outDir, template = {}) {
parts.push(`<div>${stepLinkRewrite(step.descriptionHtml, ast)}</div>`); parts.push(`<div>${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
} }
for (const tb of afterDescription) {
parts.push(blockMacro(tb, ast));
}
for (const tb of beforeImage) {
parts.push(blockMacro(tb, ast));
}
const attachment = attachmentNames.get(step.stepId); const attachment = attachmentNames.get(step.stepId);
if (attachment) { if (attachment) {
parts.push(`<p><ac:image><ri:attachment ri:filename="${escapeXml(attachment)}" /></ac:image></p>`); parts.push(`<p><ac:image><ri:attachment ri:filename="${escapeXml(attachment)}" /></ac:image></p>`);
} }
for (const tb of afterImage) {
parts.push(blockMacro(tb, ast));
}
for (const block of rest) { for (const block of rest) {
if (block.kind === 'text') { if (block.kind === 'text') {
parts.push(blockMacro(block, ast)); parts.push(blockMacro(block, ast));
+18 -5
View File
@@ -10,8 +10,8 @@ const { guideMetaLines, guideSummary, tocEntries } = require('./document-layout'
/** /**
* DOCX exporter: WordprocessingML built directly (no dependency), one * DOCX exporter: WordprocessingML built directly (no dependency), one
* heading + description + screenshot per step, text blocks, code blocks * heading per step with positioned text blocks around the description and
* (Courier), and tables. * screenshot, plus code blocks (Courier) and tables.
*/ */
const DEFAULT_TEMPLATE = { const DEFAULT_TEMPLATE = {
@@ -274,16 +274,27 @@ function exportDocx(ast, outDir, template = {}) {
const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22; const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22;
const bookmarkId = ++bookmarkCounter; const bookmarkId = ++bookmarkCounter;
const anchor = bookmarkName(step); const anchor = bookmarkName(step);
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
for (const tb of beforeTitle) emitTextBlock(tb);
body.push(p( body.push(p(
`<w:bookmarkStart w:id="${bookmarkId}" w:name="${anchor}"/>` + `<w:bookmarkStart w:id="${bookmarkId}" w:name="${anchor}"/>` +
run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }) + run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }) +
`<w:bookmarkEnd w:id="${bookmarkId}"/>`, `<w:bookmarkEnd w:id="${bookmarkId}"/>`,
headingParagraphProps(step.depth, step.forceNewPage) headingParagraphProps(step.depth, step.forceNewPage)
)); ));
for (const tb of afterTitle) emitTextBlock(tb);
const { before, rest } = stepContentGroups(step); for (const tb of beforeDescription) emitTextBlock(tb);
for (const tb of before) emitTextBlock(tb);
if (step.descriptionText) body.push(p(run(step.descriptionText, { size: 20, color: '1F2937' }))); if (step.descriptionText) body.push(p(run(step.descriptionText, { size: 20, color: '1F2937' })));
for (const tb of afterDescription) emitTextBlock(tb);
for (const tb of beforeImage) emitTextBlock(tb);
const img = images.get(step.stepId); const img = images.get(step.stepId);
if (img) { if (img) {
@@ -295,6 +306,8 @@ function exportDocx(ast, outDir, template = {}) {
stepImageCount += 1; stepImageCount += 1;
} }
for (const tb of afterImage) emitTextBlock(tb);
for (const block of rest) { for (const block of rest) {
if (block.kind === 'text') { if (block.kind === 'text') {
emitTextBlock(block); emitTextBlock(block);
+28 -4
View File
@@ -40,6 +40,10 @@ function blockHtml(tb) {
return `<div class="block block-${tb.level}"><strong>${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}</strong>${tb.descriptionHtml ? `<div>${tb.descriptionHtml}</div>` : ''}</div>`; return `<div class="block block-${tb.level}"><strong>${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}</strong>${tb.descriptionHtml ? `<div>${tb.descriptionHtml}</div>` : ''}</div>`;
} }
function renderBlockListHtml(blocks) {
return blocks.map((block) => blockHtml(block)).join('\n');
}
function renderMetaChips(ast) { function renderMetaChips(ast) {
return [ return [
...guideMetaLines(ast).map((line) => `<span class="chip">${escapeHtml(line)}</span>`), ...guideMetaLines(ast).map((line) => `<span class="chip">${escapeHtml(line)}</span>`),
@@ -85,11 +89,22 @@ function renderStepCard(step, ast, images, tpl, { rich = false, selected = false
<span class="step-title">${escapeHtml(step.title || 'Untitled step')}</span> <span class="step-title">${escapeHtml(step.title || 'Untitled step')}</span>
<span class="status-chip status-${step.status || 'todo'}${step.skipped ? ' skipped' : ''}">${escapeHtml(statusText)}</span> <span class="status-chip status-${step.status || 'todo'}${step.skipped ? ' skipped' : ''}">${escapeHtml(statusText)}</span>
</h2>` : `<h2>${title}</h2>`; </h2>` : `<h2>${title}</h2>`;
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
return ` return `
<section class="step-card${step.skipped ? ' skipped' : ''}${rich && selected ? ' selected' : ''}" id="${anchorFor(step)}"> <section class="step-card${step.skipped ? ' skipped' : ''}${rich && selected ? ' selected' : ''}" id="${anchorFor(step)}">
${renderBlockListHtml(beforeTitle)}
${head} ${head}
${renderBlockListHtml(afterTitle)}
<div class="step-body"> <div class="step-body">
${stepBodyHtml(step, ast, images, tpl)} ${renderStepBodyHtml({ beforeDescription, afterDescription, beforeImage, afterImage, rest }, step, ast, images, tpl)}
</div> </div>
</section>`; </section>`;
} }
@@ -117,15 +132,24 @@ function bodyStyle(tpl) {
return `--accent:${tpl.accentColor};--accent-rgb:${r},${g},${b};`; return `--accent:${tpl.accentColor};--accent-rgb:${r},${g},${b};`;
} }
function stepBodyHtml(step, ast, images, tpl) { function renderStepBodyHtml(groups, step, ast, images, tpl) {
const parts = []; const parts = [];
const { before, rest } = stepContentGroups(step); const {
for (const tb of before) parts.push(blockHtml(tb)); beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = groups;
for (const tb of beforeDescription) parts.push(blockHtml(tb));
if (step.descriptionHtml) parts.push(`<div class="desc">${stepLinkRewrite(step.descriptionHtml, ast)}</div>`); if (step.descriptionHtml) parts.push(`<div class="desc">${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
for (const tb of afterDescription) parts.push(blockHtml(tb));
for (const tb of beforeImage) parts.push(blockHtml(tb));
const img = images.get(step.stepId); const img = images.get(step.stepId);
if (img && tpl.includeImages) { if (img && tpl.includeImages) {
parts.push(`<img class="shot" alt="Step ${escapeHtml(step.number)}" src="${dataUri(img)}" width="${img.width}">`); parts.push(`<img class="shot" alt="Step ${escapeHtml(step.number)}" src="${dataUri(img)}" width="${img.width}">`);
} }
for (const tb of afterImage) parts.push(blockHtml(tb));
for (const block of rest) { for (const block of rest) {
if (block.kind === 'text') { if (block.kind === 'text') {
parts.push(blockHtml(block)); parts.push(blockHtml(block));
+15 -4
View File
@@ -96,15 +96,25 @@ function renderMarkdownGuide(ast, outDir, template = {}, {
for (const step of ast.steps) { for (const step of ast.steps) {
const heading = step.depth > 0 ? '###' : '##'; const heading = step.depth > 0 ? '###' : '##';
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
lines.push(`<a id="${anchorFor(step)}"></a>`, ''); lines.push(`<a id="${anchorFor(step)}"></a>`, '');
for (const tb of beforeTitle) emitBlock(lines, tb, { alertStyle });
lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, ''); lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, '');
if (step.skipped) lines.push('*(skipped)*', ''); if (step.skipped) lines.push('*(skipped)*', '');
for (const tb of afterTitle) emitBlock(lines, tb, { alertStyle });
const { before, rest } = stepContentGroups(step); for (const tb of beforeDescription) emitBlock(lines, tb, { alertStyle });
for (const tb of before) emitBlock(lines, tb, { alertStyle });
if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), ''); if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), '');
for (const tb of afterDescription) emitBlock(lines, tb, { alertStyle });
for (const tb of beforeImage) emitBlock(lines, tb, { alertStyle });
const img = images.get(step.stepId); const img = images.get(step.stepId);
if (img) { if (img) {
if (tpl.azureWiki && tpl.imageMaxWidth > 0) { if (tpl.azureWiki && tpl.imageMaxWidth > 0) {
@@ -113,6 +123,7 @@ function renderMarkdownGuide(ast, outDir, template = {}, {
lines.push(`![Step ${step.number}](${img.relPath})`, ''); lines.push(`![Step ${step.number}](${img.relPath})`, '');
} }
} }
for (const tb of afterImage) emitBlock(lines, tb, { alertStyle });
for (const block of rest) { for (const block of rest) {
if (block.kind === 'text') { if (block.kind === 'text') {
+31 -8
View File
@@ -203,10 +203,10 @@ function exportPdf(ast, outDir, template = {}) {
/** /**
* Compute the vertical space a step will need: `head` is the title, the * Compute the vertical space a step will need: `head` is the title, the
* accent rule, any before-description blocks, the description, and the * accent rule, positioned text blocks, the description, and the image
* image kept together on one page and `total` adds the remaining * kept together on one page and `total` adds the remaining blocks plus
* blocks plus the trailing gap, used to decide whether the whole step * the trailing gap, used to decide whether the whole step fits on a
* fits on a fresh page. * fresh page.
*/ */
const measureStep = (step) => { const measureStep = (step) => {
const headSize = step.depth > 0 ? 12 : 14; const headSize = step.depth > 0 ? 12 : 14;
@@ -214,8 +214,18 @@ function exportPdf(ast, outDir, template = {}) {
const titleLines = pdf.wrapText(titleText, headSize, usableW, 'F2'); const titleLines = pdf.wrapText(titleText, headSize, usableW, 'F2');
let head = Math.max(40, titleLines.length * headSize * 1.35 + 8); let head = Math.max(40, titleLines.length * headSize * 1.35 + 8);
const { before, rest } = stepContentGroups(step); const {
for (const tb of before) head += measureBlock(tb); beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
for (const tb of [...beforeTitle, ...afterTitle, ...beforeDescription, ...afterDescription, ...beforeImage, ...afterImage]) {
head += measureBlock(tb);
}
if (step.descriptionHtml) head += layoutDescription(pdf, step.descriptionHtml, usableW, 10.5).height; if (step.descriptionHtml) head += layoutDescription(pdf, step.descriptionHtml, usableW, 10.5).height;
head += measureImage(step.stepId); head += measureImage(step.stepId);
@@ -298,14 +308,26 @@ function exportPdf(ast, outDir, template = {}) {
pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`); pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`);
const tocTarget = tocTargets.get(step.stepId); const tocTarget = tocTargets.get(step.stepId);
if (tocTarget) tocTarget.pageIndex = pdf.pages.length - 1; if (tocTarget) tocTarget.pageIndex = pdf.pages.length - 1;
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
for (const tb of beforeTitle) emitBlock(tb);
const headSize = step.depth > 0 ? 12 : 14; const headSize = step.depth > 0 ? 12 : 14;
writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' }); writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' });
pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] }); pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] });
y += 8; y += 8;
const { before, rest } = stepContentGroups(step); for (const tb of afterTitle) emitBlock(tb);
for (const tb of before) emitBlock(tb); for (const tb of beforeDescription) emitBlock(tb);
if (step.descriptionHtml) writeDescription(step.descriptionHtml); if (step.descriptionHtml) writeDescription(step.descriptionHtml);
for (const tb of afterDescription) emitBlock(tb);
for (const tb of beforeImage) emitBlock(tb);
const img = images.get(step.stepId); const img = images.get(step.stepId);
if (img) { if (img) {
@@ -317,6 +339,7 @@ function exportPdf(ast, outDir, template = {}) {
pdf.image(img, M, y, w, h); pdf.image(img, M, y, w, h);
y += h + 10; y += h + 10;
} }
for (const tb of afterImage) emitBlock(tb);
for (const block of rest) { for (const block of rest) {
if (block.kind === 'text') { if (block.kind === 'text') {
+56 -22
View File
@@ -9,8 +9,9 @@ const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups } = require('
const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout'); const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
/** /**
* PPTX exporter: a title slide plus one 16:9 slide per step (title bar + * PPTX exporter: a title slide plus one 16:9 slide per step, with
* screenshot + description). PresentationML written directly. * positioned text blocks laid out around the step title, description, and
* screenshot. PresentationML written directly.
*/ */
const DEFAULT_TEMPLATE = { const DEFAULT_TEMPLATE = {
@@ -208,24 +209,63 @@ function exportPptx(ast, outDir, template = {}) {
let mediaCounter = 0; let mediaCounter = 0;
for (const step of ast.steps) { for (const step of ast.steps) {
const { before, rest } = stepContentGroups(step); const {
const beforeBlocks = before.filter((block) => block.kind === 'text'); beforeTitle,
const restBlocks = rest.filter((block) => block.kind === 'text'); afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
} = stepContentGroups(step);
let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB'); let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
content += textBox(457200, TITLE_Y, SLIDE_W - 914400, TITLE_H, const beforeTitleReserve = beforeTitle.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0);
para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true })); const titleY = beforeTitle.length ? 220000 + beforeTitleReserve + 120000 : TITLE_Y;
content += rectShape(457200, TITLE_RULE_Y, 2400000, 12000, '2563EB'); const titleRuleY = beforeTitle.length ? titleY + TITLE_H + 120000 : TITLE_RULE_Y;
const contentY = beforeTitle.length ? Math.max(CONTENT_Y, titleRuleY + 180000) : CONTENT_Y;
const beforeTitleY = beforeTitle.length ? 220000 : CONTENT_Y;
let y = CONTENT_Y; let y = beforeTitleY;
for (const tb of beforeBlocks) { for (const tb of beforeTitle) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml; content += block.xml;
y += block.height + CALL_OUT_GAP; y += block.height + CALL_OUT_GAP;
} }
const descReserve = step.descriptionText ? descriptionHeight(step.descriptionText) + 120000 : 0; content += textBox(457200, titleY, SLIDE_W - 914400, TITLE_H,
const restReserve = restBlocks.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0); para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true }));
content += rectShape(457200, titleRuleY, 2400000, 12000, '2563EB');
y = contentY;
for (const tb of afterTitle) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
for (const tb of beforeDescription) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
if (step.descriptionText) {
const descH = descriptionHeight(step.descriptionText);
content += textBox(457200, y, SLIDE_W - 914400, Math.max(360000, descH || 420000),
para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' }));
y += Math.max(360000, descH || 420000) + 90000;
}
for (const tb of afterDescription) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
for (const tb of beforeImage) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
const postImageReserve = afterImage.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0);
const rels = []; const rels = [];
const media = []; const media = [];
@@ -236,23 +276,17 @@ function exportPptx(ast, outDir, template = {}) {
media.push({ name, data: encodePng(img) }); media.push({ name, data: encodePng(img) });
const relId = 2; // rId1 = layout, rId2 = image const relId = 2; // rId1 = layout, rId2 = image
rels.push({ id: relId, name }); rels.push({ id: relId, name });
// Fit image into a centered region below the title. // Fit image into the remaining centered region before the trailing blocks.
const maxW = SLIDE_W - 1219200; const maxW = SLIDE_W - 1219200;
const maxH = Math.max(0, SLIDE_H - y - descReserve - restReserve - 100000); const maxH = Math.max(0, SLIDE_H - y - postImageReserve - 100000);
let w = img.width * EMU_PER_PX, h = img.height * EMU_PER_PX; let w = img.width * EMU_PER_PX, h = img.height * EMU_PER_PX;
const scale = Math.min(maxW / w, maxH / h, 1); const scale = Math.min(maxW / w, maxH / h, 1);
w = Math.round(w * scale); h = Math.round(h * scale); w = Math.round(w * scale); h = Math.round(h * scale);
content += picture(relId, Math.round((SLIDE_W - w) / 2), y, w, h); content += picture(relId, Math.round((SLIDE_W - w) / 2), y, w, h);
y += h + 100000; y += h + 100000;
} }
if (step.descriptionText) {
const descH = Math.max(360000, descReserve || 420000);
content += textBox(457200, Math.max(y, SLIDE_H - CONTENT_FOOTER_Y - descH), SLIDE_W - 914400, descH,
para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' }));
y = Math.max(y, SLIDE_H - CONTENT_FOOTER_Y - descH) + descH + 90000;
}
for (const tb of restBlocks) { for (const tb of afterImage) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml; content += block.xml;
y += block.height + CALL_OUT_GAP; y += block.height + CALL_OUT_GAP;
+65
View File
@@ -12,6 +12,7 @@ const { exportWikiJs } = require('../../exporters/wikijs');
const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html'); const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html');
const { exportConfluence } = require('../../exporters/confluence'); const { exportConfluence } = require('../../exporters/confluence');
const { htmlToMarkdown } = require('../../exporters/htmlmd'); const { htmlToMarkdown } = require('../../exporters/htmlmd');
const { stepContentGroups } = require('../../exporters/common');
const { decodePng } = require('../../core/png'); const { decodePng } = require('../../core/png');
const { buildFixtureGuide } = require('./fixture-guide'); const { buildFixtureGuide } = require('./fixture-guide');
const { makeTmpDir, rmrf } = require('./helpers'); const { makeTmpDir, rmrf } = require('./helpers');
@@ -146,6 +147,70 @@ test('Markdown export: TOC anchors resolve, images exist, blocks rendered', (t)
assert.ok(md.includes('<p>Admins only.</p>')); assert.ok(md.includes('<p>Admins only.</p>'));
}); });
test('text block positions render around the title, description, and image', (t) => {
const root = makeTmpDir('exppositions');
t.after(() => rmrf(root));
const { store, guide, s1 } = buildFixtureGuide(path.join(root, 'data'));
const step = store.getStep(guide.guideId, s1.stepId);
step.textBlocks = [
{ id: 'tb-before-title', order: 1, position: 'before-title', level: 'info', title: 'Before title', descriptionHtml: '<p>Before title.</p>' },
{ id: 'tb-after-title', order: 2, position: 'after-title', level: 'info', title: 'After title', descriptionHtml: '<p>After title.</p>' },
{ id: 'tb-before-description', order: 3, position: 'before-description', level: 'info', title: 'Before description', descriptionHtml: '<p>Before description.</p>' },
{ id: 'tb-after-description', order: 4, position: 'after-description', level: 'info', title: 'After description', descriptionHtml: '<p>After description.</p>' },
{ id: 'tb-before-image', order: 5, position: 'before-image', level: 'info', title: 'Before image', descriptionHtml: '<p>Before image.</p>' },
{ id: 'tb-after-image', order: 6, position: 'after-image', level: 'info', title: 'After image', descriptionHtml: '<p>After image.</p>' },
];
store.saveStep(guide.guideId, step);
const ast = buildRenderAst(store, guide.guideId);
const groups = stepContentGroups(ast.steps[0]);
assert.equal(groups.beforeTitle.length, 1);
assert.equal(groups.afterTitle.length, 1);
assert.equal(groups.beforeDescription.length, 1);
assert.equal(groups.afterDescription.length, 1);
assert.equal(groups.beforeImage.length, 1);
assert.equal(groups.afterImage.length, 1);
const assertIncreasingOrder = (text, markers) => {
let previous = -1;
for (const marker of markers) {
const index = text.indexOf(marker);
assert.ok(index >= 0, `missing marker: ${marker}`);
assert.ok(index > previous, `marker out of order: ${marker}`);
previous = index;
}
};
const mdOut = path.join(root, 'md');
const md = fs.readFileSync(exportMarkdown(ast, mdOut).file, 'utf8');
assertIncreasingOrder(md, [
'Before title',
'## 1. Open AcmeSync settings',
'After title',
'Before description',
'docs.example.com',
'After description',
'Before image',
'![Step 1](',
'After image',
]);
const htmlOut = path.join(root, 'html');
const html = fs.readFileSync(exportHtmlSimple(ast, htmlOut).file, 'utf8');
assertIncreasingOrder(html, [
'Before title',
'<h2>1. Open AcmeSync settings</h2>',
'After title',
'Before description',
'docs.example.com',
'After description',
'Before image',
'alt="Step 1"',
'After image',
]);
});
test('Wiki.js export: TOC is included, wiki callouts render, images exist', (t) => { test('Wiki.js export: TOC is included, wiki callouts render, images exist', (t) => {
const root = makeTmpDir('expwikijs'); const root = makeTmpDir('expwikijs');
t.after(() => rmrf(root)); t.after(() => rmrf(root));
+2
View File
@@ -56,12 +56,14 @@ test('settings persist, deep-merge with defaults, and store global placeholders'
assert.equal(s1.get('appearance'), DEFAULT_SETTINGS.appearance); assert.equal(s1.get('appearance'), DEFAULT_SETTINGS.appearance);
s1.set('appearance', 'dark'); s1.set('appearance', 'dark');
s1.set('capture.delayMs', 1500); s1.set('capture.delayMs', 1500);
s1.set('ai.ollama.model', 'qwen3:0.6b');
s1.setGlobalPlaceholders({ Company: 'Acme', Author: 'Tyler' }); s1.setGlobalPlaceholders({ Company: 'Acme', Author: 'Tyler' });
// A fresh instance reads back the changed values merged over defaults. // A fresh instance reads back the changed values merged over defaults.
const s2 = new Settings(dir); const s2 = new Settings(dir);
assert.equal(s2.get('appearance'), 'dark'); assert.equal(s2.get('appearance'), 'dark');
assert.equal(s2.get('capture.delayMs'), 1500); assert.equal(s2.get('capture.delayMs'), 1500);
assert.equal(s2.get('ai.ollama.model'), 'qwen3:0.6b');
assert.equal(s2.get('capture.clickMarker'), DEFAULT_SETTINGS.capture.clickMarker); assert.equal(s2.get('capture.clickMarker'), DEFAULT_SETTINGS.capture.clickMarker);
assert.deepEqual(s2.getGlobalPlaceholders(), { Company: 'Acme', Author: 'Tyler' }); assert.deepEqual(s2.getGlobalPlaceholders(), { Company: 'Acme', Author: 'Tyler' });
}); });
+102 -9
View File
@@ -1,5 +1,7 @@
'use strict'; 'use strict';
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
@@ -309,21 +311,112 @@ test('ollama connection test reports installed models', async (t) => {
settings: makeSettings(), settings: makeSettings(),
getWindow: () => null, getWindow: () => null,
dataDir: root, dataDir: root,
fetchImpl: async () => ({ fetchImpl: async (url) => {
ok: true, const pathname = new URL(url).pathname;
json: async () => ({ if (pathname === '/api/tags') {
models: [ return {
{ name: 'llama3.2:1b' }, ok: true,
{ name: 'qwen3:0.6b' }, json: async () => ({
], models: [
}), { name: 'llama3.2:1b' },
}), { name: 'qwen3:0.6b' },
],
}),
};
}
if (pathname === '/api/show') {
return {
ok: true,
json: async () => ({
capabilities: ['completion', 'vision'],
}),
};
}
throw new Error(`unexpected fetch: ${pathname}`);
},
}); });
const result = await service.testAiConnection(); const result = await service.testAiConnection();
assert.equal(result.ok, true); assert.equal(result.ok, true);
assert.equal(result.installed, true); assert.equal(result.installed, true);
assert.equal(result.model, 'llama3.2:1b'); assert.equal(result.model, 'llama3.2:1b');
assert.equal(result.vision, true);
});
test('vision-capable models receive the screenshot in the chat request', async (t) => {
const root = makeTmpDir('text-intel-ai-vision');
t.after(() => rmrf(root));
const imagePath = path.join(root, 'step.png');
fs.writeFileSync(imagePath, Buffer.from('fake screenshot bytes'));
const step = createStep({
title: 'Old title',
descriptionHtml: '<p>Old text</p>',
image: {
originalPath: 'original.png',
workingPath: 'working.png',
size: { width: 10, height: 10 },
},
captureMetadata: {
windowTitle: 'Settings',
appName: 'chrome',
ocrText: 'Open settings',
titleCandidate: 'Open settings',
mode: 'fullscreen',
},
});
const fetchCalls = [];
const service = new TextIntelService({
store: {
settingsDir: root,
getGuide: () => ({ guideId: 'g1', title: 'Guide', descriptionHtml: '', stepsOrder: ['s1'] }),
getStep: () => step,
stepImagePath: () => imagePath,
saveStep: (_, next) => next,
},
settings: makeSettings(),
getWindow: () => null,
dataDir: root,
fetchImpl: async (url, init = {}) => {
const pathname = new URL(url).pathname;
fetchCalls.push({ pathname, init });
if (pathname === '/api/show') {
return {
ok: true,
json: async () => ({
capabilities: ['completion', 'vision'],
}),
};
}
if (pathname === '/api/chat') {
return {
ok: true,
json: async () => ({
message: {
content: JSON.stringify({
title: 'Open settings',
description: 'Use the AI tab.',
}),
},
}),
};
}
throw new Error(`unexpected fetch: ${pathname}`);
},
});
const result = await service.generateStepPatch({
guideId: 'g1',
stepId: 's1',
target: 'all',
});
assert.equal(result.ok, true);
const chatCall = fetchCalls.find((call) => call.pathname === '/api/chat');
assert.ok(chatCall, 'expected an Ollama chat request');
const body = JSON.parse(chatCall.init.body);
assert.deepEqual(body.messages[1].images, [fs.readFileSync(imagePath).toString('base64')]);
assert.match(body.messages[1].content, /Screenshot: attached/i);
}); });
test('invalid ollama output fails safely without saving the step', async (t) => { test('invalid ollama output fails safely without saving the step', async (t) => {