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:
@@ -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
|
||||
// ====================
|
||||
|
||||
Reference in New Issue
Block a user