mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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]>
140 lines
4.3 KiB
JavaScript
140 lines
4.3 KiB
JavaScript
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),
|
|
};
|
|
},
|
|
});
|
|
}
|