diff --git a/frontend/index.html b/frontend/index.html index 481ae84..9edf578 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1280,6 +1280,152 @@ padding-top: 16px; } + .index-action-status { + margin-bottom: 14px; + } + + .index-job-pill { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: 'IBM Plex Mono', monospace; + font-size: 12px; + color: #c7d3e0; + background-color: #1b2837; + border-radius: 999px; + padding: 6px 12px; + } + + .index-job-pill.idle { + color: #8ea2b7; + } + + .index-job-pill.running { + color: #fcd34d; + background-color: #f59e0b22; + } + + .index-job-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #fcd34d; + animation: index-job-pulse 1.2s ease-in-out infinite; + } + + @keyframes index-job-pulse { + 0%, 100% { opacity: 0.35; } + 50% { opacity: 1; } + } + + .index-job-tail { + margin: 0 0 14px; + max-height: 140px; + overflow: auto; + background-color: #0d1622; + border: 1px solid #263548; + border-radius: 6px; + padding: 10px 12px; + font-family: 'IBM Plex Mono', monospace; + font-size: 11px; + color: #8ea2b7; + white-space: pre-wrap; + } + + .index-action-buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + + .index-action-hint { + margin-top: 12px; + font-size: 12px; + color: #8ea2b7; + line-height: 1.5; + } + + .merge-candidate-list { + display: flex; + flex-direction: column; + gap: 14px; + } + + .merge-candidate-card { + background-color: #0d1622; + border: 1px solid #263548; + border-radius: 8px; + padding: 16px; + } + + .merge-candidate-people { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + } + + .merge-candidate-person { + flex: 1; + min-width: 160px; + } + + .merge-candidate-name { + font-size: 14px; + font-weight: 600; + color: #e5edf5; + } + + .merge-candidate-email { + margin-top: 2px; + font-size: 12px; + color: #8ea2b7; + font-family: 'IBM Plex Mono', monospace; + } + + .merge-candidate-vs { + flex-shrink: 0; + font-size: 11px; + font-weight: 600; + color: #8ea2b7; + text-transform: uppercase; + letter-spacing: 0.08em; + } + + .merge-candidate-context { + margin-top: 10px; + font-size: 12px; + color: #c7d3e0; + } + + .merge-candidate-suggestion { + margin-top: 12px; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + .merge-candidate-confidence { + font-family: 'IBM Plex Mono', monospace; + font-size: 12px; + color: #8ea2b7; + } + + .merge-candidate-reason { + margin-top: 8px; + font-size: 13px; + line-height: 1.5; + color: #c7d3e0; + } + + .merge-candidate-actions { + margin-top: 14px; + display: flex; + gap: 10px; + flex-wrap: wrap; + } + .confirmation-dialog { position: fixed; top: 0; @@ -9118,26 +9264,108 @@ ); }; - const SystemStatusPage = ({ token }) => { + const SystemStatusPage = ({ token, user, onShowToast }) => { + const isAdmin = user?.role === 'admin'; const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [actionBusy, setActionBusy] = useState(false); + const [candidates, setCandidates] = useState([]); + const [candidatesLoading, setCandidatesLoading] = useState(false); + const [decidingId, setDecidingId] = useState(null); + + const loadStatus = useCallback(async () => { + const result = await api('/api/system/status', {}, token); + setData(result.data); + return result.data; + }, [token]); useEffect(() => { + let cancelled = false; const load = async () => { try { setLoading(true); - const result = await api('/api/system/status', {}, token); - setData(result.data); - setError(''); + await loadStatus(); + if (!cancelled) setError(''); } catch (err) { - setError(getErrorMessage(err, 'Failed to load system status')); + if (!cancelled) setError(getErrorMessage(err, 'Failed to load system status')); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } }; load(); - }, [token]); + return () => { cancelled = true; }; + }, [loadStatus]); + + const indexJob = data?.index_job || null; + const jobRunning = !!(indexJob && indexJob.running); + + // Poll the status endpoint while an index job is running so the + // indicator + last result update without a manual refresh. + useEffect(() => { + if (!jobRunning) return undefined; + let cancelled = false; + const interval = setInterval(async () => { + try { + if (!cancelled) await loadStatus(); + } catch (_) { /* transient; next tick retries */ } + }, 3000); + return () => { cancelled = true; clearInterval(interval); }; + }, [jobRunning, loadStatus]); + + const loadCandidates = useCallback(async () => { + if (!isAdmin) return; + try { + setCandidatesLoading(true); + const result = await api('/api/entities/merge-candidates', {}, token); + setCandidates(Array.isArray(result.candidates) ? result.candidates : []); + } catch (err) { + onShowToast(getErrorMessage(err, 'Failed to load duplicate review queue'), 'error'); + } finally { + setCandidatesLoading(false); + } + }, [isAdmin, token, onShowToast]); + + useEffect(() => { + loadCandidates(); + }, [loadCandidates]); + + const runIndexAction = async (endpoint, label, confirmMessage) => { + if (jobRunning || actionBusy) return; + if (confirmMessage && !window.confirm(confirmMessage)) return; + try { + setActionBusy(true); + const result = await api(endpoint, { method: 'POST' }, token); + const kind = result?.data?.kind || label; + onShowToast(`Started: ${kind}`, 'success'); + } catch (err) { + if (err?.status === 409 || err?.payload?.error === 'job_running') { + onShowToast('An index job is already running', 'error'); + } else { + onShowToast(getErrorMessage(err, `Failed to start ${label}`), 'error'); + } + } finally { + setActionBusy(false); + try { await loadStatus(); } catch (_) { /* ignore refresh failure */ } + } + }; + + const decideCandidate = async (candidate, decision) => { + if (decidingId) return; + try { + setDecidingId(candidate.id); + await api(`/api/entities/merge-candidates/${candidate.id}`, { + method: 'POST', + body: JSON.stringify({ decision }) + }, token); + setCandidates((prev) => prev.filter((c) => c.id !== candidate.id)); + onShowToast(decision === 'approve' ? 'Contacts merged' : 'Kept separate', 'success'); + } catch (err) { + onShowToast(getErrorMessage(err, 'Failed to record decision'), 'error'); + } finally { + setDecidingId(null); + } + }; if (loading) return
; if (error) return
{error}
; @@ -9202,6 +9430,121 @@ )} + {isAdmin && ( +
+
Index Actions
+
+ {jobRunning ? ( + + running: {indexJob.kind || 'job'} + {indexJob.started_at ? ` · started ${formatDate(indexJob.started_at)}` : ''} + + ) : indexJob && indexJob.result ? ( + + last: {indexJob.kind ? `${indexJob.kind} — ` : ''}{indexJob.result} + {indexJob.finished_at ? ` · ${formatDate(indexJob.finished_at)}` : ''} + + ) : ( + No index job has run yet. + )} +
+ {jobRunning && indexJob.tail ? ( +
{indexJob.tail}
+ ) : null} +
+ + + +
+
+ Update is a fast incremental pass. Rebuild re-embeds everything. Find duplicates runs the local model and fills the review queue below. +
+
+ )} + + {isAdmin && ( +
+
+ Duplicate Review + {candidates.length} pending +
+ {candidatesLoading ? ( + + ) : candidates.length === 0 ? ( +
No duplicates to review.
+ ) : ( +
+ {candidates.map((c) => { + const verdict = (c.verdict || '').toLowerCase(); + const confidencePct = c.confidence != null + ? `${Math.round((c.confidence <= 1 ? c.confidence * 100 : c.confidence))}%` + : null; + const busy = decidingId === c.id; + return ( +
+
+
+
{c.name_a || '(no name)'}
+
{c.email_a || '—'}
+
+
vs
+
+
{c.name_b || '(no name)'}
+
{c.email_b || '—'}
+
+
+ {c.context ? ( +
{c.context}
+ ) : null} +
+ + Suggestion: {c.verdict || 'unknown'} + + {confidencePct ? ( + {confidencePct} confidence + ) : null} +
+ {c.reason ? ( +
{c.reason}
+ ) : null} +
+ + +
+
+ ); + })} +
+ )} +
+ )} +
Thesis
@@ -9486,7 +9829,7 @@ {page === 'pipeline' && } {page === 'communications' && } {page === 'thesis' && } - {page === 'system-status' && } + {page === 'system-status' && } {page === 'feature-requests' && } {page === 'instructions' && } {page === 'settings' && ( diff --git a/start9/0.4/startos/utils.ts b/start9/0.4/startos/utils.ts index c9edbc8..d0251e3 100644 --- a/start9/0.4/startos/utils.ts +++ b/start9/0.4/startos/utils.ts @@ -9,8 +9,9 @@ export const PACKAGE_TITLE = 'Ten31 Database' // * Cleanup: 0.1.0:40 (seed removed + multi-threaded server + abuser auto-ban) // * 0.1.0:41 (frontend persists auth across refreshes) // * 0.1.0:42 (Gmail integration) / 0.1.0:43 (Gmail POST-body hotfix) -// * Current: 0.1.0:44 (Phase-0 ingest + MCP server in image; build-index action) -export const PACKAGE_VERSION = '0.1.0:44' +// * 0.1.0:44 (Phase-0 ingest + MCP server in image; build-index action) +// * Current: 0.1.0:45 (Phase-1 thesis system; dual approval; merge review; in-app index) +export const PACKAGE_VERSION = '0.1.0:45' export const DATA_MOUNT_PATH = '/data' export const WEB_PORT = 8080 diff --git a/start9/0.4/startos/versions/index.ts b/start9/0.4/startos/versions/index.ts index f83e997..5250c04 100644 --- a/start9/0.4/startos/versions/index.ts +++ b/start9/0.4/startos/versions/index.ts @@ -5,8 +5,9 @@ import { v_0_1_0_41 } from './v0.1.0.41' import { v_0_1_0_42 } from './v0.1.0.42' import { v_0_1_0_43 } from './v0.1.0.43' import { v_0_1_0_44 } from './v0.1.0.44' +import { v_0_1_0_45 } from './v0.1.0.45' export const versionGraph = VersionGraph.of({ - current: v_0_1_0_44, - other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43], + current: v_0_1_0_45, + other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44], }) diff --git a/start9/0.4/startos/versions/v0.1.0.45.ts b/start9/0.4/startos/versions/v0.1.0.45.ts new file mode 100644 index 0000000..ca29231 --- /dev/null +++ b/start9/0.4/startos/versions/v0.1.0.45.ts @@ -0,0 +1,31 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +// Phase-1 release: the Architect's thesis system + in-app data/index controls. +// +// Adds (web server + image; all schema via the CRM's own migration runner — +// NO StartOS data migration, the live /data volume is preserved): +// * Thesis substrate (migration 0002): versioned per-segment thesis lines, a +// node tree + revisions, dual-approval review (thesis_reviews), segments. +// * Entity-merge review (migration 0003): the fuzzy/Qwen tier writes merge +// CANDIDATES for human approve/reject instead of auto-merging. +// * Web UI: a "Thesis" dual-approval review view and a "System Status" view +// (entity/index health, one-click index actions, duplicate-review queue). +// * Server routes: thesis review/approval, /api/system/status, UI-triggered +// index jobs (rebuild/update/find-duplicates), merge-candidate decisions. +export const v_0_1_0_45 = VersionInfo.of({ + version: '0.1.0:45', + releaseNotes: { + en_US: [ + 'Phase 1: the Architect thesis system. Adds versioned, per-segment thesis', + 'lines with a dual partner sign-off review in the web app; a human-in-the-loop', + 'duplicate-contact review queue (the local model suggests, you approve/reject);', + 'one-click index actions and a system-status dashboard in the CRM; and the', + 'supporting schema, created additively by the CRM migration runner — your', + 'data is preserved.', + ].join(' '), + }, + migrations: { + up: async () => {}, + down: async () => {}, + }, +})