Added llm client and ui

This commit is contained in:
Radu Macocian (admac)
2026-06-10 15:59:13 +02:00
parent f3a6d44493
commit bbf01ed041
8 changed files with 537 additions and 0 deletions
+1
View File
@@ -25,3 +25,4 @@ go.work.sum
# env file # env file
.env .env
.config.json
+117
View File
@@ -0,0 +1,117 @@
package config
import (
"encoding/json"
"errors"
"io"
"log/slog"
"os"
)
var ENV_PREFIX = "SOUV__"
type Config struct {
Api ApiConfig
Llm LLMConfig
Logging []LogConfig
}
type ApiConfig struct {
Url string
Key string
}
type LLMConfig struct {
Model string
}
type LogConfig struct {
Level slog.Level
Format string
Target string
}
func Load(path string) (*Config, error) {
cfg := defaultConfig()
if path != "" {
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
}
}
overrideFromEnv(cfg)
configLogger(*cfg)
return cfg, cfg.validate()
}
func configLogger(cfg Config) {
if len(cfg.Logging) < 1 {
opts := &slog.HandlerOptions{Level: slog.LevelInfo.Level()}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, opts)))
return
}
var handlers []slog.Handler
for _, c := range cfg.Logging {
opts := &slog.HandlerOptions{Level: c.Level}
target, err := getLogTarget(c.Target)
if err != nil {
slog.Error("Config error. Could not open target", "target", c.Target)
}
switch c.Format {
case "text":
handlers = append(handlers, slog.NewTextHandler(target, opts))
case "json":
handlers = append(handlers, slog.NewJSONHandler(target, opts))
}
}
slog.SetDefault(slog.New(slog.NewMultiHandler(handlers...)))
}
func getLogTarget(target string) (io.Writer, error) {
switch target {
case "", "stdout":
return os.Stdout, nil
case "stderr":
return os.Stderr, nil
case "discrd":
return io.Discard, nil
default:
return os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
}
}
func defaultConfig() *Config {
return &Config{
ApiConfig{
Url: "",
Key: ""},
LLMConfig{Model: ""},
[]LogConfig{{Level: slog.LevelInfo.Level(), Format: "text"}},
}
}
func overrideFromEnv(cfg *Config) {
val := os.Getenv(ENV_PREFIX + "api__url")
if val != "" {
cfg.Api.Url = val
}
val = os.Getenv(ENV_PREFIX + "api__key")
if val != "" {
cfg.Api.Key = val
}
val = os.Getenv(ENV_PREFIX + "llm__model")
if val != "" {
cfg.Llm.Model = val
}
}
func (cfg Config) validate() error {
if cfg.Api.Key == "" {
return errors.New("No API key configured")
}
if cfg.Api.Url == "" {
return errors.New("No API url configured")
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
package config
import "encoding/json"
type Tool struct {
Type string `json:"type"`
Function FunctionDefinition `json:"function"`
}
type FunctionDefinition struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
}
func AvailableTools() []Tool {
return []Tool{}
}
+25
View File
@@ -1,3 +1,28 @@
module git.estatecloud.org/radumaco/souvenir module git.estatecloud.org/radumaco/souvenir
go 1.26.3 go 1.26.3
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.7
charm.land/lipgloss/v2 v2.0.3
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
)
+46
View File
@@ -0,0 +1,46 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek=
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"log/slog"
"os"
tea "charm.land/bubbletea/v2"
"git.estatecloud.org/radumaco/souvenir/config"
ui "git.estatecloud.org/radumaco/souvenir/ui"
)
func main() {
config, err := config.Load(".config.json")
if err != nil {
slog.Error("Config error:", "message", err.Error())
os.Exit(1)
}
var logger = slog.Default().With("Compoent", "Main")
logger.Debug("Config initialized")
p := tea.NewProgram(ui.InitialModel(*config))
if _, err := p.Run(); err != nil {
logger.Error("Alas, there's been an error:", "message", err)
os.Exit(1)
}
}
+129
View File
@@ -0,0 +1,129 @@
package llm
import (
"bytes"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strconv"
config "git.estatecloud.org/radumaco/souvenir/config"
)
type LLMClient struct {
Cfg config.Config
}
// Request Types
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Tools []config.Tool `json:"tools,omitempty"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
// Response types //
type ChatResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []Choice `json:"choices"`
Usage Usage `json:"usage"`
}
type Choice struct {
Index int `json:"index"`
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
}
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function ToolCallFunc `json:"function"`
}
type ToolCallFunc struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
func (client LLMClient) Call(query string, messages []Message) ([]Message, error) {
var logger = slog.Default().With("Component", "LLM Client")
requestBody := ChatRequest{
Model: client.Cfg.Llm.Model,
Messages: messages,
Tools: config.AvailableTools(),
}
serialized, err := json.Marshal(requestBody)
if err != nil {
return []Message{}, err
}
body := bytes.NewBuffer(serialized)
logger.Debug("Executing llm call", "query", query, "messages", messages, "body", serialized)
req, err := http.NewRequest("POST", client.Cfg.Api.Url, body)
if err != nil {
logger.Error("There was an error creating the request", "error", err.Error())
return []Message{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+client.Cfg.Api.Key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
logger.Error("There was an error during http call", "error", err.Error())
return []Message{}, err
}
logger.Debug("LLM Call returned", "response", resp)
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
logger.Error("There was an error reading the response body", "error", err.Error())
return []Message{}, err
}
if resp.StatusCode != 200 && resp.StatusCode != 202 {
logger.Error("Call returned non 200 status",
"statusCode", resp.StatusCode,
"status", resp.Status)
return []Message{},
errors.New("Call returned invalid status " +
strconv.Itoa(resp.StatusCode) +
resp.Status)
}
logger.Debug("Received data", "data", data)
var response ChatResponse
if err := json.Unmarshal(data, &response); err != nil {
logger.Error("There was an error parsing the response", "error", err.Error())
return []Message{}, err
}
var result []Message
if len(response.Choices) == 0 {
logger.Error("The call returned an empty response")
return []Message{}, errors.New("The call returned an empty response")
}
for _, choice := range response.Choices {
logger.Error("Appending response message", "message", choice.Message)
result = append(result, choice.Message)
}
return result, nil
}
+175
View File
@@ -0,0 +1,175 @@
package ui
import (
"fmt"
"log/slog"
"strings"
"charm.land/bubbles/v2/cursor"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"git.estatecloud.org/radumaco/souvenir/config"
llm "git.estatecloud.org/radumaco/souvenir/llm"
)
var logger *slog.Logger
type model struct {
viewport viewport.Model
messages []llm.Message
textarea textarea.Model
senderStyle lipgloss.Style
receiverStyle lipgloss.Style
client llm.LLMClient
waiting bool
err error
}
type agentResponseMessage struct {
response []llm.Message
err error
}
func InitialModel(config config.Config) model {
logger = slog.Default().With("Component", "UI")
ta := textarea.New()
ta.Placeholder = "Send a message..."
ta.SetVirtualCursor(false)
ta.Focus()
ta.Prompt = "⦀ "
ta.CharLimit = 280
ta.SetWidth(30)
ta.SetHeight(3)
s := ta.Styles()
s.Focused.CursorLine = lipgloss.NewStyle()
ta.SetStyles(s)
ta.ShowLineNumbers = false
vp := viewport.New(viewport.WithWidth(30), viewport.WithHeight(5))
vp.SetContent(`What are we doing on today?`)
vp.KeyMap.Left.SetEnabled(false)
vp.KeyMap.Right.SetEnabled(false)
ta.KeyMap.InsertNewline.SetEnabled(false)
return model{
textarea: ta,
messages: []llm.Message{},
viewport: vp,
senderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("5")),
client: llm.LLMClient{Cfg: config},
err: nil,
}
}
func (m model) Init() tea.Cmd {
return textarea.Blink
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
logger.Debug("Received WindowSizeMsg", "width", msg.Width, "height", msg.Height)
m.viewport.SetWidth(msg.Width)
m.textarea.SetWidth(msg.Width)
m.viewport.SetHeight(msg.Height - m.textarea.Height())
var messages = getMessages(m.messages)
if len(m.messages) > 0 {
m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(messages))
}
m.viewport.GotoBottom()
case tea.KeyPressMsg:
logger.Debug("Received KeyPressMsg")
switch msg.String() {
case "ctrl+c", "esc":
logger.Debug("Received exit sequence. Quiting")
fmt.Println(m.textarea.Value())
return m, tea.Quit
case "enter":
input := m.textarea.Value()
m.messages = append(m.messages, llm.Message{
Content: m.senderStyle.Render(input),
Role: "You",
})
logger.Debug("Received enter. Creating new message", "message", input)
var messages = getMessages(m.messages)
m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(messages))
m.textarea.Reset()
m.viewport.GotoBottom()
m.waiting = true
return m, m.callAgent(input, m.messages)
default:
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
case cursor.BlinkMsg:
logger.Debug("Received BlinkMsg")
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
case agentResponseMessage:
logger.Debug("Received agentResponseMessage")
m.waiting = false
resp := msg.response
if msg.err != nil {
logger.Error("Message is error", "error", msg.err)
resp = []llm.Message{{
Role: "Error",
Content: msg.err.Error(),
}}
}
for _, r := range resp {
logger.Debug("Appending response message", "message", r)
m.messages = append(m.messages, r)
}
var messages = getMessages(m.messages)
m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(messages))
return m, nil
}
return m, nil
}
func (m model) View() tea.View {
viewportView := m.viewport.View()
v := tea.NewView(viewportView + "\n" + m.textarea.View())
c := m.textarea.Cursor()
if c != nil {
c.Y += lipgloss.Height(viewportView)
}
v.Cursor = c
v.AltScreen = true
return v
}
func (m model) callAgent(input string, messages []llm.Message) tea.Cmd {
logger.Debug("Calling agent", "query", input, "messages", messages)
return func() tea.Msg {
resp, err := m.client.Call(input, messages)
return agentResponseMessage{response: resp, err: err}
}
}
func getMessages(m []llm.Message) string {
var messages strings.Builder
for _, m := range m {
messages.WriteString(m.Role)
messages.WriteString(": ")
messages.WriteString(m.Content)
messages.WriteString("\n")
}
return messages.String()
}