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

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
}