Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3915865029 | ||
|
|
79edb6bb56 | ||
|
|
a971ff870f | ||
|
|
9bd4a959c4 | ||
|
|
cb5e0c6837 | ||
|
|
ffaa123893 |
@@ -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
@@ -318,8 +318,13 @@ function showSettingsDialog({
|
||||
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 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 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 } = {}) => {
|
||||
aiStatus.textContent = message;
|
||||
@@ -341,7 +346,9 @@ function showSettingsDialog({
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
updateAiStatus(`Connected to ${result.host}. Model ${result.model} is not installed yet.`, { error: true });
|
||||
}
|
||||
@@ -351,6 +358,8 @@ function showSettingsDialog({
|
||||
setButtonLoading(testAiBtn, false);
|
||||
}
|
||||
};
|
||||
ollamaModel.addEventListener('input', () => persistOllamaModel());
|
||||
ollamaModel.addEventListener('blur', () => persistOllamaModel.flush());
|
||||
|
||||
const placeholderRows = el('div', { className: 'placeholder-rows' });
|
||||
const rows = [];
|
||||
|
||||
+74
-2
@@ -35,6 +35,15 @@ function clamp(v, min, max) {
|
||||
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;
|
||||
function loadCreateWorker() {
|
||||
if (createWorkerImpl) return createWorkerImpl;
|
||||
@@ -64,6 +73,7 @@ class TextIntelService {
|
||||
this.workerPromise = null;
|
||||
this.workerQueue = Promise.resolve();
|
||||
this.ocrDataDir = path.join(dataDir, 'ocr', 'eng');
|
||||
this.modelCapabilityCache = new Map();
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
@@ -372,15 +382,63 @@ public static class Win32 {
|
||||
const data = await res.json();
|
||||
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 vision = installed ? await this.modelSupportsVision({
|
||||
host: config.ollama.host,
|
||||
model: config.ollama.model,
|
||||
}) : false;
|
||||
return {
|
||||
ok: true,
|
||||
installed,
|
||||
vision,
|
||||
models,
|
||||
host: config.ollama.host,
|
||||
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 }) {
|
||||
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
|
||||
const response = await this.fetch(url, {
|
||||
@@ -403,8 +461,12 @@ public static class Win32 {
|
||||
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 userMessage = { role: 'user', content: prompt };
|
||||
if (Array.isArray(images) && images.length) {
|
||||
userMessage.images = images;
|
||||
}
|
||||
const response = await this.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -414,7 +476,7 @@ public static class Win32 {
|
||||
format: 'json',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: prompt },
|
||||
userMessage,
|
||||
],
|
||||
options: {
|
||||
temperature: 0.2,
|
||||
@@ -460,6 +522,14 @@ public static class Win32 {
|
||||
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;
|
||||
// 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.
|
||||
@@ -514,6 +584,7 @@ public static class Win32 {
|
||||
step,
|
||||
captureContext,
|
||||
block: currentBlock,
|
||||
screenshotAttached,
|
||||
});
|
||||
|
||||
const raw = await this.callOllama({
|
||||
@@ -521,6 +592,7 @@ public static class Win32 {
|
||||
model: config.ollama.model,
|
||||
prompt,
|
||||
systemPrompt,
|
||||
images: screenshotAttached ? [screenshotBase64] : [],
|
||||
});
|
||||
const patch = normalizeAiPatch(raw);
|
||||
const updated = applyAiPatchToStep(step, patch, { target, blockId });
|
||||
|
||||
@@ -673,6 +673,7 @@ function buildAiPrompt({
|
||||
step = null,
|
||||
captureContext = null,
|
||||
block = null,
|
||||
screenshotAttached = false,
|
||||
} = {}) {
|
||||
const hasDraftTitle = step && !isPlaceholderTitle(step.title);
|
||||
const hasDraftDesc = step && Boolean(htmlToText(step.descriptionHtml || ''));
|
||||
@@ -717,6 +718,7 @@ function buildAiPrompt({
|
||||
(!hasDraftTitle || target === 'description') && captureContext.titleCandidate
|
||||
? `Suggested title: ${captureContext.titleCandidate}` : null,
|
||||
] : []),
|
||||
screenshotAttached ? 'Screenshot: attached to this request.' : null,
|
||||
draftTitleLine,
|
||||
draftDescLine,
|
||||
].filter(Boolean);
|
||||
@@ -770,6 +772,9 @@ function buildAiPrompt({
|
||||
richContext
|
||||
? '- 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.',
|
||||
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 invent details not supported by the capture context.',
|
||||
'- If the target is one block, only rewrite that block.',
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
@@ -44,7 +52,7 @@ Set:
|
||||
|
||||
* `Enable AI text filling` to on
|
||||
* `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:
|
||||
|
||||
@@ -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.
|
||||
* 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.
|
||||
* 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
@@ -56,17 +56,52 @@ function stepBlocks(step) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a step's blocks into the groups exporters lay out around the
|
||||
* description/image: text blocks pinned to 'before-description', and
|
||||
* everything else (code/table blocks plus 'after-description' and
|
||||
* 'after-image' text blocks) in the same relative order they appear in the
|
||||
* editor's Blocks list.
|
||||
* Split a step's blocks into the layout buckets exporters use around the
|
||||
* step title, description, and image. Text blocks keep their block-list
|
||||
* ordering inside each bucket; code/table blocks stay in `rest`.
|
||||
*/
|
||||
function stepContentGroups(step) {
|
||||
const all = stepBlocks(step);
|
||||
const before = all.filter((b) => b.kind === 'text' && b.position === 'before-description');
|
||||
const rest = all.filter((b) => !(b.kind === 'text' && b.position === 'before-description'));
|
||||
return { before, rest };
|
||||
const groups = {
|
||||
beforeTitle: [],
|
||||
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) {
|
||||
|
||||
+29
-4
@@ -62,11 +62,25 @@ function exportConfluence(ast, outDir, template = {}) {
|
||||
}
|
||||
|
||||
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>');
|
||||
|
||||
const { before, rest } = stepContentGroups(step);
|
||||
for (const tb of before) {
|
||||
for (const tb of afterTitle) {
|
||||
parts.push(blockMacro(tb, ast));
|
||||
}
|
||||
for (const tb of beforeDescription) {
|
||||
parts.push(blockMacro(tb, ast));
|
||||
}
|
||||
|
||||
@@ -74,11 +88,22 @@ function exportConfluence(ast, outDir, template = {}) {
|
||||
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);
|
||||
if (attachment) {
|
||||
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) {
|
||||
if (block.kind === 'text') {
|
||||
parts.push(blockMacro(block, ast));
|
||||
|
||||
+18
-5
@@ -10,8 +10,8 @@ const { guideMetaLines, guideSummary, tocEntries } = require('./document-layout'
|
||||
|
||||
/**
|
||||
* DOCX exporter: WordprocessingML built directly (no dependency), one
|
||||
* heading + description + screenshot per step, text blocks, code blocks
|
||||
* (Courier), and tables.
|
||||
* heading per step with positioned text blocks around the description and
|
||||
* screenshot, plus code blocks (Courier) and tables.
|
||||
*/
|
||||
|
||||
const DEFAULT_TEMPLATE = {
|
||||
@@ -274,16 +274,27 @@ function exportDocx(ast, outDir, template = {}) {
|
||||
const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22;
|
||||
const bookmarkId = ++bookmarkCounter;
|
||||
const anchor = bookmarkName(step);
|
||||
const {
|
||||
beforeTitle,
|
||||
afterTitle,
|
||||
beforeDescription,
|
||||
afterDescription,
|
||||
beforeImage,
|
||||
afterImage,
|
||||
rest,
|
||||
} = stepContentGroups(step);
|
||||
for (const tb of beforeTitle) emitTextBlock(tb);
|
||||
body.push(p(
|
||||
`<w:bookmarkStart w:id="${bookmarkId}" w:name="${anchor}"/>` +
|
||||
run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }) +
|
||||
`<w:bookmarkEnd w:id="${bookmarkId}"/>`,
|
||||
headingParagraphProps(step.depth, step.forceNewPage)
|
||||
));
|
||||
|
||||
const { before, rest } = stepContentGroups(step);
|
||||
for (const tb of before) emitTextBlock(tb);
|
||||
for (const tb of afterTitle) emitTextBlock(tb);
|
||||
for (const tb of beforeDescription) emitTextBlock(tb);
|
||||
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);
|
||||
if (img) {
|
||||
@@ -295,6 +306,8 @@ function exportDocx(ast, outDir, template = {}) {
|
||||
stepImageCount += 1;
|
||||
}
|
||||
|
||||
for (const tb of afterImage) emitTextBlock(tb);
|
||||
|
||||
for (const block of rest) {
|
||||
if (block.kind === 'text') {
|
||||
emitTextBlock(block);
|
||||
|
||||
+28
-4
@@ -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>`;
|
||||
}
|
||||
|
||||
function renderBlockListHtml(blocks) {
|
||||
return blocks.map((block) => blockHtml(block)).join('\n');
|
||||
}
|
||||
|
||||
function renderMetaChips(ast) {
|
||||
return [
|
||||
...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="status-chip status-${step.status || 'todo'}${step.skipped ? ' skipped' : ''}">${escapeHtml(statusText)}</span>
|
||||
</h2>` : `<h2>${title}</h2>`;
|
||||
const {
|
||||
beforeTitle,
|
||||
afterTitle,
|
||||
beforeDescription,
|
||||
afterDescription,
|
||||
beforeImage,
|
||||
afterImage,
|
||||
rest,
|
||||
} = stepContentGroups(step);
|
||||
return `
|
||||
<section class="step-card${step.skipped ? ' skipped' : ''}${rich && selected ? ' selected' : ''}" id="${anchorFor(step)}">
|
||||
${renderBlockListHtml(beforeTitle)}
|
||||
${head}
|
||||
${renderBlockListHtml(afterTitle)}
|
||||
<div class="step-body">
|
||||
${stepBodyHtml(step, ast, images, tpl)}
|
||||
${renderStepBodyHtml({ beforeDescription, afterDescription, beforeImage, afterImage, rest }, step, ast, images, tpl)}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
@@ -117,15 +132,24 @@ function bodyStyle(tpl) {
|
||||
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 { before, rest } = stepContentGroups(step);
|
||||
for (const tb of before) parts.push(blockHtml(tb));
|
||||
const {
|
||||
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>`);
|
||||
for (const tb of afterDescription) parts.push(blockHtml(tb));
|
||||
for (const tb of beforeImage) parts.push(blockHtml(tb));
|
||||
const img = images.get(step.stepId);
|
||||
if (img && tpl.includeImages) {
|
||||
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) {
|
||||
if (block.kind === 'text') {
|
||||
parts.push(blockHtml(block));
|
||||
|
||||
@@ -96,15 +96,25 @@ function renderMarkdownGuide(ast, outDir, template = {}, {
|
||||
|
||||
for (const step of ast.steps) {
|
||||
const heading = step.depth > 0 ? '###' : '##';
|
||||
const {
|
||||
beforeTitle,
|
||||
afterTitle,
|
||||
beforeDescription,
|
||||
afterDescription,
|
||||
beforeImage,
|
||||
afterImage,
|
||||
rest,
|
||||
} = stepContentGroups(step);
|
||||
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'}`, '');
|
||||
if (step.skipped) lines.push('*(skipped)*', '');
|
||||
|
||||
const { before, rest } = stepContentGroups(step);
|
||||
for (const tb of before) emitBlock(lines, tb, { alertStyle });
|
||||
|
||||
for (const tb of afterTitle) emitBlock(lines, tb, { alertStyle });
|
||||
for (const tb of beforeDescription) emitBlock(lines, tb, { alertStyle });
|
||||
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);
|
||||
if (img) {
|
||||
if (tpl.azureWiki && tpl.imageMaxWidth > 0) {
|
||||
@@ -113,6 +123,7 @@ function renderMarkdownGuide(ast, outDir, template = {}, {
|
||||
lines.push(``, '');
|
||||
}
|
||||
}
|
||||
for (const tb of afterImage) emitBlock(lines, tb, { alertStyle });
|
||||
|
||||
for (const block of rest) {
|
||||
if (block.kind === 'text') {
|
||||
|
||||
+31
-8
@@ -203,10 +203,10 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
|
||||
/**
|
||||
* Compute the vertical space a step will need: `head` is the title, the
|
||||
* accent rule, any before-description blocks, the description, and the
|
||||
* image — kept together on one page — and `total` adds the remaining
|
||||
* blocks plus the trailing gap, used to decide whether the whole step
|
||||
* fits on a fresh page.
|
||||
* accent rule, positioned text blocks, the description, and the image —
|
||||
* kept together on one page — and `total` adds the remaining blocks plus
|
||||
* the trailing gap, used to decide whether the whole step fits on a
|
||||
* fresh page.
|
||||
*/
|
||||
const measureStep = (step) => {
|
||||
const headSize = step.depth > 0 ? 12 : 14;
|
||||
@@ -214,8 +214,18 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
const titleLines = pdf.wrapText(titleText, headSize, usableW, 'F2');
|
||||
let head = Math.max(40, titleLines.length * headSize * 1.35 + 8);
|
||||
|
||||
const { before, rest } = stepContentGroups(step);
|
||||
for (const tb of before) head += measureBlock(tb);
|
||||
const {
|
||||
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;
|
||||
head += measureImage(step.stepId);
|
||||
|
||||
@@ -298,14 +308,26 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`);
|
||||
const tocTarget = tocTargets.get(step.stepId);
|
||||
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;
|
||||
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] });
|
||||
y += 8;
|
||||
|
||||
const { before, rest } = stepContentGroups(step);
|
||||
for (const tb of before) emitBlock(tb);
|
||||
for (const tb of afterTitle) emitBlock(tb);
|
||||
for (const tb of beforeDescription) emitBlock(tb);
|
||||
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);
|
||||
if (img) {
|
||||
@@ -317,6 +339,7 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
pdf.image(img, M, y, w, h);
|
||||
y += h + 10;
|
||||
}
|
||||
for (const tb of afterImage) emitBlock(tb);
|
||||
|
||||
for (const block of rest) {
|
||||
if (block.kind === 'text') {
|
||||
|
||||
+56
-22
@@ -9,8 +9,9 @@ const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups } = require('
|
||||
const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
|
||||
|
||||
/**
|
||||
* PPTX exporter: a title slide plus one 16:9 slide per step (title bar +
|
||||
* screenshot + description). PresentationML written directly.
|
||||
* PPTX exporter: a title slide plus one 16:9 slide per step, with
|
||||
* positioned text blocks laid out around the step title, description, and
|
||||
* screenshot. PresentationML written directly.
|
||||
*/
|
||||
|
||||
const DEFAULT_TEMPLATE = {
|
||||
@@ -208,24 +209,63 @@ function exportPptx(ast, outDir, template = {}) {
|
||||
|
||||
let mediaCounter = 0;
|
||||
for (const step of ast.steps) {
|
||||
const { before, rest } = stepContentGroups(step);
|
||||
const beforeBlocks = before.filter((block) => block.kind === 'text');
|
||||
const restBlocks = rest.filter((block) => block.kind === 'text');
|
||||
|
||||
const {
|
||||
beforeTitle,
|
||||
afterTitle,
|
||||
beforeDescription,
|
||||
afterDescription,
|
||||
beforeImage,
|
||||
afterImage,
|
||||
} = stepContentGroups(step);
|
||||
let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
|
||||
content += textBox(457200, TITLE_Y, SLIDE_W - 914400, TITLE_H,
|
||||
para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true }));
|
||||
content += rectShape(457200, TITLE_RULE_Y, 2400000, 12000, '2563EB');
|
||||
const beforeTitleReserve = beforeTitle.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0);
|
||||
const titleY = beforeTitle.length ? 220000 + beforeTitleReserve + 120000 : TITLE_Y;
|
||||
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;
|
||||
for (const tb of beforeBlocks) {
|
||||
let y = beforeTitleY;
|
||||
for (const tb of beforeTitle) {
|
||||
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
|
||||
content += block.xml;
|
||||
y += block.height + CALL_OUT_GAP;
|
||||
}
|
||||
|
||||
const descReserve = step.descriptionText ? descriptionHeight(step.descriptionText) + 120000 : 0;
|
||||
const restReserve = restBlocks.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0);
|
||||
content += textBox(457200, titleY, SLIDE_W - 914400, TITLE_H,
|
||||
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 media = [];
|
||||
|
||||
@@ -236,23 +276,17 @@ function exportPptx(ast, outDir, template = {}) {
|
||||
media.push({ name, data: encodePng(img) });
|
||||
const relId = 2; // rId1 = layout, rId2 = image
|
||||
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 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;
|
||||
const scale = Math.min(maxW / w, maxH / h, 1);
|
||||
w = Math.round(w * scale); h = Math.round(h * scale);
|
||||
content += picture(relId, Math.round((SLIDE_W - w) / 2), y, w, h);
|
||||
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);
|
||||
content += block.xml;
|
||||
y += block.height + CALL_OUT_GAP;
|
||||
|
||||
@@ -12,6 +12,7 @@ const { exportWikiJs } = require('../../exporters/wikijs');
|
||||
const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html');
|
||||
const { exportConfluence } = require('../../exporters/confluence');
|
||||
const { htmlToMarkdown } = require('../../exporters/htmlmd');
|
||||
const { stepContentGroups } = require('../../exporters/common');
|
||||
const { decodePng } = require('../../core/png');
|
||||
const { buildFixtureGuide } = require('./fixture-guide');
|
||||
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>'));
|
||||
});
|
||||
|
||||
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',
|
||||
';
|
||||
|
||||
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) => {
|
||||
const root = makeTmpDir('expwikijs');
|
||||
t.after(() => rmrf(root));
|
||||
|
||||
@@ -56,12 +56,14 @@ test('settings persist, deep-merge with defaults, and store global placeholders'
|
||||
assert.equal(s1.get('appearance'), DEFAULT_SETTINGS.appearance);
|
||||
s1.set('appearance', 'dark');
|
||||
s1.set('capture.delayMs', 1500);
|
||||
s1.set('ai.ollama.model', 'qwen3:0.6b');
|
||||
s1.setGlobalPlaceholders({ Company: 'Acme', Author: 'Tyler' });
|
||||
|
||||
// A fresh instance reads back the changed values merged over defaults.
|
||||
const s2 = new Settings(dir);
|
||||
assert.equal(s2.get('appearance'), 'dark');
|
||||
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.deepEqual(s2.getGlobalPlaceholders(), { Company: 'Acme', Author: 'Tyler' });
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
@@ -309,7 +311,10 @@ test('ollama connection test reports installed models', async (t) => {
|
||||
settings: makeSettings(),
|
||||
getWindow: () => null,
|
||||
dataDir: root,
|
||||
fetchImpl: async () => ({
|
||||
fetchImpl: async (url) => {
|
||||
const pathname = new URL(url).pathname;
|
||||
if (pathname === '/api/tags') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: [
|
||||
@@ -317,13 +322,101 @@ test('ollama connection test reports installed models', async (t) => {
|
||||
{ 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();
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.installed, true);
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user