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:
@@ -24,11 +24,9 @@ import (
|
||||
"code.chimeric.al/chimerical/odidere/internal/job"
|
||||
"code.chimeric.al/chimerical/odidere/internal/llm"
|
||||
"code.chimeric.al/chimerical/odidere/internal/service/templates"
|
||||
"code.chimeric.al/chimerical/odidere/internal/tool"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/robfig/cron/v3"
|
||||
openai "github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
// Context keys for request-scoped values.
|
||||
@@ -57,7 +55,7 @@ type Service struct {
|
||||
mux *http.ServeMux
|
||||
server *http.Server
|
||||
tmpl *template.Template
|
||||
tools *tool.Registry
|
||||
tools *llm.Registry
|
||||
}
|
||||
|
||||
// New creates a Service from the provided configuration.
|
||||
@@ -70,7 +68,7 @@ func New(cfg *config.Config, log *slog.Logger) (*Service, error) {
|
||||
}
|
||||
|
||||
// Setup tool registry.
|
||||
registry, err := tool.NewRegistry(cfg.Tools)
|
||||
registry, err := llm.NewRegistry(cfg.Tools)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load tools: %v", err)
|
||||
}
|
||||
@@ -441,28 +439,24 @@ func (svc *Service) status(w http.ResponseWriter, r *http.Request) {
|
||||
// Request is the incoming request format for the chat endpoints.
|
||||
type Request struct {
|
||||
// Messages is the conversation history.
|
||||
Messages []openai.ChatCompletionMessage `json:"messages"`
|
||||
Messages []llm.Message `json:"messages"`
|
||||
// Provider is the LLM provider name. If empty, the default provider is used.
|
||||
Provider string `json:"provider,omitempty"`
|
||||
// Model is the LLM model ID. If empty, the default model is used.
|
||||
Model string `json:"model,omitempty"`
|
||||
// SystemMessage overrides the configured system message for this request.
|
||||
SystemMessage string `json:"system_message,omitempty"`
|
||||
// Voice is the voice ID for TTS.
|
||||
Voice string `json:"voice,omitempty"`
|
||||
}
|
||||
|
||||
// Response is the response format for chat and voice endpoints.
|
||||
type Response struct {
|
||||
// Messages is the full list of messages generated during the query,
|
||||
// Messages is the full list of messages generated during the completions request,
|
||||
// including tool calls and tool results.
|
||||
Messages []openai.ChatCompletionMessage `json:"messages,omitempty"`
|
||||
Messages []llm.Message `json:"messages,omitempty"`
|
||||
// Provider is the LLM provider used for the response.
|
||||
Provider string `json:"used_provider,omitempty"`
|
||||
// Model is the LLM model used for the response.
|
||||
Model string `json:"used_model,omitempty"`
|
||||
// Voice is the voice used for TTS synthesis.
|
||||
Voice string `json:"used_voice,omitempty"`
|
||||
}
|
||||
|
||||
// chat processes text chat requests.
|
||||
@@ -490,7 +484,9 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "messages required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.InfoContext(ctx, "messages",
|
||||
log.DebugContext(
|
||||
ctx,
|
||||
"messages",
|
||||
slog.Any("data", req.Messages),
|
||||
)
|
||||
|
||||
@@ -540,7 +536,9 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
msgs, err := llmc.Query(ctx, req.Messages, model, req.SystemMessage, 0)
|
||||
msgs, err := llmc.Completions(
|
||||
ctx, model, req.SystemMessage, 0, req.Messages,
|
||||
)
|
||||
if err != nil {
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
@@ -559,10 +557,10 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
final := msgs[len(msgs)-1]
|
||||
log.InfoContext(
|
||||
log.DebugContext(
|
||||
ctx,
|
||||
"LLM response",
|
||||
slog.String("text", final.Content),
|
||||
slog.String("text", final.Text()),
|
||||
slog.String("provider", provider),
|
||||
slog.String("model", model),
|
||||
)
|
||||
@@ -572,7 +570,6 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
|
||||
Messages: msgs,
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
Voice: req.Voice,
|
||||
}); err != nil {
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
@@ -586,14 +583,14 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
|
||||
type StreamMessage struct {
|
||||
// Error is an error message, if any.
|
||||
Error string `json:"error,omitempty"`
|
||||
// Delta is a text fragment from the assistant response.
|
||||
Delta string `json:"delta,omitempty"`
|
||||
// Message is the chat completion message.
|
||||
Message openai.ChatCompletionMessage `json:"message"`
|
||||
Message *llm.Message `json:"message,omitempty"`
|
||||
// Provider is the LLM provider used for the response.
|
||||
Provider string `json:"provider,omitempty"`
|
||||
// Model is the LLM model used for the response.
|
||||
Model string `json:"model,omitempty"`
|
||||
// Voice is the voice used for TTS synthesis.
|
||||
Voice string `json:"voice,omitempty"`
|
||||
}
|
||||
|
||||
// chatStream processes chat requests with streaming SSE output.
|
||||
@@ -685,7 +682,7 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
|
||||
// Helper to send an SSE event.
|
||||
send := func(msg StreamMessage) {
|
||||
send := func(event string, msg StreamMessage) {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
log.ErrorContext(ctx, "failed to marshal SSE event",
|
||||
@@ -693,54 +690,47 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) {
|
||||
)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "event: message\ndata: %s\n\n", data)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// Start streaming LLM query.
|
||||
// Start streaming LLM completions request.
|
||||
var (
|
||||
events = make(chan llm.StreamEvent)
|
||||
llmErr error
|
||||
errs = make(chan error, 1)
|
||||
)
|
||||
go func() {
|
||||
llmErr = llmc.QueryStream(
|
||||
ctx, req.Messages, model, req.SystemMessage, 0, events,
|
||||
errs <- llmc.CompletionsStream(
|
||||
ctx, model, req.SystemMessage, 0, req.Messages, events,
|
||||
)
|
||||
close(errs)
|
||||
}()
|
||||
|
||||
// Consume events and send as SSE.
|
||||
var last StreamMessage
|
||||
for evt := range events {
|
||||
msg := StreamMessage{Message: evt.Message}
|
||||
|
||||
// Track the last assistant message for TTS.
|
||||
if evt.Message.Role == openai.ChatMessageRoleAssistant &&
|
||||
len(evt.Message.ToolCalls) == 0 {
|
||||
last = msg
|
||||
continue
|
||||
msg := StreamMessage{
|
||||
Delta: evt.Delta,
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
}
|
||||
|
||||
send(msg)
|
||||
if evt.Message.Role != "" {
|
||||
msg.Message = &evt.Message
|
||||
}
|
||||
send(evt.Type, msg)
|
||||
}
|
||||
// Check for LLM errors.
|
||||
if llmErr != nil {
|
||||
if err := <-errs; err != nil {
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
"LLM stream failed",
|
||||
slog.Any("error", llmErr),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
send(StreamMessage{
|
||||
Message: openai.ChatCompletionMessage{
|
||||
Role: openai.ChatMessageRoleAssistant,
|
||||
},
|
||||
Error: fmt.Sprintf("LLM error: %v", llmErr),
|
||||
msg := llm.Message{Role: llm.RoleAssistant}
|
||||
send("message", StreamMessage{
|
||||
Message: &msg,
|
||||
Error: fmt.Sprintf("LLM error: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
last.Provider = provider
|
||||
last.Model = model
|
||||
last.Voice = req.Voice
|
||||
send(last)
|
||||
}
|
||||
|
||||
// ModelStatus represents the availability status of a model.
|
||||
|
||||
@@ -554,6 +554,10 @@ body {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.message--streaming {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.message__debug {
|
||||
display: none;
|
||||
padding: var(--s-2) var(--s-1);
|
||||
|
||||
@@ -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