- Store captureMetadata (OCR text, window/app/element info) with each step at capture time so AI always has the original rich context. - Add buildCaptureContext() to TextIntelService; capture.js uses it instead of buildCaptureTitle() so both title and metadata come from one pass. - generateStepPatch() prefers stored captureMetadata over re-running OCR, giving the AI the best possible context when the user clicks an AI button later. - Add autoDoc setting: when enabled every capture (shoot, region, and session hotkey/click) is automatically documented by AI. Manual captures await AI before returning; session captures fire-and-forget and push a step:updated event so the renderer reloads seamlessly. - Add ai:rewriteText IPC and rewriteText() method for plain-text polishing via a separate callOllamaText() that skips JSON mode. - Add "AI Rewrite" section in the editor right panel: textarea + AI button that rewrites whatever the user types in place. - Improve buildAiPrompt() rules: action-focused title instructions, explicit anti-junk rules (no "Capture the screen / OCR" blocks), and a context-quality gate that suppresses blocks when context is thin. - Add autoDoc checkbox to AI settings dialog. - Renderer handles step:updated to reload the selected step after background auto-doc finishes. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
564 lines
18 KiB
JavaScript
564 lines
18 KiB
JavaScript
'use strict';
|
||
const { deepClone, htmlToText, escapeHtml } = require('./util');
|
||
const { sanitizeHtml } = require('./sanitize');
|
||
const {
|
||
TEXTBLOCK_LEVELS,
|
||
TEXTBLOCK_POSITIONS,
|
||
normalizeTextBlock,
|
||
normalizeCodeBlock,
|
||
normalizeTableBlock,
|
||
} = require('./schema');
|
||
|
||
const DEFAULT_CAPTURE_TITLES = {
|
||
fullscreen: 'Screen capture',
|
||
window: 'Window capture',
|
||
region: 'Region capture',
|
||
};
|
||
|
||
const AI_LEVEL_ALIASES = new Map([
|
||
['note', 'info'],
|
||
['info', 'info'],
|
||
['tip', 'success'],
|
||
['success', 'success'],
|
||
['warning', 'warn'],
|
||
['warn', 'warn'],
|
||
['important', 'error'],
|
||
['error', 'error'],
|
||
]);
|
||
|
||
const GENERIC_OCR_PHRASES = new Set([
|
||
'button',
|
||
'click',
|
||
'double click',
|
||
'menu',
|
||
'item',
|
||
'field',
|
||
'text field',
|
||
'search',
|
||
'submit',
|
||
'cancel',
|
||
'ok',
|
||
'open',
|
||
'select',
|
||
'enter',
|
||
'type',
|
||
]);
|
||
|
||
const BROWSER_NAME_PHRASES = new Set([
|
||
'google chrome',
|
||
'chrome',
|
||
'chromium',
|
||
'microsoft edge',
|
||
'edge',
|
||
'brave',
|
||
'firefox',
|
||
'safari',
|
||
'opera',
|
||
'vivaldi',
|
||
]);
|
||
|
||
const ACTION_PREFIXES = [
|
||
'click',
|
||
'select',
|
||
'open',
|
||
'choose',
|
||
'enter',
|
||
'type',
|
||
'search',
|
||
'switch to',
|
||
'go to',
|
||
'navigate to',
|
||
'toggle',
|
||
'turn on',
|
||
'turn off',
|
||
'enable',
|
||
'disable',
|
||
'pick',
|
||
'focus',
|
||
'launch',
|
||
'activate',
|
||
];
|
||
|
||
function normalizeWhitespace(text) {
|
||
return String(text == null ? '' : text)
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function titleCaseWord(word) {
|
||
if (!word) return word;
|
||
if (/^[A-Z0-9]{2,}$/.test(word)) return word;
|
||
if (/^\d+$/.test(word)) return word;
|
||
return word[0].toUpperCase() + word.slice(1).toLowerCase();
|
||
}
|
||
|
||
function displayText(text) {
|
||
const clean = normalizeWhitespace(text)
|
||
.replace(/^[\s"'`([{<]+|[\s"'`)}\]>.,;:!?]+$/g, '')
|
||
.trim();
|
||
if (!clean) return '';
|
||
if (clean === clean.toUpperCase()) {
|
||
return clean.split(/\s+/).map(titleCaseWord).join(' ');
|
||
}
|
||
return clean.replace(/\s+/g, ' ');
|
||
}
|
||
|
||
function sentenceCase(text) {
|
||
const clean = displayText(text);
|
||
if (!clean) return '';
|
||
return clean.charAt(0).toUpperCase() + clean.slice(1);
|
||
}
|
||
|
||
function isPathOrUrlLike(text) {
|
||
return /^(?:https?:\/\/|file:\/\/|about:blank|chrome:\/\/|edge:\/\/|moz-extension:\/\/|view-source:|localhost(?:[:/]|$)|www\.)/i.test(text) ||
|
||
/[A-Za-z]:\\/.test(text) ||
|
||
/\/(?:[^/\s]+\/){2,}/.test(text) ||
|
||
/\\/.test(text);
|
||
}
|
||
|
||
function isBrowserNoise(text) {
|
||
const clean = normalizeWhitespace(text).toLowerCase();
|
||
if (!clean) return true;
|
||
if (BROWSER_NAME_PHRASES.has(clean)) return true;
|
||
if (isPathOrUrlLike(clean)) return true;
|
||
let foundBrowserName = false;
|
||
for (const name of BROWSER_NAME_PHRASES) {
|
||
if (clean.includes(name)) {
|
||
foundBrowserName = true;
|
||
break;
|
||
}
|
||
}
|
||
return foundBrowserName && /[\s|•·*]{2,}|[-–—]|\/|\\/.test(clean);
|
||
}
|
||
|
||
function isUsefulTitleCandidate(text, { source = 'ocr' } = {}) {
|
||
const clean = displayText(text);
|
||
if (!clean) return false;
|
||
const lower = clean.toLowerCase();
|
||
if (GENERIC_OCR_PHRASES.has(lower)) return false;
|
||
if (BROWSER_NAME_PHRASES.has(lower)) return false;
|
||
if (isPathOrUrlLike(clean)) return false;
|
||
if ((source === 'window' || source === 'app') && isBrowserNoise(clean)) return false;
|
||
if (/^[\p{P}\p{S}0-9]+$/u.test(clean)) return false;
|
||
return true;
|
||
}
|
||
|
||
function splitTitleFragments(text) {
|
||
const clean = normalizeWhitespace(text);
|
||
if (!clean) return [];
|
||
return clean
|
||
.split(/\s*(?:\*\*+|[|•·]+|::|\/+|\\+|\s[-–—]\s|\s{2,})\s*/g)
|
||
.map((part) => displayText(part))
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function candidateWords(text) {
|
||
const clean = normalizeWhitespace(text);
|
||
if (!clean) return [];
|
||
return clean.split(/\s+/).filter(Boolean);
|
||
}
|
||
|
||
function scoreCandidate(text, { source = 'ocr' } = {}) {
|
||
const clean = displayText(text);
|
||
if (!clean) return -Infinity;
|
||
const words = candidateWords(clean);
|
||
if (!words.length) return -Infinity;
|
||
let score = 0;
|
||
score += source === 'ocr' ? 140 : source === 'element' ? 95 : source === 'window' ? 35 : source === 'app' ? 25 : 90;
|
||
score += Math.min(words.length, 5) * 10;
|
||
score -= Math.max(0, words.length - 5) * 11;
|
||
score -= Math.max(0, clean.length - 42) * 0.8;
|
||
if (GENERIC_OCR_PHRASES.has(clean.toLowerCase())) score -= 50;
|
||
if (BROWSER_NAME_PHRASES.has(clean.toLowerCase())) score -= 80;
|
||
if (isBrowserNoise(clean)) score -= 60;
|
||
if (clean.length <= 24) score += 10;
|
||
if (/^(click|select|open|choose|enter|type|search|switch to|go to|navigate to|toggle|turn on|turn off|enable|disable|pick|focus|launch|activate)\b/i.test(clean)) score += 12;
|
||
if (/^[\p{P}\p{S}0-9]+$/u.test(clean)) score -= 100;
|
||
return score;
|
||
}
|
||
|
||
function pickBestOcrPhrase(ocrText) {
|
||
const text = normalizeWhitespace(ocrText);
|
||
if (!text) return '';
|
||
const parts = text
|
||
.split(/\n+/)
|
||
.flatMap((line) => splitTitleFragments(line))
|
||
.filter((line) => isUsefulTitleCandidate(line, { source: 'ocr' }));
|
||
if (!parts.length) return '';
|
||
let best = '';
|
||
let bestScore = -Infinity;
|
||
for (const part of parts) {
|
||
const score = scoreCandidate(part, { source: 'ocr' });
|
||
if (score > bestScore) {
|
||
best = part;
|
||
bestScore = score;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function isShortUiLabel(text) {
|
||
const words = candidateWords(text);
|
||
return words.length > 0 && words.length <= 2 && text.length <= 24;
|
||
}
|
||
|
||
function isDirectiveTitle(text) {
|
||
const clean = displayText(text);
|
||
if (!clean) return false;
|
||
const lower = clean.toLowerCase();
|
||
return ACTION_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
||
}
|
||
|
||
function verbForElementRole(role) {
|
||
const clean = normalizeWhitespace(role).toLowerCase();
|
||
if (!clean) return null;
|
||
if (/(tab|menu item|menuitem|option|list item|tree item|radio button|dropdown list|combo box option)/.test(clean)) {
|
||
return 'Select';
|
||
}
|
||
if (/(button|check box|checkbox|toggle button|switch|link|item|command)/.test(clean)) {
|
||
return 'Click';
|
||
}
|
||
if (/(text field|edit|search box|combo box|textbox|text box|input|field)/.test(clean)) {
|
||
return 'Click';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function formatCaptureTitle(text, { source = 'ocr', metadata = {} } = {}) {
|
||
const clean = displayText(text);
|
||
if (!clean) return '';
|
||
|
||
if (isDirectiveTitle(clean)) {
|
||
return sentenceCase(clean);
|
||
}
|
||
|
||
const roleVerb = (source === 'ocr' || source === 'element') ? verbForElementRole(metadata.elementRole) : null;
|
||
if (roleVerb) {
|
||
return `${roleVerb} ${sentenceCase(clean)}`;
|
||
}
|
||
|
||
if (source === 'window' || source === 'app') {
|
||
return `Open ${sentenceCase(clean)}`;
|
||
}
|
||
|
||
if (source === 'ocr' || source === 'element') {
|
||
return isShortUiLabel(clean) ? `Click ${sentenceCase(clean)}` : sentenceCase(clean);
|
||
}
|
||
|
||
return sentenceCase(clean);
|
||
}
|
||
|
||
function pickBestTitleFragment(text, { source = 'window', metadata = {} } = {}) {
|
||
const fragments = splitTitleFragments(text).filter((line) => isUsefulTitleCandidate(line, { source }));
|
||
if (!fragments.length) return '';
|
||
let best = '';
|
||
let bestScore = -Infinity;
|
||
for (const part of fragments) {
|
||
const score = scoreCandidate(part, { source });
|
||
if (score > bestScore) {
|
||
best = part;
|
||
bestScore = score;
|
||
}
|
||
}
|
||
return best ? formatCaptureTitle(best, { source, metadata }) : '';
|
||
}
|
||
|
||
function buildCaptureTitle({ mode = 'fullscreen', metadata = {}, ocrText = '' } = {}) {
|
||
const ocrPhrase = pickBestOcrPhrase(ocrText);
|
||
if (ocrPhrase) return formatCaptureTitle(ocrPhrase, { source: 'ocr', metadata });
|
||
|
||
const elementPhrase = pickBestTitleFragment(metadata.elementLabel, { source: 'element', metadata });
|
||
if (elementPhrase) return elementPhrase;
|
||
|
||
const windowPhrase = pickBestTitleFragment(metadata.windowTitle, { source: 'window', metadata });
|
||
if (windowPhrase) return windowPhrase;
|
||
|
||
const appPhrase = pickBestTitleFragment(metadata.appName, { source: 'app', metadata });
|
||
if (appPhrase && appPhrase !== windowPhrase) return appPhrase;
|
||
|
||
return DEFAULT_CAPTURE_TITLES[mode] || 'Capture';
|
||
}
|
||
|
||
function plainTextToHtml(text) {
|
||
const trimmed = normalizeWhitespace(text);
|
||
if (!trimmed) return '';
|
||
return trimmed
|
||
.split(/\n{2,}/)
|
||
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br>')}</p>`)
|
||
.join('');
|
||
}
|
||
|
||
function normalizeOllamaHost(host) {
|
||
const raw = normalizeWhitespace(host);
|
||
if (!raw) return '';
|
||
if (/^https?:\/\//i.test(raw)) return raw.replace(/\/+$/, '');
|
||
return `http://${raw.replace(/\/+$/, '')}`;
|
||
}
|
||
|
||
function normalizeAiLevel(level) {
|
||
const key = normalizeWhitespace(level).toLowerCase();
|
||
return AI_LEVEL_ALIASES.get(key) || (TEXTBLOCK_LEVELS.includes(key) ? key : 'info');
|
||
}
|
||
|
||
function normalizeAiPosition(position) {
|
||
const key = normalizeWhitespace(position).toLowerCase();
|
||
return TEXTBLOCK_POSITIONS.includes(key) ? key : 'after-description';
|
||
}
|
||
|
||
function normalizeAiBlock(block) {
|
||
if (!block || typeof block !== 'object') return null;
|
||
const kind = normalizeWhitespace(block.kind).toLowerCase();
|
||
if (kind === 'text') {
|
||
const normalized = normalizeTextBlock({
|
||
id: block.id,
|
||
order: Number.isFinite(block.order) ? block.order : null,
|
||
position: normalizeAiPosition(block.position),
|
||
level: normalizeAiLevel(block.level),
|
||
title: displayText(block.title),
|
||
descriptionHtml: plainTextToHtml(block.body ?? block.description ?? block.text ?? ''),
|
||
}, Number.isFinite(block.order) ? block.order : null);
|
||
return { ...normalized, kind: 'text' };
|
||
}
|
||
if (kind === 'code') {
|
||
return {
|
||
...normalizeCodeBlock({
|
||
id: block.id,
|
||
order: Number.isFinite(block.order) ? block.order : null,
|
||
language: displayText(block.language).toLowerCase(),
|
||
code: String(block.code ?? ''),
|
||
}, Number.isFinite(block.order) ? block.order : null),
|
||
kind: 'code',
|
||
};
|
||
}
|
||
if (kind === 'table') {
|
||
const rows = Array.isArray(block.rows)
|
||
? block.rows.map((row) => (Array.isArray(row) ? row.map((cell) => displayText(cell)) : []))
|
||
: [];
|
||
return {
|
||
...normalizeTableBlock({
|
||
id: block.id,
|
||
order: Number.isFinite(block.order) ? block.order : null,
|
||
rows,
|
||
}, Number.isFinite(block.order) ? block.order : null),
|
||
kind: 'table',
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function normalizeAiPatch(raw) {
|
||
let data = raw;
|
||
if (typeof raw === 'string') {
|
||
const trimmed = raw.trim().replace(/^```(?:json)?\s*|\s*```$/g, '');
|
||
const start = trimmed.indexOf('{');
|
||
const end = trimmed.lastIndexOf('}');
|
||
const jsonText = start >= 0 && end > start ? trimmed.slice(start, end + 1) : trimmed;
|
||
data = JSON.parse(jsonText);
|
||
}
|
||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||
throw new Error('AI response must be a JSON object');
|
||
}
|
||
const out = {
|
||
title: displayText(data.title),
|
||
descriptionHtml: plainTextToHtml(data.description ?? data.descriptionText ?? ''),
|
||
blocks: Array.isArray(data.blocks)
|
||
? data.blocks.map((block) => normalizeAiBlock(block)).filter(Boolean)
|
||
: [],
|
||
};
|
||
return out;
|
||
}
|
||
|
||
function summarizeBlocks(step = {}) {
|
||
const parts = [];
|
||
for (const block of step.textBlocks || []) {
|
||
const body = htmlToText(block.descriptionHtml || '');
|
||
parts.push(`- Text (${block.level || 'info'}, ${block.position || 'after-description'}): ${block.title || ''}${body ? ` — ${body}` : ''}`.trim());
|
||
}
|
||
for (const block of step.codeBlocks || []) {
|
||
const code = String(block.code || '').trim();
|
||
parts.push(`- Code (${block.language || 'plain'}):\n${code || '(empty)'}`);
|
||
}
|
||
for (const block of step.tableBlocks || []) {
|
||
const rows = Array.isArray(block.rows) ? block.rows.length : 0;
|
||
const cols = rows > 0 && Array.isArray(block.rows[0]) ? block.rows[0].length : 0;
|
||
parts.push(`- Table (${rows}x${cols})`);
|
||
}
|
||
return parts.length ? parts.join('\n') : '(none)';
|
||
}
|
||
|
||
function summarizeStepForAi(step = {}) {
|
||
return [
|
||
`Step title: ${step.title || '(empty)'}`,
|
||
`Step description: ${htmlToText(step.descriptionHtml || '') || '(empty)'}`,
|
||
`Step status: ${step.status || 'todo'}`,
|
||
`Blocks:\n${summarizeBlocks(step)}`,
|
||
].join('\n');
|
||
}
|
||
|
||
function summarizeGuideForAi(guide = {}) {
|
||
return [
|
||
`Guide title: ${guide.title || '(untitled)'}`,
|
||
`Guide description: ${htmlToText(guide.descriptionHtml || '') || '(empty)'}`,
|
||
].join('\n');
|
||
}
|
||
|
||
function hasRichCaptureContext(captureContext) {
|
||
if (!captureContext) return false;
|
||
const ocr = normalizeWhitespace(captureContext.ocrText || '');
|
||
const win = normalizeWhitespace(captureContext.windowTitle || '');
|
||
const app = normalizeWhitespace(captureContext.appName || '');
|
||
return ocr.length > 3 || (win.length > 2 && !isBrowserNoise(win)) || app.length > 1;
|
||
}
|
||
|
||
function buildAiPrompt({
|
||
target = 'all',
|
||
guide = null,
|
||
step = null,
|
||
captureContext = null,
|
||
block = null,
|
||
} = {}) {
|
||
const targetText = {
|
||
title: 'rewrite only the step title',
|
||
description: 'rewrite only the step description',
|
||
block: 'rewrite only one block',
|
||
all: 'rewrite the step title, description, and any useful blocks',
|
||
}[target] || 'rewrite the step';
|
||
|
||
const richContext = hasRichCaptureContext(captureContext);
|
||
|
||
const allowedBlockNote = [
|
||
'Use block.kind = "text" with level in [info, warn, error, success] for note / warning / important / tip blocks.',
|
||
'Use block.kind = "code" for code snippets.',
|
||
'Use block.kind = "table" for tables, with rows as arrays of strings.',
|
||
'Use block.position values from [before-title, after-title, before-image, after-image, before-description, after-description].',
|
||
].join(' ');
|
||
|
||
const contextLines = captureContext ? [
|
||
captureContext.windowTitle ? `Active window: ${captureContext.windowTitle}` : null,
|
||
captureContext.appName ? `App: ${captureContext.appName}` : null,
|
||
captureContext.elementLabel ? `UI element: ${captureContext.elementLabel}${captureContext.elementRole ? ` (${captureContext.elementRole})` : ''}` : null,
|
||
captureContext.ocrText ? `OCR text near click:\n${captureContext.ocrText}` : null,
|
||
captureContext.titleCandidate ? `Suggested title: ${captureContext.titleCandidate}` : null,
|
||
].filter(Boolean) : [];
|
||
|
||
const prompt = [
|
||
'You write concise, action-focused step-by-step documentation for a desktop application guide.',
|
||
'Return JSON only. No markdown fences, no commentary, no extra keys outside the schema below.',
|
||
'Schema:',
|
||
'{',
|
||
' "title": string,',
|
||
' "description": string,',
|
||
' "blocks": [{',
|
||
' "kind": "text" | "code" | "table",',
|
||
' "position"?: "before-title" | "after-title" | "before-image" | "after-image" | "before-description" | "after-description",',
|
||
' "level"?: "info" | "warn" | "error" | "success",',
|
||
' "title"?: string,',
|
||
' "body"?: string,',
|
||
' "language"?: string,',
|
||
' "code"?: string,',
|
||
' "rows"?: string[][]',
|
||
' }]',
|
||
'}',
|
||
'',
|
||
`Target: ${targetText}.`,
|
||
allowedBlockNote,
|
||
'',
|
||
guide ? summarizeGuideForAi(guide) : 'Guide: (not provided)',
|
||
'',
|
||
step ? summarizeStepForAi(step) : 'Step: (not provided)',
|
||
'',
|
||
contextLines.length
|
||
? `Capture context:\n${contextLines.join('\n')}`
|
||
: 'Capture context: (not available)',
|
||
'',
|
||
block ? `Target block:\n${JSON.stringify(block, null, 2)}` : null,
|
||
'',
|
||
'Rules:',
|
||
'- Write titles as short imperative actions: "Click Save", "Select New document", "Open Settings".',
|
||
'- If the suggested title is already a good imperative action, use it as-is or refine it slightly.',
|
||
'- Write descriptions in 1–2 sentences describing exactly what the user does in this step.',
|
||
'- Only include blocks that provide genuinely useful supplemental information (warnings, tips, code).',
|
||
richContext
|
||
? '- You have rich context: use the OCR text, window title, and element info to write specific documentation.'
|
||
: '- Context is limited: write a minimal title. Leave description empty and return no blocks unless the step already has meaningful content.',
|
||
'- 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.',
|
||
].filter((l) => l !== null).join('\n');
|
||
|
||
return {
|
||
systemPrompt: 'You are a technical documentation writer. Emit only valid JSON matching the schema. Never add commentary or markdown.',
|
||
prompt,
|
||
};
|
||
}
|
||
|
||
function applyAiPatchToStep(step, patch, { target = 'all', blockId = null } = {}) {
|
||
const next = deepClone(step);
|
||
if ((target === 'all' || target === 'title') && patch.title) {
|
||
next.title = displayText(patch.title);
|
||
}
|
||
if ((target === 'all' || target === 'description') && patch.descriptionHtml) {
|
||
next.descriptionHtml = sanitizeHtml(patch.descriptionHtml);
|
||
}
|
||
|
||
if (target === 'all' && Array.isArray(patch.blocks) && patch.blocks.length) {
|
||
const textBlocks = [];
|
||
const codeBlocks = [];
|
||
const tableBlocks = [];
|
||
let nextOrder = 1;
|
||
for (const block of patch.blocks) {
|
||
const clone = deepClone(block);
|
||
clone.order = nextOrder++;
|
||
if (clone.kind === 'text') textBlocks.push(clone);
|
||
else if (clone.kind === 'code') codeBlocks.push(clone);
|
||
else if (clone.kind === 'table') tableBlocks.push(clone);
|
||
}
|
||
next.textBlocks = textBlocks;
|
||
next.codeBlocks = codeBlocks;
|
||
next.tableBlocks = tableBlocks;
|
||
} else if (target === 'block' && blockId && Array.isArray(patch.blocks) && patch.blocks.length) {
|
||
const replacement = patch.blocks[0];
|
||
const textBlock = (next.textBlocks || []).find((block) => block.id === blockId);
|
||
const codeBlock = (next.codeBlocks || []).find((block) => block.id === blockId);
|
||
const tableBlock = (next.tableBlocks || []).find((block) => block.id === blockId);
|
||
if (textBlock && replacement.kind === 'text') {
|
||
if (replacement.position) textBlock.position = replacement.position;
|
||
if (replacement.level) textBlock.level = replacement.level;
|
||
if (replacement.title) textBlock.title = replacement.title;
|
||
if (replacement.descriptionHtml) textBlock.descriptionHtml = sanitizeHtml(replacement.descriptionHtml);
|
||
} else if (codeBlock && replacement.kind === 'code') {
|
||
if (replacement.language) codeBlock.language = replacement.language;
|
||
if (replacement.code) codeBlock.code = replacement.code;
|
||
} else if (tableBlock && replacement.kind === 'table') {
|
||
if (replacement.rows) tableBlock.rows = replacement.rows;
|
||
}
|
||
}
|
||
if (!next.image) {
|
||
const hasBody = Boolean(
|
||
next.title ||
|
||
htmlToText(next.descriptionHtml || '') ||
|
||
(next.textBlocks || []).length ||
|
||
(next.codeBlocks || []).length ||
|
||
(next.tableBlocks || []).length,
|
||
);
|
||
if (hasBody) next.kind = 'content';
|
||
}
|
||
return next;
|
||
}
|
||
|
||
module.exports = {
|
||
DEFAULT_CAPTURE_TITLES,
|
||
buildCaptureTitle,
|
||
plainTextToHtml,
|
||
normalizeOllamaHost,
|
||
normalizeAiPatch,
|
||
buildAiPrompt,
|
||
applyAiPatchToStep,
|
||
summarizeStepForAi,
|
||
summarizeGuideForAi,
|
||
displayText,
|
||
normalizeWhitespace,
|
||
scoreCandidate,
|
||
pickBestOcrPhrase,
|
||
};
|