ava-engine 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +63 -0
- package/CLA.md +43 -0
- package/COMMERCIAL-LICENSE.md +43 -0
- package/LICENSE +73 -0
- package/README.md +238 -2
- package/THIRD_PARTY_NOTICES.md +38 -0
- package/TRADEMARKS.md +31 -0
- package/bin/ava-engine.mjs +12 -0
- package/lib/asr.mjs +143 -0
- package/lib/cli/config.mjs +95 -0
- package/lib/cli/main.mjs +152 -0
- package/lib/cli/setup.mjs +219 -0
- package/lib/fetch-models.mjs +104 -0
- package/lib/guard.mjs +92 -0
- package/lib/models.mjs +47 -0
- package/lib/openrouter.mjs +153 -0
- package/lib/ours.mjs +766 -0
- package/lib/platforms.mjs +201 -0
- package/lib/state.mjs +150 -0
- package/lib/stt.mjs +22 -0
- package/lib/tts.mjs +178 -0
- package/lib/workflow.mjs +18 -0
- package/package.json +72 -4
- package/public/app.js +1708 -0
- package/public/demo.js +420 -0
- package/public/favicon.svg +1 -0
- package/public/fonts/OFL.txt +96 -0
- package/public/fonts/inter-tight-var.woff2 +0 -0
- package/public/fonts/inter-var.woff2 +0 -0
- package/public/fonts/plex-mono-400.woff2 +0 -0
- package/public/fonts/plex-mono-500.woff2 +0 -0
- package/public/img/cta-1600.avif +0 -0
- package/public/img/cta-1600.webp +0 -0
- package/public/img/cta-2400.avif +0 -0
- package/public/img/cta-2400.webp +0 -0
- package/public/img/cta-960.avif +0 -0
- package/public/img/cta-960.webp +0 -0
- package/public/img/flows-1600.avif +0 -0
- package/public/img/flows-1600.webp +0 -0
- package/public/img/flows-2400.avif +0 -0
- package/public/img/flows-2400.webp +0 -0
- package/public/img/flows-960.avif +0 -0
- package/public/img/flows-960.webp +0 -0
- package/public/img/hero-1600.avif +0 -0
- package/public/img/hero-1600.webp +0 -0
- package/public/img/hero-2400.avif +0 -0
- package/public/img/hero-2400.webp +0 -0
- package/public/img/hero-960.avif +0 -0
- package/public/img/hero-960.webp +0 -0
- package/public/img/p-csm-480.avif +0 -0
- package/public/img/p-csm-480.webp +0 -0
- package/public/img/p-csm-800.avif +0 -0
- package/public/img/p-csm-800.webp +0 -0
- package/public/img/p-docs-480.avif +0 -0
- package/public/img/p-docs-480.webp +0 -0
- package/public/img/p-docs-800.avif +0 -0
- package/public/img/p-docs-800.webp +0 -0
- package/public/img/p-quiet-480.avif +0 -0
- package/public/img/p-quiet-480.webp +0 -0
- package/public/img/p-quiet-800.avif +0 -0
- package/public/img/p-quiet-800.webp +0 -0
- package/public/img/p-tickets-480.avif +0 -0
- package/public/img/p-tickets-480.webp +0 -0
- package/public/img/p-tickets-800.avif +0 -0
- package/public/img/p-tickets-800.webp +0 -0
- package/public/img/p-trial-480.avif +0 -0
- package/public/img/p-trial-480.webp +0 -0
- package/public/img/p-trial-800.avif +0 -0
- package/public/img/p-trial-800.webp +0 -0
- package/public/img/sees-1600.avif +0 -0
- package/public/img/sees-1600.webp +0 -0
- package/public/img/sees-2400.avif +0 -0
- package/public/img/sees-2400.webp +0 -0
- package/public/img/sees-960.avif +0 -0
- package/public/img/sees-960.webp +0 -0
- package/public/img/speaks-1600.avif +0 -0
- package/public/img/speaks-1600.webp +0 -0
- package/public/img/speaks-2400.avif +0 -0
- package/public/img/speaks-2400.webp +0 -0
- package/public/img/speaks-960.avif +0 -0
- package/public/img/speaks-960.webp +0 -0
- package/public/img/voice-1600.avif +0 -0
- package/public/img/voice-1600.webp +0 -0
- package/public/img/voice-2400.avif +0 -0
- package/public/img/voice-2400.webp +0 -0
- package/public/img/voice-960.avif +0 -0
- package/public/img/voice-960.webp +0 -0
- package/public/index.html +440 -0
- package/public/landing.html +975 -0
- package/public/pcm-worklet.js +11 -0
- package/public/perceptionWorker.js +103 -0
- package/public/rtc.js +60 -0
- package/public/specialist.html +92 -0
- package/public/specialist.js +132 -0
- package/public/style.css +836 -0
- package/public/vendor/lenis.min.js +3 -0
- package/scripts/fetch-models.sh +15 -0
- package/server.mjs +502 -0
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/*! Lenis v1.1.13 | MIT License | Copyright (c) darkroom.engineering | https://github.com/darkroomengineering/lenis */
|
|
2
|
+
!function(t,i){"object"==typeof exports&&"undefined"!=typeof module?module.exports=i():"function"==typeof define&&define.amd?define(i):(t="undefined"!=typeof globalThis?globalThis:t||self).Lenis=i()}(this,(function(){"use strict";function clamp(t,i,e){return Math.max(t,Math.min(i,e))}class Animate{constructor(){this.isRunning=!1,this.value=0,this.from=0,this.to=0,this.currentTime=0}advance(t){var i;if(!this.isRunning)return;let e=!1;if(this.duration&&this.easing){this.currentTime+=t;const i=clamp(0,this.currentTime/this.duration,1);e=i>=1;const s=e?1:this.easing(i);this.value=this.from+(this.to-this.from)*s}else this.lerp?(this.value=function damp(t,i,e,s){return function lerp(t,i,e){return(1-e)*t+e*i}(t,i,1-Math.exp(-e*s))}(this.value,this.to,60*this.lerp,t),Math.round(this.value)===this.to&&(this.value=this.to,e=!0)):(this.value=this.to,e=!0);e&&this.stop(),null===(i=this.onUpdate)||void 0===i||i.call(this,this.value,e)}stop(){this.isRunning=!1}fromTo(t,i,{lerp:e,duration:s,easing:o,onStart:n,onUpdate:l}){this.from=this.value=t,this.to=i,this.lerp=e,this.duration=s,this.easing=o,this.currentTime=0,this.isRunning=!0,null==n||n(),this.onUpdate=l}}class Dimensions{constructor(t,i,{autoResize:e=!0,debounce:s=250}={}){this.wrapper=t,this.content=i,this.width=0,this.height=0,this.scrollHeight=0,this.scrollWidth=0,this.resize=()=>{this.onWrapperResize(),this.onContentResize()},this.onWrapperResize=()=>{this.wrapper instanceof Window?(this.width=window.innerWidth,this.height=window.innerHeight):(this.width=this.wrapper.clientWidth,this.height=this.wrapper.clientHeight)},this.onContentResize=()=>{this.wrapper instanceof Window?(this.scrollHeight=this.content.scrollHeight,this.scrollWidth=this.content.scrollWidth):(this.scrollHeight=this.wrapper.scrollHeight,this.scrollWidth=this.wrapper.scrollWidth)},e&&(this.debouncedResize=function debounce(t,i){let e;return function(...s){let o=this;clearTimeout(e),e=setTimeout((()=>{e=void 0,t.apply(o,s)}),i)}}(this.resize,s),this.wrapper instanceof Window?window.addEventListener("resize",this.debouncedResize,!1):(this.wrapperResizeObserver=new ResizeObserver(this.debouncedResize),this.wrapperResizeObserver.observe(this.wrapper)),this.contentResizeObserver=new ResizeObserver(this.debouncedResize),this.contentResizeObserver.observe(this.content)),this.resize()}destroy(){var t,i;null===(t=this.wrapperResizeObserver)||void 0===t||t.disconnect(),null===(i=this.contentResizeObserver)||void 0===i||i.disconnect(),this.wrapper===window&&this.debouncedResize&&window.removeEventListener("resize",this.debouncedResize,!1)}get limit(){return{x:this.scrollWidth-this.width,y:this.scrollHeight-this.height}}}class Emitter{constructor(){this.events={}}emit(t,...i){var e;let s=this.events[t]||[];for(let t=0,o=s.length;t<o;t++)null===(e=s[t])||void 0===e||e.call(s,...i)}on(t,i){var e;return(null===(e=this.events[t])||void 0===e?void 0:e.push(i))||(this.events[t]=[i]),()=>{var e;this.events[t]=null===(e=this.events[t])||void 0===e?void 0:e.filter((t=>i!==t))}}off(t,i){var e;this.events[t]=null===(e=this.events[t])||void 0===e?void 0:e.filter((t=>i!==t))}destroy(){this.events={}}}const t=100/6,i={passive:!1};class VirtualScroll{constructor(e,s={wheelMultiplier:1,touchMultiplier:1}){this.element=e,this.options=s,this.touchStart={x:0,y:0},this.lastDelta={x:0,y:0},this.window={width:0,height:0},this.emitter=new Emitter,this.onTouchStart=t=>{const{clientX:i,clientY:e}=t.targetTouches?t.targetTouches[0]:t;this.touchStart.x=i,this.touchStart.y=e,this.lastDelta={x:0,y:0},this.emitter.emit("scroll",{deltaX:0,deltaY:0,event:t})},this.onTouchMove=t=>{const{clientX:i,clientY:e}=t.targetTouches?t.targetTouches[0]:t,s=-(i-this.touchStart.x)*this.options.touchMultiplier,o=-(e-this.touchStart.y)*this.options.touchMultiplier;this.touchStart.x=i,this.touchStart.y=e,this.lastDelta={x:s,y:o},this.emitter.emit("scroll",{deltaX:s,deltaY:o,event:t})},this.onTouchEnd=t=>{this.emitter.emit("scroll",{deltaX:this.lastDelta.x,deltaY:this.lastDelta.y,event:t})},this.onWheel=i=>{let{deltaX:e,deltaY:s,deltaMode:o}=i;e*=1===o?t:2===o?this.window.width:1,s*=1===o?t:2===o?this.window.height:1,e*=this.options.wheelMultiplier,s*=this.options.wheelMultiplier,this.emitter.emit("scroll",{deltaX:e,deltaY:s,event:i})},this.onWindowResize=()=>{this.window={width:window.innerWidth,height:window.innerHeight}},window.addEventListener("resize",this.onWindowResize,!1),this.onWindowResize(),this.element.addEventListener("wheel",this.onWheel,i),this.element.addEventListener("touchstart",this.onTouchStart,i),this.element.addEventListener("touchmove",this.onTouchMove,i),this.element.addEventListener("touchend",this.onTouchEnd,i)}on(t,i){return this.emitter.on(t,i)}destroy(){this.emitter.destroy(),window.removeEventListener("resize",this.onWindowResize,!1),this.element.removeEventListener("wheel",this.onWheel,i),this.element.removeEventListener("touchstart",this.onTouchStart,i),this.element.removeEventListener("touchmove",this.onTouchMove,i),this.element.removeEventListener("touchend",this.onTouchEnd,i)}}return class Lenis{constructor({wrapper:t=window,content:i=document.documentElement,eventsTarget:e=t,smoothWheel:s=!0,syncTouch:o=!1,syncTouchLerp:n=.075,touchInertiaMultiplier:l=35,duration:r,easing:h=(t=>Math.min(1,1.001-Math.pow(2,-10*t))),lerp:a=.1,infinite:c=!1,orientation:u="vertical",gestureOrientation:d="vertical",touchMultiplier:p=1,wheelMultiplier:m=1,autoResize:v=!0,prevent:g,virtualScroll:S,__experimental__naiveDimensions:w=!1}={}){this._isScrolling=!1,this._isStopped=!1,this._isLocked=!1,this._preventNextNativeScrollEvent=!1,this._resetVelocityTimeout=null,this.time=0,this.userData={},this.lastVelocity=0,this.velocity=0,this.direction=0,this.animate=new Animate,this.emitter=new Emitter,this.onPointerDown=t=>{1===t.button&&this.reset()},this.onVirtualScroll=t=>{if("function"==typeof this.options.virtualScroll&&!1===this.options.virtualScroll(t))return;const{deltaX:i,deltaY:e,event:s}=t;if(this.emitter.emit("virtual-scroll",{deltaX:i,deltaY:e,event:s}),s.ctrlKey)return;const o=s.type.includes("touch"),n=s.type.includes("wheel");this.isTouching="touchstart"===s.type||"touchmove"===s.type;if(this.options.syncTouch&&o&&"touchstart"===s.type&&!this.isStopped&&!this.isLocked)return void this.reset();const l=0===i&&0===e,r="vertical"===this.options.gestureOrientation&&0===e||"horizontal"===this.options.gestureOrientation&&0===i;if(l||r)return;let h=s.composedPath();h=h.slice(0,h.indexOf(this.rootElement));const a=this.options.prevent;if(h.find((t=>{var i,e,s,l,r;return t instanceof HTMLElement&&("function"==typeof a&&(null==a?void 0:a(t))||(null===(i=t.hasAttribute)||void 0===i?void 0:i.call(t,"data-lenis-prevent"))||o&&(null===(e=t.hasAttribute)||void 0===e?void 0:e.call(t,"data-lenis-prevent-touch"))||n&&(null===(s=t.hasAttribute)||void 0===s?void 0:s.call(t,"data-lenis-prevent-wheel"))||(null===(l=t.classList)||void 0===l?void 0:l.contains("lenis"))&&!(null===(r=t.classList)||void 0===r?void 0:r.contains("lenis-stopped")))})))return;if(this.isStopped||this.isLocked)return void s.preventDefault();if(!(this.options.syncTouch&&o||this.options.smoothWheel&&n))return this.isScrolling="native",void this.animate.stop();s.preventDefault();let c=e;"both"===this.options.gestureOrientation?c=Math.abs(e)>Math.abs(i)?e:i:"horizontal"===this.options.gestureOrientation&&(c=i);const u=o&&this.options.syncTouch,d=o&&"touchend"===s.type&&Math.abs(c)>5;d&&(c=this.velocity*this.options.touchInertiaMultiplier),this.scrollTo(this.targetScroll+c,Object.assign({programmatic:!1},u?{lerp:d?this.options.syncTouchLerp:1}:{lerp:this.options.lerp,duration:this.options.duration,easing:this.options.easing}))},this.onNativeScroll=()=>{if(null!==this._resetVelocityTimeout&&(clearTimeout(this._resetVelocityTimeout),this._resetVelocityTimeout=null),this._preventNextNativeScrollEvent)this._preventNextNativeScrollEvent=!1;else if(!1===this.isScrolling||"native"===this.isScrolling){const t=this.animatedScroll;this.animatedScroll=this.targetScroll=this.actualScroll,this.lastVelocity=this.velocity,this.velocity=this.animatedScroll-t,this.direction=Math.sign(this.animatedScroll-t),this.isScrolling="native",this.emit(),0!==this.velocity&&(this._resetVelocityTimeout=setTimeout((()=>{this.lastVelocity=this.velocity,this.velocity=0,this.isScrolling=!1,this.emit()}),400))}},window.lenisVersion="1.1.13",t&&t!==document.documentElement&&t!==document.body||(t=window),this.options={wrapper:t,content:i,eventsTarget:e,smoothWheel:s,syncTouch:o,syncTouchLerp:n,touchInertiaMultiplier:l,duration:r,easing:h,lerp:a,infinite:c,gestureOrientation:d,orientation:u,touchMultiplier:p,wheelMultiplier:m,autoResize:v,prevent:g,virtualScroll:S,__experimental__naiveDimensions:w},this.dimensions=new Dimensions(t,i,{autoResize:v}),this.updateClassName(),this.targetScroll=this.animatedScroll=this.actualScroll,this.options.wrapper.addEventListener("scroll",this.onNativeScroll,!1),this.options.wrapper.addEventListener("pointerdown",this.onPointerDown,!1),this.virtualScroll=new VirtualScroll(e,{touchMultiplier:p,wheelMultiplier:m}),this.virtualScroll.on("scroll",this.onVirtualScroll)}destroy(){this.emitter.destroy(),this.options.wrapper.removeEventListener("scroll",this.onNativeScroll,!1),this.options.wrapper.removeEventListener("pointerdown",this.onPointerDown,!1),this.virtualScroll.destroy(),this.dimensions.destroy(),this.cleanUpClassName()}on(t,i){return this.emitter.on(t,i)}off(t,i){return this.emitter.off(t,i)}setScroll(t){this.isHorizontal?this.rootElement.scrollLeft=t:this.rootElement.scrollTop=t}resize(){this.dimensions.resize(),this.animatedScroll=this.targetScroll=this.actualScroll,this.emit()}emit(){this.emitter.emit("scroll",this)}reset(){this.isLocked=!1,this.isScrolling=!1,this.animatedScroll=this.targetScroll=this.actualScroll,this.lastVelocity=this.velocity=0,this.animate.stop()}start(){this.isStopped&&(this.isStopped=!1,this.reset())}stop(){this.isStopped||(this.isStopped=!0,this.animate.stop(),this.reset())}raf(t){const i=t-(this.time||t);this.time=t,this.animate.advance(.001*i)}scrollTo(t,{offset:i=0,immediate:e=!1,lock:s=!1,duration:o=this.options.duration,easing:n=this.options.easing,lerp:l=this.options.lerp,onStart:r,onComplete:h,force:a=!1,programmatic:c=!0,userData:u}={}){if(!this.isStopped&&!this.isLocked||a){if("string"==typeof t&&["top","left","start"].includes(t))t=0;else if("string"==typeof t&&["bottom","right","end"].includes(t))t=this.limit;else{let e;if("string"==typeof t?e=document.querySelector(t):t instanceof HTMLElement&&(null==t?void 0:t.nodeType)&&(e=t),e){if(this.options.wrapper!==window){const t=this.rootElement.getBoundingClientRect();i-=this.isHorizontal?t.left:t.top}const s=e.getBoundingClientRect();t=(this.isHorizontal?s.left:s.top)+this.animatedScroll}}if("number"==typeof t){if(t+=i,t=Math.round(t),this.options.infinite?c&&(this.targetScroll=this.animatedScroll=this.scroll):t=clamp(0,t,this.limit),t===this.targetScroll)return null==r||r(this),void(null==h||h(this));if(this.userData=null!=u?u:{},e)return this.animatedScroll=this.targetScroll=t,this.setScroll(this.scroll),this.reset(),this.preventNextNativeScrollEvent(),this.emit(),null==h||h(this),void(this.userData={});c||(this.targetScroll=t),this.animate.fromTo(this.animatedScroll,t,{duration:o,easing:n,lerp:l,onStart:()=>{s&&(this.isLocked=!0),this.isScrolling="smooth",null==r||r(this)},onUpdate:(t,i)=>{this.isScrolling="smooth",this.lastVelocity=this.velocity,this.velocity=t-this.animatedScroll,this.direction=Math.sign(this.velocity),this.animatedScroll=t,this.setScroll(this.scroll),c&&(this.targetScroll=t),i||this.emit(),i&&(this.reset(),this.emit(),null==h||h(this),this.userData={},this.preventNextNativeScrollEvent())}})}}}preventNextNativeScrollEvent(){this._preventNextNativeScrollEvent=!0,requestAnimationFrame((()=>{this._preventNextNativeScrollEvent=!1}))}get rootElement(){return this.options.wrapper===window?document.documentElement:this.options.wrapper}get limit(){return this.options.__experimental__naiveDimensions?this.isHorizontal?this.rootElement.scrollWidth-this.rootElement.clientWidth:this.rootElement.scrollHeight-this.rootElement.clientHeight:this.dimensions.limit[this.isHorizontal?"x":"y"]}get isHorizontal(){return"horizontal"===this.options.orientation}get actualScroll(){return this.isHorizontal?this.rootElement.scrollLeft:this.rootElement.scrollTop}get scroll(){return this.options.infinite?function modulo(t,i){return(t%i+i)%i}(this.animatedScroll,this.limit):this.animatedScroll}get progress(){return 0===this.limit?1:this.scroll/this.limit}get isScrolling(){return this._isScrolling}set isScrolling(t){this._isScrolling!==t&&(this._isScrolling=t,this.updateClassName())}get isStopped(){return this._isStopped}set isStopped(t){this._isStopped!==t&&(this._isStopped=t,this.updateClassName())}get isLocked(){return this._isLocked}set isLocked(t){this._isLocked!==t&&(this._isLocked=t,this.updateClassName())}get isSmooth(){return"smooth"===this.isScrolling}get className(){let t="lenis";return this.isStopped&&(t+=" lenis-stopped"),this.isLocked&&(t+=" lenis-locked"),this.isScrolling&&(t+=" lenis-scrolling"),"smooth"===this.isScrolling&&(t+=" lenis-smooth"),t}updateClassName(){this.cleanUpClassName(),this.rootElement.className=`${this.rootElement.className} ${this.className}`.trim()}cleanUpClassName(){this.rootElement.className=this.rootElement.className.replace(/lenis(-\w+)?/g,"").trim()}}}));
|
|
3
|
+
//# sourceMappingURL=lenis.min.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Download the offline speech-to-text models into ./models.
|
|
3
|
+
# Without them Ava falls back to the browser's speech recognition.
|
|
4
|
+
#
|
|
5
|
+
# ./scripts/fetch-models.sh # English (fast, 122 MB)
|
|
6
|
+
# ./scripts/fetch-models.sh english # English, most accurate (296 MB)
|
|
7
|
+
# ./scripts/fetch-models.sh multilingual # zh/en/ja/ko/yue (1 GB)
|
|
8
|
+
# ./scripts/fetch-models.sh all # english + multilingual
|
|
9
|
+
#
|
|
10
|
+
# Installed with npm? Use `npx ava-engine models` instead.
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
cd "$(dirname "$0")/.."
|
|
13
|
+
args=()
|
|
14
|
+
for a in "$@"; do case "$a" in zipformer) args+=(english) ;; sensevoice) args+=(multilingual) ;; *) args+=("$a") ;; esac; done
|
|
15
|
+
exec node lib/fetch-models.mjs "${args[@]}"
|
package/server.mjs
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { brotliCompressSync, gzipSync, constants as zc } from "node:zlib";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { dirname, join, extname, normalize, sep } from "node:path";
|
|
7
|
+
|
|
8
|
+
import { MOCK, chat, requestScope } from "./lib/openrouter.mjs";
|
|
9
|
+
import { MODELS, ROLE_LABEL, PROVIDER } from "./lib/models.mjs";
|
|
10
|
+
import { newSession, getSession, getSessionBySpecialistKey, hasCapacity, scoreView, endSession, onSpecialistRevoked } from "./lib/state.mjs";
|
|
11
|
+
import { oursTurnStream, greetStream, observeUpdate, screenOff, proactiveNudge, truncateAssistant, sessionSummary, requestHandoff, handoffStatus, cancelHandoff, bridgePoll, specialistJoin, specialistSay, specialistLeave, userSayToBridge } from "./lib/ours.mjs";
|
|
12
|
+
import { synth, TTS_ENABLED, providers, defaultProvider, warmKokoro, kokoroStream, KOKORO_STREAMS, elevenStream, ELEVEN_STREAMS, TtsBusyError } from "./lib/tts.mjs";
|
|
13
|
+
import { transcribe, STT_ENABLED } from "./lib/stt.mjs";
|
|
14
|
+
import { createAsrSession, asrAvailable, warmAsr, availableEngines, defaultEngine, ENGINE_LABELS } from "./lib/asr.mjs";
|
|
15
|
+
import { platformsView, platformById } from "./lib/platforms.mjs";
|
|
16
|
+
import { AUTH_ENABLED, isAuthorized, challengeHeaders, authCookieHeader, clientIp, sameOrigin, limiter } from "./lib/guard.mjs";
|
|
17
|
+
import { WebSocketServer } from "ws";
|
|
18
|
+
|
|
19
|
+
// A stray rejection is logged, not fatal. An uncaught exception leaves the process
|
|
20
|
+
// in an unknown state, so log it and exit — the supervisor (Fly, Docker) restarts us.
|
|
21
|
+
process.on("unhandledRejection", (e) => console.error("[unhandled rejection]", e));
|
|
22
|
+
process.on("uncaughtException", (e) => { console.error("[uncaught exception]", e); process.exit(1); });
|
|
23
|
+
|
|
24
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const PUBLIC = join(__dir, "public");
|
|
26
|
+
const PORT = Number(process.env.PORT) || 5190;
|
|
27
|
+
|
|
28
|
+
// Spend limits. Each session is capped in model spend; each client IP in request rate.
|
|
29
|
+
const SESSION_BUDGET_USD = Number(process.env.SESSION_BUDGET_USD) || 2;
|
|
30
|
+
const MAX_USER_TEXT = 2000;
|
|
31
|
+
const MAX_FRAME_BYTES = 2 * 1024 * 1024; // a downscaled JPEG screen frame is ~100-300 KB
|
|
32
|
+
const FRAME_RE = /^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/=]+$/;
|
|
33
|
+
const rate = {
|
|
34
|
+
session: limiter("session", Number(process.env.RATE_SESSIONS_PER_MIN) || 10, 60_000), // new calls per IP per minute
|
|
35
|
+
api: limiter("api", Number(process.env.RATE_API_PER_MIN) || 240, 60_000), // all other /api calls per IP per minute
|
|
36
|
+
authFail: limiter("authFail", 20, 10 * 60_000), // wrong passwords per IP per 10 minutes
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// A wrong password counts against the client; past the limit, stop checking.
|
|
40
|
+
// (A first visit with no credentials at all is just the login prompt, not a guess.)
|
|
41
|
+
const triedPassword = (req) => !!req.headers.authorization || /ava_auth=/.test(String(req.headers.cookie || ""));
|
|
42
|
+
function lockedOut(req) { return triedPassword(req) && rate.authFail.blocked(clientIp(req)); }
|
|
43
|
+
function denyAuth(req, res) {
|
|
44
|
+
if (triedPassword(req)) rate.authFail(clientIp(req));
|
|
45
|
+
return send(res, 401, "authentication required", challengeHeaders());
|
|
46
|
+
}
|
|
47
|
+
const LOCKED = "too many failed logins — try again in 10 minutes";
|
|
48
|
+
const asrPerIp = new Map(); // ip → open /asr sockets
|
|
49
|
+
const MAX_ASR_PER_IP = 3, MAX_ASR_TOTAL = Number(process.env.MAX_ASR_CONNECTIONS) || 40;
|
|
50
|
+
let asrOpen = 0;
|
|
51
|
+
|
|
52
|
+
const SECURITY_HEADERS = {
|
|
53
|
+
"X-Content-Type-Options": "nosniff",
|
|
54
|
+
"Referrer-Policy": "no-referrer", // specialist links carry a key in the query
|
|
55
|
+
"X-Frame-Options": "DENY",
|
|
56
|
+
"Permissions-Policy": "geolocation=(), payment=(), usb=(), microphone=(self), display-capture=(self)",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const MIME = {
|
|
60
|
+
".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json",
|
|
61
|
+
".svg": "image/svg+xml", ".ico": "image/x-icon", ".avif": "image/avif", ".webp": "image/webp", ".png": "image/png", ".jpg": "image/jpeg",
|
|
62
|
+
".woff2": "font/woff2",
|
|
63
|
+
};
|
|
64
|
+
const COMPRESSIBLE = new Set([".html", ".js", ".css", ".json", ".svg"]);
|
|
65
|
+
const packed = new Map(); // "path|mtime|enc" → compressed bytes, so each file is compressed once per version
|
|
66
|
+
const cspCache = new Map(); // "path|mtime" → CSP header for that HTML page
|
|
67
|
+
|
|
68
|
+
// Content-Security-Policy: same-origin everything, and inline <script>s allowed
|
|
69
|
+
// only by hash (computed per file version) — never 'unsafe-inline' for scripts.
|
|
70
|
+
function cspFor(html, key, host) {
|
|
71
|
+
let scripts = cspCache.get(key);
|
|
72
|
+
if (!scripts) {
|
|
73
|
+
const hashes = [...html.toString("utf8").matchAll(/<script>([\s\S]*?)<\/script>/g)]
|
|
74
|
+
.map((m) => `'sha256-${createHash("sha256").update(m[1]).digest("base64")}'`);
|
|
75
|
+
scripts = ["'self'", ...hashes].join(" ");
|
|
76
|
+
cspCache.set(key, scripts);
|
|
77
|
+
}
|
|
78
|
+
const ws = host ? ` ws://${host} wss://${host}` : "";
|
|
79
|
+
return [
|
|
80
|
+
"default-src 'self'", `script-src ${scripts}`, "style-src 'self' 'unsafe-inline'",
|
|
81
|
+
"img-src 'self' data: blob:", "media-src 'self' blob: mediastream:", "font-src 'self'",
|
|
82
|
+
`connect-src 'self'${ws}`, "worker-src 'self' blob:", "object-src 'none'",
|
|
83
|
+
"base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'",
|
|
84
|
+
].join("; ");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function send(res, code, body, headers = {}) {
|
|
88
|
+
const data = typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body);
|
|
89
|
+
res.writeHead(code, { "Content-Type": typeof body === "object" ? "application/json" : "text/plain", ...headers });
|
|
90
|
+
res.end(data);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Upstream errors (OpenRouter, ElevenLabs, Deepgram…) can carry account or quota
|
|
94
|
+
// detail. Log it here; the browser only gets a short, generic reason.
|
|
95
|
+
function upstreamFail(res, e, what) {
|
|
96
|
+
console.error(` [${what}]`, e?.message || e);
|
|
97
|
+
return send(res, e?.status === 429 ? 429 : 502, { error: `${what} failed` });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
class HttpError extends Error { constructor(status, msg) { super(msg); this.status = status; } }
|
|
101
|
+
|
|
102
|
+
function readRaw(req, limit = 5 * 1024 * 1024) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
let size = 0; const chunks = [];
|
|
105
|
+
req.on("data", (c) => { size += c.length; if (size > limit) { reject(new Error("body too large")); req.destroy(); } else chunks.push(c); });
|
|
106
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
107
|
+
req.on("error", reject);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// JSON bodies only: requiring the content type also blocks cross-site form posts,
|
|
112
|
+
// which can't set application/json without a CORS preflight.
|
|
113
|
+
function readBody(req, limit = 3 * 1024 * 1024) {
|
|
114
|
+
if (!/^application\/json\b/i.test(String(req.headers["content-type"] || ""))) return Promise.reject(new HttpError(415, "expected application/json"));
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
let size = 0; const chunks = [];
|
|
117
|
+
req.on("data", (c) => { size += c.length; if (size > limit) { reject(new HttpError(413, "body too large")); req.destroy(); } else chunks.push(c); });
|
|
118
|
+
req.on("end", () => {
|
|
119
|
+
try { const v = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {}; resolve(v && typeof v === "object" ? v : {}); }
|
|
120
|
+
catch { reject(new HttpError(400, "invalid JSON")); }
|
|
121
|
+
});
|
|
122
|
+
req.on("error", reject);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function safeHost(req) { const h = String(req.headers.host || ""); return /^[a-z0-9.-]+(:\d+)?$/i.test(h) ? h : ""; }
|
|
127
|
+
function validFrame(f) { return typeof f === "string" && f.length <= MAX_FRAME_BYTES && FRAME_RE.test(f) ? f : null; }
|
|
128
|
+
function overBudget(sess) { return sess.score.ours.cost >= SESSION_BUDGET_USD; }
|
|
129
|
+
|
|
130
|
+
async function serveStatic(req, res, pathname) {
|
|
131
|
+
let p;
|
|
132
|
+
try { p = decodeURIComponent(pathname); } catch { return send(res, 400, "bad request"); }
|
|
133
|
+
if (p === "/") p = "/landing.html"; // marketing landing page
|
|
134
|
+
else if (p === "/app" || p === "/app/") p = "/index.html"; // the live assistant
|
|
135
|
+
const full = normalize(join(PUBLIC, p));
|
|
136
|
+
if (!full.startsWith(PUBLIC + sep)) return send(res, 403, "forbidden");
|
|
137
|
+
try {
|
|
138
|
+
const st = await stat(full);
|
|
139
|
+
if (!st.isFile()) return send(res, 404, "not found");
|
|
140
|
+
const ext = extname(full), etag = `W/"${st.size.toString(36)}-${st.mtimeMs.toString(36)}"`;
|
|
141
|
+
// Photos, fonts and vendored libs rarely change: cache them hard. Pages and app code revalidate (cheap 304s).
|
|
142
|
+
const longLived = /^\/(img|fonts|vendor)\//.test(p) || p === "/favicon.svg";
|
|
143
|
+
const headers = {
|
|
144
|
+
"Content-Type": MIME[ext] || "application/octet-stream",
|
|
145
|
+
"Cache-Control": longLived ? "public, max-age=2592000, stale-while-revalidate=86400" : "no-cache",
|
|
146
|
+
ETag: etag, Vary: "Accept-Encoding",
|
|
147
|
+
...(ext === ".html" ? authCookieHeader(req) : {}),
|
|
148
|
+
};
|
|
149
|
+
if (req.headers["if-none-match"] === etag) { res.writeHead(304, headers); return res.end(); }
|
|
150
|
+
let body = await readFile(full);
|
|
151
|
+
if (ext === ".html") headers["Content-Security-Policy"] = cspFor(body, `${full}|${st.mtimeMs}`, safeHost(req));
|
|
152
|
+
const accept = String(req.headers["accept-encoding"] || "");
|
|
153
|
+
const enc = !COMPRESSIBLE.has(ext) || body.length < 1024 ? null : /\bbr\b/.test(accept) ? "br" : /\bgzip\b/.test(accept) ? "gzip" : null;
|
|
154
|
+
if (enc) {
|
|
155
|
+
const key = `${full}|${st.mtimeMs}|${enc}`;
|
|
156
|
+
let z = packed.get(key);
|
|
157
|
+
if (!z) {
|
|
158
|
+
z = enc === "br" ? brotliCompressSync(body, { params: { [zc.BROTLI_PARAM_QUALITY]: 11, [zc.BROTLI_PARAM_SIZE_HINT]: body.length } }) : gzipSync(body, { level: 9 });
|
|
159
|
+
packed.set(key, z);
|
|
160
|
+
}
|
|
161
|
+
body = z; headers["Content-Encoding"] = enc;
|
|
162
|
+
}
|
|
163
|
+
headers["Content-Length"] = body.length;
|
|
164
|
+
res.writeHead(200, headers);
|
|
165
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
166
|
+
} catch {
|
|
167
|
+
send(res, 404, "not found");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Pages that open a call (and everything they talk to) sit behind ACCESS_PASSWORD
|
|
172
|
+
// when it's set. The landing page and its assets stay public.
|
|
173
|
+
function needsAuth(pathname) {
|
|
174
|
+
return pathname.startsWith("/api/") && pathname !== "/api/health"
|
|
175
|
+
|| pathname === "/app" || pathname === "/app/" || pathname === "/index.html"
|
|
176
|
+
|| pathname === "/specialist" || pathname === "/specialist.html";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Aborts when the client goes away before we've answered (not after a normal finish).
|
|
180
|
+
function clientGone(res) {
|
|
181
|
+
const ac = new AbortController();
|
|
182
|
+
res.once("close", () => { if (!res.writableFinished) ac.abort(); });
|
|
183
|
+
return ac.signal;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const server = http.createServer((req, res) => requestScope.run({ signal: clientGone(res) }, () => handle(req, res)));
|
|
187
|
+
|
|
188
|
+
async function handle(req, res) {
|
|
189
|
+
for (const [k, v] of Object.entries(SECURITY_HEADERS)) res.setHeader(k, v);
|
|
190
|
+
let url;
|
|
191
|
+
try { url = new URL(req.url, "http://x"); } catch { return send(res, 400, "bad request"); }
|
|
192
|
+
if (needsAuth(url.pathname)) {
|
|
193
|
+
// Locked-out clients get no answer about any password, right or wrong.
|
|
194
|
+
if (lockedOut(req)) return send(res, 429, LOCKED, { "Retry-After": "600" });
|
|
195
|
+
if (!isAuthorized(req)) return denyAuth(req, res);
|
|
196
|
+
}
|
|
197
|
+
if (url.pathname.startsWith("/api/") && url.pathname !== "/api/health" && !rate.api(clientIp(req))) {
|
|
198
|
+
return send(res, 429, { error: "too many requests — slow down" }, { "Retry-After": "30" });
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
if (req.method === "GET" && url.pathname === "/api/health") {
|
|
202
|
+
return send(res, 200, {
|
|
203
|
+
mock: MOCK,
|
|
204
|
+
models: { talker: MODELS.talker, perception: MODELS.perception, planner: MODELS.planner, intent: MODELS.intent },
|
|
205
|
+
labels: ROLE_LABEL,
|
|
206
|
+
tts: { enabled: TTS_ENABLED, providers: providers(), default: defaultProvider() },
|
|
207
|
+
stt: { enabled: STT_ENABLED, streaming: asrAvailable(), engines: availableEngines().map((id) => ({ id, label: ENGINE_LABELS[id] || id })), defaultEngine: defaultEngine() },
|
|
208
|
+
modes: [
|
|
209
|
+
{ id: "flexible", name: "Flexible agent", tagline: "Sees your screen and helps with anything you're doing." },
|
|
210
|
+
{ id: "onboarding", name: "Onboarding agent", tagline: "Focused on getting a new user into their dashboard on a chosen platform." },
|
|
211
|
+
],
|
|
212
|
+
platforms: platformsView(),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// End-of-call recap for the session report.
|
|
217
|
+
if (req.method === "POST" && url.pathname === "/api/summary") {
|
|
218
|
+
const { sessionId } = await readBody(req);
|
|
219
|
+
const sess = getSession(sessionId);
|
|
220
|
+
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
221
|
+
try { return send(res, 200, await sessionSummary(sess)); }
|
|
222
|
+
catch (e) { return upstreamFail(res, e, "summary"); }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The user barged in — rewrite Ava's last reply to what was actually heard.
|
|
226
|
+
if (req.method === "POST" && url.pathname === "/api/truncate") {
|
|
227
|
+
const { sessionId, heard = "" } = await readBody(req);
|
|
228
|
+
const sess = getSession(sessionId);
|
|
229
|
+
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
230
|
+
return send(res, 200, truncateAssistant(sess, String(heard).slice(0, 2000)));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (req.method === "POST" && url.pathname === "/api/stt") {
|
|
234
|
+
if (!STT_ENABLED) return send(res, 503, { error: "stt disabled" });
|
|
235
|
+
const type = String(req.headers["content-type"] || "");
|
|
236
|
+
if (!/^audio\//i.test(type)) return send(res, 415, { error: "expected audio/*" });
|
|
237
|
+
const buf = await readRaw(req);
|
|
238
|
+
try { return send(res, 200, { text: await transcribe(buf, type) }); }
|
|
239
|
+
catch (e) { return upstreamFail(res, e, "speech-to-text"); }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (req.method === "POST" && url.pathname === "/api/tts") {
|
|
243
|
+
const { text, provider = defaultProvider() } = await readBody(req);
|
|
244
|
+
if (typeof text !== "string" || !text.trim()) return send(res, 400, { error: "text required" });
|
|
245
|
+
// Streaming providers (Kokoro, ElevenLabs) emit clause-by-clause: each frame
|
|
246
|
+
// is a 4-byte big-endian length + a complete audio clip (WAV for Kokoro, MP3
|
|
247
|
+
// for ElevenLabs — the client decodes both). The client plays after frame
|
|
248
|
+
// one, so time-to-first-audio is one short chunk, not the whole reply.
|
|
249
|
+
const streamFn = (provider === "kokoro" && KOKORO_STREAMS) ? kokoroStream
|
|
250
|
+
: (provider === "elevenlabs" && ELEVEN_STREAMS) ? elevenStream : null;
|
|
251
|
+
if (streamFn) {
|
|
252
|
+
try {
|
|
253
|
+
const t0 = Date.now();
|
|
254
|
+
let first = 0;
|
|
255
|
+
// Frame: [4-byte meta length][meta JSON {t: chunk, n: audio bytes}][audio]
|
|
256
|
+
// The chunk text rides with its audio so the client knows exactly which
|
|
257
|
+
// chunks were HEARD when the user barges in.
|
|
258
|
+
let started = false;
|
|
259
|
+
await streamFn(text, (audio, chunk) => {
|
|
260
|
+
if (res.destroyed) throw new Error("client went away"); // stop synthesizing for nobody
|
|
261
|
+
if (!started) { started = true; res.writeHead(200, { "Content-Type": "application/x-wav-stream", "Cache-Control": "no-store", "X-Accel-Buffering": "no", "X-TTS-Provider": provider }); }
|
|
262
|
+
if (!first) { first = Date.now() - t0; console.log(` ${provider} first chunk in ${first}ms`); }
|
|
263
|
+
const meta = Buffer.from(JSON.stringify({ t: chunk, n: audio.length }));
|
|
264
|
+
const head = Buffer.alloc(4); head.writeUInt32BE(meta.length, 0);
|
|
265
|
+
res.write(head); res.write(meta); res.write(audio);
|
|
266
|
+
});
|
|
267
|
+
res.end();
|
|
268
|
+
} catch (e) {
|
|
269
|
+
if (res.destroyed) return;
|
|
270
|
+
if (!res.headersSent) return e instanceof TtsBusyError ? send(res, 429, { error: "voice busy" }, { "Retry-After": "2" }) : upstreamFail(res, e, "voice");
|
|
271
|
+
try { res.end(); } catch {}
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const { audio, chars, latencyMs, model, mime } = await synth(provider, text);
|
|
277
|
+
return send(res, 200, audio, { "Content-Type": mime || "audio/mpeg", "X-TTS-Chars": String(chars), "X-TTS-Latency": String(latencyMs), "X-TTS-Model": model, "X-TTS-Provider": provider });
|
|
278
|
+
} catch (e) {
|
|
279
|
+
if (e instanceof TtsBusyError) return send(res, 429, { error: "voice busy" }, { "Retry-After": "2" });
|
|
280
|
+
if (e.status === 400) return send(res, 400, { error: e.message });
|
|
281
|
+
return upstreamFail(res, e, "voice");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (req.method === "POST" && url.pathname === "/api/session") {
|
|
286
|
+
if (!rate.session(clientIp(req))) return send(res, 429, { error: "too many new sessions — try again in a minute" }, { "Retry-After": "60" });
|
|
287
|
+
if (!hasCapacity()) return send(res, 503, { error: "Ava is at capacity right now — try again in a few minutes" }, { "Retry-After": "60" });
|
|
288
|
+
const body = await readBody(req);
|
|
289
|
+
const mode = body.mode === "onboarding" ? "onboarding" : "flexible";
|
|
290
|
+
const platform = typeof body.platformId === "string" ? platformById(body.platformId) : null; // knowledge in any mode
|
|
291
|
+
const s = newSession({ mode, platform });
|
|
292
|
+
if (!s) return send(res, 503, { error: "Ava is at capacity right now — try again in a few minutes" }, { "Retry-After": "60" });
|
|
293
|
+
// pre-warm the talker route so the greeting isn't a cold start
|
|
294
|
+
if (!MOCK) chat({ model: MODELS.talker, role: "talker", provider: PROVIDER.talker, messages: [{ role: "user", content: "hi" }], maxTokens: 1 }).catch(() => {});
|
|
295
|
+
return send(res, 200, { sessionId: s.id, mock: MOCK, models: MODELS, mode, platform: platform ? { id: platform.id, name: platform.name, url: platform.url } : null });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (req.method === "POST" && url.pathname === "/api/turn") {
|
|
299
|
+
const body = await readBody(req);
|
|
300
|
+
const { sessionId, frameHash = null, greet = false, voice = false, changed = false, screenActive = false } = body;
|
|
301
|
+
const sess = getSession(sessionId);
|
|
302
|
+
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
303
|
+
if (overBudget(sess)) return send(res, 402, { error: "this session reached its spend limit — start a new call" });
|
|
304
|
+
const userText = String(body.userText ?? "").slice(0, MAX_USER_TEXT);
|
|
305
|
+
const frame = validFrame(body.frame);
|
|
306
|
+
|
|
307
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
|
|
308
|
+
const emit = (ev, data) => { if (res.destroyed) return; try { res.write(`event: ${ev}\ndata: ${JSON.stringify(data)}\n\n`); } catch {} };
|
|
309
|
+
try {
|
|
310
|
+
if (greet) await greetStream(sess, emit, !!voice);
|
|
311
|
+
else await oursTurnStream(sess, userText, frame, !!screenActive, frameHash, emit, !!voice, !!changed);
|
|
312
|
+
} catch (e) {
|
|
313
|
+
console.error(" [turn]", e?.message || e);
|
|
314
|
+
emit("error", { error: "Ava couldn't answer that one — try again" });
|
|
315
|
+
}
|
|
316
|
+
res.end();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (req.method === "POST" && url.pathname === "/api/observe") {
|
|
321
|
+
const { sessionId, frame: rawFrame, frameHash = null, stopped = false, changed = false } = await readBody(req);
|
|
322
|
+
const sess = getSession(sessionId);
|
|
323
|
+
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
324
|
+
if (stopped) return send(res, 200, screenOff(sess)); // user stopped sharing → mark state stale now
|
|
325
|
+
if (overBudget(sess)) return send(res, 402, { error: "spend limit reached" });
|
|
326
|
+
const frame = validFrame(rawFrame);
|
|
327
|
+
if (!frame) return send(res, 400, { error: "no frame" });
|
|
328
|
+
try { return send(res, 200, await observeUpdate(sess, frame, frameHash, !!changed)); }
|
|
329
|
+
catch (e) { return upstreamFail(res, e, "screen read"); }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Specialist console page (opened at /specialist?key=<specialist key>).
|
|
333
|
+
if (req.method === "GET" && url.pathname === "/specialist") {
|
|
334
|
+
try {
|
|
335
|
+
const full = join(PUBLIC, "specialist.html"), html = await readFile(full), st = await stat(full);
|
|
336
|
+
return send(res, 200, html, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Content-Security-Policy": cspFor(html, `${full}|${st.mtimeMs}`, safeHost(req)), ...authCookieHeader(req) });
|
|
337
|
+
}
|
|
338
|
+
catch { return send(res, 404, "not found"); }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Live bridge between the user's call and a human specialist. The user is
|
|
342
|
+
// identified by sessionId; the specialist only ever holds the specialist key
|
|
343
|
+
// minted when the user asked for a person (revoked when the handoff ends).
|
|
344
|
+
if (req.method === "GET" && url.pathname === "/api/bridge") {
|
|
345
|
+
const key = url.searchParams.get("key");
|
|
346
|
+
const sess = key ? getSessionBySpecialistKey(key) : getSession(url.searchParams.get("sessionId"));
|
|
347
|
+
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
348
|
+
return send(res, 200, bridgePoll(sess, Number(url.searchParams.get("since")) || 0));
|
|
349
|
+
}
|
|
350
|
+
if (req.method === "POST" && url.pathname === "/api/bridge") {
|
|
351
|
+
const { sessionId, key, action = "say", text = "" } = await readBody(req);
|
|
352
|
+
if (action === "usersay") {
|
|
353
|
+
const sess = getSession(sessionId);
|
|
354
|
+
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
355
|
+
return send(res, 200, userSayToBridge(sess, String(text).slice(0, MAX_USER_TEXT)));
|
|
356
|
+
}
|
|
357
|
+
const sess = getSessionBySpecialistKey(key);
|
|
358
|
+
if (!sess) return send(res, 404, { error: "unknown or expired link" });
|
|
359
|
+
if (action === "join") { const r = specialistJoin(sess); return r ? send(res, 200, r) : send(res, 409, { error: "no handoff requested" }); }
|
|
360
|
+
if (action === "leave") return send(res, 200, specialistLeave(sess));
|
|
361
|
+
if (action === "say") return send(res, 200, specialistSay(sess, String(text).slice(0, MAX_USER_TEXT)));
|
|
362
|
+
return send(res, 400, { error: "unknown action" });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// The customer hung up: end any handoff and free the session slot right away.
|
|
366
|
+
if (req.method === "POST" && url.pathname === "/api/end") {
|
|
367
|
+
const { sessionId } = await readBody(req);
|
|
368
|
+
return send(res, 200, { ended: endSession(sessionId) });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Human handoff — request a real person, poll the connect state.
|
|
372
|
+
if (req.method === "POST" && url.pathname === "/api/handoff") {
|
|
373
|
+
const { sessionId, action = "request" } = await readBody(req);
|
|
374
|
+
const sess = getSession(sessionId);
|
|
375
|
+
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
376
|
+
if (action === "cancel") return send(res, 200, cancelHandoff(sess));
|
|
377
|
+
if (action === "status") return send(res, 200, handoffStatus(sess));
|
|
378
|
+
return send(res, 200, requestHandoff(sess));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (req.method === "POST" && url.pathname === "/api/nudge") {
|
|
382
|
+
const { sessionId } = await readBody(req);
|
|
383
|
+
const sess = getSession(sessionId);
|
|
384
|
+
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
385
|
+
if (overBudget(sess)) return send(res, 402, { error: "spend limit reached" });
|
|
386
|
+
try { return send(res, 200, await proactiveNudge(sess)); }
|
|
387
|
+
catch (e) { return upstreamFail(res, e, "nudge"); }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (req.method === "GET" && url.pathname === "/api/scoreboard") {
|
|
391
|
+
const sess = getSession(url.searchParams.get("sessionId"));
|
|
392
|
+
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
393
|
+
return send(res, 200, { ours: scoreView(sess.score.ours) });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (req.method === "GET" || req.method === "HEAD") return await serveStatic(req, res, url.pathname);
|
|
397
|
+
return send(res, 405, "method not allowed");
|
|
398
|
+
} catch (e) {
|
|
399
|
+
if (res.headersSent) { try { res.end(); } catch {} return; }
|
|
400
|
+
if (e instanceof HttpError) return send(res, e.status, { error: e.message });
|
|
401
|
+
console.error(" [request]", req.method, url.pathname, e);
|
|
402
|
+
return send(res, 500, { error: "internal error" });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── /asr WebSocket: browser streams echo-cancelled 16kHz Int16 PCM; we stream
|
|
407
|
+
// back {partial|final} transcripts from the offline sherpa recognizer. ──
|
|
408
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 }); // ~1s of 16 kHz PCM is 32 KB
|
|
409
|
+
// ── /rtc WebSocket: signaling relay between the two peers (user ↔ specialist)
|
|
410
|
+
// of one session, so they can open a WebRTC connection (screen video + voice). ──
|
|
411
|
+
const rtcWss = new WebSocketServer({ noServer: true, maxPayload: 256 * 1024 }); // SDP + ICE only
|
|
412
|
+
const rtcRooms = new Map(); // sessionId → { user: ws|null, specialist: ws|null }
|
|
413
|
+
// A revoked handoff link must also cut the specialist's live call, not just future requests.
|
|
414
|
+
onSpecialistRevoked((sess) => {
|
|
415
|
+
const ws = rtcRooms.get(sess.id)?.specialist;
|
|
416
|
+
if (ws) { try { ws.close(4403, "handoff ended"); } catch {} }
|
|
417
|
+
});
|
|
418
|
+
server.on("upgrade", (req, socket, head) => {
|
|
419
|
+
let path;
|
|
420
|
+
try { path = new URL(req.url, "http://x").pathname; } catch { return socket.destroy(); }
|
|
421
|
+
const reject = (code, msg) => { try { socket.write(`HTTP/1.1 ${code} ${msg}\r\nConnection: close\r\n\r\n`); } catch {} socket.destroy(); };
|
|
422
|
+
if (!sameOrigin(req)) return reject(403, "Forbidden");
|
|
423
|
+
if (lockedOut(req)) return reject(429, "Too Many Requests");
|
|
424
|
+
if (!isAuthorized(req)) { if (triedPassword(req)) rate.authFail(clientIp(req)); return reject(401, "Unauthorized"); }
|
|
425
|
+
if (path === "/asr") {
|
|
426
|
+
const ip = clientIp(req);
|
|
427
|
+
if (asrOpen >= MAX_ASR_TOTAL || (asrPerIp.get(ip) || 0) >= MAX_ASR_PER_IP) return reject(503, "Service Unavailable");
|
|
428
|
+
}
|
|
429
|
+
if (path === "/asr") return wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
|
|
430
|
+
if (path === "/rtc") return rtcWss.handleUpgrade(req, socket, head, (ws) => rtcWss.emit("connection", ws, req));
|
|
431
|
+
socket.destroy();
|
|
432
|
+
});
|
|
433
|
+
rtcWss.on("connection", (ws, req) => {
|
|
434
|
+
const u = new URL(req.url, "http://x");
|
|
435
|
+
const role = u.searchParams.get("role") === "specialist" ? "specialist" : "user";
|
|
436
|
+
const other = role === "user" ? "specialist" : "user";
|
|
437
|
+
// The user proves the call with its session id, the specialist with the handoff key.
|
|
438
|
+
const sess = role === "specialist" ? getSessionBySpecialistKey(u.searchParams.get("key")) : getSession(u.searchParams.get("session"));
|
|
439
|
+
if (!sess) { try { ws.close(4404, "unknown session"); } catch {} return; }
|
|
440
|
+
const session = sess.id;
|
|
441
|
+
let room = rtcRooms.get(session);
|
|
442
|
+
if (!room) { room = { user: null, specialist: null }; rtcRooms.set(session, room); }
|
|
443
|
+
if (room[role] && room[role] !== ws) { try { room[role].close(); } catch {} }
|
|
444
|
+
room[role] = ws;
|
|
445
|
+
const j = (w, m) => { try { if (w && w.readyState === 1) w.send(JSON.stringify(m)); } catch {} };
|
|
446
|
+
j(ws, { type: "peer", present: !!room[other] }); // tell me if my peer is already here
|
|
447
|
+
j(room[other], { type: "peer", present: true, joined: role }); // tell my peer I arrived
|
|
448
|
+
ws.on("message", (data, isBinary) => { const w = room[other]; if (w && w.readyState === 1) { try { w.send(data, { binary: isBinary }); } catch {} } });
|
|
449
|
+
ws.on("close", () => {
|
|
450
|
+
if (room[role] !== ws) return; // replaced by a newer socket — its peer is still present
|
|
451
|
+
room[role] = null;
|
|
452
|
+
j(room[other], { type: "peer", present: false, left: role });
|
|
453
|
+
if (!room.user && !room.specialist) rtcRooms.delete(session);
|
|
454
|
+
});
|
|
455
|
+
ws.on("error", () => {});
|
|
456
|
+
});
|
|
457
|
+
wss.on("connection", (ws, req) => {
|
|
458
|
+
const engine = new URL(req.url, "http://x").searchParams.get("engine") || undefined;
|
|
459
|
+
const session = createAsrSession(engine);
|
|
460
|
+
if (!session) { ws.send(JSON.stringify({ type: "error", error: "asr unavailable" })); ws.close(); return; }
|
|
461
|
+
const ip = clientIp(req);
|
|
462
|
+
asrOpen += 1; asrPerIp.set(ip, (asrPerIp.get(ip) || 0) + 1);
|
|
463
|
+
ws.once("close", () => { asrOpen -= 1; const n = (asrPerIp.get(ip) || 1) - 1; if (n > 0) asrPerIp.set(ip, n); else asrPerIp.delete(ip); });
|
|
464
|
+
try { ws.send(JSON.stringify({ type: "ready", engine: session.engine })); } catch {}
|
|
465
|
+
ws.on("message", (data, isBinary) => {
|
|
466
|
+
if (!isBinary) return; // no control messages yet
|
|
467
|
+
try {
|
|
468
|
+
const out = session.feed(data);
|
|
469
|
+
if (out.partial) ws.send(JSON.stringify({ type: "partial", text: out.partial }));
|
|
470
|
+
if (out.final) ws.send(JSON.stringify({ type: "final", text: out.final }));
|
|
471
|
+
} catch (e) {
|
|
472
|
+
console.error(" [asr]", e?.message || e);
|
|
473
|
+
try { ws.send(JSON.stringify({ type: "error", error: "transcription failed" })); } catch {}
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
ws.on("close", () => session.close());
|
|
477
|
+
ws.on("error", () => {});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// HOST=127.0.0.1 keeps Ava private to this computer (the CLI's "just me" setup).
|
|
481
|
+
const HOST = process.env.HOST || undefined;
|
|
482
|
+
const LOCAL_ONLY = HOST === "127.0.0.1" || HOST === "localhost" || HOST === "::1";
|
|
483
|
+
|
|
484
|
+
server.on("error", (e) => {
|
|
485
|
+
if (e.code === "EADDRINUSE") console.error(`\n ✗ Port ${PORT} is already in use. Stop whatever is using it, or pick another port (PORT=5191, or \`ava-engine start --port 5191\`).\n`);
|
|
486
|
+
else if (e.code === "EACCES") console.error(`\n ✗ No permission to use port ${PORT}. Pick a port above 1024.\n`);
|
|
487
|
+
else console.error(" ✗ server error:", e);
|
|
488
|
+
process.exit(1);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
server.listen(PORT, HOST, () => {
|
|
492
|
+
console.log(`\n Ava — screen-aware voice agent`);
|
|
493
|
+
console.log(` → http://localhost:${PORT}/app`);
|
|
494
|
+
console.log(` mode: ${MOCK ? "MOCK (no OPENROUTER_API_KEY set — synthetic replies)" : "LIVE (OpenRouter)"}`);
|
|
495
|
+
if (!MOCK) console.log(` models: talker=${MODELS.talker} perception=${MODELS.perception} planner=${MODELS.planner} intent=${MODELS.intent}`);
|
|
496
|
+
if (LOCAL_ONLY) console.log(" access: this computer only");
|
|
497
|
+
else if (!AUTH_ENABLED) console.log(" access: OPEN — anyone who can reach this server can spend your API credit. Set ACCESS_PASSWORD before exposing it.");
|
|
498
|
+
else console.log(" access: password protected");
|
|
499
|
+
console.log("");
|
|
500
|
+
warmKokoro(); // preload the offline voice so the first reply is fast
|
|
501
|
+
warmAsr(); // preload the offline ear so the first utterance is fast
|
|
502
|
+
});
|