Add service orchestration and web UI

This commit is contained in:
dwrz
2026-02-13 15:03:02 +00:00
parent 6f0509ff18
commit d5a27c776e
17 changed files with 3890 additions and 0 deletions

130
internal/config/config.go Normal file
View File

@@ -0,0 +1,130 @@
// Package config loads and validates YAML configuration for odidere.
package config
import (
"fmt"
"os"
"regexp"
"time"
"github.com/chimerical-llc/odidere/internal/llm"
"github.com/chimerical-llc/odidere/internal/stt"
"github.com/chimerical-llc/odidere/internal/tool"
"github.com/chimerical-llc/odidere/internal/tts"
"gopkg.in/yaml.v3"
)
// Config holds all application configuration sections.
type Config struct {
// Address is the host:port to listen on.
Address string `yaml:"address"`
// Concurrency is the number of concurrent requests to allow.
// Defaults to 1.
Concurrency int `yaml:"concurrency"`
// ShutdownTimeout is the maximum time to wait for graceful shutdown.
// Defaults to "30s".
ShutdownTimeout string `yaml:"shutdown_timeout"`
// shutdownTimeout is the parsed duration, set during Load.
shutdownTimeout time.Duration `yaml:"-"`
// LLM configures the language model client.
LLM llm.Config `yaml:"llm"`
// STT configures the speech-to-text client.
STT stt.Config `yaml:"stt"`
// Tools defines external commands available to the LLM.
Tools []tool.Tool `yaml:"tools"`
// TTS configures the text-to-speech client.
TTS tts.Config `yaml:"tts"`
}
// ApplyDefaults sets default values for optional configuration fields.
// Called automatically by Load before validation.
func (cfg *Config) ApplyDefaults() {
if cfg.Concurrency == 0 {
cfg.Concurrency = 1
}
if cfg.ShutdownTimeout == "" {
cfg.ShutdownTimeout = "30s"
}
if cfg.Address == "" {
cfg.Address = ":8080"
}
}
// GetShutdownTimeout returns the parsed shutdown timeout duration.
func (cfg *Config) GetShutdownTimeout() time.Duration {
return cfg.shutdownTimeout
}
// Validate checks that all required fields are present and valid.
// Delegates to each subsection's Validate method.
func (cfg *Config) Validate() error {
if cfg.Address == "" {
return fmt.Errorf("address required")
}
if err := cfg.LLM.Validate(); err != nil {
return fmt.Errorf("invalid llm config: %w", err)
}
if err := cfg.STT.Validate(); err != nil {
return fmt.Errorf("invalid stt config: %w", err)
}
if err := cfg.TTS.Validate(); err != nil {
return fmt.Errorf("invalid tts config: %w", err)
}
if _, err := time.ParseDuration(cfg.ShutdownTimeout); err != nil {
return fmt.Errorf(
"invalid shutdown_timeout %q: %w",
cfg.ShutdownTimeout, err,
)
}
for i, t := range cfg.Tools {
if err := t.Validate(); err != nil {
return fmt.Errorf("tools[%d] invalid: %w", i, err)
}
}
return nil
}
// Load reads a YAML configuration file, expands environment variables,
// applies defaults, and validates the result. Returns an error if the
// file cannot be read, parsed, or contains invalid configuration.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
// Expand environment variables.
// Unset variables are replaced with empty strings.
re := regexp.MustCompile(`\$\{([^}]+)\}`)
expanded := re.ReplaceAllStringFunc(
string(data),
func(match string) string {
// Extract variable name from ${VAR}.
v := match[2 : len(match)-1]
return os.Getenv(v)
},
)
var cfg Config
if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
cfg.ApplyDefaults()
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %v", err)
}
d, err := time.ParseDuration(cfg.ShutdownTimeout)
if err != nil {
return nil, fmt.Errorf("parse shutdown timeout: %v", err)
}
cfg.shutdownTimeout = d
return &cfg, nil
}

View File

@@ -0,0 +1,399 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
)
// validConfig returns a minimal valid YAML configuration.
func validConfig() string {
return `
address: ":9090"
concurrency: 2
shutdown_timeout: "60s"
llm:
model: test-model
url: http://localhost:8080
stt:
url: http://localhost:8178
tts:
url: http://localhost:8880
voice: af_heart
`
}
func writeConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write config file: %v", err)
}
return path
}
func TestLoad(t *testing.T) {
tests := []struct {
name string
config string
setup func(t *testing.T)
shouldErr bool
check func(t *testing.T, cfg *Config)
}{
{
name: "valid config",
config: validConfig(),
check: func(t *testing.T, cfg *Config) {
if cfg.Address != ":9090" {
t.Errorf(
"Address = %q, want %q",
cfg.Address, ":9090",
)
}
if cfg.Concurrency != 2 {
t.Errorf(
"Concurrency = %d, want 2",
cfg.Concurrency,
)
}
if cfg.ShutdownTimeout != "60s" {
t.Errorf(
"ShutdownTimeout = %q, want %q",
cfg.ShutdownTimeout, "60s",
)
}
if cfg.GetShutdownTimeout() != 60*time.Second {
t.Errorf(
"GetShutdownTimeout() = %v, want 60s",
cfg.GetShutdownTimeout(),
)
}
},
},
{
name: "defaults applied",
config: `
llm:
model: test-model
url: http://localhost:8080
stt:
url: http://localhost:8178
tts:
url: http://localhost:8880
voice: af_heart
`,
check: func(t *testing.T, cfg *Config) {
if cfg.Address != ":8080" {
t.Errorf(
"Address = %q, want %q",
cfg.Address, ":8080",
)
}
if cfg.Concurrency != 1 {
t.Errorf(
"Concurrency = %d, want 1",
cfg.Concurrency,
)
}
if cfg.ShutdownTimeout != "30s" {
t.Errorf(
"ShutdownTimeout = %q, want %q",
cfg.ShutdownTimeout, "30s",
)
}
if cfg.GetShutdownTimeout() != 30*time.Second {
t.Errorf(
"GetShutdownTimeout() = %v, want 30s",
cfg.GetShutdownTimeout(),
)
}
},
},
{
name: "env expansion",
config: `
llm:
model: test-model
url: http://localhost:8080
key: ${TEST_LLM_KEY}
stt:
url: http://localhost:8178
tts:
url: http://localhost:8880
voice: af_heart
`,
setup: func(t *testing.T) {
t.Setenv("TEST_LLM_KEY", "secret-api-key")
},
check: func(t *testing.T, cfg *Config) {
if cfg.LLM.Key != "secret-api-key" {
t.Errorf(
"LLM.Key = %q, want %q",
cfg.LLM.Key, "secret-api-key",
)
}
},
},
{
name: "env expansion unset var becomes empty",
config: `
llm:
model: test-model
url: http://localhost:8080
key: ${TEST_UNSET_VAR}
stt:
url: http://localhost:8178
tts:
url: http://localhost:8880
voice: af_heart
`,
check: func(t *testing.T, cfg *Config) {
if cfg.LLM.Key != "" {
t.Errorf(
"LLM.Key = %q, want empty",
cfg.LLM.Key,
)
}
},
},
{
name: "invalid yaml",
config: `llm: [invalid yaml`,
shouldErr: true,
},
{
name: "invalid shutdown_timeout",
config: `
shutdown_timeout: "not-a-duration"
llm:
model: test-model
url: http://localhost:8080
stt:
url: http://localhost:8178
tts:
url: http://localhost:8880
voice: af_heart
`,
shouldErr: true,
},
{
name: "missing llm model",
config: `
llm:
url: http://localhost:8080
stt:
url: http://localhost:8178
tts:
url: http://localhost:8880
voice: af_heart
`,
shouldErr: true,
},
{
name: "missing llm url",
config: `
llm:
model: test-model
stt:
url: http://localhost:8178
tts:
url: http://localhost:8880
voice: af_heart
`,
shouldErr: true,
},
{
name: "missing stt url",
config: `
llm:
model: test-model
url: http://localhost:8080
stt:
timeout: "30s"
tts:
url: http://localhost:8880
voice: af_heart
`,
shouldErr: true,
},
{
name: "missing tts url",
config: `
llm:
model: test-model
url: http://localhost:8080
stt:
url: http://localhost:8178
tts:
voice: af_heart
`,
shouldErr: true,
},
{
name: "missing tts voice",
config: `
llm:
model: test-model
url: http://localhost:8080
stt:
url: http://localhost:8178
tts:
url: http://localhost:8880
`,
shouldErr: true,
},
{
name: "config with tools",
config: `
llm:
model: test-model
url: http://localhost:8080
stt:
url: http://localhost:8178
tts:
url: http://localhost:8880
voice: af_heart
tools:
- name: echo
description: echoes input
command: echo
arguments:
- "{{.message}}"
`,
check: func(t *testing.T, cfg *Config) {
if len(cfg.Tools) != 1 {
t.Errorf(
"len(Tools) = %d, want 1",
len(cfg.Tools),
)
}
if cfg.Tools[0].Name != "echo" {
t.Errorf(
"Tools[0].Name = %q, want %q",
cfg.Tools[0].Name, "echo",
)
}
},
},
{
name: "invalid tool",
config: `
llm:
model: test-model
url: http://localhost:8080
stt:
url: http://localhost:8178
tts:
url: http://localhost:8880
voice: af_heart
tools:
- name: ""
description: missing name
command: echo
`,
shouldErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setup != nil {
tt.setup(t)
}
path := writeConfig(t, tt.config)
cfg, err := Load(path)
if tt.shouldErr {
if err == nil {
t.Fatal("Load() expected error")
}
return
}
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if tt.check != nil {
tt.check(t, cfg)
}
})
}
}
func TestLoad_FileNotFound(t *testing.T) {
if _, err := Load("/nonexistent/path/config.yaml"); err == nil {
t.Fatal("Load() expected error for missing file")
}
}
func TestApplyDefaults(t *testing.T) {
cfg := &Config{}
cfg.ApplyDefaults()
if cfg.Address != ":8080" {
t.Errorf("Address = %q, want %q", cfg.Address, ":8080")
}
if cfg.Concurrency != 1 {
t.Errorf("Concurrency = %d, want 1", cfg.Concurrency)
}
if cfg.ShutdownTimeout != "30s" {
t.Errorf(
"ShutdownTimeout = %q, want %q",
cfg.ShutdownTimeout, "30s",
)
}
}
func TestApplyDefaults_NoOverwrite(t *testing.T) {
cfg := &Config{
Address: ":9999",
Concurrency: 5,
ShutdownTimeout: "120s",
}
cfg.ApplyDefaults()
if cfg.Address != ":9999" {
t.Errorf("Address = %q, want %q", cfg.Address, ":9999")
}
if cfg.Concurrency != 5 {
t.Errorf("Concurrency = %d, want 5", cfg.Concurrency)
}
if cfg.ShutdownTimeout != "120s" {
t.Errorf(
"ShutdownTimeout = %q, want %q",
cfg.ShutdownTimeout, "120s",
)
}
}

856
internal/service/service.go Normal file
View File

@@ -0,0 +1,856 @@
// Package service orchestrates the odidere voice assistant server.
// It coordinates the HTTP server, STT/LLM/TTS clients, and handles
// graceful shutdown.
package service
import (
"context"
"embed"
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"os"
"os/signal"
"runtime/debug"
"strings"
"syscall"
"time"
"github.com/chimerical-llc/odidere/internal/config"
"github.com/chimerical-llc/odidere/internal/llm"
"github.com/chimerical-llc/odidere/internal/service/templates"
"github.com/chimerical-llc/odidere/internal/stt"
"github.com/chimerical-llc/odidere/internal/tool"
"github.com/chimerical-llc/odidere/internal/tts"
"github.com/google/uuid"
openai "github.com/sashabaranov/go-openai"
"golang.org/x/sync/semaphore"
)
//go:embed all:static/*
var static embed.FS
// Service is the main application coordinator.
// It owns the HTTP server and all processing clients.
type Service struct {
cfg *config.Config
llm *llm.Client
log *slog.Logger
mux *http.ServeMux
sem *semaphore.Weighted
server *http.Server
stt *stt.Client
tmpl *template.Template
tools *tool.Registry
tts *tts.Client
}
// New creates a Service from the provided configuration.
// It initializes all clients and the HTTP server.
func New(cfg *config.Config, log *slog.Logger) (*Service, error) {
var svc = &Service{
cfg: cfg,
log: log,
mux: http.NewServeMux(),
sem: semaphore.NewWeighted(int64(cfg.Concurrency)),
}
// Setup tool registry.
registry, err := tool.NewRegistry(cfg.Tools)
if err != nil {
return nil, fmt.Errorf("load tools: %v", err)
}
svc.tools = registry
// Create STT client.
sttClient, err := stt.NewClient(cfg.STT, log)
if err != nil {
return nil, fmt.Errorf("create STT client: %v", err)
}
svc.stt = sttClient
// Create LLM client.
llmClient, err := llm.NewClient(cfg.LLM, registry, log)
if err != nil {
return nil, fmt.Errorf("create LLM client: %v", err)
}
svc.llm = llmClient
// Create TTS client.
ttsClient, err := tts.NewClient(cfg.TTS, log)
if err != nil {
return nil, fmt.Errorf("create TTS client: %v", err)
}
svc.tts = ttsClient
// Parse templates.
tmpl, err := templates.Parse()
if err != nil {
return nil, fmt.Errorf("parse templates: %v", err)
}
svc.tmpl = tmpl
// Setup static file server.
staticFS, err := fs.Sub(static, "static")
if err != nil {
return nil, fmt.Errorf("setup static fs: %v", err)
}
// Register routes.
svc.mux.HandleFunc("GET /", svc.home)
svc.mux.HandleFunc("GET /status", svc.status)
svc.mux.Handle(
"GET /static/",
http.StripPrefix(
"/static/", http.FileServer(http.FS(staticFS)),
),
)
svc.mux.HandleFunc("POST /v1/chat/voice", svc.voice)
svc.mux.HandleFunc("POST /v1/chat/voice/stream", svc.voiceStream)
svc.mux.HandleFunc("GET /v1/voices", svc.voices)
svc.mux.HandleFunc("GET /v1/models", svc.models)
svc.server = &http.Server{
Addr: cfg.Address,
Handler: svc,
}
return svc, nil
}
// ServeHTTP implements http.Handler. It logs requests, assigns a UUID,
// sets context values, handles panics, and delegates to the mux.
func (svc *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var (
start = time.Now()
id = uuid.NewString()
ip = func() string {
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
if idx := strings.Index(ip, ","); idx != -1 {
return strings.TrimSpace(ip[:idx])
}
return ip
}
return r.RemoteAddr
}()
log = svc.log.With(slog.Group(
"request",
slog.String("id", id),
slog.String("ip", ip),
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
))
)
// Log completion time.
defer func() {
log.InfoContext(
r.Context(),
"completed",
slog.Duration("duration", time.Since(start)),
)
}()
// Panic recovery.
defer func() {
if err := recover(); err != nil {
log.ErrorContext(
r.Context(),
"panic recovered",
slog.Any("error", err),
slog.String("stack", string(debug.Stack())),
)
http.Error(
w,
http.StatusText(
http.StatusInternalServerError,
),
http.StatusInternalServerError,
)
}
}()
// Enrich context with request-scoped values.
ctx := r.Context()
ctx = context.WithValue(ctx, "log", log)
ctx = context.WithValue(ctx, "id", id)
ctx = context.WithValue(ctx, "ip", ip)
r = r.WithContext(ctx)
log.InfoContext(ctx, "handling")
// Pass the request on to the multiplexer.
svc.mux.ServeHTTP(w, r)
}
// Run starts the service and blocks until shutdown.
// Shutdown is triggered by SIGINT or SIGTERM.
func (svc *Service) Run(ctx context.Context) error {
svc.log.Info(
"starting odidere",
slog.Int("concurrency", svc.cfg.Concurrency),
slog.Group(
"llm",
slog.String("url", svc.cfg.LLM.URL),
slog.String("model", svc.cfg.LLM.Model),
),
slog.Group(
"server",
slog.String("address", svc.cfg.Address),
),
slog.Group(
"stt",
slog.String("url", svc.cfg.STT.URL),
),
slog.Group(
"tools",
slog.Int("count", len(svc.tools.List())),
slog.Any(
"names",
strings.Join(svc.tools.List(), ","),
),
),
slog.Group(
"tts",
slog.String("url", svc.cfg.TTS.URL),
slog.String("default_voice", svc.cfg.TTS.Voice),
),
slog.Any("shutdown_timeout", svc.cfg.GetShutdownTimeout()),
)
// Setup signal handling for graceful shutdown.
ctx, cancel := signal.NotifyContext(
ctx,
os.Interrupt, syscall.SIGTERM,
)
defer cancel()
// Start HTTP server in background.
var errs = make(chan error, 1)
go func() {
svc.log.Info(
"HTTP server listening",
slog.String("address", svc.cfg.Address),
)
if err := svc.server.ListenAndServe(); err != nil &&
err != http.ErrServerClosed {
errs <- err
}
close(errs)
}()
// Wait for shutdown signal or server error.
select {
case <-ctx.Done():
svc.log.Info("shutdown signal received")
case err := <-errs:
if err != nil {
return fmt.Errorf("server error: %w", err)
}
}
// Graceful shutdown with timeout.
shutdownCtx, shutdownCancel := context.WithTimeout(
context.Background(),
svc.cfg.GetShutdownTimeout(),
)
defer shutdownCancel()
svc.log.Info("shutting down HTTP server")
if err := svc.server.Shutdown(shutdownCtx); err != nil {
svc.log.Warn(
"shutdown timeout reached",
slog.Any("error", err),
)
}
svc.log.Info("terminating")
return nil
}
func (svc *Service) home(w http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
log = ctx.Value("log").(*slog.Logger)
)
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := svc.tmpl.ExecuteTemplate(
w, "index.gohtml", nil,
); err != nil {
log.ErrorContext(
ctx, "template error", slog.Any("error", err),
)
http.Error(
w,
http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError,
)
}
}
// status returns server status.
func (svc *Service) status(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
// Request is the incoming request format for chat and voice endpoints.
type Request struct {
// Audio is base64-encoded audio data (webm) for transcription.
Audio string `json:"audio,omitempty"`
// Messages is the conversation history.
Messages []openai.ChatCompletionMessage `json:"messages"`
// Model is the LLM model ID. If empty, the default model is used.
Model string `json:"model,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 {
// Audio is the base64-encoded WAV audio response.
Audio string `json:"audio,omitempty"`
// DetectedLanguage is the language detected in the input speech.
DetectedLanguage string `json:"detected_language,omitempty"`
// Messages is the full list of messages generated during the query,
// including tool calls and tool results.
Messages []openai.ChatCompletionMessage `json:"messages,omitempty"`
// Model is the LLM model used for the response.
Model string `json:"used_model,omitempty"`
// Transcription is the transcribed user speech from the input audio.
Transcription string `json:"transcription,omitempty"`
// Voice is the voice used for TTS synthesis.
Voice string `json:"used_voice,omitempty"`
}
// voice processes voice requests with audio input/output.
func (svc *Service) voice(w http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
log = ctx.Value("log").(*slog.Logger)
)
// Parse request.
r.Body = http.MaxBytesReader(w, r.Body, 32<<20)
var req Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.ErrorContext(
ctx,
"failed to decode request",
slog.Any("error", err),
)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
// Validate messages.
if len(req.Messages) == 0 {
http.Error(w, "messages required", http.StatusBadRequest)
return
}
log.InfoContext(ctx, "messages",
slog.Any("data", req.Messages),
)
var (
messages = req.Messages
transcription string
detectedLang string
)
// If audio provided, transcribe and append to last message.
if req.Audio != "" {
last := &messages[len(messages)-1]
if last.Role != openai.ChatMessageRoleUser {
http.Error(
w,
"last message must be role=user when audio is provided",
http.StatusBadRequest,
)
return
}
data, err := base64.StdEncoding.DecodeString(req.Audio)
if err != nil {
log.ErrorContext(
ctx,
"failed to decode audio",
slog.Any("error", err),
)
http.Error(w, "invalid audio", http.StatusBadRequest)
return
}
output, err := svc.stt.Transcribe(ctx, data)
if err != nil {
log.ErrorContext(
ctx,
"STT failed",
slog.Any("error", err),
)
http.Error(
w,
"STT error",
http.StatusInternalServerError,
)
return
}
transcription = strings.TrimSpace(output.Text)
detectedLang = output.DetectedLanguage
if detectedLang == "" {
detectedLang = output.Language
}
log.InfoContext(
ctx,
"transcribed audio",
slog.String("text", transcription),
slog.String("language", detectedLang),
)
// Append transcription to last message's content.
switch {
// Already using MultiContent, append text part.
case len(last.MultiContent) > 0:
last.MultiContent = append(last.MultiContent,
openai.ChatMessagePart{
Type: openai.ChatMessagePartTypeText,
Text: transcription,
},
)
last.Content = ""
// Has string content, convert to MultiContent.
case last.Content != "":
last.MultiContent = []openai.ChatMessagePart{
{
Type: openai.ChatMessagePartTypeText,
Text: last.Content,
},
{
Type: openai.ChatMessagePartTypeText,
Text: transcription,
},
}
last.Content = ""
// Empty message, just set content.
// Clear MultiContent, as required by the API spec.
default:
last.Content = transcription
last.MultiContent = nil
}
}
// Get LLM response.
var model = req.Model
if model == "" {
model = svc.llm.DefaultModel()
}
msgs, err := svc.llm.Query(ctx, messages, model)
if err != nil {
log.ErrorContext(
ctx,
"LLM request failed",
slog.Any("error", err),
)
http.Error(w, "LLM error", http.StatusInternalServerError)
return
}
if len(msgs) == 0 {
http.Error(
w,
"no response from LLM",
http.StatusInternalServerError,
)
return
}
final := msgs[len(msgs)-1]
log.InfoContext(
ctx,
"LLM response",
slog.String("text", final.Content),
slog.String("model", model),
)
// Determine voice to use.
var voice = req.Voice
if req.Voice == "" && detectedLang != "" {
if autoVoice := svc.tts.SelectVoice(
detectedLang,
); autoVoice != "" {
voice = autoVoice
log.InfoContext(ctx, "auto-selected voice",
slog.String("language", detectedLang),
slog.String("voice", voice),
)
}
} else if req.Voice == "" {
log.WarnContext(
ctx,
"auto-voice enabled but no language detected",
)
}
// Generate audio response with selected voice.
audio, err := svc.tts.Synthesize(ctx, final.Content, voice)
if err != nil {
log.ErrorContext(ctx, "TTS failed", slog.Any("error", err))
http.Error(w, "TTS error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(Response{
Audio: base64.StdEncoding.EncodeToString(audio),
DetectedLanguage: detectedLang,
Messages: msgs,
Model: model,
Transcription: transcription,
Voice: voice,
}); err != nil {
log.ErrorContext(
ctx,
"failed to json encode response",
slog.Any("error", err),
)
}
}
// StreamMessage is the SSE event payload for the streaming voice endpoint.
type StreamMessage struct {
// Audio is the base64-encoded WAV audio response.
Audio string `json:"audio,omitempty"`
// DetectedLanguage is the language detected in the input speech.
DetectedLanguage string `json:"detected_language,omitempty"`
// Error is an error message, if any.
Error string `json:"error,omitempty"`
// Message is the chat completion message.
Message openai.ChatCompletionMessage `json:"message"`
// Model is the LLM model used for the response.
Model string `json:"model,omitempty"`
// Transcription is the transcribed user speech from the input audio.
Transcription string `json:"transcription,omitempty"`
// Voice is the voice used for TTS synthesis.
Voice string `json:"voice,omitempty"`
}
// voiceStream processes voice requests with streaming SSE output.
func (svc *Service) voiceStream(w http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
log = ctx.Value("log").(*slog.Logger)
)
// Check that the response writer supports flushing.
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(
w,
"streaming not supported",
http.StatusInternalServerError,
)
return
}
// Parse request.
r.Body = http.MaxBytesReader(w, r.Body, 32<<20)
var req Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.ErrorContext(
ctx,
"failed to decode request",
slog.Any("error", err),
)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
// Validate messages.
if len(req.Messages) == 0 {
http.Error(w, "messages required", http.StatusBadRequest)
return
}
// Acquire semaphore.
if err := svc.sem.Acquire(ctx, 1); err != nil {
http.Error(
w,
"service unavailable",
http.StatusServiceUnavailable,
)
return
}
defer svc.sem.Release(1)
var (
messages = req.Messages
transcription string
detectedLang string
)
// If audio provided, transcribe and append to last message.
if req.Audio != "" {
last := &messages[len(messages)-1]
if last.Role != openai.ChatMessageRoleUser {
http.Error(
w,
"last message must be role=user when audio is provided",
http.StatusBadRequest,
)
return
}
data, err := base64.StdEncoding.DecodeString(req.Audio)
if err != nil {
log.ErrorContext(
ctx,
"failed to decode audio",
slog.Any("error", err),
)
http.Error(w, "invalid audio", http.StatusBadRequest)
return
}
output, err := svc.stt.Transcribe(ctx, data)
if err != nil {
log.ErrorContext(
ctx,
"STT failed",
slog.Any("error", err),
)
http.Error(
w,
"STT error",
http.StatusInternalServerError,
)
return
}
transcription = strings.TrimSpace(output.Text)
detectedLang = output.DetectedLanguage
if detectedLang == "" {
detectedLang = output.Language
}
log.InfoContext(
ctx,
"transcribed audio",
slog.String("text", transcription),
slog.String("language", detectedLang),
)
// Append transcription to last message's content.
switch {
case len(last.MultiContent) > 0:
last.MultiContent = append(last.MultiContent,
openai.ChatMessagePart{
Type: openai.ChatMessagePartTypeText,
Text: transcription,
},
)
last.Content = ""
case last.Content != "":
last.MultiContent = []openai.ChatMessagePart{
{
Type: openai.ChatMessagePartTypeText,
Text: last.Content,
},
{
Type: openai.ChatMessagePartTypeText,
Text: transcription,
},
}
last.Content = ""
default:
last.Content = transcription
last.MultiContent = nil
}
}
// Set SSE headers.
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Content-Type", "text/event-stream")
// Helper to send an SSE event.
send := func(msg StreamMessage) {
data, err := json.Marshal(msg)
if err != nil {
log.ErrorContext(ctx, "failed to marshal SSE event",
slog.Any("error", err),
)
return
}
fmt.Fprintf(w, "event: message\ndata: %s\n\n", data)
flusher.Flush()
}
// If audio was transcribed, send user message with transcription.
if transcription != "" {
send(StreamMessage{
Message: openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleUser,
Content: transcription,
},
Transcription: transcription,
DetectedLanguage: detectedLang,
})
}
// Get model.
var model = req.Model
if model == "" {
model = svc.llm.DefaultModel()
}
// Determine voice to use.
var voice = req.Voice
if req.Voice == "" && detectedLang != "" {
if autoVoice := svc.tts.SelectVoice(
detectedLang,
); autoVoice != "" {
voice = autoVoice
log.InfoContext(ctx, "auto-selected voice",
slog.String("language", detectedLang),
slog.String("voice", voice),
)
}
}
// Start streaming LLM query.
var (
events = make(chan llm.StreamEvent)
llmErr error
)
go func() {
llmErr = svc.llm.QueryStream(ctx, messages, model, events)
}()
// 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
}
send(msg)
}
// Check for LLM errors.
if llmErr != nil {
log.ErrorContext(
ctx,
"LLM stream failed",
slog.Any("error", llmErr),
)
send(StreamMessage{
Message: openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleAssistant,
},
Error: fmt.Sprintf("LLM error: %v", llmErr),
})
return
}
// Synthesize TTS for the final assistant message.
if last.Message.Content != "" {
audio, err := svc.tts.Synthesize(
ctx, last.Message.Content, voice,
)
if err != nil {
log.ErrorContext(
ctx, "TTS failed", slog.Any("error", err),
)
last.Error = fmt.Sprintf("TTS error: %v", err)
} else {
last.Audio = base64.StdEncoding.EncodeToString(audio)
}
}
last.Model = model
last.Voice = voice
send(last)
}
// models returns available LLM models.
func (svc *Service) models(w http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
log = ctx.Value("log").(*slog.Logger)
)
models, err := svc.llm.ListModels(ctx)
if err != nil {
log.ErrorContext(
ctx,
"failed to list models",
slog.Any("error", err),
)
http.Error(
w,
"failed to list models",
http.StatusInternalServerError,
)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(struct {
Models []openai.Model `json:"models"`
DefaultModel string `json:"default_model"`
}{
Models: models,
DefaultModel: svc.llm.DefaultModel(),
}); err != nil {
log.ErrorContext(
ctx,
"failed to encode models response",
slog.Any("error", err),
)
}
}
// voices returns available TTS voices.
func (svc *Service) voices(w http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
log = ctx.Value("log").(*slog.Logger)
)
voices, err := svc.tts.ListVoices(ctx)
if err != nil {
log.ErrorContext(
ctx,
"failed to list voices",
slog.Any("error", err),
)
http.Error(
w,
"failed to list voices",
http.StatusInternalServerError,
)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string][]string{
"voices": voices,
}); err != nil {
log.ErrorContext(
ctx,
"failed to encode voices response",
slog.Any("error", err),
)
}
}

View File

@@ -0,0 +1,24 @@
package service
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestStatusHandler(t *testing.T) {
svc := &Service{}
req := httptest.NewRequest(http.MethodGet, "/status", nil)
w := httptest.NewRecorder()
svc.status(w, req)
if w.Code != http.StatusOK {
t.Errorf(
"status handler returned %d, want %d",
w.Code,
http.StatusOK,
)
}
}

View File

@@ -0,0 +1,93 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="4"/>
<path d="M20 21a8 8 0 1 0-16 0"/>
</symbol>
<symbol id="assistant" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/>
<path d="M20 3v4"/>
<path d="M22 5h-4"/>
<path d="M4 17v2"/>
<path d="M5 18H3"/>
</symbol>
<symbol id="copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
</symbol>
<symbol id="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5"/>
</symbol>
<symbol id="inspect" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</symbol>
<symbol id="tool" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</symbol>
<symbol id="reasoning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/>
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/>
<path d="M3.477 10.896a4 4 0 0 1 .585-.396"/>
<path d="M19.938 10.5a4 4 0 0 1 .585.396"/>
<path d="M6 18a4 4 0 0 1-1.967-.516"/>
<path d="M19.967 17.484A4 4 0 0 1 18 18"/>
</symbol>
<symbol id="reset" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</symbol>
<symbol id="attach" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</symbol>
<symbol id="mic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" x2="12" y1="19" y2="22"/>
</symbol>
<symbol id="send" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/>
<path d="m21.854 2.147-10.94 10.939"/>
</symbol>
<symbol id="menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" x2="20" y1="12" y2="12"/>
<line x1="4" x2="20" y1="6" y2="6"/>
<line x1="4" x2="20" y1="18" y2="18"/>
</symbol>
<symbol id="close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/>
<path d="m6 6 12 12"/>
</symbol>
<symbol id="volume" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z"/>
<path d="M16 9a5 5 0 0 1 0 6"/>
<path d="M19.364 18.364a9 9 0 0 0 0-12.728"/>
</symbol>
<symbol id="volume-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z"/>
<line x1="22" x2="16" y1="9" y2="15"/>
<line x1="16" x2="22" y1="9" y2="15"/>
</symbol>
<symbol id="x-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="m15 9-6 6"/>
<path d="m9 9 6 6"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@@ -0,0 +1,692 @@
:root {
--base-font-size: calc(1rem + 0.1618vw);
--ratio: 1.618;
--s-2: calc(var(--s-1) / var(--ratio));
--s-1: calc(var(--s0) / var(--ratio));
--s0: var(--base-font-size);
--s1: calc(var(--s0) * var(--ratio));
--s2: calc(var(--s1) * var(--ratio));
--s3: calc(var(--s2) * var(--ratio));
--font-sans: system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, monospace;
--color-black: #1d1f21;
--color-blue: #4271ae;
--color-brown: #a3685a;
--color-cyan: #3e999f;
--color-gray0: #efefef;
--color-gray1: #e0e0e0;
--color-gray2: #d6d6d6;
--color-gray3: #8e908c;
--color-gray4: #969896;
--color-gray5: #4d4d4c;
--color-gray6: #282a2e;
--color-green: #718c00;
--color-orange: #f5871f;
--color-purple: #8959a8;
--color-red: #c82829;
--color-yellow: #eab700;
--color-bg: var(--color-gray0);
--color-surface: white;
--color-text: var(--color-black);
--color-text-muted: var(--color-gray3);
--color-border: var(--color-black);
--color-border-light: var(--color-gray2);
--color-primary: var(--color-blue);
--color-primary-hover: var(--color-cyan);
--color-recording: var(--color-red);
--color-error: var(--color-yellow);
--measure: 80ch;
--radius: 0.375rem;
--icon-size: 1.25rem;
--action-icon-size: 0.875rem;
--border-width: 2px;
--textarea-line-height: 1.5rem;
}
/* ==================== */
/* Reset */
/* ==================== */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
}
/* ==================== */
/* Base */
/* ==================== */
html {
font-size: 100%;
font-family: var(--font-sans);
line-height: 1.5;
color: var(--color-text);
background: var(--color-bg);
}
body {
overflow: hidden;
min-height: 100dvh;
}
/* ==================== */
/* Layout */
/* ==================== */
.container {
display: flex;
flex-direction: column;
min-height: 100dvh;
height: 100dvh;
max-width: calc(var(--measure) + var(--s3) * 2);
margin-inline: auto;
padding: var(--s0);
padding-bottom: 0;
}
/* ==================== */
/* Chat */
/* ==================== */
.chat {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: var(--s1);
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-radius: var(--radius);
}
.chat > * + * {
margin-top: var(--s0);
}
/* ==================== */
/* Collapsible */
/* ==================== */
.collapsible {
border: 1px dashed var(--color-border-light);
border-radius: var(--radius);
}
.collapsible[open] .collapsible__summary::before {
transform: rotate(90deg);
}
.collapsible:last-of-type:has(~ .message__actions:empty) {
border-bottom: none;
}
.collapsible__content {
padding: var(--s-2) var(--s-1);
border-top: 1px dashed var(--color-border-light);
}
.collapsible__content > pre {
margin: 0;
font-family: var(--font-mono);
font-size: var(--s-1);
white-space: pre-wrap;
word-break: break-word;
}
.collapsible__label {
font-size: var(--s-1);
color: var(--color-text-muted);
}
.collapsible__pre {
margin: 0;
padding: var(--s-2);
font-family: var(--font-mono);
font-size: var(--s-1);
background: var(--color-bg);
border-radius: var(--radius);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
.collapsible__section {
margin-bottom: var(--s-2);
}
.collapsible__section:last-child {
margin-bottom: 0;
}
.collapsible__section-label {
font-size: var(--s-2);
font-weight: 500;
color: var(--color-text-muted);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.collapsible__summary {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-2) var(--s-1);
cursor: pointer;
user-select: none;
list-style: none;
}
.collapsible__summary::-webkit-details-marker {
display: none;
}
.collapsible__summary::before {
content: "▶";
font-size: 0.625em;
color: var(--color-text-muted);
transition: transform 0.15s ease;
}
.collapsible__summary .icon {
width: var(--icon-size);
height: var(--icon-size);
color: var(--color-text-muted);
}
.collapsible--reasoning .collapsible__summary .icon {
color: var(--color-purple);
}
.collapsible--reasoning:has(+ .collapsible--tool) {
margin-bottom: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.collapsible--reasoning + .collapsible--tool {
margin-top: 0;
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.collapsible--tool .collapsible__summary .icon {
color: var(--color-orange);
}
/* ==================== */
/* Compose */
/* ==================== */
.compose {
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: var(--s0);
}
.compose__action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
background: transparent;
border: none;
border-radius: 0.25rem;
color: var(--color-text-muted);
cursor: pointer;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
@media (hover: hover) {
.compose__action-btn:hover {
color: var(--color-text);
}
}
.compose__action-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
.compose__action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.compose__action-btn .icon {
width: var(--action-icon-size);
height: var(--action-icon-size);
}
.compose__action-btn--record.recording {
color: white;
background: var(--color-recording);
animation: pulse 1s ease-in-out infinite;
}
.compose__action-btn--send.loading {
position: relative;
color: transparent;
pointer-events: none;
}
.compose__action-btn--send.loading::after {
content: "";
position: absolute;
width: 0.75rem;
height: 0.75rem;
border: 2px solid var(--color-gray2);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.compose__actions {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.125rem var(--s-2);
border-top: 1px dotted var(--color-border-light);
}
.compose__actions-right {
display: flex;
align-items: center;
gap: 0.125rem;
}
.compose__attachment {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
font-size: var(--s-1);
background: var(--color-gray1);
border-radius: var(--radius);
color: var(--color-text);
}
.compose__attachment-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compose__attachment-remove {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s ease;
}
@media (hover: hover) {
.compose__attachment-remove:hover {
color: var(--color-red);
}
}
.compose__attachment-remove .icon {
width: 0.875rem;
height: 0.875rem;
}
.compose__attachments {
display: flex;
flex-wrap: wrap;
gap: var(--s-2);
padding: var(--s-2) var(--s-1);
border-top: 1px dotted var(--color-border-light);
}
.compose__attachments:empty {
display: none;
}
.compose__textarea {
display: block;
width: 100%;
height: auto;
min-height: calc(var(--textarea-line-height) + var(--s-1) * 2);
max-height: calc(var(--textarea-line-height) * 5 + var(--s-1) * 2);
padding: var(--s-1);
font-family: inherit;
font-size: var(--s0);
line-height: var(--textarea-line-height);
color: var(--color-text);
background: var(--color-surface);
border: none;
resize: none;
overflow-y: auto;
field-sizing: content;
}
.compose__textarea:focus {
outline: none;
}
.compose__textarea::placeholder {
color: var(--color-text-muted);
}
/* ==================== */
/* Footer */
/* ==================== */
.footer {
margin-top: var(--s0);
background: var(--color-bg);
}
.footer__select {
height: 2rem;
padding: 0 var(--s-1);
font-family: inherit;
font-size: var(--s-1);
line-height: 2rem;
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-radius: var(--radius);
cursor: pointer;
max-width: 150px;
}
.footer__select:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.footer__toolbar {
border-inline: none;
border-bottom: none;
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-2);
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-radius: var(--radius);
}
.footer__toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
height: 2rem;
width: 2rem;
padding: 0;
background: transparent;
border: 1px solid var(--color-border-light);
border-radius: var(--radius);
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.15s ease;
}
@media (hover: hover) {
.footer__toolbar-btn:hover {
color: var(--color-text);
border-color: var(--color-text-muted);
}
}
.footer__toolbar-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.footer__toolbar-btn .icon {
width: 1rem;
height: 1rem;
}
.footer__toolbar-btn--muted {
color: var(--color-red);
border-color: var(--color-red);
}
@media (hover: hover) {
.footer__toolbar-btn--muted:hover {
color: var(--color-red);
border-color: var(--color-red);
}
}
.footer__toolbar-spacer {
flex: 1;
}
/* ==================== */
/* Message */
/* ==================== */
.message {
display: flex;
max-width: 100%;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
}
.message--assistant .message__icon {
color: var(--color-primary);
}
.message--debug-open .message__debug {
display: block;
}
.message--error {
border-color: var(--color-error);
}
.message--error .message__icon {
border-color: var(--color-error);
}
.message--user .message__icon {
color: var(--color-green);
}
.message__action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.125rem;
background: transparent;
border: none;
border-radius: 0.125rem;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s ease;
}
@media (hover: hover) {
.message__action-btn:hover {
color: var(--color-text);
}
}
.message__action-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
.message__action-btn svg {
width: var(--action-icon-size);
height: var(--action-icon-size);
}
.message__action-btn--success {
color: var(--color-primary);
}
.message__actions {
display: flex;
justify-content: flex-end;
gap: 0.125rem;
padding: 0.125rem 0.25rem;
border-top: 1px dotted var(--color-border-light);
background: var(--color-surface);
}
.message__actions:empty {
display: none;
}
.message__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.message__content {
padding: var(--s-2) var(--s-1);
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
.message__content img {
display: block;
max-width: 100%;
height: auto;
margin-block: var(--s-1);
border: 1px solid var(--color-border-light);
border-radius: var(--radius);
}
.message__debug {
display: none;
padding: var(--s-2) var(--s-1);
border-top: 1px dotted var(--color-border-light);
background: var(--color-surface);
}
.message__debug-list {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.125rem 1rem;
margin: 0;
font-size: var(--s-1);
}
.message__debug-list dt,
.message__debug-list dd {
margin: 0;
text-align: left;
color: var(--color-text);
}
.message__debug-list dt {
white-space: nowrap;
}
.message__icon {
display: flex;
align-items: center;
justify-content: center;
padding: var(--s-2);
border-right: var(--border-width) solid var(--color-border);
background: var(--color-bg);
color: var(--color-text-muted);
}
.message__icon svg {
width: var(--icon-size);
height: var(--icon-size);
}
/* ==================== */
/* Animations */
/* ==================== */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ==================== */
/* Media: Mobile */
/* ==================== */
@media (max-width: 639px) {
.container {
padding: 0;
padding-top: var(--s-1);
}
.chat {
border-radius: 0;
border-inline: none;
}
.footer {
margin-top: var(--s-1);
padding-bottom: 0;
}
.compose {
border-radius: 0;
border-inline: none;
margin-bottom: var(--s-1);
}
.compose__action-btn {
min-width: 44px;
min-height: 44px;
padding: var(--s-1);
}
.compose__action-btn .icon {
width: var(--icon-size);
height: var(--icon-size);
}
.footer__toolbar {
border-inline: none;
border-bottom: none;
border-radius: 0;
margin-top: 0;
}
.footer__toolbar-btn {
height: 1.75rem;
width: 1.75rem;
}
.footer__select {
height: 1.75rem;
line-height: 1.75rem;
max-width: 128px;
font-size: var(--s-1);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
{{ define "body" }}
<body>
{{ template "main" . }}
{{ template "templates" . }}
</body>
{{ end }}

View File

@@ -0,0 +1,41 @@
{{ define "footer/compose" }}
<div class="compose">
<textarea
class="compose__textarea"
id="text-input"
placeholder="Type a message..."
aria-label="Message input"
rows="1"
></textarea>
<div class="compose__attachments" id="attachments"></div>
<div class="compose__actions">
<button
type="button"
class="compose__action-btn"
id="attach"
aria-label="Attach files"
>
<svg class="icon"><use href="/static/icons.svg#attach"></use></svg>
</button>
<input type="file" id="file-input" multiple hidden />
<div class="compose__actions-right">
<button
type="button"
class="compose__action-btn compose__action-btn--record"
id="ptt"
aria-label="Push to talk"
>
<svg class="icon"><use href="/static/icons.svg#mic"></use></svg>
</button>
<button
type="button"
class="compose__action-btn compose__action-btn--send"
id="send"
aria-label="Send message"
>
<svg class="icon"><use href="/static/icons.svg#send"></use></svg>
</button>
</div>
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,6 @@
{{ define "footer" }}
<footer class="footer">
{{ template "footer/compose" . }}
{{ template "footer/toolbar" . }}
</footer>
{{ end }}

View File

@@ -0,0 +1,31 @@
{{ define "footer/toolbar" }}
<div class="footer__toolbar">
<button
type="button"
class="footer__toolbar-btn"
id="reset"
aria-label="Reset conversation"
>
<svg class="icon"><use href="/static/icons.svg#reset"></use></svg>
</button>
<div class="footer__toolbar-spacer"></div>
<select id="model" class="footer__select" aria-label="Model">
<option value="" disabled selected>
Loading...
</option>
</select>
<select id="voice" class="footer__select" aria-label="Voice">
<option value="" disabled selected>
Loading...
</option>
</select>
<button
type="button"
class="footer__toolbar-btn"
id="mute"
aria-label="Mute"
>
<svg class="icon"><use href="/static/icons.svg#volume"></use></svg>
</button>
</div>
{{ end }}

View File

@@ -0,0 +1,11 @@
{{ define "head" }}
<head>
<meta name="author" content="Chimerical LLC">
<meta charset="utf-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, shrink-to-fit= no, interactive-widget=resizes-content">
<title>Odidere</title>
<link rel="stylesheet" href="/static/main.css">
<script defer src="/static/main.js"></script>
</head>
{{ end }}

View File

@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en">
{{ template "head" . }}
{{ template "body" . }}
</html>

View File

@@ -0,0 +1,7 @@
{{ define "main" }}
<main class="container">
<section class="chat" id="chat" aria-live="polite">
</section>
{{ template "footer" . }}
</main>
{{ end }}

View File

@@ -0,0 +1,126 @@
{{ define "templates" }}
<template id="tpl-user-message">
<div class="message message--user">
<div class="message__icon" aria-hidden="true">
<svg class="icon"><use href="/static/icons.svg#user"></use></svg>
</div>
<div class="message__body">
<div class="message__content"></div>
<div class="message__actions">
<button
type="button"
class="message__action-btn"
data-action="inspect"
aria-label="Show details"
aria-expanded="false"
>
<svg class="icon"><use href="/static/icons.svg#inspect"></use></svg>
</button>
<button
type="button"
class="message__action-btn"
data-action="copy"
aria-label="Copy to clipboard"
>
<svg class="icon"><use href="/static/icons.svg#copy"></use></svg>
</button>
</div>
<div class="message__debug">
<dl class="message__debug-list"></dl>
</div>
</div>
</div>
</template>
<template id="tpl-assistant-message">
<div class="message message--assistant">
<div class="message__icon" aria-hidden="true">
<svg class="icon"><use href="/static/icons.svg#assistant"></use></svg>
</div>
<div class="message__body">
<div class="message__content"></div>
<div class="message__actions">
<button
type="button"
class="message__action-btn"
data-action="inspect"
aria-label="Show details"
aria-expanded="false"
>
<svg class="icon"><use href="/static/icons.svg#inspect"></use></svg>
</button>
<button
type="button"
class="message__action-btn"
data-action="copy"
aria-label="Copy to clipboard"
>
<svg class="icon"><use href="/static/icons.svg#copy"></use></svg>
</button>
</div>
<div class="message__debug">
<dl class="message__debug-list"></dl>
</div>
</div>
</div>
</template>
<template id="tpl-error-message">
<div class="message message--error message--assistant">
<div class="message__icon" aria-hidden="true">
<svg class="icon"><use href="/static/icons.svg#assistant"></use></svg>
</div>
<div class="message__body">
<div class="message__content"></div>
</div>
</div>
</template>
<template id="tpl-collapsible">
<details class="collapsible">
<summary class="collapsible__summary">
<span class="collapsible__label"></span>
</summary>
<div class="collapsible__content">
<pre></pre>
</div>
</details>
</template>
<template id="tpl-tool-call">
<details class="collapsible collapsible--tool">
<summary class="collapsible__summary">
<svg class="icon"><use href="/static/icons.svg#tool"></use></svg>
<span class="collapsible__label"></span>
</summary>
<div class="collapsible__content">
<div class="collapsible__section">
<div class="collapsible__section-label">Arguments</div>
<pre class="collapsible__pre" data-args></pre>
</div>
<div class="collapsible__section collapsible__section--output" hidden>
<div class="collapsible__section-label">Output</div>
<pre class="collapsible__pre" data-output></pre>
</div>
</div>
</details>
</template>
<template id="tpl-attachment-chip">
<div class="compose__attachment">
<span class="compose__attachment-name"></span>
<button
type="button"
class="compose__attachment-remove"
aria-label="Remove attachment"
>
<svg class="icon"><use href="/static/icons.svg#x-circle"></use></svg>
</button>
</div>
</template>
<template id="tpl-debug-row">
<dt></dt>
<dd></dd>
</template>
{{ end }}

View File

@@ -0,0 +1,43 @@
package templates
import (
"embed"
"fmt"
"html/template"
"io/fs"
"strings"
)
//go:embed static/*
var static embed.FS
const fileType = ".gohtml"
// Parse walks the embedded static directory and parses all .gohtml templates.
func Parse() (*template.Template, error) {
tmpl := template.New("")
parseFS := func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.Contains(path, fileType) {
if _, err := tmpl.ParseFS(static, path); err != nil {
return fmt.Errorf(
"failed to parse template %s: %w",
path, err,
)
}
}
return nil
}
if err := fs.WalkDir(static, ".", parseFS); err != nil {
return nil, fmt.Errorf("failed to parse templates: %w", err)
}
return tmpl, nil
}