diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..2d7da59 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,22 @@ +name: Deploy to VPS + +on: + push: + branches: [main, voice-to-graph] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + script: | + export PATH=$PATH:/home/ian/.npm-global/bin + cd /home/ian/experiments/voice-to-graph + git pull origin ${{ github.ref_name }} + npm install --omit=dev + pm2 restart voice-to-graph diff --git a/public/img/placeholder.png b/public/img/placeholder.png new file mode 100644 index 0000000..0989209 Binary files /dev/null and b/public/img/placeholder.png differ diff --git a/public/index.html b/public/index.html index 0983a29..f98e510 100644 --- a/public/index.html +++ b/public/index.html @@ -3,8 +3,11 @@ - ai-to-graph - + voice-to-graph + + + + -
- - - idle -
-
- + + + + + + +
+
+ + ready +
+ +
+ + diff --git a/public/js/avatar.js b/public/js/avatar.js index 7c4e704..155face 100644 --- a/public/js/avatar.js +++ b/public/js/avatar.js @@ -1,18 +1,6 @@ const KEY = 'avatar'; -const FALLBACK = 'data:image/svg+xml;utf8,' + encodeURIComponent(` - - - - - - - - - - - -`); +const FALLBACK = 'img/placeholder.png'; export function getAvatar() { try { diff --git a/public/js/graph.js b/public/js/graph.js index 32badd5..7785b4d 100644 --- a/public/js/graph.js +++ b/public/js/graph.js @@ -2,6 +2,8 @@ import ForceGraph3D from '3d-force-graph'; import * as THREE from 'three'; import { getAvatar } from './avatar.js'; +const fontReady = document.fonts.ready; + let graph; let tipEl; let mouse = { x: 0, y: 0 }; @@ -12,19 +14,71 @@ let lastConceptId = null; let fitTimer = null; let branchCounters = {}; let nextBranchCharCode = 65; +let meHoverCb = null; const AVATAR_TEXTURE_SIZE = 256; const AVATAR_SPRITE_SIZE = 28; const CONCEPT_NODE_RADIUS = 4; -function drawFallback(ctx, size) { - const grad = ctx.createRadialGradient(size / 2, size * 0.4, size * 0.1, size / 2, size / 2, size / 2); - grad.addColorStop(0, '#6f86ff'); - grad.addColorStop(1, '#1a2150'); - ctx.fillStyle = grad; +function drawFallbackAvatar(ctx, size) { + const cx = size / 2; + + // dark circle background with subtle radial gradient + const bg = ctx.createRadialGradient(cx, cx, 0, cx, cx, cx); + bg.addColorStop(0, '#141e35'); + bg.addColorStop(1, '#0a0f1c'); + ctx.beginPath(); + ctx.arc(cx, cx, cx, 0, Math.PI * 2); + ctx.fillStyle = bg; + ctx.fill(); + + // outer ring — thin glowing border + ctx.beginPath(); + ctx.arc(cx, cx, cx - 2, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(80,140,255,0.2)'; + ctx.lineWidth = 2; + ctx.stroke(); + + // clip everything inside the circle + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cx, cx - 2, 0, Math.PI * 2); + ctx.clip(); + + // head — soft glowing circle + const headR = size * 0.14; + const headY = size * 0.35; + const headGlow = ctx.createRadialGradient(cx, headY, 0, cx, headY, headR * 1.6); + headGlow.addColorStop(0, 'rgba(100,160,255,0.12)'); + headGlow.addColorStop(1, 'transparent'); + ctx.fillStyle = headGlow; + ctx.fillRect(0, 0, size, size); + + ctx.beginPath(); + ctx.arc(cx, headY, headR, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(120,170,255,0.35)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(120,170,255,0.45)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // body — shoulders/torso arc + const bodyTop = size * 0.56; + const bodyGlow = ctx.createRadialGradient(cx, bodyTop + size * 0.12, 0, cx, bodyTop + size * 0.12, size * 0.35); + bodyGlow.addColorStop(0, 'rgba(100,160,255,0.1)'); + bodyGlow.addColorStop(1, 'transparent'); + ctx.fillStyle = bodyGlow; + ctx.fillRect(0, bodyTop - 20, size, size); + ctx.beginPath(); - ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); + ctx.ellipse(cx, bodyTop + size * 0.2, size * 0.3, size * 0.26, 0, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(120,170,255,0.25)'; ctx.fill(); + ctx.strokeStyle = 'rgba(120,170,255,0.35)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + ctx.restore(); } function getAvatarTexture(dataUrl) { @@ -36,30 +90,34 @@ function getAvatarTexture(dataUrl) { canvas.height = size; const ctx = canvas.getContext('2d'); - drawFallback(ctx, size); + // draw fallback immediately so there's never a blank frame + drawFallbackAvatar(ctx, size); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = 8; - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.onload = () => { - ctx.clearRect(0, 0, size, size); - ctx.save(); - ctx.beginPath(); - ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); - ctx.closePath(); - ctx.clip(); - const srcSize = Math.min(img.width, img.height); - const sx = (img.width - srcSize) / 2; - const sy = (img.height - srcSize) / 2; - ctx.drawImage(img, sx, sy, srcSize, srcSize, 0, 0, size, size); - ctx.restore(); - texture.needsUpdate = true; - }; - img.onerror = err => console.error('[graph] avatar image load failed', err); - img.src = dataUrl; + // if dataUrl is a real image (data: or uploaded), overlay it + if (dataUrl && !dataUrl.endsWith('.png')) { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + ctx.clearRect(0, 0, size, size); + ctx.save(); + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + const srcSize = Math.min(img.width, img.height); + const sx = (img.width - srcSize) / 2; + const sy = (img.height - srcSize) / 2; + ctx.drawImage(img, sx, sy, srcSize, srcSize, 0, 0, size, size); + ctx.restore(); + texture.needsUpdate = true; + }; + img.onerror = () => {}; // fallback already drawn + img.src = dataUrl; + } cachedAvatarUrl = dataUrl; cachedAvatarTexture = texture; @@ -71,7 +129,7 @@ function makeTextSprite(text) { const fontSize = 44; const measureCanvas = document.createElement('canvas'); const mctx = measureCanvas.getContext('2d'); - mctx.font = `600 ${fontSize}px -apple-system, system-ui, sans-serif`; + mctx.font = `300 ${fontSize}px 'Sora', system-ui, sans-serif`; const textWidth = Math.ceil(mctx.measureText(text).width); const w = textWidth + pad * 2; @@ -81,8 +139,7 @@ function makeTextSprite(text) { canvas.height = h; const ctx = canvas.getContext('2d'); - ctx.fillStyle = 'rgba(8,12,24,0.72)'; - const r = 10; + const r = 12; ctx.beginPath(); ctx.moveTo(r, 0); ctx.lineTo(w - r, 0); @@ -94,10 +151,14 @@ function makeTextSprite(text) { ctx.lineTo(0, r); ctx.quadraticCurveTo(0, 0, r, 0); ctx.closePath(); + ctx.fillStyle = 'rgba(8,14,28,0.6)'; ctx.fill(); + ctx.strokeStyle = 'rgba(100,150,255,0.12)'; + ctx.lineWidth = 1; + ctx.stroke(); - ctx.font = `600 ${fontSize}px -apple-system, system-ui, sans-serif`; - ctx.fillStyle = '#f0f4ff'; + ctx.font = `300 ${fontSize}px 'Sora', system-ui, sans-serif`; + ctx.fillStyle = 'rgba(200,215,240,0.9)'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.fillText(text, w / 2, h / 2); @@ -210,6 +271,16 @@ export function init(container, tip) { } else { tipEl.hidden = true; } + }) + .onNodeHover(node => { + if (meHoverCb) { + if (node && node.id === 'me') { + const coords = graph.graph2ScreenCoords(node.x, node.y, node.z); + meHoverCb({ x: coords.x, y: coords.y }); + } else { + meHoverCb(null); + } + } }); graph.d3Force('charge').strength(-220); @@ -220,6 +291,12 @@ export function init(container, tip) { links: [] }); + graph.cameraPosition({ x: 0, y: 0, z: 250 }); + + fontReady.then(() => { + graph.nodeThreeObject(graph.nodeThreeObject()); + }); + container.addEventListener('mousemove', e => { mouse.x = e.clientX; mouse.y = e.clientY; @@ -312,6 +389,10 @@ export function removeNode(ref) { return true; } +export function onMeHover(cb) { + meHoverCb = cb; +} + export function moveNode(ref, newParentRef) { const data = graph.graphData(); const node = findNode(ref, data.nodes); diff --git a/public/js/main.js b/public/js/main.js index 6c185d8..58b092a 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -3,8 +3,10 @@ import * as realtime from './realtime.js'; import { setAvatarFromFile } from './avatar.js'; const statusEl = document.getElementById('status'); -const toggleBtn = document.getElementById('toggle'); +const statusPill = document.getElementById('status-pill'); +const micBtn = document.getElementById('mic-btn'); const avatarInput = document.getElementById('avatar-input'); +const avatarEdit = document.getElementById('avatar-edit'); const graphEl = document.getElementById('graph'); const tipEl = document.getElementById('tip'); @@ -12,10 +14,27 @@ let live = false; function setStatus(s) { statusEl.textContent = s; + micBtn.dataset.state = s; + + const isLive = s === 'connected' || s === 'listening'; + statusPill.dataset.live = isLive; } graph.init(graphEl, tipEl); +// avatar edit: show pencil on "me" hover, click to open file picker +graph.onMeHover((screenPos) => { + if (screenPos) { + avatarEdit.hidden = false; + avatarEdit.style.left = (screenPos.x + 14) + 'px'; + avatarEdit.style.top = (screenPos.y - 14) + 'px'; + } else { + avatarEdit.hidden = true; + } +}); + +avatarEdit.addEventListener('click', () => avatarInput.click()); + window.addEventListener('avatar-changed', e => { graph.setAvatar(e.detail); }); @@ -26,7 +45,7 @@ avatarInput.addEventListener('change', async () => { try { await setAvatarFromFile(file); } catch (err) { - setStatus('avatar error: ' + err.message); + setStatus('error'); } }); @@ -37,7 +56,7 @@ function uuid() { async function handleTranscript({ transcript, assistantPrior }) { try { - const res = await fetch('/extract', { + const res = await fetch('extract', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript, assistantPrior, nodes: graph.getNodeLabels() }) @@ -68,30 +87,28 @@ async function handleTranscript({ transcript, assistantPrior }) { } } -toggleBtn.addEventListener('click', async () => { +micBtn.addEventListener('click', async () => { if (live) { realtime.stop(); live = false; - toggleBtn.textContent = 'Start'; - toggleBtn.dataset.live = 'false'; - setStatus('idle'); + micBtn.dataset.live = 'false'; + setStatus('ready'); return; } - toggleBtn.disabled = true; + micBtn.disabled = true; try { await realtime.start({ onTranscript: handleTranscript, onStatus: setStatus }); live = true; - toggleBtn.textContent = 'Stop'; - toggleBtn.dataset.live = 'true'; + micBtn.dataset.live = 'true'; } catch (err) { console.error(err); - setStatus('error: ' + err.message); + setStatus('error'); realtime.stop(); } finally { - toggleBtn.disabled = false; + micBtn.disabled = false; } }); diff --git a/public/js/realtime.js b/public/js/realtime.js index c9419dc..299eef1 100644 --- a/public/js/realtime.js +++ b/public/js/realtime.js @@ -10,6 +10,12 @@ You are a curious, warm conversational partner. Have a natural spoken conversation with the user about whatever they bring up. Ask short, genuine follow-up questions and keep the energy going. Begin by warmly greeting the user and asking what's on their mind today. + +The user is building a knowledge graph as you talk. They may give graph +commands like "move B1 to A2", "delete A4", "remove C3", "add X to Y", +or similar. These are handled by a separate system — you do NOT need to +act on them. Just briefly acknowledge (e.g. "Done", "Got it", "On it") +and continue the conversation naturally. Do not ask what the codes mean. `.trim(); function sendSessionUpdate() { @@ -32,7 +38,7 @@ function sendSessionUpdate() { export async function start({ onTranscript, onStatus }) { onStatus?.('connecting'); - const sessionRes = await fetch('/session', { method: 'POST' }); + const sessionRes = await fetch('session', { method: 'POST' }); if (!sessionRes.ok) { const text = await sessionRes.text(); throw new Error(`/session failed: ${sessionRes.status} ${text}`); diff --git a/public/style.css b/public/style.css index b7c11b7..84c7097 100644 --- a/public/style.css +++ b/public/style.css @@ -1,71 +1,250 @@ +/* === base === */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + html, body { - margin: 0; - padding: 0; height: 100%; - background: #07090f; - color: #e8ecf3; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + background: #050810; + color: #c8d4e8; + font-family: 'Sora', system-ui, sans-serif; overflow: hidden; + cursor: default; } -#bar { +/* === graph fills viewport === */ +#graph { + position: absolute; + inset: 0; +} + +/* === tooltip === */ +#tip { position: fixed; - top: 0; left: 0; right: 0; - z-index: 10; - display: flex; - align-items: center; - gap: 12px; + z-index: 40; + max-width: 280px; padding: 10px 14px; - background: rgba(10, 14, 22, 0.7); + border-radius: 10px; + background: rgba(10, 16, 30, 0.85); + backdrop-filter: blur(12px); + border: 1px solid rgba(120, 160, 255, 0.12); + color: #c8d4e8; + font-size: 12px; + font-weight: 300; + line-height: 1.5; + letter-spacing: 0.01em; + pointer-events: none; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), inset 0 0.5px 0 rgba(255,255,255,0.04); +} + +/* === avatar pencil edit overlay === */ +#avatar-edit { + position: fixed; + z-index: 30; + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(10, 16, 30, 0.7); backdrop-filter: blur(8px); - border-bottom: 1px solid rgba(255,255,255,0.06); + border: 1px solid rgba(120, 160, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + color: rgba(160, 190, 255, 0.8); + cursor: pointer; + transition: all 0.25s ease; + pointer-events: auto; + opacity: 0; + transform: scale(0.85); +} + +#avatar-edit:not([hidden]) { + opacity: 1; + transform: scale(1); +} + +#avatar-edit:hover { + background: rgba(80, 120, 255, 0.25); + border-color: rgba(120, 160, 255, 0.45); + color: #fff; + transform: scale(1.1); +} + +/* === bottom controls === */ +#controls { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 20; + display: flex; + flex-direction: column; + align-items: center; + padding-bottom: 36px; + pointer-events: none; +} + +/* === status pill === */ +#status-pill { + display: flex; + align-items: center; + gap: 7px; + padding: 6px 16px; + margin-bottom: 16px; + border-radius: 20px; + background: rgba(10, 16, 30, 0.6); + backdrop-filter: blur(12px); + border: 1px solid rgba(120, 160, 255, 0.08); + font-size: 11px; + font-weight: 300; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgba(160, 180, 220, 0.7); + transition: all 0.4s ease; + pointer-events: auto; +} + +#status-pill[data-live="true"] { + border-color: rgba(80, 200, 160, 0.2); + color: rgba(140, 220, 190, 0.85); +} + +#status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(120, 140, 180, 0.4); + transition: all 0.4s ease; + flex-shrink: 0; +} + +#status-pill[data-live="true"] #status-dot { + background: #50e8a0; + box-shadow: 0 0 8px rgba(80, 232, 160, 0.5); + animation: dot-pulse 2s ease-in-out infinite; +} + +@keyframes dot-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } } -#bar .file span, -#bar button { - display: inline-block; - padding: 6px 12px; - border-radius: 6px; - background: #1b2230; - color: #e8ecf3; - border: 1px solid rgba(255,255,255,0.08); +/* === mic button === */ +#mic-btn { + position: relative; + width: 64px; + height: 64px; + border-radius: 50%; + border: 1px solid rgba(120, 160, 255, 0.15); + background: rgba(12, 18, 35, 0.65); + backdrop-filter: blur(16px); + color: rgba(160, 190, 255, 0.75); cursor: pointer; - font-size: 13px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.35s cubic-bezier(0.22, 1, 0.36, 1); + pointer-events: auto; + outline: none; + -webkit-tap-highlight-color: transparent; } -#bar button:hover, -#bar .file span:hover { - background: #243049; +#mic-btn::before { + content: ''; + position: absolute; + inset: -1px; + border-radius: 50%; + background: conic-gradient( + from 0deg, + transparent 0%, + rgba(80, 140, 255, 0.15) 25%, + transparent 50%, + rgba(80, 200, 160, 0.1) 75%, + transparent 100% + ); + opacity: 0; + transition: opacity 0.4s ease; + z-index: -1; } -#bar button[data-live="true"] { - background: #b4254b; - border-color: #d23a63; +#mic-btn:hover { + border-color: rgba(120, 160, 255, 0.35); + color: #fff; + background: rgba(20, 30, 60, 0.7); + transform: scale(1.05); } -#status { - font-size: 12px; - color: #98a2b3; - margin-left: auto; - font-variant-numeric: tabular-nums; +#mic-btn:hover::before { + opacity: 1; } -#graph { +#mic-btn:active { + transform: scale(0.96); +} + +#mic-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +/* live / recording state */ +#mic-btn[data-live="true"] { + border-color: rgba(255, 80, 100, 0.3); + background: rgba(255, 40, 70, 0.1); + color: #ff6b7a; + box-shadow: 0 0 30px rgba(255, 60, 80, 0.12); +} + +#mic-btn[data-live="true"]:hover { + background: rgba(255, 40, 70, 0.18); + border-color: rgba(255, 80, 100, 0.5); +} + +#mic-btn[data-live="true"] .mic-icon { display: none; } +#mic-btn[data-live="true"] .stop-icon { display: block !important; } + +/* === pulsing rings (visible only when live) === */ +.mic-rings { position: absolute; inset: 0; + pointer-events: none; } -#tip { - position: fixed; - z-index: 20; - max-width: 320px; - padding: 8px 10px; - border-radius: 6px; - background: rgba(15, 20, 30, 0.96); - border: 1px solid rgba(255,255,255,0.1); - color: #e8ecf3; - font-size: 12px; - line-height: 1.4; - pointer-events: none; - box-shadow: 0 6px 24px rgba(0,0,0,0.35); +.ring { + position: absolute; + inset: -4px; + border-radius: 50%; + border: 1px solid rgba(80, 200, 160, 0.3); + opacity: 0; + transform: scale(1); +} + +#mic-btn[data-live="true"] .ring { + animation: ring-expand 2.4s cubic-bezier(0.22, 1, 0.36, 1) infinite; +} + +#mic-btn[data-live="true"] .ring-1 { animation-delay: 0s; } +#mic-btn[data-live="true"] .ring-2 { animation-delay: 0.8s; } +#mic-btn[data-live="true"] .ring-3 { animation-delay: 1.6s; } + +@keyframes ring-expand { + 0% { + opacity: 0.5; + transform: scale(1); + border-color: rgba(80, 200, 160, 0.35); + } + 100% { + opacity: 0; + transform: scale(1.8); + border-color: rgba(80, 200, 160, 0); + } +} + +/* === connecting state: subtle breathing on mic === */ +#mic-btn[data-state="connecting"] { + animation: breathe 1.5s ease-in-out infinite; +} + +@keyframes breathe { + 0%, 100% { border-color: rgba(120, 160, 255, 0.15); } + 50% { border-color: rgba(120, 160, 255, 0.4); } }