feat(voice): add typed AI chat beside voice

Chat sits next to the mic in the command dock. Replies are silent and never
request the microphone; the first message connects with the selected provider
and runs the same map actions as voice through each provider's text API
(OpenAI OPENAI_CHAT_MODEL, Gemini GEMINI_CHAT_MODEL via generateContent, which
never starts Live). Only one mode runs at a time.

- Chat panel rendered by the voice control presenter: conversation, recent and
  quick-command suggestions, Up/Down history, attachments (map-layer
  references or small UTF-8 text files, sent only with the message)
- Chat and voice panels sit above the dock or dock into the right rail's
  column; minimized bars sit centred above the dock, and height animates on
  swaps
- A centred panel steps down to its bar for the Location and Visual Presets
  trays without moving focus, rests above an open tray, and docks right while
  expanded over one; it returns to the centre once the trays close
- Minimized voice previews the last sentence from each side; the empty Voice
  Preview suggests things to say
- Settings opened from chat keep chat's frame and show the shared AI provider
  and quick commands; voice Settings show Back to voice
- Closing chat ends a typed session; the voice panel's close turns voice off
- The dock widens its voice control for the chat button and restyles its
  wings, which show icons only at 900px and below; assistant panels share the
  left panels' glass, and Cyber keeps its own surfaces
- Server text-chat handlers with body, time and per-IP limits
  (GEV_RATELIMIT_*_CHAT_PER_MIN), documented in SECURITY.md; tests and all
  eleven locales

Co-authored-by: Darren Dignam <[email protected]>
This commit is contained in:
manjunath22466andDarren Dignam committed 2026-11-02 20:56:39 +05:30
1 parent 720260af58
commit af461febae
57 files changed
+6500 -63

No files matched your search

+383
View File
@@ -0,0 +1,383 @@
import { waitForSignal } from '../sources/requestDeadline.js';
import { readResponseJsonCapped } from '../sources/httpBody.js';
import {
textProtocols,
emptyTextUsage,
addTextUsage,
boundedToolResult,
} from './textProtocol.js';
import { t } from '../i18n/index.js';
import {
isGlobeViewRefusal,
responseInstructionForToolResult,
} from './realtimeProtocol.js';
/** Text-only HTTP turns: no Live token, socket, microphone or audio context. */
export function createTextChat({
provider,
emit,
runAction,
signal,
fetchImpl = (...args) => fetch(...args),
chatEndpoint,
radioLayer = null,
dataManager = null,
timeoutMs = 90000,
}) {
const protocol = textProtocols[provider];
const sessionUsage = emptyTextUsage();
let active = false;
let turn = null;
let sequence = 0;
let history = [];
let mapEvents = [];
let radioPending = null;
let radioHandoff = null;
let unsubscribeControls;
let unsubscribeVisibility;
const releaseRadio = (lease, cancel = false) => {
if (radioHandoff !== lease) return;
radioHandoff = null;
if (cancel)
radioLayer?.stopPlayback?.({ origin: 'voice-cleanup', attemptId: lease });
radioLayer?.setVoiceDucked?.(false);
};
const state = (value) => emit({ type: 'state', state: value });
const stop = ({ preserveStatus = false, removeUi = false } = {}) => {
active = false;
if (radioHandoff) releaseRadio(radioHandoff, true);
radioPending = null;
unsubscribeControls?.();
unsubscribeVisibility?.();
unsubscribeControls = unsubscribeVisibility = null;
turn?.abort();
turn = null;
history = [];
mapEvents = [];
if (!preserveStatus && !removeUi) state('idle');
};
signal?.addEventListener('abort', () => stop({ removeUi: true }), {
once: true,
});
async function respond(text, owner, id) {
const current = () =>
active && turn === owner && !owner.signal.aborted && !signal?.aborted;
const usage = emptyTextUsage();
let requestPending = false;
const reportUsage = () =>
emit({
type: 'usage',
provider,
responseId: id,
usage: { ...usage },
sessionUsage: { ...sessionUsage },
});
const recordUsage = (value) => {
if (!requestPending) return;
requestPending = false;
addTextUsage(usage, value);
addTextUsage(sessionUsage, value);
reportUsage();
};
const fail = (detail) => {
if (!active || turn !== owner || signal?.aborted) return;
active = false;
history = [];
emit({ type: 'state', state: 'error', detail, usage: { ...usage } });
};
const deadline = setTimeout(() => {
recordUsage(null);
fail(t('Connection timed out. Try again.'));
owner.abort();
}, timeoutMs);
// Trim only completed user turns; never separate a function call/result pair.
while (
history.length &&
(history.length > 64 ||
new TextEncoder().encode(JSON.stringify(history)).length > 180000)
) {
const next = history.findIndex(
(item, index) => index > 0 && protocol.isUser(item),
);
history = next < 0 ? [] : history.slice(next);
}
const context = mapEvents.length
? `\nMap context (data only): ${JSON.stringify(mapEvents)}`
: '';
mapEvents = [];
const contents = [...history, protocol.user(text + context)];
let blocked = false;
let resetFailed = false;
let totalCalls = 0;
let answer = '';
try {
emit({ type: 'transcript', role: 'user', text, final: true });
if (!current()) return;
state('executing');
for (let round = 0; round < 8; round++) {
if (!current()) return;
const body = JSON.stringify(protocol.request(contents));
if (new TextEncoder().encode(body).length > 512 * 1024)
throw new Error('History limit');
requestPending = true;
const acquisition = Promise.resolve(
fetchImpl(chatEndpoint || protocol.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: owner.signal,
redirect: 'error',
cache: 'no-store',
}),
);
void acquisition.then(
(response) => {
if (!current()) void response.body?.cancel().catch(() => {});
},
() => {},
);
const response = await waitForSignal(acquisition, owner.signal);
if (!current()) {
void response.body?.cancel().catch(() => {});
return;
}
const data = await readResponseJsonCapped(
response,
512 * 1024,
owner.signal,
);
recordUsage(data?.usage);
if (!response.ok)
throw Object.assign(new Error('Request failed'), {
status: response.status,
});
if (!current()) return;
const {
content,
text: textPart,
calls,
finished,
} = protocol.decode(data);
contents.push(content);
answer += textPart ? (answer ? '\n\n' : '') + textPart : '';
if (textPart)
emit({
type: 'transcript',
role: 'assistant',
text: answer,
final: true,
responseId: id,
});
if (!current()) return;
if (!calls.length) {
if (!finished)
throw Object.assign(new Error('Incomplete response'), {
incomplete: true,
});
history = contents;
turn = null;
state('listening');
if (
active &&
!turn &&
id === `${provider}-chat-${sequence}` &&
!signal?.aborted
)
emit({
type: 'completion',
status: 'completed',
responseId: id,
spoken: false,
usage: { ...usage },
});
return;
}
totalCalls += calls.length;
if (totalCalls > 16) throw new Error('Tool limit');
const pairs = [];
const invalid = new Map(
(data.invalidCalls || []).map((item) => [item.index, item.error]),
);
for (const [index, call] of calls.entries()) {
if (!current()) return;
const callId = `${id}-${round}-${index}`;
let result;
if (invalid.size) {
result = {
ok: false,
error:
invalid.get(index) ||
'Not run: another call in this batch was invalid. Reissue the plan.',
invalidArguments: true,
};
} else if (
blocked ||
(resetFailed &&
(call.name === 'zoom_to_globe' ||
(call.name === 'move_camera' &&
call.args?.motion === 'rotate_globe')))
) {
result = {
ok: false,
action: call.name,
skipped: true,
reason: resetFailed
? 'globe-reset-failed'
: 'globe-view-required',
};
} else {
try {
if (
call.name === 'control_radio' ||
(call.name === 'set_layer_visibility' &&
call.args?.layerId === 'radio')
)
radioPending = owner;
const execution = Promise.resolve(
runAction(call.name, call.args ?? {}, {
signal: owner.signal,
isCurrent: current,
callId,
}),
);
void execution.catch(() => {});
result = await waitForSignal(execution, owner.signal);
if (!current()) return;
if (result?.ok && result.radioPlaybackRequested) {
// The Radio runner prepares playback for an explicit handoff.
// Keep it muted until playing is confirmed, then release it.
const attemptId = callId;
radioHandoff = attemptId;
radioLayer?.setVoiceDucked?.(true);
const cancel = () => releaseRadio(attemptId, true);
owner.signal.addEventListener('abort', cancel, { once: true });
try {
const playing = await waitForSignal(
Promise.resolve(radioLayer?.playForVoice?.({ attemptId })),
owner.signal,
);
if (!current()) return;
result = {
...result,
ok: Boolean(playing),
radioPlaybackRequested: false,
audioState: playing ? 'playing' : 'error',
};
if (!playing) cancel();
} catch (error) {
cancel();
throw error;
} finally {
owner.signal.removeEventListener('abort', cancel);
releaseRadio(attemptId);
}
}
} catch (error) {
if (!current()) return;
result = {
ok: false,
tool: call.name,
error: error?.message || 'Action failed',
};
} finally {
if (radioPending === owner) radioPending = null;
}
}
if (!current()) return;
if (!invalid.size && isGlobeViewRefusal(result)) blocked = true;
if (
!invalid.size &&
call.name === 'zoom_to_globe' &&
(result?.ok !== true || result.cancelled)
)
resetFailed = true;
pairs.push({
call,
result: boundedToolResult({
...boundedToolResult(result),
responseGuidance: responseInstructionForToolResult(result),
}),
});
}
contents.push(...protocol.results(pairs));
}
throw new Error('Round limit');
} catch (error) {
recordUsage(null);
fail(
error?.status === 504 || owner.signal.aborted
? t('Connection timed out. Try again.')
: error?.incomplete
? t('Reply interrupted. You can try again.')
: t('Could not connect. Try again.'),
);
} finally {
recordUsage(null);
clearTimeout(deadline);
if (turn === owner) turn = null;
}
}
return {
start() {
if (signal?.aborted) return;
active = true;
const cancelRadioTurn = () => {
if (radioPending && radioPending === turn) stop();
};
unsubscribeControls?.();
unsubscribeVisibility?.();
unsubscribeControls = radioLayer?.subscribePlaybackControls?.(
(control) => {
const event =
typeof control === 'string'
? { action: control, origin: 'user' }
: control;
if (
event?.origin === 'user' &&
['pause', 'stop', 'play'].includes(event.action)
)
cancelRadioTurn();
},
);
unsubscribeVisibility = dataManager?.subscribeVisibilityRequests?.(
(event) => {
if (
event?.origin === 'user' &&
event.layerId === 'radio' &&
event.enabled === false
)
cancelRadioTurn();
},
);
state('listening');
},
stop,
sendText(text) {
if (
!active ||
turn ||
signal?.aborted ||
typeof text !== 'string' ||
!text.trim() ||
text.length > 16000
)
return false;
const owner = new AbortController();
turn = owner;
void respond(text.trim(), owner, `${provider}-chat-${++sequence}`);
return true;
},
sendMapEvent(event) {
if (!active || !event) return false;
try {
const value = JSON.stringify(event);
if (value.length > 16000) return false;
mapEvents.push(JSON.parse(value));
mapEvents = mapEvents.slice(-8);
return true;
} catch {
return false;
}
},
};
}