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:
2026-08-23 01:40:22 -07:00
parent 94ff1e4408
commit 41e08611c9
30 changed files with 3784 additions and 278 deletions
+245 -7
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
window.renderMarkdownContent = renderMarkdownContent;
window.initMarkdownViews = initMarkdownViews;
window.setMarkdownContainerView = setMarkdownContainerView;
window.copyMarkdownFromContainer = copyMarkdownFromContainer;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long