/* =================================================================== KabelFlux β AI Copilot sidebar (views-ai.jsx) Exposes window.AICopilot. ES2015-safe (no object-rest, no {...} literals). =================================================================== */ const AICopilot = ({ pid, me, onClose }) => { const { useState, useRef, useEffect } = React; const [messages, setMessages] = useState([]); // [{role, text}] for display const [history, setHistory] = useState([]); // Anthropic-format message list const [input, setInput] = useState(''); const [busy, setBusy] = useState(false); const [pending, setPending] = useState(null); const [autoKinds, setAutoKinds] = useState({}); // { create_wire: true, ... } const [toolsOpen, setToolsOpen] = useState(false); // AI-tools chip tray const scroller = useRef(null); useEffect(() => { if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight; }, [messages, pending]); function pushMsg(role, text) { setMessages(prev => prev.concat([{ role: role, text: text }])); } async function runTurn(convo) { setBusy(true); let streamed = ''; let done = null; setMessages(prev => prev.concat([{ role: 'assistant', text: '' }])); await kfxAiChatStream({ project_id: pid, messages: convo }, function (ev) { if (ev.type === 'text') { streamed += ev.text; setMessages(prev => { const copy = prev.slice(); copy[copy.length - 1] = { role: 'assistant', text: streamed }; return copy; }); } else if (ev.type === 'error') { pushMsg('error', ev.message || 'AI error'); } else if (ev.type === 'done') { done = ev; } }); setBusy(false); if (done) await handleDone(convo, done); } async function handleDone(convo, done, noResume) { const writes = done.pending_writes || []; const baseHistory = convo.concat([done.assistant_message]); if (!writes.length) { setHistory(baseHistory); return; } const toolResults = []; const rr = done.read_results || {}; Object.keys(rr).forEach(function (rid) { toolResults.push({ type: 'tool_result', tool_use_id: rid, content: JSON.stringify(rr[rid]) }); }); for (let i = 0; i < writes.length; i++) { const w = writes[i]; let approved = !!autoKinds[w.name]; if (!approved) { const decision = await askConfirm(w); if (decision.auto) setAutoKinds(prev => Object.assign({}, prev, makeKind(w.name))); approved = decision.approve || decision.auto; if (!approved) { toolResults.push({ type: 'tool_result', tool_use_id: w.id, is_error: true, content: 'User rejected this action.' + (decision.feedback ? ' Feedback: ' + decision.feedback : '') }); continue; } } try { const res = await kfxAiExecute(pid, w.name, w.input); pushMsg('system', 'β ' + w.preview); toolResults.push({ type: 'tool_result', tool_use_id: w.id, content: JSON.stringify(res.result || res) }); } catch (e) { toolResults.push({ type: 'tool_result', tool_use_id: w.id, is_error: true, content: 'Execution failed: ' + (e && e.message ? e.message : e) }); } } const nextHistory = baseHistory.concat([{ role: 'user', content: toolResults }]); setHistory(nextHistory); if (!noResume) await runTurn(nextHistory); } const fileRef = useRef(null); async function uploadSketch(file) { if (busy || !file) return; pushMsg('user', 'πΌ Uploaded a harness sketch'); setBusy(true); let streamed = ''; let done = null; setMessages(prev => prev.concat([{ role: 'assistant', text: '' }])); await window.kfxAiSketchStream(pid, file, function (ev) { if (ev.type === 'text') { streamed += ev.text; setMessages(prev => { const c = prev.slice(); c[c.length - 1] = { role: 'assistant', text: streamed }; return c; }); } else if (ev.type === 'error') { pushMsg('error', ev.message || 'Sketch import failed.'); } else if (ev.type === 'done') { done = ev; } }); setBusy(false); // Confirm + apply proposed writes; don't resume chat (image isn't in client history). if (done) await handleDone([{ role: 'user', content: '[harness sketch]' }], done, true); } // CSV / Netlist-KBL import: pick a file, map it, show the proposal, then // confirm to write the wires. importKindRef tracks which mapper to use. const importRef = useRef(null); const importKindRef = useRef('import'); async function onImportFile(file) { if (busy || !file) return; const netlist = importKindRef.current === 'import-netlist'; pushMsg('user', (netlist ? 'π Netlist/KBL import: ' : 'π₯ CSV import: ') + file.name); setBusy(true); try { const mapper = netlist ? window.kfxAiImportNetlist : window.kfxAiImportMap; const d = await mapper(pid, file); const wires = d.wires || []; const preview = JSON.stringify(wires, null, 2); pushMsg('assistant', (d.notes ? d.notes + '\n\n' : '') + 'Proposed **' + (d.count || wires.length) + '** wires:\n\n```json\n' + (preview.length > 2000 ? preview.slice(0, 2000) + '\nβ¦' : preview) + '\n```'); if (wires.length && window.confirm('Import ' + wires.length + ' wires into this project?')) { const n = await window.kfxImportWires(pid, wires); pushMsg('assistant', 'Imported **' + n + '** wires β open the Wires tab to review.'); } } catch (e) { pushMsg('error', (e && e.message) || 'Import failed'); } setBusy(false); } function makeKind(name) { const o = {}; o[name] = true; return o; } const [confirm, setConfirm] = useState(null); // {write, resolve} function askConfirm(write) { return new Promise(function (resolve) { setConfirm({ write: write, resolve: resolve }); }); } function decide(d) { if (confirm) { confirm.resolve(d); setConfirm(null); } } // One-tap read-only analyses (Feature B = review, N = deep review). // These never propose writes, so we just stream the answer into the thread. async function runQuick(label, streamFn) { if (busy) return; pushMsg('user', label); setBusy(true); let streamed = ''; setMessages(prev => prev.concat([{ role: 'assistant', text: '' }])); await streamFn(pid, function (ev) { if (ev.type === 'text') { streamed += ev.text; setMessages(prev => { const c = prev.slice(); c[c.length - 1] = { role: 'assistant', text: streamed }; return c; }); } else if (ev.type === 'error') { pushMsg('error', ev.message || 'AI error'); } }); setBusy(false); } // AI Tools, run from the copilot. Streams the answer straight into the // thread (same as runQuick). Tools needing free-text input prompt for it; // file-based importers stay in the AI Tools panel. async function runTool(t) { if (busy || !pid) return; if (t.k === 'import' || t.k === 'import-netlist') { importKindRef.current = t.k; setToolsOpen(false); if (importRef.current) importRef.current.click(); return; } let arg = ''; if (t.k === 'continuity') { arg = (window.prompt('Failure to diagnose (e.g. pins 4-7 open on J3):') || '').trim(); if (!arg) return; } else if (t.k === 'change') { arg = (window.prompt('Change to assess (e.g. swap J5 from DB9 to DB15):') || '').trim(); if (!arg) return; } else if (t.k === 'standards') { arg = (window.prompt('Standards question (e.g. shield termination, IPC-620 Class 3?):') || '').trim(); if (!arg) return; } else if (t.k === 'rfq') { arg = (window.prompt('Paste the customer RFQ email text:') || '').trim(); if (!arg) return; } setToolsOpen(false); pushMsg('user', t.icon + ' ' + t.l + (arg ? ': ' + arg : '')); setBusy(true); let streamed = ''; setMessages(prev => prev.concat([{ role: 'assistant', text: '' }])); const put = function (s) { setMessages(prev => { const c = prev.slice(); c[c.length - 1] = { role: 'assistant', text: s }; return c; }); }; const onEv = function (ev) { if (ev.type === 'text') { streamed += ev.text; put(streamed); } else if (ev.type === 'error') { pushMsg('error', ev.message || 'AI error'); } }; try { if (t.k === 'narrate') await window.kfxAiNarrateStream(pid, onEv); else if (t.k === 'parts') await window.kfxAiPartsStream(pid, onEv); else if (t.k === 'continuity') await window.kfxAiContinuityStream(pid, arg, onEv); else if (t.k === 'change') await window.kfxAiChangeImpactStream(pid, arg, onEv); else if (t.k === 'standards') await window.kfxAiStandardsStream(pid, arg, '', onEv); else if (t.k === 'wire-sizing') await window.kfxAiWireSizingStream(pid, onEv); else if (t.k === 'quote') await window.kfxAiQuoteStream(pid, onEv); else if (t.k === 'build') await window.kfxAiBuildInstructionsStream(pid, onEv); else if (t.k === 'rfq') await window.kfxAiRfqStream(pid, arg, onEv); else if (t.k === 'anomaly') await window.kfxAiAnomalyStream(pid, onEv); else if (t.k === 'estimates') { const d = await window.kfxGetEstimates(pid); const lines = ['**Wire lengths (estimated, mm):**']; (d.lengths || []).forEach(function (l) { lines.push('- #' + l.id + ': ' + l.length_mm + ' mm'); }); lines.push(''); lines.push('**Bundles (estimated OD):**'); (d.bundles || []).forEach(function (b) { lines.push('- ' + b.group + ': ' + b.wire_count + ' wires, ~' + b.bundle_od_mm + ' mm'); }); if (d.cost) { lines.push(''); lines.push('**Cost estimate:**'); lines.push('- Labour: ' + d.cost.labour.total_minutes + ' min @ ' + d.cost.labour_rate + '/hr = ' + d.cost.labour_cost); lines.push('- Material: ' + d.cost.material.total + ((d.cost.material.unpriced_parts || []).length ? ' (unpriced: ' + d.cost.material.unpriced_parts.join(', ') + ')' : '')); lines.push('- Overhead (' + d.cost.overhead_pct + '%): ' + d.cost.overhead_cost); lines.push('- **Total: ' + d.cost.total + '**'); } put(lines.join('\n')); } } catch (e) { pushMsg('error', (e && e.message) || 'Tool failed'); } setBusy(false); } function exportPdf() { if (!messages.length) return; const md = messages.map(function (m) { const who = m.role === 'user' ? '**You**' : m.role === 'assistant' ? '**Flux AI**' : m.role === 'system' ? '_system_' : '_error_'; return who + '\n\n' + (m.text || ''); }).join('\n\n---\n\n'); window.kfxExportMarkdownPdf('Flux AI β conversation', md); } // F = NL -> draft harness. Proposes writes, so it routes through handleDone // (the same per-action confirm flow as chat). noResume: image/draft prompt // isn't replayed as client history. async function runDraft() { if (busy) return; const desc = (window.prompt('Describe the harness to draft (connectors, wires, pins):') || '').trim(); if (!desc) return; pushMsg('user', 'β Draft: ' + desc); setBusy(true); let streamed = ''; let done = null; setMessages(prev => prev.concat([{ role: 'assistant', text: '' }])); await kfxAiDraftStream(pid, desc, function (ev) { if (ev.type === 'text') { streamed += ev.text; setMessages(prev => { const c = prev.slice(); c[c.length - 1] = { role: 'assistant', text: streamed }; return c; }); } else if (ev.type === 'error') { pushMsg('error', ev.message || 'Draft failed.'); } else if (ev.type === 'done') { done = ev; } }); setBusy(false); if (done) await handleDone([{ role: 'user', content: desc }], done, true); } async function send() { const text = input.trim(); if (!text || busy) return; setInput(''); pushMsg('user', text); const convo = history.concat([{ role: 'user', content: text }]); setHistory(convo); await runTurn(convo); } return (
$1');
s = s.replace(/\*\*([^*]+)\*\*/g, '$1');
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2');
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_m, txt, url) {
return /^https?:\/\//i.test(url)
? '' + txt + '' : txt;
});
// light LaTeX cleanup the models emit ($\rightarrow$, $t55.1$ β¦)
s = s.replace(/\$\\rightarrow\$/g, 'β').replace(/\$\\leftarrow\$/g, 'β');
s = s.replace(/\$([^$]+)\$/g, '$1');
return s;
}
function mdToHtml(text) {
const lines = _mdEsc(text || '').split('\n');
const out = [];
let list = null;
function closeList() { if (list) { out.push('' + list + '>'); list = null; } }
for (let i = 0; i < lines.length; i++) {
const line = lines[i]; let m;
// GFM table: header row, a |---|---| separator, then data rows.
if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length
&& /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1])) {
closeList();
const cellsOf = function (r) { return r.trim().replace(/^\||\|$/g, '').split('|').map(function (c) { return c.trim(); }); };
const headers = cellsOf(line);
i++; // consume separator
let t = '| ' + _mdInline(h) + ' | '; }); t += '
|---|
| ' + _mdInline(cs[ci] || '') + ' | '; }); t += '