mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
720260af58
commit
af461febae
57 files changed
+6500
-63
No files matched your search
@@ -0,0 +1,139 @@
|
||||
import { createTextChatHandler } from '../common/textChat.js';
|
||||
import { invalidCalls } from '../common/textTools.js';
|
||||
import { textInstructions } from '../common/textInstructions.js';
|
||||
import { GEV_REALTIME_TOOLS } from './tools.js';
|
||||
|
||||
const object = (value) =>
|
||||
value && typeof value === 'object' && !Array.isArray(value);
|
||||
const keys = (value, allowed) =>
|
||||
Object.keys(value).every((key) => allowed.includes(key));
|
||||
const callShape = (call) =>
|
||||
object(call) &&
|
||||
keys(call, ['id', 'type', 'function']) &&
|
||||
typeof call.id === 'string' &&
|
||||
call.id.length > 0 &&
|
||||
call.id.length <= 1024 &&
|
||||
call.type === 'function' &&
|
||||
object(call.function) &&
|
||||
keys(call.function, ['name', 'arguments']) &&
|
||||
typeof call.function.name === 'string' &&
|
||||
call.function.name.length <= 128 &&
|
||||
typeof call.function.arguments === 'string';
|
||||
function assistantShape(message) {
|
||||
return (
|
||||
object(message) &&
|
||||
keys(message, ['role', 'content', 'tool_calls']) &&
|
||||
message.role === 'assistant' &&
|
||||
(message.content == null || typeof message.content === 'string') &&
|
||||
(message.tool_calls === undefined ||
|
||||
(Array.isArray(message.tool_calls) &&
|
||||
message.tool_calls.length > 0 &&
|
||||
message.tool_calls.length <= 32 &&
|
||||
message.tool_calls.every(callShape) &&
|
||||
new Set(message.tool_calls.map((call) => call.id)).size ===
|
||||
message.tool_calls.length))
|
||||
);
|
||||
}
|
||||
function validMessages(messages) {
|
||||
if (!Array.isArray(messages) || !messages.length || messages.length > 96)
|
||||
return false;
|
||||
let pending = [];
|
||||
for (const message of messages) {
|
||||
if (!object(message)) return false;
|
||||
if (pending.length) {
|
||||
if (
|
||||
!keys(message, ['role', 'content', 'tool_call_id']) ||
|
||||
message.role !== 'tool' ||
|
||||
typeof message.content !== 'string' ||
|
||||
message.tool_call_id !== pending.shift()
|
||||
)
|
||||
return false;
|
||||
} else if (message.role === 'user') {
|
||||
if (
|
||||
!keys(message, ['role', 'content']) ||
|
||||
typeof message.content !== 'string'
|
||||
)
|
||||
return false;
|
||||
} else if (assistantShape(message)) {
|
||||
pending = (message.tool_calls || []).map((call) => call.id);
|
||||
} else return false;
|
||||
}
|
||||
return !pending.length;
|
||||
}
|
||||
|
||||
export function createOpenAiChatHandler({
|
||||
annotationGuidance,
|
||||
tools = GEV_REALTIME_TOOLS,
|
||||
resolveApiKey = () => process.env.OPENAI_API_KEY,
|
||||
resolveChatModel = () => process.env.OPENAI_CHAT_MODEL || 'gpt-4.1-mini',
|
||||
...options
|
||||
} = {}) {
|
||||
return createTextChatHandler({
|
||||
...options,
|
||||
provider: 'openai',
|
||||
resolveApiKey,
|
||||
prepare(input) {
|
||||
const model = String(resolveChatModel()).trim();
|
||||
if (
|
||||
!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,119}$/.test(model) ||
|
||||
/realtime|audio|tts/i.test(model)
|
||||
)
|
||||
return { errorStatus: 503 };
|
||||
if (
|
||||
!object(input) ||
|
||||
!keys(input, ['messages']) ||
|
||||
!validMessages(input.messages)
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
endpoint: 'https://api.openai.com/v1/chat/completions',
|
||||
headers: (key) => ({ Authorization: `Bearer ${key}` }),
|
||||
body: {
|
||||
model,
|
||||
n: 1,
|
||||
stream: false,
|
||||
store: false,
|
||||
max_completion_tokens: 4096,
|
||||
messages: [
|
||||
{ role: 'system', content: textInstructions(annotationGuidance) },
|
||||
...input.messages,
|
||||
],
|
||||
tools: tools.map(({ name, description, parameters }) => ({
|
||||
type: 'function',
|
||||
function: { name, description, parameters, strict: false },
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
parse(data) {
|
||||
const choice = data?.choices?.[0];
|
||||
const raw = choice?.message;
|
||||
if (!raw) return null;
|
||||
const content = {
|
||||
role: raw.role,
|
||||
content: raw.content ?? null,
|
||||
...(raw.tool_calls?.length ? { tool_calls: raw.tool_calls } : {}),
|
||||
};
|
||||
if (
|
||||
!assistantShape(content) ||
|
||||
(!content.content && !content.tool_calls?.length)
|
||||
)
|
||||
return null;
|
||||
const calls = (content.tool_calls || []).map((call) => {
|
||||
try {
|
||||
return {
|
||||
name: call.function.name,
|
||||
args: JSON.parse(call.function.arguments),
|
||||
};
|
||||
} catch {
|
||||
return { name: call.function.name, parseError: true };
|
||||
}
|
||||
});
|
||||
return {
|
||||
content,
|
||||
finishReason: choice.finish_reason,
|
||||
invalidCalls: invalidCalls(calls, tools),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
const DEFAULT_ANNOTATION_GUIDANCE =
|
||||
'Put related places in one annotate_map call. Set flyTo true only when the user is not already looking at the place (off-screen marks auto-frame, so when unsure leave it false). Do not say you are drawing or annotating — talk about the places while the marks appear. Marks ACCUMULATE AND PERSIST across flights and topics so the user can build up the map: never pass clearPrevious, and call clear_annotations ONLY when the user EXPLICITLY asks to clear or reset the map.';
|
||||
|
||||
function realtimeInstructions(
|
||||
annotationGuidance = DEFAULT_ANNOTATION_GUIDANCE,
|
||||
) {
|
||||
function realtimeDirectives(annotationGuidance = DEFAULT_ANNOTATION_GUIDANCE) {
|
||||
return [
|
||||
"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.',
|
||||
@@ -64,7 +62,11 @@ function realtimeInstructions(
|
||||
annotationGuidance,
|
||||
'annotate_map results: work any say line in naturally (places not found, a straight-line fallback with no travel time, or a full map — then ask before clearing). outlinePending means the boundary is still being traced: never claim it is drawn. A later map_annotation_outline system item reports each outcome; use it to quietly confirm or correct yourself.',
|
||||
'ROUTES vs DISTANCES: "walking/driving route from A to B" → type=route with the ordered points and the mode; the app draws the street path and reports distance and time you can read aloud. "How far is X from Y" or "X is next to Y" → type=arrow (straight-line distance), never route.',
|
||||
].join('\n');
|
||||
];
|
||||
}
|
||||
|
||||
export { realtimeInstructions };
|
||||
function realtimeInstructions(annotationGuidance) {
|
||||
return realtimeDirectives(annotationGuidance).join('\n');
|
||||
}
|
||||
|
||||
export { realtimeInstructions, realtimeDirectives };
|
||||
Reference in new issue
Block a user