Implement optional single-image intake, Signal ingestion, and multimodal vision analysis
- Add image intake service with format validation (JPEG, PNG, WebP) and EXIF/GPS stripping - Enforce strict single-image rule across Web and Signal attachment channels - Implement token-optimized vision downscaling and JPEG compression - Add IMAGE_CONTEXT pipeline stage with OmniRoute vision routing and resilient failover - Seed and manage versioned idea-image-interpreter prompt in catalog - Update Web UI with responsive image picker, preview chip, and Visual Context tab - Add comprehensive automated test suite in test_image_intake.py - Update README and Labyricorn devlog
This commit is contained in:
+245
-7
@@ -6,6 +6,7 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initTabs();
|
||||
initIntakeBox();
|
||||
initMarkdownViews();
|
||||
});
|
||||
|
||||
// ----------------- Toast Notifications -----------------
|
||||
@@ -104,6 +105,34 @@ function initIntakeBox() {
|
||||
const counter = document.getElementById('char-count');
|
||||
const urlCount = document.getElementById('detected-urls');
|
||||
const form = document.getElementById('intake-form');
|
||||
const fileInput = document.getElementById('submission-image');
|
||||
const chooseBtn = document.getElementById('choose-image-btn');
|
||||
const previewChip = document.getElementById('image-preview-chip');
|
||||
const fileNameSpan = document.getElementById('image-file-name');
|
||||
const removeBtn = document.getElementById('remove-image-btn');
|
||||
const imageStatus = document.getElementById('image-status');
|
||||
|
||||
if (chooseBtn && fileInput) {
|
||||
chooseBtn.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files && fileInput.files.length > 0) {
|
||||
const file = fileInput.files[0];
|
||||
if (fileNameSpan) fileNameSpan.innerText = file.name;
|
||||
if (previewChip) previewChip.style.display = 'inline-flex';
|
||||
if (chooseBtn) chooseBtn.style.display = 'none';
|
||||
if (imageStatus) imageStatus.innerText = `${(file.size / 1024).toFixed(0)} KB attached`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (removeBtn && fileInput) {
|
||||
removeBtn.addEventListener('click', () => {
|
||||
fileInput.value = '';
|
||||
if (previewChip) previewChip.style.display = 'none';
|
||||
if (chooseBtn) chooseBtn.style.display = 'inline-flex';
|
||||
if (imageStatus) imageStatus.innerText = 'Max 1 image';
|
||||
});
|
||||
}
|
||||
|
||||
if (textarea) {
|
||||
textarea.addEventListener('input', () => {
|
||||
@@ -131,17 +160,50 @@ function initIntakeBox() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerText = 'Preserving & Ingesting...';
|
||||
|
||||
const file = fileInput && fileInput.files && fileInput.files.length > 0 ? fileInput.files[0] : null;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/ideas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Submission failed');
|
||||
let res;
|
||||
if (file) {
|
||||
const formData = new FormData();
|
||||
formData.append('text', text);
|
||||
formData.append('image', file);
|
||||
res = await fetch('/api/ideas', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
} else {
|
||||
res = await fetch('/api/ideas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text })
|
||||
});
|
||||
}
|
||||
|
||||
let data = {};
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
data = { detail: await res.text() };
|
||||
}
|
||||
} else {
|
||||
const rawErr = await res.text();
|
||||
data = { detail: rawErr || `Server error (${res.status})` };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || `Submission failed with status ${res.status}`);
|
||||
}
|
||||
|
||||
showToast(`Idea preserved as ${data.id}! Ingestion started.`, 'success');
|
||||
textarea.value = '';
|
||||
if (fileInput) fileInput.value = '';
|
||||
if (previewChip) previewChip.style.display = 'none';
|
||||
if (chooseBtn) chooseBtn.style.display = 'inline-flex';
|
||||
if (imageStatus) imageStatus.innerText = 'Max 1 image';
|
||||
|
||||
const isAuthenticated = Boolean(document.querySelector('.nav-auth-pill'));
|
||||
setTimeout(() => {
|
||||
if (isAuthenticated) {
|
||||
@@ -423,3 +485,179 @@ async function syncIdeaToGitea(ideaId) {
|
||||
if (btn) btn.innerText = '🔄 Publish / Re-sync to Gitea';
|
||||
}
|
||||
}
|
||||
|
||||
async function reprocessIdea(ideaId) {
|
||||
try {
|
||||
showToast('Enqueuing idea for complete intake & research processing...', 'info');
|
||||
const res = await fetch(`/api/ideas/${ideaId}/reprocess`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bypass_duplicate_check: true })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Reprocess request failed');
|
||||
showToast('Pipeline started! Page will refresh shortly...', 'success');
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} catch (err) {
|
||||
showToast(err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------- Universal Markdown Renderer & View Toggler -----------------
|
||||
|
||||
function renderMarkdownContent(rawText) {
|
||||
if (!rawText) return '';
|
||||
if (typeof marked !== 'undefined') {
|
||||
try {
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
pedantic: false
|
||||
});
|
||||
const parsed = marked.parse(rawText);
|
||||
if (typeof DOMPurify !== 'undefined' && typeof DOMPurify.sanitize === 'function') {
|
||||
return DOMPurify.sanitize(parsed);
|
||||
}
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
console.warn('[ThinkStorm] marked.parse error, falling back:', e);
|
||||
}
|
||||
}
|
||||
// Fallback simple escape
|
||||
const div = document.createElement('div');
|
||||
div.textContent = rawText;
|
||||
return `<p style="white-space:pre-wrap;">${div.innerHTML}</p>`;
|
||||
}
|
||||
|
||||
function initMarkdownViews(root = document) {
|
||||
const containers = root.querySelectorAll('.markdown-view-container, [data-markdown-view]');
|
||||
containers.forEach(container => {
|
||||
if (container.dataset.initialized === 'true') return;
|
||||
container.dataset.initialized = 'true';
|
||||
|
||||
const formattedBox = container.querySelector('.markdown-formatted');
|
||||
const codeBox = container.querySelector('.markdown-code');
|
||||
const rawSourceEl = container.querySelector('.raw-markdown-source');
|
||||
|
||||
let rawText = '';
|
||||
if (rawSourceEl) {
|
||||
rawText = rawSourceEl.value || rawSourceEl.textContent || '';
|
||||
} else if (codeBox && codeBox.querySelector('code')) {
|
||||
rawText = codeBox.querySelector('code').textContent || '';
|
||||
} else if (container.dataset.content) {
|
||||
rawText = container.dataset.content;
|
||||
}
|
||||
|
||||
// Render formatted markdown HTML
|
||||
if (formattedBox && rawText) {
|
||||
formattedBox.innerHTML = renderMarkdownContent(rawText);
|
||||
}
|
||||
|
||||
// Ensure codeBox has raw text
|
||||
if (codeBox && (!codeBox.querySelector('code') || !codeBox.querySelector('code').textContent)) {
|
||||
codeBox.innerHTML = `<pre><code>${escapeHtml(rawText)}</code></pre>`;
|
||||
}
|
||||
|
||||
// Default to Formatted view
|
||||
if (formattedBox) formattedBox.style.display = 'block';
|
||||
if (codeBox) codeBox.style.display = 'none';
|
||||
|
||||
// Setup toggle buttons
|
||||
const toggleBtns = container.querySelectorAll('.btn-toggle-view');
|
||||
toggleBtns.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const mode = btn.getAttribute('data-view');
|
||||
setMarkdownContainerView(container, mode);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Also auto-render any standalone .markdown-body-auto
|
||||
const standaloneBodies = root.querySelectorAll('.markdown-body-auto');
|
||||
standaloneBodies.forEach(el => {
|
||||
if (el.dataset.initialized === 'true') return;
|
||||
el.dataset.initialized = 'true';
|
||||
const rawText = el.textContent || '';
|
||||
if (rawText.trim()) {
|
||||
el.innerHTML = renderMarkdownContent(rawText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setMarkdownContainerView(container, mode) {
|
||||
const formattedBox = container.querySelector('.markdown-formatted');
|
||||
const codeBox = container.querySelector('.markdown-code');
|
||||
const toggleBtns = container.querySelectorAll('.btn-toggle-view');
|
||||
|
||||
toggleBtns.forEach(b => {
|
||||
if (b.getAttribute('data-view') === mode) {
|
||||
b.classList.add('active');
|
||||
} else {
|
||||
b.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
if (mode === 'code') {
|
||||
if (formattedBox) formattedBox.style.display = 'none';
|
||||
if (codeBox) codeBox.style.display = 'block';
|
||||
} else {
|
||||
// Formatted by default
|
||||
if (formattedBox) formattedBox.style.display = 'block';
|
||||
if (codeBox) codeBox.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function copyMarkdownFromContainer(btn) {
|
||||
const container = btn.closest('.markdown-view-container');
|
||||
if (!container) return;
|
||||
const rawSourceEl = container.querySelector('.raw-markdown-source');
|
||||
const codeEl = container.querySelector('.markdown-code code');
|
||||
const text = rawSourceEl ? (rawSourceEl.value || rawSourceEl.textContent) : (codeEl ? codeEl.textContent : '');
|
||||
|
||||
if (!text) {
|
||||
showToast('No markdown content to copy', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.innerHTML = '<span>✅ Copied!</span>';
|
||||
showToast('Markdown copied to clipboard!', 'success');
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = origHtml;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
showToast('Failed to copy to clipboard', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
window.renderMarkdownContent = renderMarkdownContent;
|
||||
window.initMarkdownViews = initMarkdownViews;
|
||||
window.setMarkdownContainerView = setMarkdownContainerView;
|
||||
window.copyMarkdownFromContainer = copyMarkdownFromContainer;
|
||||
|
||||
Vendored
+69
File diff suppressed because one or more lines are too long
Vendored
+3
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user