feat(ui): replace Vue/Vite frontend with self-contained static HTML

The UI was previously a Vue 3 SPA served via a Vite dev server on :1420.
This had two problems:
  - Building from source required Node, npm, and the Tauri CLI
  - Running required starting a Vite dev server alongside the binary

Replace with a single self-contained dist/index.html using vanilla JS
and the Tauri 2 withGlobalTauri IPC global. No build step, no npm, no
dev server — the binary loads the static file directly.

Changes:
  - dist/index.html: full Robin UI (chat, events, debug tabs) in ~750 LOC
      - Chat tab: streaming LLM responses via robin:chat-token events
      - Events tab: live matched system events with severity badges
      - Debug tab: migration config, Ollama status probe, notif level picker
      - Onboarding form shown on first run (calls complete_onboarding IPC)
      - All user/LLM text via textContent/DOM construction, no innerHTML
      - Markdown renderer (fenced code, inline code) built from DOM nodes
  - tauri.conf.json: add withGlobalTauri: true, remove devUrl and Node hooks
  - tauri.conf.json: update CSP to allow inline scripts (desktop app)
  - manage.sh: remove Vite dev server auto-start and kill logic
  - manage.sh: build/dev now use cargo directly, add bundle command for
    full .deb/.rpm/.AppImage (still requires Tauri CLI)
  - .gitignore: track dist/index.html, only ignore dist/assets/ (Vite output)
This commit is contained in:
pyr0ball 2026-05-20 12:23:04 -07:00
parent 17c6b27bfe
commit 42472ee024
4 changed files with 783 additions and 36 deletions

4
.gitignore vendored
View file

@ -8,7 +8,9 @@ pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
dist # dist/ was previously generated by Vite; now it holds the static UI (dist/index.html).
# Ignore generated assets but track the hand-crafted index.html.
dist/assets/
dist-ssr dist-ssr
*.local *.local

753
dist/index.html vendored Normal file
View file

@ -0,0 +1,753 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Robin</title>
<style>
:root {
--bg: #0f172a;
--surface: #1e293b;
--surface2: #334155;
--border: #475569;
--text: #f1f5f9;
--muted: #94a3b8;
--accent: #f97316;
--accent-bg: #1c0a00;
--info: #3b82f6;
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
--radius: 6px;
--font: 'Segoe UI', system-ui, -apple-system, sans-serif;
--mono: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%; background: var(--bg); color: var(--text);
font-family: var(--font); font-size: 14px; line-height: 1.5; overflow: hidden;
}
#app { display: flex; flex-direction: column; height: 100vh; }
header {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px; background: var(--surface);
border-bottom: 1px solid var(--border); flex-shrink: 0; user-select: none;
}
header .logo { font-size: 18px; line-height: 1; }
header h1 { font-size: 15px; font-weight: 600; color: var(--text); flex: 1; }
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--muted); flex-shrink: 0; transition: background 0.3s;
}
.status-dot.ok { background: var(--success); }
.status-dot.warn { background: var(--warning); }
.status-dot.error { background: var(--error); }
.status-label { font-size: 11px; color: var(--muted); }
.tabs {
display: flex; background: var(--surface);
border-bottom: 1px solid var(--border); flex-shrink: 0;
}
.tab-btn {
padding: 7px 14px; background: none;
border: none; border-bottom: 2px solid transparent;
color: var(--muted); cursor: pointer; font-size: 13px;
font-family: var(--font); transition: color 0.15s, border-color 0.15s;
display: flex; align-items: center; gap: 5px;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
.badge {
display: inline-flex; align-items: center; justify-content: center;
background: var(--accent); color: #fff; font-size: 10px; font-weight: 700;
border-radius: 999px; min-width: 16px; height: 16px; padding: 0 4px; line-height: 1;
}
.badge.hidden { display: none; }
.pane { display: none; flex-direction: column; flex: 1; overflow: hidden; min-height: 0; }
.pane.active { display: flex; }
#onboarding {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: flex-start;
padding: 24px 20px; gap: 14px; text-align: center; overflow-y: auto;
}
#onboarding.hidden { display: none; }
#onboarding .bird { font-size: 48px; line-height: 1; }
#onboarding h2 { font-size: 18px; font-weight: 600; }
#onboarding p { font-size: 13px; color: var(--muted); max-width: 300px; }
.form-group { width: 100%; max-width: 320px; text-align: left; }
.form-group label {
display: block; font-size: 12px; color: var(--muted);
margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.05em;
}
.form-group select, .form-group input {
width: 100%; padding: 8px 10px; background: var(--surface2);
border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text); font-size: 13px; font-family: var(--font);
outline: none; transition: border-color 0.15s;
}
.form-group select:focus, .form-group input:focus { border-color: var(--accent); }
.form-group select option { background: var(--surface2); }
.btn {
padding: 8px 16px; border-radius: var(--radius); border: none;
font-size: 13px; font-family: var(--font); font-weight: 500;
cursor: pointer; transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; } .btn:active { opacity: 0.7; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); }
.btn-ghost:hover { color: var(--text); border-color: var(--text); }
.btn-icon {
width: 34px; height: 34px; padding: 0; display: flex;
align-items: center; justify-content: center; font-size: 16px;
background: var(--accent); color: #fff; border-radius: var(--radius);
border: none; cursor: pointer; flex-shrink: 0; transition: opacity 0.15s;
}
.btn-icon:hover { opacity: 0.85; } .btn-icon:active { opacity: 0.7; }
.btn-icon:disabled { opacity: 0.4; cursor: not-allowed; }
#messages {
flex: 1; overflow-y: auto; padding: 12px;
display: flex; flex-direction: column; gap: 10px; min-height: 0;
}
#messages::-webkit-scrollbar { width: 4px; }
#messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.msg {
max-width: 90%; padding: 8px 12px; border-radius: var(--radius);
font-size: 13px; line-height: 1.55; word-break: break-word;
}
.msg.user {
background: var(--accent-bg); border: 1px solid var(--accent);
align-self: flex-end;
}
.msg.assistant {
background: var(--surface); border: 1px solid var(--border);
align-self: flex-start;
}
.msg.assistant pre {
background: var(--surface2); padding: 8px 10px; border-radius: 4px;
font-family: var(--mono); font-size: 12px;
overflow-x: auto; margin: 6px 0; white-space: pre-wrap;
}
.msg.assistant code {
background: var(--surface2); padding: 1px 4px;
border-radius: 3px; font-family: var(--mono); font-size: 12px;
}
.msg-role {
font-size: 10px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 3px;
}
.msg.user .msg-role { color: var(--accent); }
.msg.system-msg {
background: transparent; border: 1px dashed var(--border);
align-self: center; color: var(--muted);
font-size: 12px; max-width: 100%; text-align: center;
}
.typing-cursor {
display: inline-block; width: 7px; height: 13px;
background: var(--accent); border-radius: 1px;
vertical-align: middle; animation: blink 0.9s step-end infinite; margin-left: 2px;
}
@keyframes blink { 50% { opacity: 0; } }
.chat-input-row {
display: flex; gap: 8px; padding: 10px 12px;
border-top: 1px solid var(--border);
background: var(--surface); flex-shrink: 0; align-items: flex-end;
}
#chat-input {
flex: 1; padding: 8px 10px; background: var(--surface2);
border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text); font-size: 13px; font-family: var(--font);
resize: none; outline: none; line-height: 1.4;
max-height: 100px; overflow-y: auto; transition: border-color 0.15s;
}
#chat-input:focus { border-color: var(--accent); }
#chat-input::placeholder { color: var(--muted); }
#events-list {
flex: 1; overflow-y: auto; padding: 10px;
display: flex; flex-direction: column; gap: 8px; min-height: 0;
}
#events-list::-webkit-scrollbar { width: 4px; }
#events-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.event-card {
padding: 10px 12px; background: var(--surface);
border: 1px solid var(--border); border-radius: var(--radius);
border-left: 3px solid var(--muted);
}
.event-card.severity-warn { border-left-color: var(--warning); }
.event-card.severity-crit { border-left-color: var(--error); }
.event-card.severity-info { border-left-color: var(--info); }
.event-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.event-title { font-size: 13px; font-weight: 600; flex: 1; }
.severity-badge {
font-size: 10px; font-weight: 700; text-transform: uppercase;
padding: 2px 6px; border-radius: 3px; letter-spacing: 0.04em;
}
.severity-badge.warn { background: #422006; color: var(--warning); }
.severity-badge.crit { background: #450a0a; color: var(--error); }
.severity-badge.info { background: #0c1a3a; color: var(--info); }
.event-body { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.event-meta { font-size: 11px; color: var(--border); font-family: var(--mono); }
.events-toolbar {
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
border-bottom: 1px solid var(--border);
background: var(--surface); flex-shrink: 0;
}
.events-toolbar span { flex: 1; font-size: 12px; color: var(--muted); }
.empty-state {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 8px; color: var(--muted); font-size: 13px; padding: 24px; text-align: center;
}
.empty-state .icon { font-size: 32px; opacity: 0.4; }
#pane-debug {
flex: 1; overflow-y: auto; padding: 12px;
display: flex; flex-direction: column; gap: 12px; min-height: 0;
}
#pane-debug::-webkit-scrollbar { width: 4px; }
#pane-debug::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.debug-section {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden;
}
.debug-section-header {
padding: 8px 12px; font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.07em;
color: var(--muted); background: var(--surface2);
border-bottom: 1px solid var(--border);
}
.debug-rows { padding: 8px 0; }
.debug-row { display: flex; align-items: center; padding: 5px 12px; gap: 8px; font-size: 12px; }
.debug-row .key { width: 130px; flex-shrink: 0; color: var(--muted); }
.debug-row .val { color: var(--text); font-family: var(--mono); font-size: 12px; word-break: break-all; }
.debug-row .val.ok { color: var(--success); }
.debug-row .val.warn { color: var(--warning); }
.debug-row .val.err { color: var(--error); }
.notif-select {
background: var(--surface2); border: 1px solid var(--border); border-radius: 4px;
color: var(--text); font-size: 12px; font-family: var(--font);
padding: 2px 6px; outline: none; cursor: pointer;
}
.notif-select:focus { border-color: var(--accent); }
.notif-select option { background: var(--surface2); }
.hidden { display: none !important; }
.scroll-anchor { height: 1px; flex-shrink: 0; }
</style>
</head>
<body>
<div id="app">
<header>
<span class="logo">&#x1F426;</span>
<h1>Robin</h1>
<span class="status-label" id="status-label">connecting&#8230;</span>
<span class="status-dot" id="status-dot"></span>
</header>
<div id="onboarding">
<div class="bird">&#x1F426;</div>
<h2>Welcome to Robin</h2>
<p>Set up your migration profile so Robin can give you personalised help.</p>
<div class="form-group">
<label for="src-os">Coming from</label>
<select id="src-os">
<option value="macos">macOS</option>
<option value="windows" selected>Windows</option>
<option value="linux">Linux (different distro)</option>
<option value="android">Android</option>
<option value="ipad">iPad / iOS</option>
</select>
</div>
<div class="form-group">
<label for="distro">Your Linux distro (blank to auto-detect)</label>
<input type="text" id="distro" placeholder="e.g. linuxmint, ubuntu, arch" />
</div>
<div class="form-group">
<label for="dual-boot">Dual-booting with (optional)</label>
<select id="dual-boot">
<option value="">None</option>
<option value="windows">Windows</option>
<option value="macos">macOS</option>
</select>
</div>
<button class="btn btn-primary" id="onboarding-save" style="width:100%;max-width:320px">Get started</button>
<p id="onboarding-error" class="hidden" style="color:var(--error);font-size:12px"></p>
</div>
<div class="tabs hidden" id="tabs">
<button class="tab-btn active" data-tab="chat">Chat</button>
<button class="tab-btn" data-tab="events">Events <span class="badge hidden" id="events-badge">0</span></button>
<button class="tab-btn" data-tab="debug">Debug</button>
</div>
<div class="pane active" id="pane-chat">
<div id="messages">
<div class="msg system-msg" id="welcome-msg">Robin is watching your system logs. Ask me anything about Linux!</div>
<div class="scroll-anchor" id="chat-bottom"></div>
</div>
<div class="chat-input-row">
<textarea id="chat-input" rows="1" placeholder="Ask Robin&#8230; (Enter to send, Shift+Enter for newline)" aria-label="Chat message"></textarea>
<button class="btn-icon" id="send-btn" title="Send (Enter)" aria-label="Send">&#x25B6;</button>
</div>
</div>
<div class="pane" id="pane-events">
<div class="events-toolbar">
<span id="events-count">No events yet</span>
<button class="btn btn-ghost" id="clear-events-btn" style="font-size:12px;padding:4px 10px">Clear</button>
</div>
<div id="events-list">
<div class="empty-state" id="events-empty">
<span class="icon">&#x1F4CB;</span>
<span>No system events matched yet.<br>Robin is watching your logs.</span>
</div>
</div>
</div>
<div class="pane" id="pane-debug">
<div class="debug-section">
<div class="debug-section-header">Migration Profile</div>
<div class="debug-rows">
<div class="debug-row"><span class="key">Source OS</span><span class="val" id="dbg-source-os">&#8212;</span></div>
<div class="debug-row"><span class="key">Distro</span><span class="val" id="dbg-distro">&#8212;</span></div>
<div class="debug-row"><span class="key">Distro family</span><span class="val" id="dbg-distro-family">&#8212;</span></div>
<div class="debug-row"><span class="key">Dual boot</span><span class="val" id="dbg-dual-boot">none</span></div>
<div class="debug-row"><span class="key">Fluency level</span><span class="val" id="dbg-fluency">0 / 5</span></div>
</div>
</div>
<div class="debug-section">
<div class="debug-section-header">LLM Config</div>
<div class="debug-rows">
<div class="debug-row"><span class="key">Ollama URL</span><span class="val" id="dbg-ollama-url">&#8212;</span></div>
<div class="debug-row"><span class="key">Model</span><span class="val" id="dbg-ollama-model">&#8212;</span></div>
<div class="debug-row"><span class="key">Connection</span><span class="val" id="dbg-ollama-status">&#8212;</span></div>
</div>
</div>
<div class="debug-section">
<div class="debug-section-header">Notifications</div>
<div class="debug-rows">
<div class="debug-row">
<span class="key">Level</span>
<select class="notif-select" id="notif-level-select" aria-label="Notification level">
<option value="off">Off</option>
<option value="badge_only">Badge only</option>
<option value="badge_and_toast">Badge + toast</option>
</select>
</div>
</div>
</div>
<div class="debug-section">
<div class="debug-section-header">App Info</div>
<div class="debug-rows">
<div class="debug-row"><span class="key">Tier</span><span class="val" id="dbg-tier">free</span></div>
<div class="debug-row"><span class="key">Events captured</span><span class="val" id="dbg-event-count">0</span></div>
</div>
</div>
<div class="debug-section">
<div class="debug-section-header">Profile</div>
<div class="debug-rows">
<div class="debug-row">
<button class="btn btn-ghost" id="reset-onboarding-btn" style="font-size:12px;padding:4px 10px">Change migration profile&#8230;</button>
</div>
</div>
</div>
</div>
</div>
<script>
"use strict";
/* Robin frontend -- vanilla JS, Tauri 2 IPC (withGlobalTauri: true)
No build step, no npm, no Node required.
Security note: All user-supplied and LLM-supplied text goes through
setTextContent() / DOM text nodes rather than innerHTML.
The chat markdown renderer builds the DOM tree element-by-element so
no unsanitised HTML is ever passed to innerHTML.
*/
var _invoke = null;
var _listen = null;
// State
var currentConfig = null;
var streamBuf = '';
var streamMsgEl = null;
var isSending = false;
var eventCount = 0;
var newEventCount = 0;
function $(id) { return document.getElementById(id); }
// ── DOM refs
var statusDot = $('status-dot');
var statusLabel = $('status-label');
var onboardingEl = $('onboarding');
var tabsEl = $('tabs');
var messagesEl = $('messages');
var chatBottom = $('chat-bottom');
var chatInput = $('chat-input');
var sendBtn = $('send-btn');
var eventsList = $('events-list');
var eventsEmpty = $('events-empty');
var eventsCountEl = $('events-count');
var eventsBadge = $('events-badge');
var onbError = $('onboarding-error');
var dbgEventCount = $('dbg-event-count');
// ── Status
function setStatus(state, label) {
statusDot.className = 'status-dot ' + state;
statusLabel.textContent = label;
}
// ── Timestamp
function fmtTs(ts) {
if (!ts) return '';
var d = new Date(ts * 1000);
var p = function(n) { return String(n).padStart(2,'0'); };
return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
}
// ── Scroll
function scrollBottom() { chatBottom.scrollIntoView({ behavior: 'smooth' }); }
// ── Tabs
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var tab = btn.dataset.tab;
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.pane').forEach(function(p) { p.classList.remove('active'); });
btn.classList.add('active');
$('pane-' + tab).classList.add('active');
if (tab === 'chat') {
if (_invoke) _invoke('panel_opened').catch(function(){});
clearBadge(); scrollBottom();
} else {
if (_invoke) _invoke('panel_closed').catch(function(){});
if (tab === 'events') clearBadge();
}
});
});
// ── Badge
function bumpBadge() {
newEventCount++;
var chatActive = document.querySelector('[data-tab="chat"]').classList.contains('active');
if (!chatActive) {
eventsBadge.textContent = newEventCount > 99 ? '99+' : String(newEventCount);
eventsBadge.classList.remove('hidden');
}
}
function clearBadge() { newEventCount = 0; eventsBadge.classList.add('hidden'); }
// ── Event cards -- built with DOM methods; no innerHTML for user data
function addEventCard(ev) {
eventsEmpty.classList.add('hidden');
var sev = (ev.severity || 'info').toLowerCase();
var card = document.createElement('div');
card.className = 'event-card severity-' + sev;
var header = document.createElement('div');
header.className = 'event-card-header';
var titleEl = document.createElement('span');
titleEl.className = 'event-title';
titleEl.textContent = ev.title; // textContent: safe
var sevBadge = document.createElement('span');
sevBadge.className = 'severity-badge ' + sev;
sevBadge.textContent = sev; // textContent: safe
header.appendChild(titleEl);
header.appendChild(sevBadge);
var bodyEl = document.createElement('div');
bodyEl.className = 'event-body';
bodyEl.textContent = ev.body; // textContent: safe
var metaEl = document.createElement('div');
metaEl.className = 'event-meta';
metaEl.textContent = ev.pattern_id + ' \xB7 ' + fmtTs(ev.timestamp); // textContent: safe
card.appendChild(header);
card.appendChild(bodyEl);
card.appendChild(metaEl);
var first = eventsList.querySelector('.event-card');
if (first) { eventsList.insertBefore(card, first); } else { eventsList.appendChild(card); }
eventCount++;
dbgEventCount.textContent = String(eventCount);
eventsCountEl.textContent = eventCount + ' event' + (eventCount !== 1 ? 's' : '');
bumpBadge();
}
// ── Chat: add a bubble
function addMsg(role, text) {
var div = document.createElement('div');
div.className = 'msg ' + role;
if (role !== 'system-msg') {
var lbl = document.createElement('div');
lbl.className = 'msg-role';
lbl.textContent = role === 'user' ? 'You' : 'Robin';
div.appendChild(lbl);
}
var body = document.createElement('div');
body.className = 'msg-content';
if (role === 'system-msg') {
body.textContent = text; // textContent: safe
} else if (role === 'user') {
body.textContent = text; // user text: no formatting needed
} else {
renderMdInto(body, text); // markdown renderer builds DOM nodes
}
div.appendChild(body);
messagesEl.insertBefore(div, chatBottom);
scrollBottom();
return div;
}
// ── Markdown renderer: builds DOM nodes, never assigns innerHTML with user data
// Supports: ```code blocks``` and `inline code`. All other text is text nodes.
function renderMdInto(container, text) {
// Split on fenced code blocks
var parts = text.split(/(```[\s\S]*?```)/g);
parts.forEach(function(part) {
if (part.startsWith('```')) {
var code = part.slice(3, -3).replace(/^\n/, '');
var pre = document.createElement('pre');
var codeEl = document.createElement('code');
codeEl.textContent = code; // textContent: safe, displays < > & correctly
pre.appendChild(codeEl);
container.appendChild(pre);
} else {
// Handle inline code within this segment
var inlineParts = part.split(/(`[^`\n]+`)/g);
inlineParts.forEach(function(seg) {
if (seg.startsWith('`') && seg.endsWith('`') && seg.length > 2) {
var codeEl = document.createElement('code');
codeEl.textContent = seg.slice(1, -1); // textContent: safe
container.appendChild(codeEl);
} else {
// Plain text: convert newlines to <br> using text nodes + br elements
var lines = seg.split('\n');
lines.forEach(function(line, i) {
container.appendChild(document.createTextNode(line));
if (i < lines.length - 1) container.appendChild(document.createElement('br'));
});
}
});
}
});
}
// ── Streaming
function startStream() {
var div = document.createElement('div');
div.className = 'msg assistant';
var lbl = document.createElement('div');
lbl.className = 'msg-role';
lbl.textContent = 'Robin';
div.appendChild(lbl);
var body = document.createElement('div');
body.className = 'msg-content';
var cursor = document.createElement('span');
cursor.className = 'typing-cursor';
body.appendChild(cursor);
div.appendChild(body);
messagesEl.insertBefore(div, chatBottom);
streamMsgEl = div;
streamBuf = '';
scrollBottom();
}
function appendToken(token) {
if (!streamMsgEl) startStream();
streamBuf += token;
// Rebuild the content node from scratch on each token.
// This is simple and correct; for very long responses it could be optimised
// by appending incrementally, but correctness wins here.
var body = streamMsgEl.querySelector('.msg-content');
while (body.firstChild) body.removeChild(body.firstChild);
renderMdInto(body, streamBuf);
var cursor = document.createElement('span');
cursor.className = 'typing-cursor';
body.appendChild(cursor);
scrollBottom();
}
function endStream() {
if (!streamMsgEl) return;
var body = streamMsgEl.querySelector('.msg-content');
while (body.firstChild) body.removeChild(body.firstChild);
renderMdInto(body, streamBuf);
streamMsgEl = null; streamBuf = '';
isSending = false;
sendBtn.disabled = false;
chatInput.disabled = false;
chatInput.focus();
}
// ── Send
async function sendMessage() {
var text = chatInput.value.trim();
if (!text || isSending || !_invoke) return;
isSending = true;
sendBtn.disabled = true; chatInput.disabled = true;
chatInput.value = ''; chatInput.style.height = 'auto';
addMsg('user', text);
try {
await _invoke('chat', { message: text });
} catch(err) {
isSending = false; sendBtn.disabled = false; chatInput.disabled = false;
addMsg('system-msg', 'Error: ' + String(err));
}
}
chatInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
sendBtn.addEventListener('click', sendMessage);
chatInput.addEventListener('input', function() {
chatInput.style.height = 'auto';
chatInput.style.height = Math.min(chatInput.scrollHeight, 100) + 'px';
});
// ── Clear events
$('clear-events-btn').addEventListener('click', function() {
eventsList.querySelectorAll('.event-card').forEach(function(c) { c.remove(); });
eventsEmpty.classList.remove('hidden');
eventCount = 0; dbgEventCount.textContent = '0';
eventsCountEl.textContent = 'No events yet'; clearBadge();
});
// ── Notification level
$('notif-level-select').addEventListener('change', function() {
if (_invoke) _invoke('update_notification_level', { level: this.value }).catch(function(e) {
console.error('update_notification_level:', e);
});
});
// ── Debug panel
function populateDebug(cfg) {
if (!cfg) return;
var m = cfg.migration;
if (m) {
$('dbg-source-os').textContent = m.source_os || '—';
$('dbg-distro').textContent = m.distro || '—';
$('dbg-distro-family').textContent = m.source_distro_family || '(auto)';
$('dbg-dual-boot').textContent = m.dual_boot_with || 'none';
$('dbg-fluency').textContent = (m.fluency_level || 0) + ' / 5';
} else {
$('dbg-source-os').textContent = '(not configured)';
}
$('dbg-ollama-url').textContent = (cfg.ollama && cfg.ollama.base_url) || '—';
$('dbg-ollama-model').textContent = (cfg.ollama && cfg.ollama.model) || '—';
$('dbg-tier').textContent = cfg.tier || 'free';
var lvl = (cfg.display && cfg.display.notification_level) || 'badge_and_toast';
$('notif-level-select').value = lvl;
probeOllama(cfg.ollama && cfg.ollama.base_url);
}
async function probeOllama(url) {
var el = $('dbg-ollama-status');
if (!url) { el.textContent = 'no URL'; el.className = 'val err'; return; }
try {
var res = await fetch(url + '/api/tags', { signal: AbortSignal.timeout(3000) });
if (res.ok) {
var data = await res.json();
var count = (data.models && data.models.length) || 0;
el.textContent = 'reachable (' + count + ' model' + (count !== 1 ? 's' : '') + ')';
el.className = 'val ok';
} else {
el.textContent = 'HTTP ' + res.status; el.className = 'val warn';
}
} catch(e) {
el.textContent = 'unreachable'; el.className = 'val err';
}
}
// ── Onboarding
function showOnboarding() {
onboardingEl.classList.remove('hidden'); tabsEl.classList.add('hidden');
document.querySelectorAll('.pane').forEach(function(p) { p.classList.remove('active'); });
setStatus('warn', 'setup needed');
}
function showMain() {
onboardingEl.classList.add('hidden'); tabsEl.classList.remove('hidden');
$('pane-chat').classList.add('active');
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelector('[data-tab="chat"]').classList.add('active');
if (_invoke) _invoke('panel_opened').catch(function(){});
setStatus('ok', 'ready');
}
$('onboarding-save').addEventListener('click', async function() {
onbError.classList.add('hidden');
var srcOs = $('src-os').value;
var distro = $('distro').value.trim() || 'unknown';
var dualBoot = $('dual-boot').value || null;
try {
await _invoke('complete_onboarding', {
sourceOs: srcOs, distro: distro, sourceDistro: null, dualBootWith: dualBoot
});
currentConfig = await _invoke('get_config');
populateDebug(currentConfig);
showMain();
addMsg('system-msg', '✓ Profile saved. Robin is watching your logs.');
} catch(err) {
onbError.textContent = String(err); onbError.classList.remove('hidden');
}
});
$('reset-onboarding-btn').addEventListener('click', showOnboarding);
// ── Init
async function init() {
_invoke = window.__TAURI__.core.invoke;
_listen = window.__TAURI__.event.listen;
await _listen('robin:chat-token', function(e) { appendToken(e.payload); });
await _listen('robin:chat-done', function() { endStream(); });
await _listen('robin:chat-error', function(e) {
endStream(); addMsg('system-msg', 'LLM error: ' + e.payload);
});
await _listen('robin:event', function(e) { addEventCard(e.payload); });
try {
currentConfig = await _invoke('get_config');
populateDebug(currentConfig);
} catch(e) { console.error('get_config:', e); setStatus('error', 'config error'); }
var needsSetup = true;
try { needsSetup = await _invoke('needs_onboarding'); } catch(e) {}
if (needsSetup) {
showOnboarding();
} else {
showMain();
try {
var pending = await _invoke('get_pending_events');
pending.forEach(addEventCard);
} catch(e) {}
}
}
function waitAndInit(tries) {
if (window.__TAURI__ && window.__TAURI__.core) {
init().catch(function(e) { setStatus('error', 'init error'); console.error('Robin init:', e); });
} else if (tries > 0) {
setTimeout(function() { waitAndInit(tries - 1); }, 100);
} else {
setStatus('error', 'IPC unavailable');
$('welcome-msg').textContent = 'Could not connect to the Robin backend — try restarting the app.';
}
}
waitAndInit(30);
</script>
</body>
</html>

View file

@ -52,8 +52,7 @@ case "$cmd" in
start) start)
# Start Robin in the background (daemonised via nohup). # Start Robin in the background (daemonised via nohup).
# Also starts the Vite dev server if a release binary is not available # No dev server needed — the webview loads dist/index.html directly.
# and Node/nvm is present, so the webview has something to connect to.
if pgrep -x "$APP_NAME" > /dev/null 2>&1; then if pgrep -x "$APP_NAME" > /dev/null 2>&1; then
echo "Robin is already running (PID $(pgrep -x "$APP_NAME"))" echo "Robin is already running (PID $(pgrep -x "$APP_NAME"))"
exit 0 exit 0
@ -62,21 +61,6 @@ case "$cmd" in
export DISPLAY="${DISPLAY:-:0}" export DISPLAY="${DISPLAY:-:0}"
export RUST_LOG="${RUST_LOG:-robin_lib=info,warn}" export RUST_LOG="${RUST_LOG:-robin_lib=info,warn}"
mkdir -p "$(dirname "$LOG_FILE")" mkdir -p "$(dirname "$LOG_FILE")"
# If using the debug binary and no Vite server is running, start one.
if [[ "$bin" == *"target/debug"* ]] && ! curl -sf http://localhost:1420 > /dev/null 2>&1; then
NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [[ -s "$NVM_DIR/nvm.sh" ]] && command -v npm > /dev/null 2>&1 || { source "$NVM_DIR/nvm.sh" 2>/dev/null && command -v npm > /dev/null 2>&1; }; then
echo "Starting Vite dev server on :1420..."
nohup npm --prefix "$SCRIPT_DIR" run dev >> /tmp/robin-vite.log 2>&1 &
echo "Vite PID $! — logs: /tmp/robin-vite.log"
sleep 2 # give Vite time to bind before Robin connects
else
echo "Note: no release binary and Node not found — webview will show connection error."
echo "Run 'npm install && npm run dev' in $SCRIPT_DIR to fix this."
fi
fi
nohup "$bin" >> "$LOG_FILE" 2>&1 & nohup "$bin" >> "$LOG_FILE" 2>&1 &
echo "Robin started (PID $!). Logs: $LOG_FILE" echo "Robin started (PID $!). Logs: $LOG_FILE"
;; ;;
@ -88,11 +72,6 @@ case "$cmd" in
else else
echo "Robin is not running." echo "Robin is not running."
fi fi
# Stop Vite dev server if we started it.
if pgrep -f "vite" > /dev/null 2>&1; then
pkill -f "node.*vite" 2>/dev/null || true
echo "Vite dev server stopped."
fi
;; ;;
restart) restart)
@ -119,15 +98,27 @@ case "$cmd" in
;; ;;
dev) dev)
echo "Starting Robin in dev mode (hot-reload)..." # Dev mode: build debug binary and run it directly.
cd "$SCRIPT_DIR" # The webview loads dist/index.html from the build artefact path —
npm run tauri dev # no npm or Vite server required.
echo "Building and running Robin in dev mode..."
cargo build --manifest-path "$SCRIPT_DIR/src-tauri/Cargo.toml"
export DISPLAY="${DISPLAY:-:0}"
export RUST_LOG="${RUST_LOG:-robin_lib=debug,warn}"
exec "$DEBUG_BIN"
;; ;;
build) build)
echo "Building Robin release binary + installers..." echo "Building Robin release binary..."
cargo build --release --manifest-path "$SCRIPT_DIR/src-tauri/Cargo.toml"
echo "Binary: $RELEASE_BIN"
;;
bundle)
# Full release bundle (.deb/.rpm/.AppImage) — requires Tauri CLI (npm install -g @tauri-apps/cli)
echo "Building Robin release bundle (deb/rpm/AppImage)..."
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
npm run tauri build cargo tauri build
;; ;;
build-debug) build-debug)
@ -258,7 +249,7 @@ Usage: ./manage.sh <command>
Running: Running:
run Run in foreground (logs to terminal, Ctrl+C to quit) run Run in foreground (logs to terminal, Ctrl+C to quit)
start Start in background start Start in background (no dev server needed)
stop Stop background instance stop Stop background instance
restart Stop then start restart Stop then start
status Show whether Robin is running status Show whether Robin is running
@ -266,8 +257,9 @@ Running:
Building: Building:
build-debug Build debug binary (Rust only, no Node/npm needed) build-debug Build debug binary (Rust only, no Node/npm needed)
build Build release binary + .deb/.rpm/.AppImage (needs Node + Tauri CLI) build Build release binary (Rust only)
dev Start dev mode with hot-reload (needs Node + Tauri CLI) bundle Build release bundle (.deb/.rpm/.AppImage) — requires Tauri CLI
dev Build debug binary and run it immediately
test Run Rust unit tests test Run Rust unit tests
Installation: Installation:
@ -281,6 +273,8 @@ Installation:
Dependencies: Dependencies:
install-deps Install system deps (Debian/Ubuntu/Mint) install-deps Install system deps (Debian/Ubuntu/Mint)
install-deps-arch Install system deps (Arch/Manjaro/CachyOS) install-deps-arch Install system deps (Arch/Manjaro/CachyOS)
Note: No Node.js or npm required. The UI is a static HTML file bundled with the binary.
EOF EOF
;; ;;
esac esac

View file

@ -4,12 +4,10 @@
"version": "0.1.0", "version": "0.1.0",
"identifier": "tech.circuitforge.robin", "identifier": "tech.circuitforge.robin",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist"
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
}, },
"app": { "app": {
"withGlobalTauri": true,
"windows": [ "windows": [
{ {
"label": "chat", "label": "chat",
@ -32,7 +30,7 @@
"tooltip": "Robin" "tooltip": "Robin"
}, },
"security": { "security": {
"csp": "default-src 'self'; connect-src http://localhost:* ipc: asset:" "csp": "default-src 'self' tauri: asset: ipc:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src ipc: http://ipc.localhost asset: http://localhost:*"
} }
}, },
"bundle": { "bundle": {