diff --git a/.env.example b/.env.example index 2a54795..d79aa78 100644 --- a/.env.example +++ b/.env.example @@ -140,3 +140,14 @@ AISSTREAM_API_KEY= # CCTV_FORCE_AUSTIN= # force Austin-only sources # CCTV_AUTO_CALIBRATE=1 # auto-calibrate camera projection (client flag) # CCTV_DRAPE_MESH=1 # drape frames onto 3D geometry (client flag) + +# ── Local voice provider (issue #212) ───────────────────────────────────────── +# Drive the same 28 voice tools WITHOUT an OpenAI key: the browser transcribes +# (Web Speech API, needs HTTPS on iOS) and speaks; a local Ollama model or the +# Anthropic API picks the tool calls. auto = Anthropic if ANTHROPIC_API_KEY else Ollama if OLLAMA_URL. +# LOCAL_VOICE_PROVIDER=auto +# OLLAMA_URL=http://127.0.0.1:11434 +# OLLAMA_MODEL=qwen3 +# ANTHROPIC_API_KEY= +# ANTHROPIC_MODEL=claude-sonnet-5 +# LOCAL_VOICE_LANG=en-US diff --git a/server/localVoice.js b/server/localVoice.js new file mode 100644 index 0000000..3ecb953 --- /dev/null +++ b/server/localVoice.js @@ -0,0 +1,255 @@ +/** + * Local voice provider for God's Eye View (Solendra, 9 Sep 2026). + * + * Answers upstream issue #212: drive the SAME 28 voice tools with a local LLM + * (Ollama) or Anthropic instead of the OpenAI Realtime API. The browser does + * speech-to-text (Web Speech API) and text-to-speech (speechSynthesis); this + * module only turns a transcript + tool schemas into tool calls. + * + * Endpoints (installed by installLocalVoiceMiddleware): + * GET /api/local-voice/status → { configured, provider, model, lang } + * POST /api/local-voice/turn → { text, toolCalls:[{ id, name, args }] } + * + * Conversation format is provider-neutral so the client keeps ONE history: + * { role:'user', content } + * { role:'assistant', content, toolCalls:[{ id, name, args }] } + * { role:'tool', id, name, content } (content = JSON string of the result) + * + * Env: LOCAL_VOICE_PROVIDER=ollama|anthropic|auto (default auto), + * OLLAMA_URL (default http://127.0.0.1:11434), OLLAMA_MODEL (default qwen3), + * ANTHROPIC_API_KEY, ANTHROPIC_MODEL (default claude-sonnet-5), + * LOCAL_VOICE_LANG (BCP-47 hint for the browser recognizer, default navigator language). + */ + +const MAX_HISTORY = 24; +const MAX_BODY_BYTES = 256 * 1024; +const DEFAULT_OLLAMA_URL = 'http://127.0.0.1:11434'; +const DEFAULT_OLLAMA_MODEL = 'qwen3'; +const DEFAULT_ANTHROPIC_MODEL = 'claude-sonnet-5'; +const ANTHROPIC_VERSION = '2023-06-01'; + +export function resolveLocalVoiceProvider(env = process.env) { + const requested = String(env.LOCAL_VOICE_PROVIDER || 'auto').trim().toLowerCase(); + const anthropicKey = String(env.ANTHROPIC_API_KEY || '').trim(); + const ollamaUrl = String(env.OLLAMA_URL || '').trim(); + const lang = String(env.LOCAL_VOICE_LANG || '').trim() || null; + let provider = null; + if (requested === 'anthropic' && anthropicKey) provider = 'anthropic'; + else if (requested === 'ollama' && (ollamaUrl || requested === 'ollama')) provider = 'ollama'; + else if (requested === 'auto') provider = anthropicKey ? 'anthropic' : (ollamaUrl ? 'ollama' : null); + if (!provider) return { configured: false, provider: null, model: null, lang }; + if (provider === 'anthropic') { + return { configured: true, provider, model: String(env.ANTHROPIC_MODEL || DEFAULT_ANTHROPIC_MODEL).trim(), lang, apiKey: anthropicKey }; + } + return { + configured: true, + provider, + model: String(env.OLLAMA_MODEL || DEFAULT_OLLAMA_MODEL).trim(), + lang, + url: (ollamaUrl || DEFAULT_OLLAMA_URL).replace(/\/+$/, ''), + }; +} + +/** Realtime-style tool ({type:'function',name,description,parameters}) → Ollama/OpenAI chat tool. */ +export function toOllamaTools(tools) { + return (tools || []).map((t) => ({ + type: 'function', + function: { name: t.name, description: t.description || '', parameters: t.parameters || { type: 'object', properties: {} } }, + })); +} + +/** Realtime-style tool → Anthropic tool. */ +export function toAnthropicTools(tools) { + return (tools || []).map((t) => ({ + name: t.name, + description: t.description || '', + input_schema: t.parameters || { type: 'object', properties: {} }, + })); +} + +export function sanitizeHistory(messages) { + if (!Array.isArray(messages)) return []; + const clean = []; + for (const m of messages) { + if (!m || typeof m !== 'object') continue; + const role = m.role === 'assistant' || m.role === 'tool' ? m.role : 'user'; + const content = typeof m.content === 'string' ? m.content.slice(0, 8000) : ''; + if (role === 'assistant') { + const toolCalls = Array.isArray(m.toolCalls) + ? m.toolCalls + .filter((c) => c && typeof c.name === 'string') + .map((c, i) => ({ id: String(c.id || `call_${i}`), name: c.name, args: c.args && typeof c.args === 'object' ? c.args : {} })) + : []; + clean.push({ role, content, toolCalls }); + } else if (role === 'tool') { + clean.push({ role, id: String(m.id || ''), name: String(m.name || ''), content }); + } else { + clean.push({ role, content }); + } + } + return clean.slice(-MAX_HISTORY); +} + +function toOllamaMessages(instructions, history) { + const out = [{ role: 'system', content: instructions }]; + for (const m of history) { + if (m.role === 'assistant') { + const msg = { role: 'assistant', content: m.content || '' }; + if (m.toolCalls.length) msg.tool_calls = m.toolCalls.map((c) => ({ function: { name: c.name, arguments: c.args } })); + out.push(msg); + } else if (m.role === 'tool') { + out.push({ role: 'tool', content: m.content || '{}' }); + } else { + out.push({ role: 'user', content: m.content }); + } + } + return out; +} + +function toAnthropicMessages(history) { + const out = []; + let pendingResults = null; + const flushResults = () => { + if (pendingResults && pendingResults.length) out.push({ role: 'user', content: pendingResults }); + pendingResults = null; + }; + for (const m of history) { + if (m.role === 'tool') { + if (!pendingResults) pendingResults = []; + pendingResults.push({ type: 'tool_result', tool_use_id: m.id, content: m.content || '{}' }); + continue; + } + flushResults(); + if (m.role === 'assistant') { + const blocks = []; + if (m.content) blocks.push({ type: 'text', text: m.content }); + for (const c of m.toolCalls) blocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.args }); + if (blocks.length) out.push({ role: 'assistant', content: blocks }); + } else { + out.push({ role: 'user', content: [{ type: 'text', text: m.content || '…' }] }); + } + } + flushResults(); + // Anthropic requires alternating roles starting with user; merge adjacent same-role messages. + const merged = []; + for (const m of out) { + const last = merged[merged.length - 1]; + if (last && last.role === m.role) last.content = last.content.concat(m.content); + else merged.push(m); + } + if (merged.length && merged[0].role !== 'user') merged.unshift({ role: 'user', content: [{ type: 'text', text: '…' }] }); + return merged; +} + +async function ollamaTurn(config, instructions, tools, history) { + const response = await fetch(`${config.url}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: config.model, + stream: false, + think: false, + messages: toOllamaMessages(instructions, history), + tools: toOllamaTools(tools), + options: { temperature: 0.2 }, + }), + }); + if (!response.ok) throw new Error(`Ollama ${response.status}: ${(await response.text()).slice(0, 200)}`); + const data = await response.json(); + const message = data?.message || {}; + const toolCalls = Array.isArray(message.tool_calls) + ? message.tool_calls.map((c, i) => ({ + id: String(c.id || `call_${Date.now()}_${i}`), + name: c.function?.name || '', + args: parseArgs(c.function?.arguments), + })).filter((c) => c.name) + : []; + return { text: String(message.content || '').trim(), toolCalls, model: data?.model || config.model }; +} + +async function anthropicTurn(config, instructions, tools, history) { + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': config.apiKey, + 'anthropic-version': ANTHROPIC_VERSION, + }, + body: JSON.stringify({ + model: config.model, + max_tokens: 600, + system: instructions, + tools: toAnthropicTools(tools), + messages: toAnthropicMessages(history), + }), + }); + if (!response.ok) throw new Error(`Anthropic ${response.status}: ${(await response.text()).slice(0, 200)}`); + const data = await response.json(); + const blocks = Array.isArray(data?.content) ? data.content : []; + const text = blocks.filter((b) => b.type === 'text').map((b) => b.text).join(' ').trim(); + const toolCalls = blocks.filter((b) => b.type === 'tool_use').map((b) => ({ id: b.id, name: b.name, args: b.input || {} })); + return { text, toolCalls, model: data?.model || config.model }; +} + +function parseArgs(value) { + if (value && typeof value === 'object') return value; + if (typeof value === 'string' && value.trim()) { + try { return JSON.parse(value); } catch { return {}; } + } + return {}; +} + +async function readJson(req, maxBytes) { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > maxBytes) throw new Error('body too large'); + chunks.push(chunk); + } + const text = Buffer.concat(chunks).toString('utf8'); + return text ? JSON.parse(text) : {}; +} + +function sendJson(res, status, payload) { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(payload)); +} + +/** + * @param {import('connect').Server} middlewares Vite's connect app. + * @param {{ tools: object[], instructions: string, env?: object }} options + */ +export function installLocalVoiceMiddleware(middlewares, { tools, instructions, env = process.env }) { + const localInstructions = `${instructions}\n` + + 'You are running on a local/private model, not OpenAI. Reply in the language the operator speaks (Spanish when they speak Spanish). ' + + 'Keep spoken replies to one short sentence. When a tool is needed, call it; after tool results, confirm in one sentence.'; + + middlewares.use('/api/local-voice/status', (req, res) => { + const config = resolveLocalVoiceProvider(env); + sendJson(res, 200, { configured: config.configured, provider: config.provider, model: config.model, lang: config.lang }); + }); + + middlewares.use('/api/local-voice/turn', async (req, res) => { + if (req.method !== 'POST') return sendJson(res, 405, { error: 'Method not allowed' }); + const config = resolveLocalVoiceProvider(env); + if (!config.configured) return sendJson(res, 503, { error: 'No local voice provider configured (set OLLAMA_URL or ANTHROPIC_API_KEY)' }); + let body; + try { + body = await readJson(req, MAX_BODY_BYTES); + } catch (error) { + return sendJson(res, 400, { error: `Bad request: ${error.message}` }); + } + const history = sanitizeHistory(body.messages); + if (!history.length) return sendJson(res, 400, { error: 'messages required' }); + try { + const turn = config.provider === 'anthropic' + ? await anthropicTurn(config, localInstructions, tools, history) + : await ollamaTurn(config, localInstructions, tools, history); + sendJson(res, 200, { ...turn, provider: config.provider }); + } catch (error) { + sendJson(res, 502, { error: String(error?.message || error).slice(0, 300), provider: config.provider }); + } + }); +} diff --git a/src/voice/gevRealtime.js b/src/voice/gevRealtime.js index ab566fe..ea1bead 100644 --- a/src/voice/gevRealtime.js +++ b/src/voice/gevRealtime.js @@ -1,4 +1,5 @@ import { createGevActionRunner, readLayerLifecycleSummary } from './gevActions.js'; +import { LocalVoiceController, isLocalVoiceSupported } from './localVoice.js'; import { DEFAULT_VOICE_TIER, VOICE_COST_LIMITS, @@ -222,9 +223,34 @@ export function initGevVoiceCommands({ viewer, styleManager, dataManager, sceneD controller.syncCostUi(); controller.bindPushToTalkShortcut(); window.__gevVoiceCommands = controller; + // Local provider (issue #212): when the server has OLLAMA_URL / ANTHROPIC_API_KEY + // and the browser can transcribe, the same UI drives LocalVoiceController instead. + probeLocalVoiceProvider().then((status) => { + if (!status?.configured || !isLocalVoiceSupported()) return; + if (controller.isActive()) return; + ui.button.removeEventListener('click', controller.buttonHandler); + if (ui.tierButton && controller.tierHandler) ui.tierButton.removeEventListener('click', controller.tierHandler); + const local = new LocalVoiceController({ runner, ui, dataManager, status }); + local.buttonHandler = () => { if (local.isActive()) local.stop(); else local.start(); }; + ui.button.addEventListener('click', local.buttonHandler); + local.syncCostUi(); + local.bindPushToTalkShortcut(); + window.__gevVoiceCommands = local; + if (window.__godsEyeView) window.__godsEyeView.voiceCommands = local; + }).catch(() => {}); return controller; } +async function probeLocalVoiceProvider() { + try { + const response = await fetch('/api/local-voice/status', { cache: 'no-store' }); + if (!response.ok) return null; + return await response.json(); + } catch { + return null; + } +} + export class GevRealtimeController { constructor({ runner, ui, radioLayer = null, dataManager = null }) { this.runner = runner; diff --git a/src/voice/localVoice.js b/src/voice/localVoice.js new file mode 100644 index 0000000..e01c619 --- /dev/null +++ b/src/voice/localVoice.js @@ -0,0 +1,298 @@ +/** + * Local voice controller (Solendra, 9 Sep 2026) — the browser half of the + * local provider answering upstream issue #212. + * + * Ear = Web Speech API (SpeechRecognition; works on iPhone Safari over HTTPS) + * Brain = /api/local-voice/turn (Ollama or Anthropic, see server/localVoice.js) + * Hands = the SAME runner as the OpenAI session (createGevActionRunner → 28 tools) + * Mouth = speechSynthesis + * + * It mirrors the public surface of GevRealtimeController that main.js and the + * voice UI touch: isActive/start/stop/sendTextCommand/notifyMapEvent/ + * syncCostUi/bindPushToTalkShortcut/toggleVoiceTier/getDiagnostics. + */ + +const STATUS = { + idle: 'OFF', + connecting: 'CONNECTING', + listening: 'LISTENING', + executing: 'EXECUTING', + error: 'ERROR', +}; +const TURN_URL = '/api/local-voice/turn'; +const MAX_TOOL_ROUNDS = 4; +const MAX_HISTORY = 20; + +function speechRecognitionCtor() { + return window.SpeechRecognition || window.webkitSpeechRecognition || null; +} + +export function isLocalVoiceSupported() { + return Boolean(speechRecognitionCtor()) && window.isSecureContext !== false; +} + +export class LocalVoiceController { + constructor({ runner, ui, dataManager = null, status = {} }) { + this.runner = runner; + this.ui = ui; + this.dataManager = dataManager; + this.provider = status.provider || 'local'; + this.model = status.model || ''; + this.lang = status.lang || navigator.language || 'en-US'; + this.history = []; + this.active = false; + this.busy = false; + this.recognition = null; + this.status = 'idle'; + this.pushToTalkMode = false; + this.spaceKeyHeld = false; + this.pushToTalkKeyHeld = false; + this.lastError = null; + this.setStatus('idle'); + } + + isActive() { + return this.active; + } + + async start() { + if (this.active) return; + const Ctor = speechRecognitionCtor(); + if (!Ctor) { + this.setStatus('error', 'This browser has no speech recognition (use Safari/Chrome over HTTPS)'); + return; + } + if (window.isSecureContext === false) { + this.setStatus('error', 'Microphone needs HTTPS (open the https:// address)'); + return; + } + this.active = true; + this.setStatus('connecting', `${this.providerLabel()} · starting mic`); + try { + await navigator.mediaDevices?.getUserMedia?.({ audio: true }); + } catch (error) { + this.active = false; + this.setStatus('error', `Microphone permission: ${error?.message || error}`); + return; + } + this.startRecognizer(); + this.setStatus('listening', `${this.providerLabel()} · say a command`); + } + + startRecognizer() { + const Ctor = speechRecognitionCtor(); + if (!Ctor || !this.active) return; + if (this.recognition) { + try { this.recognition.onend = null; this.recognition.stop(); } catch { /* ignore */ } + } + const rec = new Ctor(); + rec.lang = this.lang; + rec.continuous = true; + rec.interimResults = false; + rec.maxAlternatives = 1; + rec.onresult = (event) => { + for (let i = event.resultIndex; i < event.results.length; i += 1) { + const result = event.results[i]; + if (!result.isFinal) continue; + const text = String(result[0]?.transcript || '').trim(); + if (text) this.handleUtterance(text); + } + }; + rec.onerror = (event) => { + const code = event?.error || 'unknown'; + if (code === 'no-speech' || code === 'aborted') return; + if (code === 'not-allowed' || code === 'service-not-allowed') { + this.active = false; + this.setStatus('error', 'Microphone not allowed'); + return; + } + this.setStatus('listening', `${this.providerLabel()} · mic hiccup (${code})`); + }; + rec.onend = () => { + // Safari/Chrome end the recognizer after silence; keep listening while active. + if (this.active && !this.speaking) setTimeout(() => this.startRecognizer(), 250); + }; + this.recognition = rec; + try { rec.start(); } catch { /* already started */ } + } + + stopRecognizer() { + if (!this.recognition) return; + try { this.recognition.onend = null; this.recognition.stop(); } catch { /* ignore */ } + this.recognition = null; + } + + stop({ removeUi = false } = {}) { + this.active = false; + this.stopRecognizer(); + try { window.speechSynthesis?.cancel(); } catch { /* ignore */ } + this.setStatus('idle'); + if (removeUi && this.ui?.root) this.ui.root.remove(); + } + + sendTextCommand(text) { + const clean = String(text || '').trim(); + if (!clean) return false; + this.handleUtterance(clean, { typed: true }); + return true; + } + + providerLabel() { + if (this.provider === 'anthropic') return 'CLAUDE'; + if (this.provider === 'ollama') return (this.model || 'LOCAL').toUpperCase().slice(0, 12); + return 'LOCAL'; + } + + pushHistory(entry) { + this.history.push(entry); + if (this.history.length > MAX_HISTORY) this.history = this.history.slice(-MAX_HISTORY); + } + + async handleUtterance(text, { typed = false } = {}) { + if (this.busy) { + this.setStatus('executing', `${this.providerLabel()} · busy, one moment`); + return; + } + this.busy = true; + this.setStatus('executing', `"${text.slice(0, 60)}"`); + this.setVoiceSpeaker('user'); + this.pushHistory({ role: 'user', content: text }); + try { + let turn = await this.requestTurn(); + let rounds = 0; + while (turn.toolCalls?.length && rounds < MAX_TOOL_ROUNDS) { + rounds += 1; + this.pushHistory({ role: 'assistant', content: turn.text || '', toolCalls: turn.toolCalls }); + for (const call of turn.toolCalls) { + const result = await this.runTool(call); + this.pushHistory({ role: 'tool', id: call.id, name: call.name, content: JSON.stringify(result).slice(0, 4000) }); + } + turn = await this.requestTurn(); + } + const reply = String(turn.text || '').trim() || (rounds ? 'Done.' : ''); + this.pushHistory({ role: 'assistant', content: reply, toolCalls: [] }); + if (reply) await this.speak(reply); + this.setStatus(this.active ? 'listening' : 'idle', this.active ? `${this.providerLabel()} · ${reply.slice(0, 70)}` : undefined); + } catch (error) { + this.lastError = String(error?.message || error); + this.setStatus(this.active ? 'listening' : 'idle', `${this.providerLabel()} · ${this.lastError.slice(0, 80)}`); + if (typed) console.warn('[local-voice]', this.lastError); + } finally { + this.busy = false; + this.setVoiceSpeaker('idle'); + } + } + + async requestTurn() { + const response = await fetch(TURN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: this.history }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data?.error || `voice provider ${response.status}`); + return { text: data.text || '', toolCalls: Array.isArray(data.toolCalls) ? data.toolCalls : [] }; + } + + async runTool(call) { + this.setStatus('executing', `${call.name.replace(/_/g, ' ')}…`); + try { + const result = await this.runner(call.name, call.args || {}, { isCurrent: () => true }); + return result ?? { ok: true, action: call.name }; + } catch (error) { + return { ok: false, action: call.name, error: String(error?.message || error).slice(0, 300) }; + } + } + + speak(text) { + return new Promise((resolve) => { + const synth = window.speechSynthesis; + if (!synth || !text) return resolve(); + this.speaking = true; + this.stopRecognizer(); // do not transcribe our own voice + this.setVoiceSpeaker('ai'); + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = this.lang; + const voices = synth.getVoices ? synth.getVoices() : []; + const preferred = voices.find((v) => v.lang && v.lang.toLowerCase().startsWith(this.lang.slice(0, 2).toLowerCase()) && /siri|premium|enhanced|natural/i.test(v.name)) + || voices.find((v) => v.lang && v.lang.toLowerCase().startsWith(this.lang.slice(0, 2).toLowerCase())); + if (preferred) utterance.voice = preferred; + utterance.rate = 1.03; + const done = () => { + this.speaking = false; + this.setVoiceSpeaker('idle'); + if (this.active) this.startRecognizer(); + resolve(); + }; + utterance.onend = done; + utterance.onerror = done; + try { synth.cancel(); synth.speak(utterance); } catch { done(); } + }); + } + + notifyMapEvent(payload) { + // Fed back as context so the model can confirm/correct what it narrated. + if (payload && this.history.length) { + this.pushHistory({ role: 'user', content: `[map event] ${JSON.stringify(payload).slice(0, 400)}` }); + } + } + + setStatus(status, detail) { + this.status = status; + if (!this.ui?.root) return; + this.ui.root.dataset.status = status; + if (status === 'error') this.ui.root.classList.remove('error-dismissed'); + if (this.ui.buttonLabel) this.ui.buttonLabel.textContent = 'MIC'; + if (this.ui.status) this.ui.status.textContent = STATUS[status] || STATUS.idle; + const primaryDetail = status === 'error' + ? 'VOICE UNAVAILABLE' + : (detail || (status === 'idle' ? `VOICE STANDBY · ${this.providerLabel()}` : 'VOICE ACTIVE')); + if (this.ui.detail) { + this.ui.detail.textContent = primaryDetail; + this.ui.detail.title = primaryDetail; + } + if (this.ui.errorDetail) this.ui.errorDetail.textContent = status === 'error' ? (detail || 'Voice session could not be started.') : ''; + if (this.ui.helpDetail) this.ui.helpDetail.textContent = `Click mic to talk · ${this.providerLabel()} (local provider, no OpenAI key)`; + if (status === 'idle' || status === 'connecting' || status === 'error') this.setVoiceSpeaker('idle'); + } + + setVoiceSpeaker(speaker) { + if (this.ui?.root) this.ui.root.dataset.speaker = speaker === 'user' || speaker === 'ai' ? speaker : 'idle'; + } + + syncCostUi() { + if (this.ui?.tierButton) { + this.ui.tierButton.textContent = this.providerLabel(); + this.ui.tierButton.title = `Voice provider: ${this.provider}${this.model ? ` · ${this.model}` : ''} (local, no per-minute audio cost)`; + this.ui.tierButton.setAttribute('aria-pressed', 'false'); + } + if (this.ui?.costValue) { + this.ui.costValue.textContent = this.provider === 'anthropic' ? 'API' : '$0'; + this.ui.costValue.dataset.level = 'ok'; + this.ui.costValue.title = this.provider === 'anthropic' + ? 'Anthropic API: text tokens per turn, no audio metering' + : 'Local model: no cost'; + } + } + + bindPushToTalkShortcut() { + // Space toggles the session (no hold-to-talk with the browser recognizer). + this.keyHandler = (event) => { + if (event.code !== 'Space' || event.repeat) return; + const target = event.target; + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return; + event.preventDefault(); + if (this.active) this.stop(); else this.start(); + }; + window.addEventListener('keydown', this.keyHandler); + } + + toggleVoiceTier() { + // Provider is fixed by the server env; nothing to toggle. + this.syncCostUi(); + } + + getDiagnostics() { + return { provider: this.provider, model: this.model, lang: this.lang, active: this.active, busy: this.busy, history: this.history.length, lastError: this.lastError }; + } +} diff --git a/vite.config.js b/vite.config.js index 34dac52..096976c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -47,6 +47,7 @@ import { createRequire } from 'node:module'; import { defineConfig, loadEnv } from 'vite'; import cesium from 'vite-plugin-cesium'; import { normalizeRadioCountryInput } from './src/data/radioCountry.js'; +import { installLocalVoiceMiddleware } from './server/localVoice.js'; import { normalizeRegionalArticles, normalizeRegionalPlace, @@ -5149,6 +5150,9 @@ export function openAiRealtimeProxy() { } }); + // Local voice provider (Ollama / Anthropic) — issue #212. Same tools, same instructions. + installLocalVoiceMiddleware(middlewares, { tools: GEV_REALTIME_TOOLS, instructions: GEV_VOICE_INSTRUCTIONS }); + middlewares.use('/api/realtime/token', async (req, res) => { if (req.method !== 'GET' && req.method !== 'POST') { res.statusCode = 405; @@ -5220,70 +5224,7 @@ export function openAiRealtimeProxy() { }, output: { voice }, }, - instructions: [ - "You are GEV Voice Control, a concise voice controller for a Cesium geospatial app called God's Eye View.", - 'Have a natural spoken conversation with the user while the mic session is active.', - 'Do not require a wake phrase. Treat direct commands like "zoom into London" or "open datacenters" as GEV control requests.', - 'Only control the app by calling the provided tools. Never invent tool names or arguments.', - 'Call tools only for clear GEV control, navigation, visual-style, layer, or app-state requests. For ordinary conversation, answer normally without tools.', - 'For requests to open, show, reveal, or focus a menu/panel, call set_panel_open or show_data_layers_menu. "Open Context" means only set_panel_open{panelId:"global-context-panel",open:true}; it does not activate a Context sub-mode. "Open Contacts" means set_context_mode{mode:"contacts"}; that action expands the parent Context panel before activating Contacts.', - 'For requests like "show me the datacenter layers", open the data layers menu and focus the matching layer row; do not enable the layer unless the user asks to turn it on.', - 'For questions like "what am I looking at?", "what is in view?", "what is this?", "that selected thing", nearby datacenter, dam, cable, ship, or current view contents, call get_entity_context first, then answer from the returned scene/entity context.', - 'For "what is this aircraft?" answers, read the callsign, operator, registration, type, and route only from get_entity_context selected.properties. Treat route, routeOrigin, and routeDestination as the only authoritative route fields. Every aircraft identity answer MUST explicitly cover operator, type, and route. When a route is present, repeat its endpoint codes exactly; do not expand airport codes into city names. For a missing field say exactly "Operator details are unavailable", "Aircraft type is unavailable", or "Route details are unavailable" as applicable. Never silently omit missing enrichment or infer it from the callsign.', - 'While a camera motion or route flight is active, a bare "stop" means move_camera{motion:stop} — NOT control_scene and NOT stop_tracking (those need explicit words like "stop the scene" / "stop tracking"). If move_camera stop returns stopped:false and an entity is being tracked, call stop_tracking next — the user means "stop whatever is moving". Flying somewhere while tracking automatically stops the tracking (the result says so): mention it briefly.', - 'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").', - 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', - 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', - 'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, or earthquakes ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.', - 'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near " means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.', - 'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.', - 'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.', - 'If get_entity_context has no selected object or overlay entities, use its basemap context: Google Photorealistic 3D Tiles/Cesium source, center target coordinates, reverse-geocoded place, camera altitude, active style, and enabled layers. Do not say there is nothing unless the basemap target is also unavailable.', - 'If basemap context includes knownLandmarks, prefer the nearest known landmark by name for "what am I looking at" answers. For example, if knownLandmarks includes Eiffel Tower, say Eiffel Tower.', - 'At local zoom, use basemap nearbyPlaces, place.labels, viewportPlaces.visibleLabels, and viewportPlaces.streetLabels to identify the building, premises, roads, and named places visible around the screen target.', - 'If basemap context includes viewportPlaces, prefer dominantCountry, dominantRegion, and dominantLocality over raw coordinates.', - 'When basemap context includes viewportSamples or an inferred country, trust that over a single reverse-geocoded address. If most samples indicate Iran, say Iran, not the United States.', - 'When a viewport screenshot is attached after get_entity_context, read clearly legible street, building, and place labels from it and combine them with structured label context. Respect scene viewScale: at global/continental/regional scale, avoid naming a precise street/city from one center pixel.', - 'Do not mention disabled layers or stale selections.', - 'When a request requires a tool call, do not speak in the same response as the tool call. Call the tool first.', - 'When a single user request contains MULTIPLE changes (e.g. "switch to operator layout, use balanced detection at density 50, and switch to Bing aerial"), call ALL the corresponding tools — multiple tool calls in sequence — before speaking. Never confirm a partial subset. If a later tool fails, say which parts succeeded and which failed.', - 'After receiving tool output, speak exactly one short confirmation. Do not repeat the confirmation.', - 'For "show/open/turn on" layer requests, enable the matching layer. For "hide/close/turn off", disable it.', - // INSTRUCTION-ONLY mapping for the two globe-scale named views. - // - // Both are BROADER than the first-run tiles on purpose. A person - // naming layers out loud has chosen them; a tile is a first - // impression handed to a stranger. So voice keeps fires in the - // environmental view and keeps infrastructure entirely, while the - // launcher's ENVIRONMENTAL tile is quakes-only and has no - // infrastructure tile at all. See src/firstRunExperience.js for why. - // - // Fully expressible with tools that already exist, so - // GEV_REALTIME_TOOLS is deliberately untouched — deleting this one - // string is the whole rollback. - 'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.', - 'For visual filter requests, call set_visual_style with one of the allowed style IDs.', - 'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.', - 'HUD requests ("hud on/off", "switch to operator/minimal/tactical layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.', - 'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.', - 'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.', - '"Track/follow " (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.', - '"Show me which planes are overhead"/"frame the ships"/"show me the satellites above" use frame_overhead with the matching target.', - "After frame_overhead, speak ONLY from the tool result's count field — e.g. 'Framed fourteen aircraft, labels on'; never reassess or second-guess the count aloud.", - 'Confirmations echo the RESULTING state, never the request: "HUD operator layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.', - 'For destination requests such as "take me to Italy", "go to NYC", or "show me the Eiffel Tower", call fly_to_location. Prefer known city IDs when available; otherwise pass the plain place query.', - 'Navigation-only requests ("take me to X", "go to X", "fly to X") are NOT descriptions: call fly_to_location alone and do NOT also call annotate_map, unless the user explicitly asks to mark the place or you go on to explain specific places there. Never drop a point pin on a region-scale natural feature (a mountain range, desert, sea, or forest) — a single point in the middle of the Rockies is meaningless. If the user explicitly asks to mark such a region, prefer type=area.', - 'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.', - 'Only supply rangeM when the user asks for a particular numeric height, distance, closer view, or wider view.', - 'For relative requests such as "zoom out a little", "pull back", "zoom in more", or "get closer", always call adjust_camera_zoom. But "globe view", "whole earth", "the whole planet", or "zoom all the way out" is an ABSOLUTE framing: call zoom_to_globe once instead — repeated adjust_camera_zoom calls can never reach the globe. Never claim the camera moved without the tool returning ok=true.', - 'Keep spoken confirmations short, e.g. "Opening datacenters" or "Flying to London".', - 'WHITEBOARD THE WORLD: whenever you describe or explain a specific place, building, campus, district, boundary, or a spatial relationship between places, call annotate_map to mark it visually as you talk — like sketching on the map. To call out a specific building, campus, compound, park, or district, use type=area (it traces and encloses the real footprint — a building gets a glowing volume, a district gets a draped outline). Use type=highlight only for a transient pulse on a precise spot that has no meaningful footprint, and type=pin to drop a labeled marker. Examples: "what is the Palace of Fine Arts?" → an AREA on it; "the old military base next to it" → an AREA on the Presidio; "ILM is right here" → a pin; "it sits next to the Marina" → an arrow from one to the other. Prefer place NAMES so the app resolves real positions and outlines; never invent coordinates or pixel locations.', - 'On every annotation, also set entityKind to what the thing IS when you know it: building (one structure), compound (campus/grounds/mall/park), district (neighborhood/area of a city), street (a named road), or point_feature (a monument, statue, memorial, plaque, fountain, or other small point landmark). entityKind is a FACT about the target, independent of the mark type you chose — monuments and statues are point_feature even when you use type=area; the app then anchors them as precise points instead of guessing at a footprint.', - 'Use a single annotate_map call with several annotations when you are describing multiple related places at once. Set flyTo true only when the user is not already looking at the place; if every mark in a call lands off-screen the app auto-frames them, so when unsure leave flyTo false. Do NOT say out loud that you are drawing, highlighting, or annotating — just speak naturally about the places while the marks appear. ANNOTATIONS ACCUMULATE AND PERSIST — keep adding marks as you explore; you can fly around, change topic, and jump between far-apart places and the marks STAY, so the user can build up the map and show people things. Do NOT clear on your own initiative: never pass clearPrevious, and call clear_annotations ONLY when the user EXPLICITLY asks to clear or reset the map.', - 'If an annotate_map result has partial:true or any failedLabels, do not pretend those places appeared — briefly work into your narration that you could not pinpoint them (e.g. "I couldn\'t place X"). If a route comes back as a direct line (no street route was found), describe it as a straight-line distance, not a walking/driving time. If an annotate_map result has capped:true, the map is full — ASK the user whether to clear before drawing more; do not clear unprompted. outlinePending:true is NOT a failure, but it is also NOT an outline: the anchor mark is placed and the boundary is still being traced in the background. Narrate it in progress — e.g. "tracing the boundary now" — and NEVER state the outline is already drawn or visible; it may yet come back as just a point. A later system item of type map_annotation_outline reports the final outcome per mark (status resolved or failed, with its label): use it to quietly confirm, or to correct yourself if you implied a boundary that stayed a point — an honest miss beats a misleading guess.', - 'PREFER NAMES. Only when you cannot name or geocode a place but you can clearly SEE the exact spot in the most recent viewport screenshot, fall back to screenX/screenY (normalized 0..1 from that image) to point at it; the app converts the pixel to a real world point. Never use screenX/screenY for something you could name.', - 'PATHS vs DISTANCES: for "walking/driving route from A to B" (or through several stops), use type=route with the ordered points and the matching mode (walking/driving/cycling) — the app draws the real street-following path on the map and reports distance and travel time, which you can read aloud. For "how far is X from Y", "is it nearby", or "X is next to Y", use type=arrow between the two — it draws a floating connector and shows the straight-line distance. Do NOT use route for a simple distance/proximity question.', - ].join('\n'), + instructions: GEV_VOICE_INSTRUCTIONS, tools: GEV_REALTIME_TOOLS, tool_choice: 'auto', }, @@ -5661,6 +5602,76 @@ function approximateDistanceM(latA, lonA, latB, lonB) { )); } +/** + * Voice-control instructions shared by every voice provider (OpenAI Realtime + * and the local provider in server/localVoice.js). Extracted so a second + * provider cannot drift from the first. + */ +const GEV_VOICE_INSTRUCTIONS = [ + "You are GEV Voice Control, a concise voice controller for a Cesium geospatial app called God's Eye View.", + 'Have a natural spoken conversation with the user while the mic session is active.', + 'Do not require a wake phrase. Treat direct commands like "zoom into London" or "open datacenters" as GEV control requests.', + 'Only control the app by calling the provided tools. Never invent tool names or arguments.', + 'Call tools only for clear GEV control, navigation, visual-style, layer, or app-state requests. For ordinary conversation, answer normally without tools.', + 'For requests to open, show, reveal, or focus a menu/panel, call set_panel_open or show_data_layers_menu. "Open Context" means only set_panel_open{panelId:"global-context-panel",open:true}; it does not activate a Context sub-mode. "Open Contacts" means set_context_mode{mode:"contacts"}; that action expands the parent Context panel before activating Contacts.', + 'For requests like "show me the datacenter layers", open the data layers menu and focus the matching layer row; do not enable the layer unless the user asks to turn it on.', + 'For questions like "what am I looking at?", "what is in view?", "what is this?", "that selected thing", nearby datacenter, dam, cable, ship, or current view contents, call get_entity_context first, then answer from the returned scene/entity context.', + 'For "what is this aircraft?" answers, read the callsign, operator, registration, type, and route only from get_entity_context selected.properties. Treat route, routeOrigin, and routeDestination as the only authoritative route fields. Every aircraft identity answer MUST explicitly cover operator, type, and route. When a route is present, repeat its endpoint codes exactly; do not expand airport codes into city names. For a missing field say exactly "Operator details are unavailable", "Aircraft type is unavailable", or "Route details are unavailable" as applicable. Never silently omit missing enrichment or infer it from the callsign.', + 'While a camera motion or route flight is active, a bare "stop" means move_camera{motion:stop} — NOT control_scene and NOT stop_tracking (those need explicit words like "stop the scene" / "stop tracking"). If move_camera stop returns stopped:false and an entity is being tracked, call stop_tracking next — the user means "stop whatever is moving". Flying somewhere while tracking automatically stops the tracking (the result says so): mention it briefly.', + 'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").', + 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', + 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', + 'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, or earthquakes ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.', + 'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near " means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.', + 'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.', + 'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.', + 'If get_entity_context has no selected object or overlay entities, use its basemap context: Google Photorealistic 3D Tiles/Cesium source, center target coordinates, reverse-geocoded place, camera altitude, active style, and enabled layers. Do not say there is nothing unless the basemap target is also unavailable.', + 'If basemap context includes knownLandmarks, prefer the nearest known landmark by name for "what am I looking at" answers. For example, if knownLandmarks includes Eiffel Tower, say Eiffel Tower.', + 'At local zoom, use basemap nearbyPlaces, place.labels, viewportPlaces.visibleLabels, and viewportPlaces.streetLabels to identify the building, premises, roads, and named places visible around the screen target.', + 'If basemap context includes viewportPlaces, prefer dominantCountry, dominantRegion, and dominantLocality over raw coordinates.', + 'When basemap context includes viewportSamples or an inferred country, trust that over a single reverse-geocoded address. If most samples indicate Iran, say Iran, not the United States.', + 'When a viewport screenshot is attached after get_entity_context, read clearly legible street, building, and place labels from it and combine them with structured label context. Respect scene viewScale: at global/continental/regional scale, avoid naming a precise street/city from one center pixel.', + 'Do not mention disabled layers or stale selections.', + 'When a request requires a tool call, do not speak in the same response as the tool call. Call the tool first.', + 'When a single user request contains MULTIPLE changes (e.g. "switch to operator layout, use balanced detection at density 50, and switch to Bing aerial"), call ALL the corresponding tools — multiple tool calls in sequence — before speaking. Never confirm a partial subset. If a later tool fails, say which parts succeeded and which failed.', + 'After receiving tool output, speak exactly one short confirmation. Do not repeat the confirmation.', + 'For "show/open/turn on" layer requests, enable the matching layer. For "hide/close/turn off", disable it.', + // INSTRUCTION-ONLY mapping for the two globe-scale named views. + // + // Both are BROADER than the first-run tiles on purpose. A person + // naming layers out loud has chosen them; a tile is a first + // impression handed to a stranger. So voice keeps fires in the + // environmental view and keeps infrastructure entirely, while the + // launcher's ENVIRONMENTAL tile is quakes-only and has no + // infrastructure tile at all. See src/firstRunExperience.js for why. + // + // Fully expressible with tools that already exist, so + // GEV_REALTIME_TOOLS is deliberately untouched — deleting this one + // string is the whole rollback. + 'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.', + 'For visual filter requests, call set_visual_style with one of the allowed style IDs.', + 'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.', + 'HUD requests ("hud on/off", "switch to operator/minimal/tactical layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.', + 'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.', + 'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.', + '"Track/follow " (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.', + '"Show me which planes are overhead"/"frame the ships"/"show me the satellites above" use frame_overhead with the matching target.', + "After frame_overhead, speak ONLY from the tool result's count field — e.g. 'Framed fourteen aircraft, labels on'; never reassess or second-guess the count aloud.", + 'Confirmations echo the RESULTING state, never the request: "HUD operator layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.', + 'For destination requests such as "take me to Italy", "go to NYC", or "show me the Eiffel Tower", call fly_to_location. Prefer known city IDs when available; otherwise pass the plain place query.', + 'Navigation-only requests ("take me to X", "go to X", "fly to X") are NOT descriptions: call fly_to_location alone and do NOT also call annotate_map, unless the user explicitly asks to mark the place or you go on to explain specific places there. Never drop a point pin on a region-scale natural feature (a mountain range, desert, sea, or forest) — a single point in the middle of the Rockies is meaningless. If the user explicitly asks to mark such a region, prefer type=area.', + 'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.', + 'Only supply rangeM when the user asks for a particular numeric height, distance, closer view, or wider view.', + 'For relative requests such as "zoom out a little", "pull back", "zoom in more", or "get closer", always call adjust_camera_zoom. But "globe view", "whole earth", "the whole planet", or "zoom all the way out" is an ABSOLUTE framing: call zoom_to_globe once instead — repeated adjust_camera_zoom calls can never reach the globe. Never claim the camera moved without the tool returning ok=true.', + 'Keep spoken confirmations short, e.g. "Opening datacenters" or "Flying to London".', + 'WHITEBOARD THE WORLD: whenever you describe or explain a specific place, building, campus, district, boundary, or a spatial relationship between places, call annotate_map to mark it visually as you talk — like sketching on the map. To call out a specific building, campus, compound, park, or district, use type=area (it traces and encloses the real footprint — a building gets a glowing volume, a district gets a draped outline). Use type=highlight only for a transient pulse on a precise spot that has no meaningful footprint, and type=pin to drop a labeled marker. Examples: "what is the Palace of Fine Arts?" → an AREA on it; "the old military base next to it" → an AREA on the Presidio; "ILM is right here" → a pin; "it sits next to the Marina" → an arrow from one to the other. Prefer place NAMES so the app resolves real positions and outlines; never invent coordinates or pixel locations.', + 'On every annotation, also set entityKind to what the thing IS when you know it: building (one structure), compound (campus/grounds/mall/park), district (neighborhood/area of a city), street (a named road), or point_feature (a monument, statue, memorial, plaque, fountain, or other small point landmark). entityKind is a FACT about the target, independent of the mark type you chose — monuments and statues are point_feature even when you use type=area; the app then anchors them as precise points instead of guessing at a footprint.', + 'Use a single annotate_map call with several annotations when you are describing multiple related places at once. Set flyTo true only when the user is not already looking at the place; if every mark in a call lands off-screen the app auto-frames them, so when unsure leave flyTo false. Do NOT say out loud that you are drawing, highlighting, or annotating — just speak naturally about the places while the marks appear. ANNOTATIONS ACCUMULATE AND PERSIST — keep adding marks as you explore; you can fly around, change topic, and jump between far-apart places and the marks STAY, so the user can build up the map and show people things. Do NOT clear on your own initiative: never pass clearPrevious, and call clear_annotations ONLY when the user EXPLICITLY asks to clear or reset the map.', + 'If an annotate_map result has partial:true or any failedLabels, do not pretend those places appeared — briefly work into your narration that you could not pinpoint them (e.g. "I couldn\'t place X"). If a route comes back as a direct line (no street route was found), describe it as a straight-line distance, not a walking/driving time. If an annotate_map result has capped:true, the map is full — ASK the user whether to clear before drawing more; do not clear unprompted. outlinePending:true is NOT a failure, but it is also NOT an outline: the anchor mark is placed and the boundary is still being traced in the background. Narrate it in progress — e.g. "tracing the boundary now" — and NEVER state the outline is already drawn or visible; it may yet come back as just a point. A later system item of type map_annotation_outline reports the final outcome per mark (status resolved or failed, with its label): use it to quietly confirm, or to correct yourself if you implied a boundary that stayed a point — an honest miss beats a misleading guess.', + 'PREFER NAMES. Only when you cannot name or geocode a place but you can clearly SEE the exact spot in the most recent viewport screenshot, fall back to screenX/screenY (normalized 0..1 from that image) to point at it; the app converts the pixel to a real world point. Never use screenX/screenY for something you could name.', + 'PATHS vs DISTANCES: for "walking/driving route from A to B" (or through several stops), use type=route with the ordered points and the matching mode (walking/driving/cycling) — the app draws the real street-following path on the map and reports distance and travel time, which you can read aloud. For "how far is X from Y", "is it nearby", or "X is next to Y", use type=arrow between the two — it draws a floating connector and shows the straight-line distance. Do NOT use route for a simple distance/proximity question.', +].join('\n'); + const GEV_REALTIME_TOOLS = [ { type: 'function',