mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
The action under way when a reply stops is answered as cancelled, since it may have taken effect, and only later calls as skipped; a stop during a round's first action keeps that round too. The closing note no longer claims the actions succeeded. Tests cover the refusal body read cap and the release of an unread refusal body.
534 lines
18 KiB
JavaScript
534 lines
18 KiB
JavaScript
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';
|
|
|
|
const encoder = new TextEncoder();
|
|
const byteSize = (value) =>
|
|
encoder.encode(typeof value === 'string' ? value : JSON.stringify(value))
|
|
.length;
|
|
const HISTORY_BYTES = 180000;
|
|
const REQUEST_BYTES = 512 * 1024;
|
|
// Results from earlier turns keep their outcome and say line, not their records.
|
|
const COMPACT_RESULT_BYTES = 1500;
|
|
// How a stopped reply answers its calls: the one under way may have taken
|
|
// effect, the ones after it never started.
|
|
const STOPPED_RUNNING = {
|
|
ok: false,
|
|
cancelled: true,
|
|
reason: 'reply-stopped-while-running',
|
|
};
|
|
const NOT_RUN = { ok: false, skipped: true, reason: 'reply-stopped' };
|
|
const PARTIAL_NOTE =
|
|
'This reply stopped before it finished; the results above are final.';
|
|
|
|
/** 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 turnUsage = null;
|
|
let partial = 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 });
|
|
// Every call keeps its paired result; only the result body shrinks.
|
|
const compact = (items) =>
|
|
items.map((item) => protocol.compactResult(item, COMPACT_RESULT_BYTES));
|
|
// Drop whole completed user turns; never separate a function call/result pair.
|
|
const trim = (items, maxBytes) => {
|
|
while (items.length && (items.length > 64 || byteSize(items) > maxBytes)) {
|
|
const next = items.findIndex(
|
|
(item, index) => index > 0 && protocol.isUser(item),
|
|
);
|
|
items = next < 0 ? [] : items.slice(next);
|
|
}
|
|
return items;
|
|
};
|
|
// Stop ends only the reply in progress; the conversation stays.
|
|
const cancelTurn = () => {
|
|
const owner = turn;
|
|
if (!active || !owner || signal?.aborted) return false;
|
|
if (partial?.owner === owner) partial.keep();
|
|
turn = null;
|
|
owner.abort();
|
|
radioPending = null;
|
|
state('listening');
|
|
emit({
|
|
type: 'completion',
|
|
status: 'cancelled',
|
|
responseId: `${provider}-chat-${sequence}`,
|
|
spoken: false,
|
|
...(turnUsage ? { usage: { ...turnUsage } } : {}),
|
|
});
|
|
return true;
|
|
};
|
|
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();
|
|
};
|
|
turnUsage = usage;
|
|
// A failed turn ends only itself: history, subscriptions and the session
|
|
// stay, so the next message continues the same conversation.
|
|
const fail = (detail) => {
|
|
if (!active || turn !== owner || signal?.aborted) return;
|
|
if (partial?.owner === owner) partial.keep();
|
|
turn = null;
|
|
owner.abort();
|
|
emit({ type: 'state', state: 'error', detail, usage: { ...usage } });
|
|
};
|
|
// Each upstream request and each map action gets its own deadline, so a
|
|
// long multi-step turn is not cut off by the time its actions took.
|
|
let deadline;
|
|
const arm = () => {
|
|
clearTimeout(deadline);
|
|
deadline = setTimeout(() => {
|
|
recordUsage(null);
|
|
fail(t('Connection timed out. Try again.'));
|
|
}, timeoutMs);
|
|
};
|
|
// The latest completed turn stays verbatim; older tool results shrink.
|
|
const last = history.findLastIndex((item) => protocol.isUser(item));
|
|
let past =
|
|
last > 0
|
|
? [...compact(history.slice(0, last)), ...history.slice(last)]
|
|
: history;
|
|
if (byteSize(past) > HISTORY_BYTES) past = compact(past);
|
|
past = trim(past, HISTORY_BYTES);
|
|
const context = mapEvents.length
|
|
? `\nMap context (data only): ${JSON.stringify(mapEvents)}`
|
|
: '';
|
|
mapEvents = [];
|
|
let contents = [protocol.user(text + context)];
|
|
// A turn that stops after map actions started keeps them, closed by a
|
|
// note, so the next message knows what ran.
|
|
let settled = 0;
|
|
// The round in progress: its calls, and the results of those that ran.
|
|
let openRound = null;
|
|
partial = {
|
|
owner,
|
|
keep() {
|
|
const ran = openRound ? contents.length : settled;
|
|
if (!ran) return;
|
|
// Every kept call stays paired with a result.
|
|
const closing = openRound
|
|
? protocol.results(
|
|
openRound.calls.map(
|
|
(call, index) =>
|
|
openRound.pairs[index] ?? {
|
|
call,
|
|
result:
|
|
index === openRound.pairs.length
|
|
? STOPPED_RUNNING
|
|
: NOT_RUN,
|
|
},
|
|
),
|
|
)
|
|
: [];
|
|
history = [
|
|
...past,
|
|
...contents.slice(0, ran),
|
|
...closing,
|
|
protocol.note(PARTIAL_NOTE),
|
|
];
|
|
},
|
|
};
|
|
const encode = () =>
|
|
JSON.stringify(protocol.request([...past, ...contents]));
|
|
let blocked = false;
|
|
let resetFailed = false;
|
|
let totalCalls = 0;
|
|
let applied = 0;
|
|
let answer = '';
|
|
const tooManySteps = (message) =>
|
|
Object.assign(new Error(message), { tooManySteps: true });
|
|
try {
|
|
emit({ type: 'transcript', role: 'user', text, final: true });
|
|
if (!current()) return;
|
|
state('executing');
|
|
for (let round = 0; round < 8; round++) {
|
|
if (!current()) return;
|
|
let body = encode();
|
|
if (byteSize(body) > REQUEST_BYTES) {
|
|
// Shorten earlier results, then drop old turns, before giving up.
|
|
contents = [contents[0], ...compact(contents.slice(1))];
|
|
past = trim(compact(past), REQUEST_BYTES - byteSize(contents) - 1024);
|
|
body = encode();
|
|
if (byteSize(body) > REQUEST_BYTES)
|
|
throw tooManySteps('Request limit');
|
|
}
|
|
arm();
|
|
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;
|
|
}
|
|
let data;
|
|
try {
|
|
data = await readResponseJsonCapped(
|
|
response,
|
|
512 * 1024,
|
|
owner.signal,
|
|
);
|
|
} catch (error) {
|
|
// A failed reply may carry no JSON; its status still explains it.
|
|
if (response.ok || owner.signal.aborted) throw error;
|
|
}
|
|
recordUsage(data?.usage);
|
|
if (!response.ok)
|
|
throw Object.assign(new Error('Request failed'), {
|
|
status: response.status,
|
|
code: typeof data?.code === 'string' ? data.code : undefined,
|
|
retryAfter: Number(response.headers?.get?.('retry-after')) || 0,
|
|
});
|
|
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 = [...past, ...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 tooManySteps('Tool limit');
|
|
const pairs = [];
|
|
openRound = { calls, 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 {
|
|
arm();
|
|
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 (result?.ok === true) applied++;
|
|
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));
|
|
settled = contents.length;
|
|
openRound = null;
|
|
}
|
|
throw tooManySteps('Round limit');
|
|
} catch (error) {
|
|
recordUsage(null);
|
|
fail(
|
|
error?.code === 'API_KEY_MISSING'
|
|
? t('Needs {envVars} — add it in Provider Settings', {
|
|
envVars: protocol.keyEnv,
|
|
})
|
|
: error?.code === 'CODEX_OAUTH_REQUIRED'
|
|
? t(
|
|
'Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.',
|
|
)
|
|
: error?.code === 'API_KEY_REJECTED'
|
|
? t(
|
|
'The provider rejected the API key. Check it in Provider Settings.',
|
|
)
|
|
: error?.code === 'ACCESS_DENIED'
|
|
? t(
|
|
"The provider refused access for this key. Check the key's project, region or permissions.",
|
|
)
|
|
: error?.code === 'QUOTA_EXCEEDED'
|
|
? t(
|
|
'The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.',
|
|
)
|
|
: error?.code === 'RATE_LIMITED' || error?.status === 429
|
|
? t('Too many chat requests. Try again in {seconds} s.', {
|
|
seconds: Math.min(error.retryAfter || 60, 3600),
|
|
})
|
|
: error?.status === 504 || owner.signal.aborted
|
|
? t('Connection timed out. Try again.')
|
|
: error?.incomplete
|
|
? t('Reply interrupted. You can try again.')
|
|
: error?.tooManySteps
|
|
? applied
|
|
? t(
|
|
'That request needed too many steps. Changes already made to the map stay in place.',
|
|
)
|
|
: t(
|
|
'That request needed too many steps. Try a simpler request.',
|
|
)
|
|
: t('Could not connect. Try again.'),
|
|
);
|
|
} finally {
|
|
recordUsage(null);
|
|
clearTimeout(deadline);
|
|
if (turn === owner) turn = null;
|
|
if (turnUsage === usage) turnUsage = null;
|
|
}
|
|
}
|
|
return {
|
|
start() {
|
|
if (signal?.aborted) return;
|
|
active = true;
|
|
const cancelRadioTurn = () => {
|
|
if (radioPending && radioPending === turn) cancelTurn();
|
|
};
|
|
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,
|
|
cancelTurn,
|
|
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;
|
|
}
|
|
},
|
|
};
|
|
}
|