Replace go-openai with native client

Drop the github.com/sashabaranov/go-openai dependency in favor of an
internal HTTP client.

The LLM package is now split into:
- llm.go: type definitions (Message, ChatRequest, ChatResponse, etc.)
- client.go: client construction and configuration
- client_completions.go: Completions and CompletionsStream methods
- client_models.go: ListModels
- client_tools.go: tool call execution

CompletionsStream uses typed SSE events (delta, done, message) so the
frontend can render progressive streaming output.

Move tool.go and tool_test.go from internal/tool/ into internal/llm/
to co-locate the registry with the client that consumes it.

Update job.go and service.go to use the new llm.Message types and
Completions/CompletionsStream methods. Remove the Voice field from
chat request/response (voice is now tracked via message metadata).

Frontend: handle delta/done/message SSE events, render a streaming
placeholder that receives progressive text deltas, and finalize with
full message binding (reasoning, tool calls, actions).
This commit is contained in:
dwrz
2026-06-26 14:22:05 +00:00
parent e480538ec2
commit f6fb21d40c
56 changed files with 1671 additions and 7854 deletions

View File

@@ -1,13 +1,26 @@
const STREAM_ENDPOINT = '/v1/chat/voice/stream';
const ICONS_URL = '/static/icons.svg';
const MODELS_ENDPOINT = '/v1/models';
const MODEL_KEY = 'odidere_model';
const PROVIDER_KEY = 'odidere_provider';
const STORAGE_KEY = 'odidere_history';
const STREAM_ENDPOINT = '/v1/chat/voice/stream';
const StreamEventDelta = 'delta';
const StreamEventDone = 'done';
const StreamEventMessage = 'message';
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
const VOICE_KEY = 'odidere_voice';
/**
* Content part type constants for OpenAI Chat Completions content parts.
* @see https://platform.openai.com/docs/api-reference/chat/create
*/
const ContentTypeImageURL = 'image_url';
const ContentTypeText = 'text';
const _ContentTypeFile = 'file';
const _ContentTypeInputAudio = 'input_audio';
const _ContentTypeRefusal = 'refusal';
/**
* VOICE_MAP maps whisper language names to default Kokoro voices.
* Used for auto-selecting a voice when the selector is set to "Auto".
*/
@@ -22,6 +35,7 @@ const VOICE_MAP = {
portuguese: 'pf_dora',
spanish: 'ef_dora',
};
/**
* Odidere is the main application class for the voice assistant UI.
* It manages audio recording, chat history, and communication with the API.
@@ -37,7 +51,7 @@ class Odidere {
this.attachments = [];
this.audioChunks = [];
this.currentAudio = null;
this.currentAudioUrl = null;
this.currentAudioURL = null;
this.currentController = null;
this.isProcessing = false;
this.isRecording = false;
@@ -55,7 +69,7 @@ class Odidere {
this.rootId = null;
// TTS state
this.ttsUrl = document.querySelector('meta[name="tts-url"]')?.content || '';
this.ttsURL = document.querySelector('meta[name="tts-url"]')?.content || '';
this.ttsDefaultVoice =
document.querySelector('meta[name="tts-default-voice"]')?.content || '';
this.playQueue = [];
@@ -63,7 +77,7 @@ class Odidere {
this.currentMessageId = null;
// STT state
this.sttUrl = document.querySelector('meta[name="stt-url"]')?.content || '';
this.sttURL = document.querySelector('meta[name="stt-url"]')?.content || '';
// Auto-scroll: enabled by default, disabled when user scrolls up,
// re-enabled when they scroll back to bottom or send a new message.
@@ -892,7 +906,7 @@ class Odidere {
* @returns {Promise<{text: string, detectedLanguage: string}>}
*/
async #transcribeAudio(audioBlob) {
if (!this.sttUrl) {
if (!this.sttURL) {
throw new Error('STT URL not configured');
}
@@ -900,7 +914,7 @@ class Odidere {
formData.append('file', audioBlob, 'audio.webm');
formData.append('response_format', 'verbose_json');
const res = await fetch(`${this.sttUrl}/inference`, {
const res = await fetch(`${this.sttURL}/inference`, {
method: 'POST',
body: formData,
});
@@ -1170,7 +1184,7 @@ class Odidere {
* @param {string} messageId
*/
#enqueueTTS(text, voice, messageId) {
if (!text || !this.ttsUrl) return;
if (!text || !this.ttsURL) return;
this.playQueue.push({ text, voice, messageId });
if (!this.isPlaying) {
this.#processQueue();
@@ -1229,7 +1243,7 @@ class Odidere {
try {
this.#stopCurrentAudio();
const res = await fetch(`${this.ttsUrl}/v1/audio/speech`, {
const res = await fetch(`${this.ttsURL}/v1/audio/speech`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -1245,10 +1259,10 @@ class Odidere {
}
const audioBlob = await res.blob();
const audioUrl = URL.createObjectURL(audioBlob);
const audioURL = URL.createObjectURL(audioBlob);
this.currentAudioUrl = audioUrl;
this.currentAudio = new Audio(audioUrl);
this.currentAudioURL = audioURL;
this.currentAudio = new Audio(audioURL);
this.currentAudio.muted = !!this.isMuted;
await new Promise((resolve, reject) => {
@@ -1339,9 +1353,9 @@ class Odidere {
* #cleanupAudio revokes the object URL and clears audio references.
*/
#cleanupAudio() {
if (this.currentAudioUrl) {
URL.revokeObjectURL(this.currentAudioUrl);
this.currentAudioUrl = null;
if (this.currentAudioURL) {
URL.revokeObjectURL(this.currentAudioURL);
this.currentAudioURL = null;
}
this.currentAudio = null;
}
@@ -1375,7 +1389,7 @@ class Odidere {
*/
async #fetchVoices() {
try {
const res = await fetch(`${this.ttsUrl}/v1/audio/voices`);
const res = await fetch(`${this.ttsURL}/v1/audio/voices`);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
@@ -1415,7 +1429,7 @@ class Odidere {
this.$textInput.value = '';
this.$textInput.style.height = 'auto';
await this.#sendRequest({ text });
await this.#sendRequest({ text, voice: this.$voice.value });
}
/**
@@ -1463,7 +1477,6 @@ class Odidere {
const $opt = this.$model.options[this.$model.selectedIndex];
const payload = {
messages,
voice: voice ?? this.$voice.value,
provider: $opt?.dataset.provider,
model: $opt?.dataset.model,
};
@@ -1480,6 +1493,7 @@ class Odidere {
if (hasNewInput) {
const meta = {};
if (detectedLanguage) meta.language = detectedLanguage;
if (voice) meta.voice = voice;
const appended = this.#appendHistory([userMessage], meta);
this.#renderMessages(appended, meta);
}
@@ -1501,6 +1515,32 @@ class Odidere {
// Stash assistant messages with tool_calls until their results arrive.
let pendingTools = null;
let streamingMessage = null;
const streamingMeta = () => {
const meta = {};
if (voice) meta.voice = voice;
return meta;
};
const mergeStreamingMessage = (message) => {
const target = streamingMessage.message;
const id = target.id;
const parent = target.parent;
const children = target.children;
const meta = target.meta;
Object.assign(target, message);
target.id = id;
target.parent = parent;
target.children = children;
target.meta = meta;
this.messagesMap.set(id, target);
this.#saveToStorage();
return target;
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
@@ -1514,9 +1554,13 @@ class Odidere {
for (const part of parts) {
if (!part.trim()) continue;
// Extract data from SSE event lines.
// Extract event type and data from SSE event lines.
let eventType = StreamEventMessage;
let data = '';
for (const line of part.split('\n')) {
if (line.startsWith('event: ')) {
eventType = line.slice(7);
}
if (line.startsWith('data: ')) {
data += line.slice(6);
}
@@ -1537,24 +1581,93 @@ class Odidere {
continue;
}
const message = event.message;
if (eventType === StreamEventDelta) {
if (!streamingMessage) {
const message = {
role: 'assistant',
content: [{ type: ContentTypeText, text: '' }],
};
const meta = streamingMeta();
const appended = this.#appendHistory([message], meta);
const $el = this.#renderStreamingAssistantMessage(
appended[0],
meta,
);
streamingMessage = { message: appended[0], $el };
}
// Stash assistant messages with tool calls; render once all
// tool results have arrived so the output is visible in the UI.
if (message.role === 'assistant' && message.tool_calls?.length > 0) {
const appended = this.#appendHistory([message]);
pendingTools = {
assistant: appended[0],
results: [],
expected: message.tool_calls.length,
};
// Enqueue TTS for content even when tool calls are present,
// so the LLM's spoken text before tool execution is played.
if (message.content) {
streamingMessage.message.content[0].text += event.delta || '';
this.#appendStreamingDelta(streamingMessage.$el, event.delta || '');
continue;
}
const message = event.message;
if (!message) continue;
if (eventType === StreamEventDone) {
if (message.role !== 'assistant') continue;
const meta = streamingMeta();
let finalMessage = message;
let $streaming = null;
if (streamingMessage) {
finalMessage = mergeStreamingMessage(message);
$streaming = streamingMessage.$el;
streamingMessage = null;
}
// Stash assistant messages with tool calls; render once all
// tool results have arrived so the output is visible in the UI.
if (finalMessage.tool_calls?.length > 0) {
if (!$streaming) {
const appended = this.#appendHistory([finalMessage], meta);
finalMessage = appended[0];
}
if ($streaming) {
$streaming = this.#finalizeStreamingAssistantMessage(
$streaming,
finalMessage,
meta,
);
}
pendingTools = {
assistant: finalMessage,
results: [],
expected: finalMessage.tool_calls.length,
$el: $streaming,
};
// Enqueue TTS for content even when tool calls are present,
// so the LLM's spoken text before tool execution is played.
if (this.#extractText(finalMessage.content)) {
this.#enqueueTTS(
this.#extractText(finalMessage.content),
voice || '',
finalMessage.id,
);
}
continue;
}
// Regular assistant message (final reply with content).
if ($streaming) {
this.#finalizeStreamingAssistantMessage(
$streaming,
finalMessage,
meta,
);
} else {
const appended = this.#appendHistory([finalMessage], meta);
finalMessage = appended[0];
this.#renderMessages(appended, meta);
}
// Enqueue TTS for the final assistant message with content.
if (this.#extractText(finalMessage.content)) {
this.#enqueueTTS(
message.content,
event.voice || this.$voice.value || '',
message.id,
this.#extractText(finalMessage.content),
voice || '',
finalMessage.id,
);
}
continue;
@@ -1571,33 +1684,21 @@ class Odidere {
// Once all tool results are in, render the combined message.
if (pendingTools.results.length >= pendingTools.expected) {
this.#renderMessages([
pendingTools.assistant,
...pendingTools.results,
]);
if (pendingTools.$el) {
this.#finalizeStreamingAssistantMessage(
pendingTools.$el,
pendingTools.assistant,
pendingTools.assistant.meta,
pendingTools.results,
);
} else {
this.#renderMessages([
pendingTools.assistant,
...pendingTools.results,
]);
}
pendingTools = null;
}
continue;
}
// Regular assistant message (final reply with content).
// Skip non-assistant messages (tool results, etc.) that may have
// fallen through above — they should not be rendered or spoken.
if (message.role !== 'assistant') continue;
const meta = {};
if (event.voice) meta.voice = event.voice;
const appended = this.#appendHistory([message], meta);
this.#renderMessages(appended, meta);
// Enqueue TTS for the final assistant message with content.
if (message.content) {
this.#enqueueTTS(
message.content,
event.voice || this.$voice.value || '',
message.id,
);
}
}
}
@@ -1634,10 +1735,10 @@ class Odidere {
for (const file of attachments) {
if (file.type.startsWith('image/')) {
const base64 = await this.#toBase64(file);
const dataUrl = `data:${file.type};base64,${base64}`;
const dataURL = `data:${file.type};base64,${base64}`;
imageParts.push({
type: 'image_url',
image_url: { url: dataUrl },
type: ContentTypeImageURL,
image_url: { url: dataURL },
});
} else {
const content = await file.text();
@@ -1664,7 +1765,7 @@ class Odidere {
// If images present, use multipart array format.
const parts = [];
if (combinedText) {
parts.push({ type: 'text', text: combinedText });
parts.push({ type: ContentTypeText, text: combinedText });
}
parts.push(...imageParts);
@@ -2082,7 +2183,7 @@ class Odidere {
}
if (Array.isArray(message.content)) {
return message.content
.filter((p) => p.type === 'text')
.filter((p) => p.type === ContentTypeText)
.map((p) => p.text)
.join('\n');
}
@@ -2121,6 +2222,155 @@ class Odidere {
this.#scrollToBottom();
}
/**
* #renderStreamingAssistantMessage renders a placeholder assistant message
* that receives streamed text deltas until the completion is done.
* @param {Object} message
* @param {Object} [meta]
* @returns {HTMLElement}
*/
#renderStreamingAssistantMessage(message, meta = null) {
const $msg =
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
if (message.id) $msg.dataset.id = message.id;
$msg.classList.add('message--streaming');
// Populate debug panel.
const $dl = $msg.querySelector('.message__debug-list');
if (meta?.voice) this.#appendDebugRow($dl, 'Voice', meta.voice);
if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', '');
// Disable actions until the full message has arrived.
const $inspect = $msg.querySelector('[data-action="inspect"]');
$inspect.addEventListener('click', () => {
const isOpen = $msg.classList.toggle('message--debug-open');
$inspect.setAttribute('aria-expanded', String(isOpen));
});
$msg.querySelector('[data-action="play"]').remove();
$msg.querySelector('[data-action="copy"]').remove();
$msg.querySelector('[data-action="delete"]').remove();
this.$chat.appendChild($msg);
this.#scrollToBottom();
return $msg;
}
/**
* #appendStreamingDelta appends text to a streaming assistant message.
* @param {HTMLElement} $msg
* @param {string} delta
*/
#appendStreamingDelta($msg, delta) {
const $content = $msg.querySelector('.message__content');
$content.textContent += delta;
this.#scrollToBottom();
}
/**
* #finalizeStreamingAssistantMessage replaces a streaming placeholder with
* a fully bound assistant message.
* @param {HTMLElement} $streaming
* @param {Object} message
* @param {Object} [meta]
* @param {Object[]} [toolResults]
* @returns {HTMLElement|null}
*/
#finalizeStreamingAssistantMessage(
$streaming,
message,
meta = null,
toolResults = [],
) {
if (!$streaming) return null;
const $msg =
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
if (message.id) $msg.dataset.id = message.id;
const $body = $msg.querySelector('.message__body');
const $content = $msg.querySelector('.message__content');
// Reasoning block (collapsible, shows LLM's chain-of-thought)
if (message.reasoning_content) {
const $reasoning = this.#createCollapsibleBlock(
'reasoning',
'Reasoning',
message.reasoning_content,
);
$body.insertBefore($reasoning, $content);
}
// Tool call blocks (collapsible, shows function name, args, and result)
if (message.tool_calls?.length > 0) {
for (const toolCall of message.tool_calls) {
const result = toolResults.find((r) => r.tool_call_id === toolCall.id);
const $toolBlock = this.#createToolCallBlock(toolCall, result);
$body.insertBefore($toolBlock, $content);
}
}
// Main content
if (this.#extractText(message.content)) {
$content.textContent = this.#extractText(message.content);
} else {
$content.remove();
}
// Populate debug panel.
const $dl = $msg.querySelector('.message__debug-list');
if (meta?.voice) this.#appendDebugRow($dl, 'Voice', meta.voice);
if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', '');
// Bind action buttons.
const $inspect = $msg.querySelector('[data-action="inspect"]');
$inspect.addEventListener('click', () => {
const isOpen = $msg.classList.toggle('message--debug-open');
$inspect.setAttribute('aria-expanded', String(isOpen));
});
// Play button: only for messages with non-empty content.
const $play = $msg.querySelector('[data-action="play"]');
if (this.#extractText(message.content)) {
const voice = meta?.voice || this.$voice.value || '';
$play.addEventListener('click', (e) =>
this.#handlePlayClick(
e,
message.id,
this.#extractText(message.content),
voice,
),
);
} else {
$play.remove();
}
// Assemble text from all available content.
const copyParts = [];
if (message.reasoning_content) copyParts.push(message.reasoning_content);
for (const tc of message.tool_calls ?? []) {
const name = tc.function?.name || 'unknown';
const args = tc.function?.arguments || '{}';
copyParts.push(`${name}(${args})`);
}
if (this.#extractText(message.content))
copyParts.push(this.#extractText(message.content));
const copyText = copyParts.join('\n\n');
const $copy = $msg.querySelector('[data-action="copy"]');
$copy.addEventListener('click', () =>
this.#copyToClipboard($copy, copyText),
);
const $delete = $msg.querySelector('[data-action="delete"]');
$delete.addEventListener('click', (e) =>
this.#handleDeleteClick(e, message.id),
);
$streaming.replaceWith($msg);
this.#scrollToBottom();
return $msg;
}
/**
* #renderAssistantMessage renders an assistant message with optional
* reasoning, tool calls, and their results.
@@ -2159,8 +2409,8 @@ class Odidere {
}
// Main content
if (message.content) {
$content.textContent = message.content;
if (this.#extractText(message.content)) {
$content.textContent = this.#extractText(message.content);
} else {
$content.remove();
}
@@ -2179,10 +2429,15 @@ class Odidere {
// Play button: only for messages with non-empty content.
const $play = $msg.querySelector('[data-action="play"]');
if (message.content) {
if (this.#extractText(message.content)) {
const voice = meta?.voice || this.$voice.value || '';
$play.addEventListener('click', (e) =>
this.#handlePlayClick(e, message.id, message.content, voice),
this.#handlePlayClick(
e,
message.id,
this.#extractText(message.content),
voice,
),
);
} else {
$play.remove();
@@ -2196,7 +2451,8 @@ class Odidere {
const args = tc.function?.arguments || '{}';
copyParts.push(`${name}(${args})`);
}
if (message.content) copyParts.push(message.content);
if (this.#extractText(message.content))
copyParts.push(this.#extractText(message.content));
const copyText = copyParts.join('\n\n');
const $copy = $msg.querySelector('[data-action="copy"]');
@@ -2327,10 +2583,10 @@ class Odidere {
const $output = $details.querySelector('[data-output]');
try {
const output = JSON.parse(result.content || '{}');
const output = JSON.parse(this.#extractText(result.content) || '{}');
$output.textContent = JSON.stringify(output, null, 2);
} catch {
$output.textContent = result.content || '';
$output.textContent = this.#extractText(result.content) || '';
}
}
@@ -2350,6 +2606,23 @@ class Odidere {
$dl.appendChild($row);
}
/**
* #extractText extracts plain text from a message content field.
* Content can be a string or an array of ContentPart objects.
* @param {string|Array} content
* @returns {string}
*/
#extractText(content) {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.filter((p) => p.type === ContentTypeText)
.map((p) => p.text)
.join('\n');
}
return '';
}
// ====================
// UTILITIES
// ====================