Files
gods-eye-view/server/providers/common/textChat.js
T
manjunath22466andDarren Dignam af461febae 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]>
2026-11-02 20:56:39 +05:30

137 lines
4.7 KiB
JavaScript

import { readRequestBody } from './request.js';
import { clientKey, makeCostRateLimiter } from './rate-limit.js';
import { waitForSignal } from '../../../src/sources/requestDeadline.js';
import { readResponseJsonCapped } from '../../../src/sources/httpBody.js';
import { isGeminiRequestOriginAllowed } from '../gemini/request.js';
import { parseUsage } from './textTools.js';
const MAX_BYTES = 512 * 1024;
const FAILURE = 'Could not connect. Try again.';
export function createTextChatHandler({
provider,
prepare,
parse,
fetchImpl = (...args) => fetch(...args),
resolveApiKey,
resolveHost = () => process.env.HOST,
resolveAllowedHosts = () => process.env.GEV_ALLOWED_HOSTS,
resolveRateLimit = () =>
process.env[`GEV_RATELIMIT_${provider.toUpperCase()}_CHAT_PER_MIN`] ?? '60',
timeoutMs = 60000,
} = {}) {
let limiter;
return async (req, res) => {
if (req.aborted || res.destroyed || res.writableEnded) return;
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('X-Content-Type-Options', 'nosniff');
const reply = (status, value) => {
if (req.aborted || res.destroyed || res.writableEnded) return;
res.statusCode = status;
if (status >= 400) res.setHeader('Connection', 'close');
res.end(
JSON.stringify(typeof value === 'string' ? { error: value } : value),
);
};
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return reply(405, 'Method not allowed');
}
const lifetime = new AbortController();
const disconnect = () => {
if (!res.writableEnded) lifetime.abort();
};
req.once('aborted', disconnect);
res.once('close', disconnect);
const timer = setTimeout(() => lifetime.abort(), timeoutMs);
timer.unref?.();
let response;
let usage = null;
try {
if (
!isGeminiRequestOriginAllowed(req, resolveHost(), resolveAllowedHosts())
)
return reply(403, FAILURE);
const apiKey = String(resolveApiKey() || '').trim();
if (!apiKey) return reply(503, 'Add an API key in Provider Settings');
if (limiter === undefined)
limiter = makeCostRateLimiter(resolveRateLimit(), 60);
if (limiter && !limiter(clientKey(req))) {
res.setHeader('Retry-After', '60');
return reply(429, FAILURE);
}
if (
!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] || '')
)
return reply(415, FAILURE);
if (Number(req.headers['content-length']) > MAX_BYTES)
return reply(413, FAILURE);
const raw = await readRequestBody(req, MAX_BYTES, lifetime.signal);
let input;
try {
input = JSON.parse(raw);
} catch {
return reply(400, FAILURE);
}
const prepared = prepare(input);
if (!prepared) return reply(400, FAILURE);
if (prepared.errorStatus) return reply(prepared.errorStatus, FAILURE);
const { endpoint, body, headers } = prepared;
lifetime.signal.throwIfAborted();
const acquisition = Promise.resolve(
fetchImpl(endpoint, {
method: 'POST',
redirect: 'error',
signal: lifetime.signal,
headers: { 'Content-Type': 'application/json', ...headers(apiKey) },
body: JSON.stringify(body),
}),
);
void acquisition.then(
(late) => {
response = late;
if (lifetime.signal.aborted) void late.body?.cancel().catch(() => {});
},
() => {},
);
response = await waitForSignal(acquisition, lifetime.signal);
lifetime.signal.throwIfAborted();
if (response.redirected || (response.url && response.url !== endpoint))
throw new Error('Unexpected destination');
if (!response.ok) {
void response.body?.cancel().catch(() => {});
return reply(
[429, 503].includes(response.status) ? response.status : 502,
FAILURE,
);
}
const data = await readResponseJsonCapped(
response,
MAX_BYTES,
lifetime.signal,
);
lifetime.signal.throwIfAborted();
usage = parseUsage(provider, data);
const result = parse(data);
if (!result || JSON.stringify(result).includes(apiKey))
return reply(502, { error: FAILURE, usage });
reply(200, { ...result, usage });
} catch (error) {
reply(
error?.code === 'BODY_TOO_LARGE'
? 413
: lifetime.signal.aborted
? 504
: 502,
{ error: FAILURE, usage },
);
} finally {
if (lifetime.signal.aborted)
void response?.body?.cancel().catch(() => {});
clearTimeout(timer);
req.off('aborted', disconnect);
res.off('close', disconnect);
}
};
}