diff --git a/.gitignore b/.gitignore index 5b90e79..5272f97 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ go.work.sum # env file .env +.config.json diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..4443619 --- /dev/null +++ b/config/config.go @@ -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 +} diff --git a/config/tools.go b/config/tools.go new file mode 100644 index 0000000..4e88655 --- /dev/null +++ b/config/tools.go @@ -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{} +} diff --git a/go.mod b/go.mod index 0da9e6f..9116d53 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,28 @@ module git.estatecloud.org/radumaco/souvenir 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..41d49a9 --- /dev/null +++ b/go.sum @@ -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= diff --git a/hello.go b/hello.go new file mode 100644 index 0000000..0c353ee --- /dev/null +++ b/hello.go @@ -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) + } +} diff --git a/llm/client.go b/llm/client.go new file mode 100644 index 0000000..16da60d --- /dev/null +++ b/llm/client.go @@ -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 +} diff --git a/ui/chat.go b/ui/chat.go new file mode 100644 index 0000000..3e1d45c --- /dev/null +++ b/ui/chat.go @@ -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() +}