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:
@@ -13,7 +13,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
|
||||
"code.chimeric.al/chimerical/odidere/internal/config"
|
||||
"code.chimeric.al/chimerical/odidere/internal/llm"
|
||||
@@ -124,7 +123,7 @@ type Result struct {
|
||||
Duration time.Duration
|
||||
// Messages is the full conversation returned by the LLM agent loop,
|
||||
// including tool calls and tool results. Empty if the job failed.
|
||||
Messages []openai.ChatCompletionMessage
|
||||
Messages []llm.Message
|
||||
// Error is set if the job failed.
|
||||
Error error
|
||||
}
|
||||
@@ -138,10 +137,15 @@ func (j *Job) Run(ctx context.Context) Result {
|
||||
log.Info("starting job")
|
||||
|
||||
// Build messages from job config.
|
||||
messages := []openai.ChatCompletionMessage{
|
||||
messages := []llm.Message{
|
||||
{
|
||||
Role: openai.ChatMessageRoleUser,
|
||||
Content: j.Task(),
|
||||
Role: llm.RoleUser,
|
||||
ContentParts: []llm.ContentPart{
|
||||
{
|
||||
Text: j.Task(),
|
||||
Type: llm.ContentTypeText,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -152,12 +156,12 @@ func (j *Job) Run(ctx context.Context) Result {
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
msgs, err := j.llm.Query(
|
||||
msgs, err := j.llm.Completions(
|
||||
ctx,
|
||||
messages,
|
||||
j.model,
|
||||
j.SystemMessage(),
|
||||
j.MaxIterations(),
|
||||
messages,
|
||||
)
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
82
internal/llm/client.go
Normal file
82
internal/llm/client.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds the configuration for an LLM client.
|
||||
type Config struct {
|
||||
// Key is the API key for authentication.
|
||||
Key string `yaml:"key"`
|
||||
// SystemMessage is prepended to all conversations.
|
||||
SystemMessage string `yaml:"system_message"`
|
||||
// Timeout is the maximum duration for a query (e.g., "5m").
|
||||
// Defaults to 5 minutes if empty.
|
||||
Timeout string `yaml:"timeout"`
|
||||
// URL is the base URL of the OpenAI-compatible API endpoint.
|
||||
URL string `yaml:"url"`
|
||||
}
|
||||
|
||||
// Validate checks that required configuration values are present and valid.
|
||||
func (cfg Config) Validate() error {
|
||||
if cfg.Timeout != "" {
|
||||
if _, err := time.ParseDuration(cfg.Timeout); err != nil {
|
||||
return fmt.Errorf("invalid timeout: %w", err)
|
||||
}
|
||||
}
|
||||
if cfg.URL == "" {
|
||||
return fmt.Errorf("missing URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Client wraps an OpenAI-compatible client with tool execution support.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpc *http.Client
|
||||
key string
|
||||
log *slog.Logger
|
||||
registry *Registry
|
||||
systemMessage string
|
||||
timeout time.Duration
|
||||
tools []APITool
|
||||
}
|
||||
|
||||
// NewClient creates a new LLM client with the provided configuration.
|
||||
// The registry is optional; if nil, tool calling is disabled.
|
||||
func NewClient(
|
||||
cfg Config,
|
||||
registry *Registry,
|
||||
log *slog.Logger,
|
||||
) (*Client, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
timeout := 5 * time.Minute
|
||||
if cfg.Timeout != "" {
|
||||
d, err := time.ParseDuration(cfg.Timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse timeout: %v", err)
|
||||
}
|
||||
timeout = d
|
||||
}
|
||||
|
||||
llm := &Client{
|
||||
baseURL: cfg.URL,
|
||||
key: cfg.Key,
|
||||
log: log,
|
||||
systemMessage: cfg.SystemMessage,
|
||||
registry: registry,
|
||||
httpc: &http.Client{},
|
||||
timeout: timeout,
|
||||
}
|
||||
|
||||
// Convert tools from registry.
|
||||
llm.tools = registry.ToAPI()
|
||||
|
||||
return llm, nil
|
||||
}
|
||||
482
internal/llm/client_completions.go
Normal file
482
internal/llm/client_completions.go
Normal file
@@ -0,0 +1,482 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrMaxIterations is returned when the agent loop exceeds the configured
|
||||
// maximum number of iterations.
|
||||
var ErrMaxIterations = fmt.Errorf("max iterations exceeded")
|
||||
|
||||
// Completions sends messages to the LLM using the specified model.
|
||||
// If systemMessage is non-empty, it overrides the configured system message.
|
||||
// maxIterations caps the number of agent loop iterations.
|
||||
// A value of 0 means unlimited (no cap).
|
||||
// Returns all messages generated during the completions request, including
|
||||
// tool calls and tool results.
|
||||
func (c *Client) Completions(
|
||||
ctx context.Context,
|
||||
model string,
|
||||
systemMessage string,
|
||||
maxIterations int,
|
||||
messages []Message,
|
||||
) ([]Message, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Use request system message if provided, config default otherwise.
|
||||
smsg := c.systemMessage
|
||||
if systemMessage != "" {
|
||||
smsg = systemMessage
|
||||
}
|
||||
// Prepend system message, if configured and not already present.
|
||||
if smsg != "" &&
|
||||
(len(messages) == 0 || messages[0].Role != RoleSystem) {
|
||||
messages = append(
|
||||
[]Message{{
|
||||
Role: RoleSystem,
|
||||
ContentParts: []ContentPart{{
|
||||
Type: ContentTypeText,
|
||||
Text: smsg,
|
||||
}},
|
||||
}},
|
||||
messages...,
|
||||
)
|
||||
}
|
||||
|
||||
// Remember the starting length so we can return only the messages
|
||||
// generated during this call (the suffix of the conversation history).
|
||||
start := len(messages)
|
||||
// Loop for tool calls. maxIterations == 0 means unlimited.
|
||||
for i := 0; maxIterations == 0 || i < maxIterations; i++ {
|
||||
cr, err := c.completions(ctx, model, messages)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("completions: %w", err)
|
||||
}
|
||||
// Use first choice for tool call loop.
|
||||
message := &cr.Choices[0].Message
|
||||
messages = append(messages, *message)
|
||||
if len(message.ToolCalls) == 0 {
|
||||
return messages[start:], nil
|
||||
}
|
||||
toolResults, err := c.callTools(ctx, *message)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("completions: %w", err)
|
||||
}
|
||||
messages = append(messages, toolResults...)
|
||||
}
|
||||
|
||||
return messages[start:], ErrMaxIterations
|
||||
}
|
||||
|
||||
// completions sends a single non-streaming request and returns the
|
||||
// full API response.
|
||||
func (c *Client) completions(
|
||||
ctx context.Context, model string, messages []Message,
|
||||
) (*ChatResponse, error) {
|
||||
body, err := json.Marshal(ChatRequest{
|
||||
Model: model,
|
||||
Messages: messages,
|
||||
Tools: c.tools,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"completions: marshal request body: %w", err,
|
||||
)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
c.baseURL+"/chat/completions",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("completions: create request: %w", err)
|
||||
}
|
||||
if c.key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.key)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
res, err := c.httpc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("completions: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
c.log.ErrorContext(
|
||||
ctx,
|
||||
"completions: failed to read error response body",
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return nil, fmt.Errorf(
|
||||
"completions: status code %d", res.StatusCode,
|
||||
)
|
||||
}
|
||||
var apiError struct {
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(
|
||||
body, &apiError,
|
||||
); err == nil && apiError.Error != nil {
|
||||
c.log.ErrorContext(
|
||||
ctx,
|
||||
"completions: request error",
|
||||
slog.Int("status", res.StatusCode),
|
||||
slog.String("code", apiError.Error.Code),
|
||||
slog.String(
|
||||
"message", apiError.Error.Message,
|
||||
),
|
||||
slog.String("type", apiError.Error.Type),
|
||||
)
|
||||
return nil, fmt.Errorf(
|
||||
"completions: %s: %s",
|
||||
apiError.Error.Code,
|
||||
apiError.Error.Message,
|
||||
)
|
||||
}
|
||||
c.log.ErrorContext(
|
||||
ctx,
|
||||
"completions: API error",
|
||||
slog.Int("status", res.StatusCode),
|
||||
slog.String("body", string(body)),
|
||||
)
|
||||
return nil, fmt.Errorf(
|
||||
"completions: %d: %s",
|
||||
res.StatusCode, string(body),
|
||||
)
|
||||
}
|
||||
|
||||
var cr = &ChatResponse{}
|
||||
if err := json.NewDecoder(res.Body).Decode(cr); err != nil {
|
||||
return nil, fmt.Errorf("completions: decode response: %w", err)
|
||||
}
|
||||
if len(cr.Choices) == 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"completions: no response choices returned",
|
||||
)
|
||||
}
|
||||
return cr, nil
|
||||
}
|
||||
|
||||
// StreamEvent wraps a message or delta produced during streaming.
|
||||
type StreamEvent struct {
|
||||
// Type identifies the event kind: delta, done, or message.
|
||||
Type string
|
||||
// Delta is a text fragment from the assistant response.
|
||||
Delta string
|
||||
// Message is the complete chat completion message.
|
||||
Message Message
|
||||
}
|
||||
|
||||
const (
|
||||
streamEventDelta = "delta"
|
||||
streamEventDone = "done"
|
||||
streamEventMessage = "message"
|
||||
)
|
||||
|
||||
// CompletionsStream sends messages to the LLM using the specified model and
|
||||
// streams results. Each complete message (assistant reply, tool call,
|
||||
// tool result) is sent to the events channel as it becomes available.
|
||||
// The channel is closed before returning.
|
||||
// If systemMessage is non-empty, it overrides the configured system message.
|
||||
// maxIterations caps the number of agent loop iterations; a value of 0
|
||||
// means unlimited (no cap).
|
||||
// Returns ErrMaxIterations if the iteration limit is exceeded.
|
||||
func (c *Client) CompletionsStream(
|
||||
ctx context.Context,
|
||||
model string,
|
||||
systemMessage string,
|
||||
maxIterations int,
|
||||
messages []Message,
|
||||
events chan<- StreamEvent,
|
||||
) error {
|
||||
defer close(events)
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Use request system message if provided, config default otherwise.
|
||||
smsg := c.systemMessage
|
||||
if systemMessage != "" {
|
||||
smsg = systemMessage
|
||||
}
|
||||
|
||||
// Prepend system message, if configured and not already present.
|
||||
if smsg != "" &&
|
||||
(len(messages) == 0 || messages[0].Role != RoleSystem) {
|
||||
messages = append(
|
||||
[]Message{{
|
||||
Role: RoleSystem,
|
||||
ContentParts: []ContentPart{
|
||||
{Type: ContentTypeText, Text: smsg},
|
||||
},
|
||||
}},
|
||||
messages...,
|
||||
)
|
||||
}
|
||||
|
||||
// Loop for tool calls. maxIterations == 0 means unlimited.
|
||||
for i := 0; maxIterations == 0 || i < maxIterations; i++ {
|
||||
choices, err := c.completionsStream(ctx, messages, model, events)
|
||||
if err != nil {
|
||||
return fmt.Errorf("completions stream: %w", err)
|
||||
}
|
||||
// Use first choice for tool call loop.
|
||||
message := choices[0]
|
||||
if len(message.ToolCalls) == 0 {
|
||||
return nil
|
||||
}
|
||||
toolResults, err := c.callTools(ctx, message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("completions stream: %w", err)
|
||||
}
|
||||
for _, tr := range toolResults {
|
||||
events <- StreamEvent{
|
||||
Type: streamEventMessage,
|
||||
Message: tr,
|
||||
}
|
||||
}
|
||||
messages = append(messages, choices...)
|
||||
messages = append(messages, toolResults...)
|
||||
}
|
||||
|
||||
return ErrMaxIterations
|
||||
}
|
||||
|
||||
// streamAccum accumulates deltas for a single choice in a streaming response.
|
||||
type streamAccum struct {
|
||||
content strings.Builder
|
||||
reasoning strings.Builder
|
||||
refusal strings.Builder
|
||||
role string
|
||||
toolCalls []ToolCall
|
||||
}
|
||||
|
||||
// completionsStream sends a single streaming request, accumulates the
|
||||
// response, and returns all assistant messages from the response choices.
|
||||
// Sends each message to the events channel.
|
||||
func (c *Client) completionsStream(ctx context.Context, messages []Message, model string, events chan<- StreamEvent) ([]Message, error) {
|
||||
body, err := json.Marshal(ChatRequest{
|
||||
Model: model,
|
||||
Messages: messages,
|
||||
Stream: true,
|
||||
Tools: c.tools,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"completions stream: marshal request body: %w", err,
|
||||
)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx, http.MethodPost,
|
||||
c.baseURL+"/chat/completions",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"completions stream: create request: %w", err,
|
||||
)
|
||||
}
|
||||
if c.key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.key)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
|
||||
res, err := c.httpc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("completions stream: %w", err)
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
c.log.ErrorContext(
|
||||
ctx,
|
||||
"completions stream: failed to read error response body",
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return nil, fmt.Errorf(
|
||||
"completions stream: status code %d",
|
||||
res.StatusCode,
|
||||
)
|
||||
}
|
||||
var apiError struct {
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(
|
||||
body, &apiError,
|
||||
); err == nil && apiError.Error != nil {
|
||||
c.log.ErrorContext(
|
||||
ctx,
|
||||
"completions stream: request error",
|
||||
slog.Int("status", res.StatusCode),
|
||||
slog.String("code", apiError.Error.Code),
|
||||
slog.String(
|
||||
"message", apiError.Error.Message,
|
||||
),
|
||||
slog.String("type", apiError.Error.Type),
|
||||
)
|
||||
return nil, fmt.Errorf(
|
||||
"completions stream: %s: %s",
|
||||
apiError.Error.Code,
|
||||
apiError.Error.Message,
|
||||
)
|
||||
}
|
||||
c.log.ErrorContext(
|
||||
ctx,
|
||||
"completions stream: API error",
|
||||
slog.Int("status", res.StatusCode),
|
||||
slog.String("body", string(body)),
|
||||
)
|
||||
return nil, fmt.Errorf(
|
||||
"completions stream: %d: %s",
|
||||
res.StatusCode, string(body),
|
||||
)
|
||||
}
|
||||
|
||||
// Accumulate the streamed response per choice index.
|
||||
accums := make(map[int]*streamAccum)
|
||||
|
||||
scanner := bufio.NewScanner(res.Body)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// Skip empty lines and comments.
|
||||
if line == "" || line == ":" {
|
||||
continue
|
||||
}
|
||||
// Parse data lines.
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
// [DONE] signals end of stream.
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
}
|
||||
var chunk StreamChunk
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, ch := range chunk.Choices {
|
||||
acc, ok := accums[ch.Index]
|
||||
if !ok {
|
||||
acc = &streamAccum{}
|
||||
accums[ch.Index] = acc
|
||||
}
|
||||
delta := ch.Delta
|
||||
if delta.Role != "" {
|
||||
acc.role = delta.Role
|
||||
}
|
||||
if delta.Content != "" {
|
||||
acc.content.WriteString(delta.Content)
|
||||
events <- StreamEvent{
|
||||
Type: streamEventDelta,
|
||||
Delta: delta.Content,
|
||||
}
|
||||
}
|
||||
if delta.Refusal != "" {
|
||||
acc.refusal.WriteString(delta.Refusal)
|
||||
}
|
||||
if delta.ReasoningContent != "" {
|
||||
acc.reasoning.WriteString(
|
||||
delta.ReasoningContent,
|
||||
)
|
||||
}
|
||||
// Accumulate tool call deltas by index.
|
||||
for _, tc := range delta.ToolCalls {
|
||||
idx := 0
|
||||
if tc.Index != nil {
|
||||
idx = *tc.Index
|
||||
}
|
||||
// Grow the slice as needed.
|
||||
for len(acc.toolCalls) <= idx {
|
||||
acc.toolCalls = append(
|
||||
acc.toolCalls,
|
||||
ToolCall{Type: "function"},
|
||||
)
|
||||
}
|
||||
if tc.ID != "" {
|
||||
acc.toolCalls[idx].ID = tc.ID
|
||||
}
|
||||
if tc.Function.Name != "" {
|
||||
acc.toolCalls[idx].Function.Name +=
|
||||
tc.Function.Name
|
||||
}
|
||||
if tc.Function.Arguments != "" {
|
||||
acc.toolCalls[idx].Function.Arguments +=
|
||||
tc.Function.Arguments
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"completions stream: read error: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// Build messages in index order.
|
||||
indices := make([]int, 0, len(accums))
|
||||
for idx := range accums {
|
||||
indices = append(indices, idx)
|
||||
}
|
||||
slices.Sort(indices)
|
||||
|
||||
msgs := make([]Message, 0, len(accums))
|
||||
for _, idx := range indices {
|
||||
acc := accums[idx]
|
||||
|
||||
// Content and refusal are mutually exclusive in the API.
|
||||
var contentParts []ContentPart
|
||||
if refusalStr := acc.refusal.String(); refusalStr != "" {
|
||||
contentParts = []ContentPart{{
|
||||
Type: ContentTypeRefusal,
|
||||
Refusal: refusalStr,
|
||||
}}
|
||||
} else {
|
||||
contentParts = []ContentPart{{
|
||||
Type: ContentTypeText,
|
||||
Text: acc.content.String(),
|
||||
}}
|
||||
}
|
||||
|
||||
message := Message{
|
||||
Role: acc.role,
|
||||
ContentParts: contentParts,
|
||||
Refusal: acc.refusal.String(),
|
||||
ReasoningContent: acc.reasoning.String(),
|
||||
ToolCalls: acc.toolCalls,
|
||||
}
|
||||
events <- StreamEvent{
|
||||
Type: streamEventDone,
|
||||
Message: message,
|
||||
}
|
||||
msgs = append(msgs, message)
|
||||
}
|
||||
|
||||
return msgs, nil
|
||||
}
|
||||
92
internal/llm/client_models.go
Normal file
92
internal/llm/client_models.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ListModels returns available models from the LLM server.
|
||||
func (c *Client) ListModels(ctx context.Context) ([]Model, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx, http.MethodGet, c.baseURL+"/models", nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
if c.key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.key)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
res, err := c.httpc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list models: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
c.log.ErrorContext(
|
||||
ctx,
|
||||
"list models: failed to read error response body",
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return nil, fmt.Errorf(
|
||||
"list models: status code %d", res.StatusCode,
|
||||
)
|
||||
}
|
||||
var apiError struct {
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(
|
||||
body, &apiError,
|
||||
); err == nil && apiError.Error != nil {
|
||||
c.log.ErrorContext(
|
||||
ctx,
|
||||
"list models: request error",
|
||||
slog.Int("status", res.StatusCode),
|
||||
slog.String("code", apiError.Error.Code),
|
||||
slog.String(
|
||||
"message", apiError.Error.Message,
|
||||
),
|
||||
slog.String("type", apiError.Error.Type),
|
||||
)
|
||||
return nil, fmt.Errorf(
|
||||
"list models: %s: %s",
|
||||
apiError.Error.Code,
|
||||
apiError.Error.Message,
|
||||
)
|
||||
}
|
||||
c.log.ErrorContext(
|
||||
ctx,
|
||||
"list models: API error",
|
||||
slog.Int("status", res.StatusCode),
|
||||
slog.String("body", string(body)),
|
||||
)
|
||||
return nil, fmt.Errorf(
|
||||
"list models: %d: %s",
|
||||
res.StatusCode, string(body),
|
||||
)
|
||||
}
|
||||
|
||||
var body ModelsResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"list models: decode response: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return body.Data, nil
|
||||
}
|
||||
61
internal/llm/client_tools.go
Normal file
61
internal/llm/client_tools.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// callTools executes each tool call in the message and returns the
|
||||
// resulting tool result messages.
|
||||
func (c *Client) callTools(ctx context.Context, msg Message) (
|
||||
results []Message, err error,
|
||||
) {
|
||||
for _, tc := range msg.ToolCalls {
|
||||
c.log.InfoContext(
|
||||
ctx,
|
||||
"calling tool",
|
||||
slog.String("name", tc.Function.Name),
|
||||
slog.String("args", tc.Function.Arguments),
|
||||
)
|
||||
|
||||
result, err := c.registry.Execute(
|
||||
ctx, tc.Function.Name, tc.Function.Arguments,
|
||||
)
|
||||
if err != nil {
|
||||
c.log.ErrorContext(
|
||||
ctx,
|
||||
"failed to call tool",
|
||||
slog.Any("error", err),
|
||||
slog.String("name", tc.Function.Name),
|
||||
)
|
||||
result = fmt.Sprintf(
|
||||
`{"ok": false, "error": %q}`, err,
|
||||
)
|
||||
} else {
|
||||
c.log.InfoContext(
|
||||
ctx,
|
||||
"called tool",
|
||||
slog.String("name", tc.Function.Name),
|
||||
)
|
||||
}
|
||||
|
||||
// Content cannot be empty.
|
||||
if strings.TrimSpace(result) == "" {
|
||||
result = `{"ok": true, "result": null}`
|
||||
}
|
||||
|
||||
toolResult := Message{
|
||||
Role: RoleTool,
|
||||
ContentParts: []ContentPart{{
|
||||
Type: ContentTypeText, Text: result,
|
||||
}},
|
||||
Name: tc.Function.Name,
|
||||
ToolCallID: tc.ID,
|
||||
}
|
||||
results = append(results, toolResult)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
@@ -1,412 +1,262 @@
|
||||
// Package llm provides an OpenAI-compatible client for LLM interactions.
|
||||
// It handles chat completions with automatic tool call execution.
|
||||
// It handles chat completions with automatic tool call execution, where the
|
||||
// client iteratively sends messages to the LLM, executes any requested tool
|
||||
// calls, and continues until the LLM produces a final text response.
|
||||
//
|
||||
// The package defines the core types for constructing requests and parsing
|
||||
// responses: Message, ChatRequest, ChatResponse, and streaming types
|
||||
// such as StreamChunk and Delta.
|
||||
//
|
||||
// Tools are defined declaratively in YAML configuration, stored as Tool
|
||||
// structs in a Registry, and executed as subprocesses when the LLM invokes
|
||||
// them. The Registry also converts tool definitions into the API format for
|
||||
// inclusion in ChatRequest bodies.
|
||||
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.chimeric.al/chimerical/odidere/internal/tool"
|
||||
|
||||
openai "github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
// Config holds the configuration for an LLM client.
|
||||
type Config struct {
|
||||
// Key is the API key for authentication.
|
||||
Key string `yaml:"key"`
|
||||
// SystemMessage is prepended to all conversations.
|
||||
SystemMessage string `yaml:"system_message"`
|
||||
// Timeout is the maximum duration for a query (e.g., "5m").
|
||||
// Defaults to 5 minutes if empty.
|
||||
Timeout string `yaml:"timeout"`
|
||||
// URL is the base URL of the OpenAI-compatible API endpoint.
|
||||
URL string `yaml:"url"`
|
||||
// Role constants for messages.
|
||||
const (
|
||||
RoleSystem = "system"
|
||||
RoleUser = "user"
|
||||
RoleAssistant = "assistant"
|
||||
RoleTool = "tool"
|
||||
RoleDeveloper = "developer"
|
||||
)
|
||||
|
||||
// APITool defines a tool available to the LLM in the API request format.
|
||||
type APITool struct {
|
||||
Type string `json:"type"` // "function"
|
||||
Function *FunctionDef `json:"function,omitempty"`
|
||||
}
|
||||
|
||||
// Validate checks that required configuration values are present and valid.
|
||||
func (cfg Config) Validate() error {
|
||||
if cfg.Timeout != "" {
|
||||
if _, err := time.ParseDuration(cfg.Timeout); err != nil {
|
||||
return fmt.Errorf("invalid timeout: %w", err)
|
||||
// ChatRequest is the request body for chat completions.
|
||||
type ChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Temperature *float32 `json:"temperature,omitempty"`
|
||||
TopP *float32 `json:"top_p,omitempty"`
|
||||
N int `json:"n,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Stop []string `json:"stop,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
|
||||
Seed *int `json:"seed,omitempty"`
|
||||
PresencePenalty *float32 `json:"presence_penalty,omitempty"`
|
||||
FrequencyPenalty *float32 `json:"frequency_penalty,omitempty"`
|
||||
LogProbs bool `json:"logprobs,omitempty"`
|
||||
TopLogProbs int `json:"top_logprobs,omitempty"`
|
||||
Tools []APITool `json:"tools,omitempty"`
|
||||
ToolChoice any `json:"tool_choice,omitempty"`
|
||||
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
|
||||
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
}
|
||||
|
||||
// ChatResponse is the non-streaming response from the API.
|
||||
type ChatResponse struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Choices []Choice `json:"choices"`
|
||||
}
|
||||
|
||||
// Choice is a single choice in a ChatResponse.
|
||||
type Choice struct {
|
||||
Index int `json:"index"`
|
||||
Message Message `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
// ContentType identifies the kind of data in a ContentPart.
|
||||
//
|
||||
// User messages (ChatCompletionContentPart) support: text, image_url,
|
||||
// input_audio, file.
|
||||
//
|
||||
// Assistant messages (ChatCompletionAssistantMessageParam) support:
|
||||
// text, refusal.
|
||||
//
|
||||
// See: https://platform.openai.com/docs/api-reference/chat/create
|
||||
type ContentType string
|
||||
|
||||
const (
|
||||
ContentTypeText ContentType = "text"
|
||||
ContentTypeImageURL ContentType = "image_url"
|
||||
ContentTypeInputAudio ContentType = "input_audio"
|
||||
ContentTypeFile ContentType = "file"
|
||||
ContentTypeRefusal ContentType = "refusal"
|
||||
)
|
||||
|
||||
// ContentPart represents a single part of a multi-content message.
|
||||
type ContentPart struct {
|
||||
Type ContentType `json:"type"`
|
||||
Text string `json:"text"`
|
||||
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||
InputAudio *InputAudio `json:"input_audio,omitempty"`
|
||||
File *FileInput `json:"file,omitempty"`
|
||||
Refusal string `json:"refusal"`
|
||||
}
|
||||
|
||||
// Delta is a partial message update in a streaming response.
|
||||
type Delta struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Refusal string `json:"refusal,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
// FileInput holds file data for file content parts.
|
||||
type FileInput struct {
|
||||
FileData string `json:"file_data,omitempty"`
|
||||
FileID string `json:"file_id,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
}
|
||||
|
||||
// Function holds the name and arguments of a tool call.
|
||||
type Function struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// FunctionDef defines a function that can be called by the LLM.
|
||||
type FunctionDef struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters any `json:"parameters"` // JSON schema
|
||||
Strict bool `json:"strict,omitempty"`
|
||||
}
|
||||
|
||||
// ImageURL holds an image reference for multi-modal messages.
|
||||
type ImageURL struct {
|
||||
URL string `json:"url"`
|
||||
Detail string `json:"detail,omitempty"` // "auto", "low", "high"
|
||||
}
|
||||
|
||||
// InputAudio holds audio data for multi-modal messages.
|
||||
type InputAudio struct {
|
||||
Data string `json:"data"`
|
||||
Format string `json:"format"` // "wav", "mp3"
|
||||
}
|
||||
|
||||
// Message represents a chat message in a conversation.
|
||||
// Content is always stored as []ContentPart internally.
|
||||
// A custom UnmarshalJSON handles the JSON polymorphism where content
|
||||
// can arrive as either a string or an array of parts.
|
||||
//
|
||||
// Refusal handling:
|
||||
//
|
||||
// In API responses (ChatCompletionMessage), the model sends refusal as a
|
||||
// top-level field alongside content:
|
||||
//
|
||||
// "content": "text response"
|
||||
// "refusal": "I can't help with that"
|
||||
//
|
||||
// Only one of content or refusal will be non-empty.
|
||||
//
|
||||
// In API requests (ChatCompletionAssistantMessageParam), the model can
|
||||
// receive refusal as a content part with type "refusal" in the content
|
||||
// array. Our ContentPart type supports this for completeness.
|
||||
//
|
||||
// See: https://platform.openai.com/docs/api-reference/chat/create
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
ContentParts []ContentPart `json:"content,omitempty"`
|
||||
Refusal string `json:"refusal,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
// Text returns the concatenated text from all text content parts.
|
||||
func (m *Message) Text() string {
|
||||
var b strings.Builder
|
||||
for _, p := range m.ContentParts {
|
||||
if p.Type == ContentTypeText {
|
||||
b.WriteString(p.Text)
|
||||
}
|
||||
}
|
||||
if cfg.URL == "" {
|
||||
return fmt.Errorf("missing URL")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// UnmarshalJSON handles the polymorphic content field which can be
|
||||
// either a string or an array of ContentPart objects.
|
||||
func (m *Message) UnmarshalJSON(data []byte) error {
|
||||
type Alias Message
|
||||
aux := &struct {
|
||||
Content any `json:"content"`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(m),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
switch c := aux.Content.(type) {
|
||||
case string:
|
||||
m.ContentParts = []ContentPart{{Type: ContentTypeText, Text: c}}
|
||||
case []any:
|
||||
for _, raw := range c {
|
||||
partBytes, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var part ContentPart
|
||||
if err := json.Unmarshal(partBytes, &part); err != nil {
|
||||
return err
|
||||
}
|
||||
m.ContentParts = append(m.ContentParts, part)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Client wraps an OpenAI-compatible client with tool execution support.
|
||||
type Client struct {
|
||||
client *openai.Client
|
||||
log *slog.Logger
|
||||
registry *tool.Registry
|
||||
systemMessage string
|
||||
timeout time.Duration
|
||||
tools []openai.Tool
|
||||
// Model represents an available model from the API.
|
||||
type Model struct {
|
||||
ID string `json:"id"`
|
||||
Created int64 `json:"created"`
|
||||
Object string `json:"object"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
}
|
||||
|
||||
// NewClient creates a new LLM client with the provided configuration.
|
||||
// The registry is optional; if nil, tool calling is disabled.
|
||||
func NewClient(
|
||||
cfg Config,
|
||||
registry *tool.Registry,
|
||||
log *slog.Logger,
|
||||
) (*Client, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
llm := &Client{
|
||||
log: log,
|
||||
systemMessage: cfg.SystemMessage,
|
||||
registry: registry,
|
||||
}
|
||||
|
||||
if cfg.Timeout == "" {
|
||||
llm.timeout = 5 * time.Minute
|
||||
} else {
|
||||
d, err := time.ParseDuration(cfg.Timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse timeout: %v", err)
|
||||
}
|
||||
llm.timeout = d
|
||||
}
|
||||
|
||||
// Setup client.
|
||||
clientConfig := openai.DefaultConfig(cfg.Key)
|
||||
clientConfig.BaseURL = cfg.URL
|
||||
llm.client = openai.NewClientWithConfig(clientConfig)
|
||||
|
||||
// Parse tools.
|
||||
if llm.registry != nil {
|
||||
for _, name := range llm.registry.List() {
|
||||
t, _ := llm.registry.Get(name)
|
||||
llm.tools = append(llm.tools, t.OpenAI())
|
||||
}
|
||||
}
|
||||
|
||||
return llm, nil
|
||||
// ModelsResponse is the response for GET /models.
|
||||
type ModelsResponse struct {
|
||||
Object string `json:"object"`
|
||||
Data []Model `json:"data"`
|
||||
}
|
||||
|
||||
// ListModels returns available models from the LLM server.
|
||||
func (c *Client) ListModels(ctx context.Context) ([]openai.Model, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := c.client.ListModels(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing models: %w", err)
|
||||
}
|
||||
|
||||
return res.Models, nil
|
||||
// ResponseFormat specifies the format of the response.
|
||||
type ResponseFormat struct {
|
||||
Type string `json:"type"`
|
||||
JSONSchema any `json:"json_schema,omitempty"`
|
||||
}
|
||||
|
||||
// ErrMaxIterations is returned when the agent loop exceeds the configured
|
||||
// maximum number of iterations.
|
||||
var ErrMaxIterations = fmt.Errorf("max iterations exceeded")
|
||||
|
||||
// Query sends messages to the LLM using the specified model.
|
||||
// If systemMessage is non-empty, it overrides the configured system message.
|
||||
// maxIterations caps the number of agent loop iterations; a value of 0
|
||||
// means unlimited (no cap).
|
||||
// Returns all messages generated during the query, including tool calls
|
||||
// and tool results. The final message is the last element in the slice.
|
||||
func (c *Client) Query(
|
||||
ctx context.Context,
|
||||
messages []openai.ChatCompletionMessage,
|
||||
model string,
|
||||
systemMessage string,
|
||||
maxIterations int,
|
||||
) ([]openai.ChatCompletionMessage, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Use per-request system message if provided, otherwise fall back to config.
|
||||
effectiveMessage := c.systemMessage
|
||||
if systemMessage != "" {
|
||||
effectiveMessage = systemMessage
|
||||
}
|
||||
|
||||
// Prepend system message, if configured and not already present.
|
||||
if effectiveMessage != "" && (len(messages) == 0 ||
|
||||
messages[0].Role != openai.ChatMessageRoleSystem) {
|
||||
messages = append(
|
||||
[]openai.ChatCompletionMessage{{
|
||||
Role: openai.ChatMessageRoleSystem,
|
||||
Content: effectiveMessage,
|
||||
}},
|
||||
messages...,
|
||||
)
|
||||
}
|
||||
|
||||
// Track messages generated during this query.
|
||||
var generated []openai.ChatCompletionMessage
|
||||
|
||||
// Loop for tool calls. maxIterations == 0 means unlimited.
|
||||
for i := 0; maxIterations == 0 || i < maxIterations; i++ {
|
||||
req := openai.ChatCompletionRequest{
|
||||
Model: model,
|
||||
Messages: messages,
|
||||
}
|
||||
if len(c.tools) > 0 {
|
||||
req.Tools = c.tools
|
||||
}
|
||||
|
||||
res, err := c.client.CreateChatCompletion(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("chat completion: %w", err)
|
||||
}
|
||||
if len(res.Choices) == 0 {
|
||||
return nil, fmt.Errorf("no response choices returned")
|
||||
}
|
||||
|
||||
choice := res.Choices[0]
|
||||
message := choice.Message
|
||||
|
||||
// If no tool calls, we're done.
|
||||
if len(message.ToolCalls) == 0 {
|
||||
generated = append(generated, message)
|
||||
return generated, nil
|
||||
}
|
||||
|
||||
// Add assistant message with tool calls to history.
|
||||
generated = append(generated, message)
|
||||
messages = append(messages, message)
|
||||
|
||||
// Process each tool call.
|
||||
for _, tc := range message.ToolCalls {
|
||||
c.log.InfoContext(
|
||||
ctx,
|
||||
"calling tool",
|
||||
slog.String("name", tc.Function.Name),
|
||||
slog.String("args", tc.Function.Arguments),
|
||||
)
|
||||
|
||||
result, err := c.registry.Execute(
|
||||
ctx, tc.Function.Name, tc.Function.Arguments,
|
||||
)
|
||||
if err != nil {
|
||||
c.log.Error(
|
||||
"failed to call tool",
|
||||
slog.Any("error", err),
|
||||
slog.String("name", tc.Function.Name),
|
||||
)
|
||||
result = fmt.Sprintf(
|
||||
`{"ok": false, "error": %q}`, err,
|
||||
)
|
||||
} else {
|
||||
c.log.Info(
|
||||
"called tool",
|
||||
slog.String("name", tc.Function.Name),
|
||||
)
|
||||
}
|
||||
|
||||
// Content cannot be empty.
|
||||
if strings.TrimSpace(result) == "" {
|
||||
result = `{"ok": true, "result": null}`
|
||||
}
|
||||
|
||||
// Add tool result to messages.
|
||||
toolResult := openai.ChatCompletionMessage{
|
||||
Role: openai.ChatMessageRoleTool,
|
||||
Content: result,
|
||||
Name: tc.Function.Name,
|
||||
ToolCallID: tc.ID,
|
||||
}
|
||||
generated = append(generated, toolResult)
|
||||
messages = append(messages, toolResult)
|
||||
}
|
||||
// Loop to get LLM's response after tool execution.
|
||||
}
|
||||
|
||||
return generated, ErrMaxIterations
|
||||
// StreamChunk is a single SSE chunk from a streaming response.
|
||||
type StreamChunk struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Index int `json:"index"`
|
||||
Delta Delta `json:"delta"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
// StreamEvent wraps a ChatCompletionMessage produced during streaming.
|
||||
type StreamEvent struct {
|
||||
Message openai.ChatCompletionMessage
|
||||
// StreamOptions controls streaming behavior.
|
||||
type StreamOptions struct {
|
||||
IncludeUsage bool `json:"include_usage,omitempty"`
|
||||
}
|
||||
|
||||
// QueryStream sends messages to the LLM using the specified model and
|
||||
// streams results. Each complete message (assistant reply, tool call,
|
||||
// tool result) is sent to the events channel as it becomes available.
|
||||
// The channel is closed before returning.
|
||||
// If systemMessage is non-empty, it overrides the configured system message.
|
||||
// maxIterations caps the number of agent loop iterations; a value of 0
|
||||
// means unlimited (no cap).
|
||||
// Returns ErrMaxIterations if the iteration limit is exceeded.
|
||||
func (c *Client) QueryStream(
|
||||
ctx context.Context,
|
||||
messages []openai.ChatCompletionMessage,
|
||||
model string,
|
||||
systemMessage string,
|
||||
maxIterations int,
|
||||
events chan<- StreamEvent,
|
||||
) error {
|
||||
defer close(events)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Use per-request system message if provided, otherwise fall back to config.
|
||||
effectiveMessage := c.systemMessage
|
||||
if systemMessage != "" {
|
||||
effectiveMessage = systemMessage
|
||||
}
|
||||
|
||||
// Prepend system message, if configured and not already present.
|
||||
if effectiveMessage != "" && (len(messages) == 0 ||
|
||||
messages[0].Role != openai.ChatMessageRoleSystem) {
|
||||
messages = append(
|
||||
[]openai.ChatCompletionMessage{{
|
||||
Role: openai.ChatMessageRoleSystem,
|
||||
Content: effectiveMessage,
|
||||
}},
|
||||
messages...,
|
||||
)
|
||||
}
|
||||
|
||||
// Loop for tool calls. maxIterations == 0 means unlimited.
|
||||
for i := 0; maxIterations == 0 || i < maxIterations; i++ {
|
||||
req := openai.ChatCompletionRequest{
|
||||
Model: model,
|
||||
Messages: messages,
|
||||
}
|
||||
if len(c.tools) > 0 {
|
||||
req.Tools = c.tools
|
||||
}
|
||||
|
||||
stream, err := c.client.CreateChatCompletionStream(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("chat completion stream: %w", err)
|
||||
}
|
||||
|
||||
// Accumulate the streamed response.
|
||||
var (
|
||||
content strings.Builder
|
||||
reasoning strings.Builder
|
||||
toolCalls []openai.ToolCall
|
||||
role string
|
||||
)
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
stream.Close()
|
||||
return fmt.Errorf("stream recv: %w", err)
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check the first Choice. Only one is expected, since
|
||||
// our request does not set N > 1.
|
||||
delta := chunk.Choices[0].Delta
|
||||
if delta.Role != "" {
|
||||
role = delta.Role
|
||||
}
|
||||
if delta.Content != "" {
|
||||
content.WriteString(delta.Content)
|
||||
}
|
||||
if delta.ReasoningContent != "" {
|
||||
reasoning.WriteString(delta.ReasoningContent)
|
||||
}
|
||||
|
||||
// Accumulate tool call deltas by index.
|
||||
for _, tc := range delta.ToolCalls {
|
||||
i := 0
|
||||
if tc.Index != nil {
|
||||
i = *tc.Index
|
||||
}
|
||||
// Grow the slice as needed.
|
||||
for len(toolCalls) <= i {
|
||||
toolCalls = append(
|
||||
toolCalls,
|
||||
openai.ToolCall{
|
||||
Type: openai.ToolTypeFunction,
|
||||
},
|
||||
)
|
||||
}
|
||||
if tc.ID != "" {
|
||||
toolCalls[i].ID = tc.ID
|
||||
}
|
||||
if tc.Function.Name != "" {
|
||||
toolCalls[i].Function.Name +=
|
||||
tc.Function.Name
|
||||
}
|
||||
if tc.Function.Arguments != "" {
|
||||
toolCalls[i].Function.Arguments +=
|
||||
tc.Function.Arguments
|
||||
}
|
||||
}
|
||||
}
|
||||
stream.Close()
|
||||
|
||||
// Build the complete message from accumulated buffers.
|
||||
message := openai.ChatCompletionMessage{
|
||||
Role: role,
|
||||
Content: content.String(),
|
||||
ReasoningContent: reasoning.String(),
|
||||
ToolCalls: toolCalls,
|
||||
}
|
||||
events <- StreamEvent{Message: message}
|
||||
|
||||
// If no tool calls, we're done.
|
||||
if len(toolCalls) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add assistant message with tool calls to history.
|
||||
messages = append(messages, message)
|
||||
|
||||
// Process each tool call.
|
||||
for _, tc := range message.ToolCalls {
|
||||
c.log.InfoContext(
|
||||
ctx,
|
||||
"calling tool",
|
||||
slog.String("name", tc.Function.Name),
|
||||
slog.String("args", tc.Function.Arguments),
|
||||
)
|
||||
|
||||
result, err := c.registry.Execute(
|
||||
ctx, tc.Function.Name, tc.Function.Arguments,
|
||||
)
|
||||
if err != nil {
|
||||
c.log.Error(
|
||||
"failed to call tool",
|
||||
slog.Any("error", err),
|
||||
slog.String("name", tc.Function.Name),
|
||||
)
|
||||
result = fmt.Sprintf(
|
||||
`{"ok": false, "error": %q}`, err,
|
||||
)
|
||||
}
|
||||
// Content cannot be empty.
|
||||
if strings.TrimSpace(result) == "" {
|
||||
result = `{"ok": true, "result": null}`
|
||||
}
|
||||
|
||||
// Add tool result to messages.
|
||||
toolResult := openai.ChatCompletionMessage{
|
||||
Content: result,
|
||||
Name: tc.Function.Name,
|
||||
Role: openai.ChatMessageRoleTool,
|
||||
ToolCallID: tc.ID,
|
||||
}
|
||||
messages = append(messages, toolResult)
|
||||
events <- StreamEvent{Message: toolResult}
|
||||
}
|
||||
// Loop to get LLM's response after tool execution.
|
||||
}
|
||||
|
||||
return ErrMaxIterations
|
||||
// ToolCall represents a tool call made by the LLM.
|
||||
type ToolCall struct {
|
||||
Index *int `json:"index,omitempty"` // streaming only
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type"` // "function"
|
||||
Function Function `json:"function"`
|
||||
}
|
||||
|
||||
208
internal/llm/llm_integration_test.go
Normal file
208
internal/llm/llm_integration_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"code.chimeric.al/chimerical/odidere/internal/config"
|
||||
)
|
||||
|
||||
// testURL returns the LLM endpoint for integration tests.
|
||||
// Set ODIDERE_LLM_TEST_URL to enable; tests skip if empty.
|
||||
func testURL(t *testing.T) string {
|
||||
t.Helper()
|
||||
url := os.Getenv("ODIDERE_LLM_TEST_URL")
|
||||
if url == "" {
|
||||
t.Skip("ODIDERE_LLM_TEST_URL not set")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// testKey returns the API key for integration tests.
|
||||
// Reads ODIDERE_LLM_TEST_KEY; empty string is valid (no auth).
|
||||
func testKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
return os.Getenv("ODIDERE_LLM_TEST_KEY")
|
||||
}
|
||||
|
||||
// testModel returns the model ID for integration tests.
|
||||
// Reads ODIDERE_LLM_TEST_MODEL; defaults to "test" if empty.
|
||||
func testModel(t *testing.T) string {
|
||||
t.Helper()
|
||||
model := os.Getenv("ODIDERE_LLM_TEST_MODEL")
|
||||
if model == "" {
|
||||
return "test"
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func TestIntegration_ListModels(t *testing.T) {
|
||||
_ = testURL(t)
|
||||
cfg := Config{
|
||||
URL: testURL(t),
|
||||
Key: testKey(t),
|
||||
Timeout: "30s",
|
||||
}
|
||||
client, err := NewClient(cfg, nil, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
|
||||
models, err := client.ListModels(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListModels: %v", err)
|
||||
}
|
||||
if len(models) == 0 {
|
||||
t.Error("expected at least one model")
|
||||
}
|
||||
for _, m := range models {
|
||||
if m.ID == "" {
|
||||
t.Error("model has empty ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Completions(t *testing.T) {
|
||||
_ = testURL(t)
|
||||
cfg := Config{
|
||||
URL: testURL(t),
|
||||
Key: testKey(t),
|
||||
Timeout: "60s",
|
||||
}
|
||||
client, err := NewClient(cfg, nil, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
|
||||
msgs, err := client.Completions(
|
||||
context.Background(),
|
||||
testModel(t),
|
||||
"",
|
||||
0,
|
||||
[]Message{{
|
||||
Role: RoleUser,
|
||||
ContentParts: []ContentPart{{
|
||||
Type: "text",
|
||||
Text: "Say hello in three words.",
|
||||
}},
|
||||
}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Completions: %v", err)
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
t.Fatal("expected at least one message")
|
||||
}
|
||||
text := msgs[len(msgs)-1].Text()
|
||||
if text == "" {
|
||||
t.Error("final message has no text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_CompletionsStream(t *testing.T) {
|
||||
_ = testURL(t)
|
||||
cfg := Config{
|
||||
URL: testURL(t),
|
||||
Key: testKey(t),
|
||||
Timeout: "60s",
|
||||
}
|
||||
client, err := NewClient(cfg, nil, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
|
||||
events := make(chan StreamEvent)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- client.CompletionsStream(
|
||||
context.Background(),
|
||||
testModel(t),
|
||||
"",
|
||||
0,
|
||||
[]Message{{
|
||||
Role: RoleUser,
|
||||
ContentParts: []ContentPart{{
|
||||
Type: "text",
|
||||
Text: "Say hello in three words.",
|
||||
}},
|
||||
}},
|
||||
events,
|
||||
)
|
||||
}()
|
||||
|
||||
var messages []Message
|
||||
for evt := range events {
|
||||
messages = append(messages, evt.Message)
|
||||
}
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("CompletionsStream: %v", err)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
t.Fatal("expected at least one message")
|
||||
}
|
||||
text := messages[len(messages)-1].Text()
|
||||
if text == "" {
|
||||
t.Error("final message has no text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_ToolCalling(t *testing.T) {
|
||||
_ = testURL(t)
|
||||
|
||||
// Build a minimal tool registry with a single echo tool.
|
||||
reg, err := NewRegistry([]config.ToolConfig{
|
||||
{
|
||||
Name: "echo",
|
||||
Description: "Echo back the input string",
|
||||
Command: "echo",
|
||||
Arguments: []string{"{{.input}}"},
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"input": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []string{"input"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRegistry: %v", err)
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
URL: testURL(t),
|
||||
Key: testKey(t),
|
||||
Timeout: "60s",
|
||||
}
|
||||
client, err := NewClient(cfg, reg, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
|
||||
msgs, err := client.Completions(
|
||||
context.Background(),
|
||||
testModel(t),
|
||||
"",
|
||||
10,
|
||||
[]Message{{
|
||||
Role: RoleUser,
|
||||
ContentParts: []ContentPart{{
|
||||
Type: "text",
|
||||
Text: "Use the echo tool to say 'hello'",
|
||||
}},
|
||||
}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Completions: %v", err)
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
t.Fatal("expected at least one message")
|
||||
}
|
||||
// The final message should be a non-tool-call assistant message.
|
||||
final := msgs[len(msgs)-1]
|
||||
if len(final.ToolCalls) > 0 {
|
||||
t.Error("final message still has tool calls")
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,4 @@
|
||||
// Package tool provides a registry for external tools that can be invoked by
|
||||
// LLMs.
|
||||
//
|
||||
// The package bridges YAML configuration to exec.CommandContext, allowing
|
||||
// tools to be defined declaratively without writing Go code. Each tool
|
||||
// specifies a command, argument templates using Go's text/template syntax,
|
||||
// JSON Schema parameters for LLM input, and an optional execution timeout.
|
||||
//
|
||||
// The registry validates all tool definitions at construction time,
|
||||
// failing fast on configuration errors. At execution time, it expands
|
||||
// argument templates with LLM-provided JSON, runs the subprocess, and
|
||||
// returns stdout.
|
||||
package tool
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -21,8 +9,6 @@ import (
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
|
||||
"code.chimeric.al/chimerical/odidere/internal/config"
|
||||
)
|
||||
|
||||
@@ -35,7 +21,8 @@ var fn = template.FuncMap{
|
||||
}
|
||||
|
||||
// Tool represents an external tool that can be invoked by LLMs.
|
||||
// Tools are executed as subprocesses with templated arguments.
|
||||
// Tools are executed as subprocesses with templated arguments,
|
||||
// and described to the LLM via their JSON Schema parameters.
|
||||
type Tool struct {
|
||||
// Name uniquely identifies the tool within a registry.
|
||||
Name string
|
||||
@@ -74,18 +61,6 @@ func NewTool(cfg config.ToolConfig) (*Tool, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenAI converts the tool to an OpenAI function definition for API calls.
|
||||
func (t *Tool) OpenAI() openai.Tool {
|
||||
return openai.Tool{
|
||||
Type: openai.ToolTypeFunction,
|
||||
Function: &openai.FunctionDefinition{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.Parameters,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseArguments expands argument templates with the provided JSON data.
|
||||
// The args parameter should be a JSON object string; empty string or "{}"
|
||||
// results in an empty data map. Templates producing empty strings are
|
||||
@@ -94,7 +69,9 @@ func (t *Tool) ParseArguments(args string) ([]string, error) {
|
||||
var data = map[string]any{}
|
||||
if args != "" && args != "{}" {
|
||||
if err := json.Unmarshal([]byte(args), &data); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments JSON: %w", err)
|
||||
return nil, fmt.Errorf(
|
||||
"invalid arguments JSON: %w", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,12 +79,16 @@ func (t *Tool) ParseArguments(args string) ([]string, error) {
|
||||
for _, v := range t.Arguments {
|
||||
tmpl, err := template.New("").Funcs(fn).Parse(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid template %q: %w", v, err)
|
||||
return nil, fmt.Errorf(
|
||||
"invalid template %q: %w", v, err,
|
||||
)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return nil, fmt.Errorf("execute template %q: %w", v, err)
|
||||
return nil, fmt.Errorf(
|
||||
"execute template %q: %w", v, err,
|
||||
)
|
||||
}
|
||||
|
||||
// Filter out empty strings (unused conditional arguments).
|
||||
@@ -119,6 +100,19 @@ func (t *Tool) ParseArguments(args string) ([]string, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ToAPI converts the tool definition into the API format suitable for
|
||||
// inclusion in a ChatRequest.
|
||||
func (t *Tool) ToAPI() APITool {
|
||||
return APITool{
|
||||
Type: "function",
|
||||
Function: &FunctionDef{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.Parameters,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Registry holds tools indexed by name and handles their execution.
|
||||
// It validates all tool definitions at construction time to fail fast on
|
||||
// configuration errors.
|
||||
@@ -135,11 +129,15 @@ func NewRegistry(tools []config.ToolConfig) (*Registry, error) {
|
||||
for _, tc := range tools {
|
||||
t, err := NewTool(tc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid tool %q: %w", tc.Name, err)
|
||||
return nil, fmt.Errorf(
|
||||
"invalid tool %q: %w", tc.Name, err,
|
||||
)
|
||||
}
|
||||
|
||||
if _, exists := r.tools[t.Name]; exists {
|
||||
return nil, fmt.Errorf("duplicate tool name: %s", t.Name)
|
||||
return nil, fmt.Errorf(
|
||||
"duplicate tool name: %s", t.Name,
|
||||
)
|
||||
}
|
||||
r.tools[t.Name] = t
|
||||
}
|
||||
@@ -162,6 +160,19 @@ func (r *Registry) List() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// ToAPI converts all registered tools into API format suitable for
|
||||
// inclusion in a ChatRequest.
|
||||
func (r *Registry) ToAPI() []APITool {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
tools := make([]APITool, 0, len(r.tools))
|
||||
for _, t := range r.tools {
|
||||
tools = append(tools, t.ToAPI())
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// Execute runs a tool by name with the provided JSON arguments.
|
||||
// It expands argument templates, executes the command as a subprocess, and
|
||||
// returns stdout on success. The context can be used for cancellation;
|
||||
@@ -1,4 +1,4 @@
|
||||
package tool
|
||||
package llm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -7,18 +7,6 @@ import (
|
||||
"code.chimeric.al/chimerical/odidere/internal/config"
|
||||
)
|
||||
|
||||
func TestTool_OpenAI(t *testing.T) {
|
||||
tool := &Tool{
|
||||
Name: "test_tool",
|
||||
Description: "test description",
|
||||
Parameters: map[string]any{"type": "object"},
|
||||
}
|
||||
openaiTool := tool.OpenAI()
|
||||
if openaiTool.Function.Name != "test_tool" {
|
||||
t.Errorf("expected name test_tool, got %s", openaiTool.Function.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_ParseArguments(t *testing.T) {
|
||||
tool := &Tool{
|
||||
Arguments: []string{"--name {{.name}}", "{{if .flag}}--enabled{{end}}"},
|
||||
@@ -144,3 +132,72 @@ func TestRegistry_NewRegistry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_ToAPI_Nil(t *testing.T) {
|
||||
var reg *Registry
|
||||
got := reg.ToAPI()
|
||||
if got != nil {
|
||||
t.Errorf("Registry.ToAPI(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_ToAPI_Empty(t *testing.T) {
|
||||
reg, err := NewRegistry(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRegistry(nil) error = %v", err)
|
||||
}
|
||||
got := reg.ToAPI()
|
||||
if len(got) != 0 {
|
||||
t.Errorf("Registry.ToAPI(empty) len = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_ToAPI_Single(t *testing.T) {
|
||||
reg, err := NewRegistry([]config.ToolConfig{
|
||||
{
|
||||
Name: "weather",
|
||||
Description: "Get the weather",
|
||||
Parameters: map[string]any{"type": "object"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRegistry error = %v", err)
|
||||
}
|
||||
got := reg.ToAPI()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].Type != "function" {
|
||||
t.Errorf("type = %q, want %q", got[0].Type, "function")
|
||||
}
|
||||
if got[0].Function.Name != "weather" {
|
||||
t.Errorf("name = %q, want %q", got[0].Function.Name, "weather")
|
||||
}
|
||||
if got[0].Function.Description != "Get the weather" {
|
||||
t.Errorf("description = %q, want %q", got[0].Function.Description, "Get the weather")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_ToAPI(t *testing.T) {
|
||||
tool := &Tool{
|
||||
Name: "test",
|
||||
Description: "A test tool",
|
||||
Parameters: map[string]any{"type": "object"},
|
||||
Command: "echo",
|
||||
Arguments: []string{"hello"},
|
||||
}
|
||||
|
||||
api := tool.ToAPI()
|
||||
if api.Type != "function" {
|
||||
t.Errorf("type = %q, want %q", api.Type, "function")
|
||||
}
|
||||
if api.Function.Name != "test" {
|
||||
t.Errorf("name = %q, want %q", api.Function.Name, "test")
|
||||
}
|
||||
if api.Function.Description != "A test tool" {
|
||||
t.Errorf("description = %q, want %q", api.Function.Description, "A test tool")
|
||||
}
|
||||
if api.Function.Parameters == nil {
|
||||
t.Error("parameters is nil")
|
||||
}
|
||||
}
|
||||
@@ -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