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:
+263
-12
@@ -579,40 +579,291 @@ a:hover {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Code & Markdown View */
|
||||
/* ============================================================
|
||||
Markdown Container & Code/Formatted View System
|
||||
============================================================ */
|
||||
|
||||
.markdown-view-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.markdown-view-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-view-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-view-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.markdown-toggle-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid var(--border-glass);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.markdown-toggle-pill .btn-toggle-view {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.markdown-toggle-pill .btn-toggle-view:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.markdown-toggle-pill .btn-toggle-view.active {
|
||||
background: var(--accent-gradient);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
|
||||
.btn-copy-markdown {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border-glass);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.78rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.btn-copy-markdown:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.markdown-view-body {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Formatted Markdown View */
|
||||
.markdown-body {
|
||||
color: #e2e8f0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
line-height: 1.75;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
|
||||
margin-top: 1.5rem;
|
||||
.markdown-body > *:first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.markdown-body > *:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
color: #f8fafc;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
margin-top: 1.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.markdown-body ul, .markdown-body ol {
|
||||
padding-left: 1.5rem;
|
||||
.markdown-body h1 {
|
||||
font-size: 1.6rem;
|
||||
border-bottom: 1px solid var(--border-glass);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: 1.35rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.markdown-body h4 {
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
padding-left: 1.6rem;
|
||||
margin-top: 0.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.markdown-body li > p {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
border-left: 3px solid var(--accent-primary);
|
||||
padding-left: 1rem;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
color: #cbd5e1;
|
||||
margin: 1rem 0;
|
||||
font-style: italic;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.markdown-body blockquote > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-glass);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
color: #818cf8;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.markdown-body a:hover {
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #e0e7ff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.86em;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
background: #0d0f18;
|
||||
background: #0b0d14;
|
||||
border: 1px solid var(--border-glass);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
border: 1px solid var(--border-glass);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
display: table;
|
||||
}
|
||||
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
padding: 0.7rem 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.markdown-body th {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #f8fafc;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border-glass);
|
||||
}
|
||||
|
||||
.markdown-body tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.markdown-body tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
/* Raw Code View */
|
||||
.markdown-code {
|
||||
background: #0b0d14;
|
||||
border: 1px solid var(--border-glass);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem 1.25rem;
|
||||
margin-top: 0.25rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown-code pre {
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.88rem;
|
||||
margin: 1rem 0;
|
||||
line-height: 1.6;
|
||||
color: #cbd5e1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Toast Notifications */
|
||||
|
||||
+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