commit 126b128db23ac64eac228ceefe3a1cad47020170 Author: NEO Date: Tue Apr 15 19:58:39 2025 +0800 Initilaize diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fee1e4 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# LazyKimi + +一个使用 Go 和 bubbletea 实现的终端界面 LLM 聊天应用。 + +## 功能特点 + +- 简洁的终端用户界面 +- 支持与 OpenAI GPT-3.5 模型对话 +- 保持对话上下文 +- 优雅的界面样式 + +## 安装 + +1. 确保已安装 Go 1.21 或更高版本 +2. 克隆此仓库 +3. 安装依赖: + ```bash + go mod tidy + ``` + +## 使用 + +1. 设置 OpenAI API 密钥: + ```bash + export OPENAI_API_KEY=your_api_key_here + ``` + +2. 运行应用: + ```bash + go run main.go + ``` + +3. 在终端中输入消息,按回车发送 +4. 按 Ctrl+C 或 Esc 退出应用 + +## 依赖 + +- github.com/charmbracelet/bubbletea +- github.com/charmbracelet/lipgloss +- github.com/sashabaranov/go-openai \ No newline at end of file diff --git a/cmd/lazykimi/main.go b/cmd/lazykimi/main.go new file mode 100644 index 0000000..9d913c5 --- /dev/null +++ b/cmd/lazykimi/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "lazykimi/internal/ui/app" + "os" + + tea "github.com/charmbracelet/bubbletea/v2" +) + +func main() { + // Get API key from environment + apiKey := os.Getenv("OPENAI_API_KEY") + if apiKey == "" { + fmt.Println("Error: Please set the OPENAI_API_KEY environment variable") + os.Exit(1) + } + + // Initialize application + app, err := app.NewApp(apiKey) + if err != nil { + fmt.Println("Error initializing: " + err.Error()) + os.Exit(1) + } + + // Create and run program + p := tea.NewProgram( + app, + tea.WithAltScreen(), + tea.WithMouseAllMotion(), + ) + + if _, err := p.Run(); err != nil { + fmt.Println("Error running program: " + err.Error()) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1d19435 --- /dev/null +++ b/go.mod @@ -0,0 +1,45 @@ +module lazykimi + +go 1.23.0 + +toolchain go1.24.2 + +require ( + github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 + github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1 + github.com/charmbracelet/glamour v0.9.1 + github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 + github.com/muesli/reflow v0.3.0 + github.com/sashabaranov/go-openai v1.38.1 +) + +require ( + github.com/alecthomas/chroma/v2 v2.16.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.3.0 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/input v0.3.4 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/x/windows v0.2.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.9 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/term v0.31.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7b3684a --- /dev/null +++ b/go.sum @@ -0,0 +1,85 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.16.0 h1:QC5ZMizk67+HzxFDjQ4ASjni5kWBTGiigRG1u23IGvA= +github.com/alecthomas/chroma/v2 v2.16.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +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-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE= +github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1 h1:yaxFt97mvofGY7bYZn8U/aSVoamXGE3O4AEvWhshUDI= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1/go.mod h1:qbcZLI5z8R49v9xBdU5V5Dh5D2uccx8wSwBqxQyErqc= +github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ= +github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0= +github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM= +github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU= +github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw= +github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0= +github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= +github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sashabaranov/go-openai v1.38.1 h1:TtZabbFQZa1nEni/IhVtDF/WQjVqDgd+cWR5OeddzF8= +github.com/sashabaranov/go-openai v1.38.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= +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= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.9 h1:rVSeT+f7/lAM+bJHVm5YHGwNrnd40i1Ch2DEocEjHQ0= +github.com/yuin/goldmark v1.7.9/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +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/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e176365 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,68 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// Config holds application configuration +type Config struct { + Model string `json:"model"` + SystemPrompt string `json:"system_prompt"` + History []string `json:"history"` +} + +// LoadConfig loads the configuration from disk +func LoadConfig() (*Config, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + + configDir := filepath.Join(homeDir, ".config", "lazykimi") + if err := os.MkdirAll(configDir, 0755); err != nil { + return nil, err + } + + configFile := filepath.Join(configDir, "config.json") + if _, err := os.Stat(configFile); os.IsNotExist(err) { + // Create default config + config := &Config{ + Model: "kimi-k1.5-preview-chat", + SystemPrompt: "You are a helpful assistant.", + History: []string{}, + } + return config, config.Save() + } + + data, err := os.ReadFile(configFile) + if err != nil { + return nil, err + } + + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, err + } + + return &config, nil +} + +// Save persists configuration to disk +func (c *Config) Save() error { + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + + configDir := filepath.Join(homeDir, ".config", "lazykimi") + configFile := filepath.Join(configDir, "config.json") + + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + + return os.WriteFile(configFile, data, 0644) +} diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go new file mode 100644 index 0000000..2d79cae --- /dev/null +++ b/internal/ui/app/app.go @@ -0,0 +1,196 @@ +package app + +import ( + "errors" + "io" + "lazykimi/internal/config" + "lazykimi/internal/ui/chatarea" + "lazykimi/internal/ui/keymaps" + "lazykimi/internal/ui/statusbar" + "lazykimi/pkg/api" + + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + "github.com/sashabaranov/go-openai" +) + +var _ tea.Model = (*Model)(nil) + +type Model struct { + api *api.Client + + keys keymaps.KeyMap + + chatarea chatarea.Model + statusbar statusbar.Model + ti textinput.Model + + markdownMode bool + currentStream *openai.ChatCompletionStream + + ready bool +} + +func NewApp(apiKey string) (*Model, error) { + // Load config + cfg, err := config.LoadConfig() + if err != nil { + return nil, err + } + + apiClient := api.NewClient(apiKey, cfg.Model) + + // Initialize with default width/height - will be resized by WindowSizeMsg + chatarea := chatarea.New(20, 80) + statusbar := statusbar.New(20) + + // Initialize text ti + ti := textinput.New() + ti.Placeholder = "Send a message..." + ti.Prompt = "> " + ti.Focus() + + // Use a more reasonable welcome message + chatarea.AddAssistantMessage("Welcome! I'm ready to chat with you.") + + return &Model{ + api: apiClient, + chatarea: chatarea, + statusbar: statusbar, + ti: ti, + keys: keymaps.Default, + }, nil +} + +// Init implements tea.Model. +func (m *Model) Init() tea.Cmd { + return nil +} + +// Update implements tea.Model. +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.chatarea.Width = msg.Width + m.chatarea.Height = msg.Height - 3 + m.ti.SetWidth(msg.Width - 4) + m.ti.Reset() + m.statusbar.SetWidth(msg.Width) + m.ready = true + + case *openai.ChatCompletionStream: + m.currentStream = msg + cmds = append(cmds, m.receiveFromStream) + + case openai.ChatCompletionStreamResponse: + content := msg.Choices[0].Delta.Content + m.chatarea.AppendCurrentResponse(content) + cmds = append(cmds, m.statusbar.Spinner.Tick) + cmds = append(cmds, m.receiveFromStream) + + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.ToggleMarkdown): + m.markdownMode = !m.markdownMode + m.statusbar.SetMarkdownMode(m.markdownMode) + m.chatarea.SetMarkdownMode(m.markdownMode) + return m, nil + case key.Matches(msg, m.keys.Clear): + m.chatarea.Clear() + m.statusbar.ClearFlashMessage() + cmds = append(cmds, statusbar.FlashSuccess("Chat cleared")) + + case key.Matches(msg, m.keys.Submit): + input := m.ti.Value() + if input == "" { + cmds = append(cmds, statusbar.FlashError("empty message")) + break + } + m.ti.Reset() + m.ti.Blur() + m.chatarea.AddUserMessage(input) + m.statusbar.ClearFlashMessage() + m.statusbar.SetGenerating(true) + cmds = append(cmds, + m.statusbar.Spinner.Tick, + func() tea.Msg { + if len(m.chatarea.Messages) == 0 { + return errors.New("no messages to send") + } + stream, err := m.api.SendChatCompletion(m.chatarea.Messages) + if err != nil { + return err + } + return stream + }, + ) + } + + case error: + return m, m.handleError(msg) + } + + if m.ti.Focused() { + m.ti, cmd = m.ti.Update(msg) + cmds = append(cmds, cmd) + } + + m.chatarea, cmd = m.chatarea.Update(msg) + cmds = append(cmds, cmd) + + m.statusbar, cmd = m.statusbar.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +// View implements tea.Model. +func (m *Model) View() string { + if !m.ready { + return "" + } + return lipgloss.JoinVertical(lipgloss.Center, m.chatarea.View(), m.ti.View(), m.statusbar.View()) +} + +func (a *Model) receiveFromStream() tea.Msg { + if a.currentStream == nil { + return errors.New("no active stream") + } + + resp, err := a.api.GetNextResponse(a.currentStream) + + if errors.Is(err, io.EOF) { + // Reset loading state + a.statusbar.SetGenerating(false) + // Normal end of stream + a.chatarea.CommitCurrentResponse() + a.api.CloseStream(a.currentStream) + a.currentStream = nil + a.statusbar.ClearFlashMessage() + a.ti.Focus() + return nil + } + + if err != nil { + return err + } + + return resp +} + +// handleError handles error messages +func (a *Model) handleError(err error) tea.Cmd { + // Handle error + if a.currentStream != nil { + a.api.CloseStream(a.currentStream) + a.currentStream = nil + } + return statusbar.FlashError(err.Error()) +} diff --git a/internal/ui/chatarea/model.go b/internal/ui/chatarea/model.go new file mode 100644 index 0000000..7478f08 --- /dev/null +++ b/internal/ui/chatarea/model.go @@ -0,0 +1,201 @@ +package chatarea + +import ( + "fmt" + "lazykimi/pkg/theme" + "strings" + + "github.com/charmbracelet/bubbles/v2/viewport" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss/v2" + "github.com/sashabaranov/go-openai" + + "github.com/muesli/reflow/wordwrap" +) + +type Style struct { + UserMessage lipgloss.Style + AssistantMessage lipgloss.Style + SystemMessage lipgloss.Style + Divider lipgloss.Style +} + +type Model struct { + Width, Height int + + systemPrompt string + Messages []openai.ChatCompletionMessage + streamingResponse string + + markdownMode bool + vp viewport.Model + glmr *glamour.TermRenderer + style Style + + ready bool +} + +func (m *Model) SetMarkdownMode(markdown bool) { + m.markdownMode = markdown +} + +func New(width, height int) Model { + vp := viewport.New() + vp.Style = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(theme.Gray). + Padding(0, 0) + vp.MouseWheelEnabled = true + + baseMessage := lipgloss.NewStyle().Padding(0, 2).Bold(true) + + return Model{ + vp: vp, + style: Style{ + UserMessage: baseMessage.Foreground(theme.Yellow), + AssistantMessage: baseMessage.Foreground(theme.Blue), + SystemMessage: baseMessage.Foreground(theme.Green), + Divider: lipgloss.NewStyle().Foreground(theme.Gray), + }, + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.Width = msg.Width + m.Height = msg.Height - 3 + m.vp.SetWidth(m.Width) + m.vp.SetHeight(m.Height) + + // // Initialize markdown renderer + // gr, err := glamour.NewTermRenderer( + // glamour.WithAutoStyle(), + // glamour.WithEmoji(), + // glamour.WithWordWrap(m.Width-6), + // ) + // if err != nil { + // panic(err) + // } + // m.glmr = gr + m.ready = true + } + m.vp, cmd = m.vp.Update(msg) + return m, cmd +} + +func (m Model) View() string { + if !m.ready { + return "" + } + var messages []string + divider := m.style.Divider.Render(strings.Repeat("─", m.Width-6)) + + // Render history messages + for _, msg := range m.Messages { + messages = append(messages, m.formatMessage(msg)) + messages = append(messages, divider) + } + + // Render current response if loading + if m.streamingResponse != "" { + messages = append(messages, m.formatMessage(openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleAssistant, + Content: m.streamingResponse, + })) + } + + // Set content with proper width + m.vp.SetContent(strings.Join(messages, "\n")) + + // // Auto-scroll to bottom if we weren't already at the bottom + // // before this update + if !m.vp.AtBottom() { + m.vp.GotoBottom() + } + + return m.vp.View() +} + +// AddUserMessage adds a user message to the chat +func (c *Model) AddUserMessage(content string) { + c.Messages = append(c.Messages, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleUser, + Content: content, + }) +} + +// AddAssistantMessage adds an assistant message to the chat +func (c *Model) AddAssistantMessage(content string) { + c.Messages = append(c.Messages, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleAssistant, + Content: content, + }) +} + +// AppendCurrentResponse updates the current streaming response +func (c *Model) AppendCurrentResponse(content string) { + c.streamingResponse += content +} + +// CommitCurrentResponse adds the current response as a message and clears it +func (c *Model) CommitCurrentResponse() { + if c.streamingResponse != "" { + c.AddAssistantMessage(c.streamingResponse) + c.streamingResponse = "" + } +} + +// Clear clears all messages except the system prompt +func (c *Model) Clear() { + c.Messages = []openai.ChatCompletionMessage{} + c.streamingResponse = "" + + // Re-add system message if set + if c.systemPrompt != "" { + c.Messages = append(c.Messages, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleSystem, + Content: c.systemPrompt, + }) + } +} + +// SetSystemPrompt sets a new system prompt and updates the first message if needed +func (c *Model) SetSystemPrompt(prompt string) { + c.systemPrompt = prompt + c.Clear() // This will add the new system prompt +} + +func (m *Model) formatMessage(msg openai.ChatCompletionMessage) string { + var ( + indicator string + style lipgloss.Style + useMd bool + ) + + switch msg.Role { + case openai.ChatMessageRoleUser: + indicator = "You" + style = m.style.UserMessage + case openai.ChatMessageRoleAssistant: + indicator = "AI" + style = m.style.AssistantMessage + useMd = m.markdownMode + case openai.ChatMessageRoleSystem: + indicator = "System" + style = m.style.SystemMessage + useMd = m.markdownMode + } + + content := strings.TrimSpace(msg.Content) + content = wordwrap.String(content, m.Width-3) + + if useMd { + renderedContent, _ := m.glmr.Render(content) + return fmt.Sprintf("%s\n%s", indicator, renderedContent) + } + + return fmt.Sprintf("%s\n%s", indicator, style.Render(content)) +} diff --git a/internal/ui/keymaps/key.go b/internal/ui/keymaps/key.go new file mode 100644 index 0000000..52911cd --- /dev/null +++ b/internal/ui/keymaps/key.go @@ -0,0 +1,74 @@ +package keymaps + +import "github.com/charmbracelet/bubbles/v2/key" + +// KeyMap defines all keyboard shortcuts +type KeyMap struct { + Quit key.Binding + Help key.Binding + ToggleMarkdown key.Binding + Submit key.Binding + Clear key.Binding + ScrollUp key.Binding + ScrollDown key.Binding + PageUp key.Binding + PageDown key.Binding +} + +// ShortHelp returns a short help message +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Help, k.Quit} +} + +// FullHelp returns complete help information +func (k KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Help}, + {k.Quit}, + {k.ToggleMarkdown}, + {k.Submit}, + {k.Clear}, + {k.ScrollUp, k.ScrollDown}, + {k.PageUp, k.PageDown}, + } +} + +// Default returns the default key bindings +var Default = KeyMap{ + Quit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + Help: key.NewBinding( + key.WithKeys("ctrl+h"), + key.WithHelp("ctrl+h", "help"), + ), + ToggleMarkdown: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "toggle view"), + ), + Submit: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "send"), + ), + Clear: key.NewBinding( + key.WithKeys("ctrl+l"), + key.WithHelp("ctrl+l", "clear"), + ), + ScrollUp: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "scroll up"), + ), + ScrollDown: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "scroll down"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup"), + key.WithHelp("PgUp", "page up"), + ), + PageDown: key.NewBinding( + key.WithKeys("pgdown"), + key.WithHelp("PgDn", "page down"), + ), +} diff --git a/internal/ui/statusbar/bar.go b/internal/ui/statusbar/bar.go new file mode 100644 index 0000000..bb1d38f --- /dev/null +++ b/internal/ui/statusbar/bar.go @@ -0,0 +1,255 @@ +package statusbar + +import ( + "fmt" + "lazykimi/pkg/theme" + "strings" + "time" + + "github.com/charmbracelet/bubbles/v2/spinner" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" +) + +type FlashType string + +const ( + FlashTypeError FlashType = "error" + FlashTypeSuccess FlashType = "success" +) + +func FlashSuccess(msg string) tea.Cmd { + return func() tea.Msg { + return EventFlashSuccess(msg) + } +} + +func FlashError(msg string) tea.Cmd { + return func() tea.Msg { + return EventFlashError(msg) + } +} + +type ( + EventFlashSuccess string + EventFlashError string +) + +// Model represents the application status bar +type Model struct { + // Layout + width int + styles Style + + // Left sections + messagesCount int + modelName string + markdownMode bool + + // Right sections + Spinner spinner.Model + isGenerating bool + startTime time.Time + flashType FlashType + flashMessage string +} + +// Style represents the styling for the status bar +type Style struct { + base lipgloss.Style + divider lipgloss.Style + message lipgloss.Style + model lipgloss.Style + mode lipgloss.Style + flash map[FlashType]lipgloss.Style + bar lipgloss.Style +} + +// New creates a new status bar component +func New(width int) Model { + s := spinner.New() + s.Spinner = spinner.Moon + + // Initialize base styles + baseStyle := lipgloss.NewStyle().Bold(true).Padding(0, 1) + + return Model{ + width: width, + Spinner: s, + styles: Style{ + base: baseStyle, + divider: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")). + Padding(0, 1), + message: baseStyle.Foreground(theme.Yellow), + model: baseStyle.Foreground(theme.Blue), + mode: baseStyle.Foreground(theme.Green), + flash: map[FlashType]lipgloss.Style{ + FlashTypeError: baseStyle.Foreground(theme.Red), + FlashTypeSuccess: baseStyle.Foreground(theme.Green), + }, + bar: lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(theme.Gray). + BorderTop(true). + Width(width), + }, + } +} + +// SetWidth updates the width of the status bar +func (s *Model) SetWidth(width int) { + s.width = width +} + +// SetLoading sets loading state +func (s *Model) SetGenerating(loading bool) { + s.isGenerating = loading + if loading { + s.startTime = time.Now() + } +} + +func (s *Model) ClearFlashMessage() { + s.flashType = "" + s.flashMessage = "" +} + +// SetError sets an error message +func (s *Model) setFlashMessage(t FlashType, msg string) { + s.flashType = t + s.flashMessage = msg +} + +// SetMessageCount sets the message count +func (s *Model) SetMessageCount(count int) { + s.messagesCount = count +} + +// SetModelName sets the model name +func (s *Model) SetModelName(name string) { + s.modelName = name +} + +// SetMarkdownMode sets the markdown mode +func (s *Model) SetMarkdownMode(enable bool) { + s.markdownMode = enable +} + +// Init implements tea.Model +func (s *Model) Init() tea.Cmd { + return s.Spinner.Tick +} + +// Update implements tea.Model +func (s Model) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.SetWidth(msg.Width) + case spinner.TickMsg: + s.Spinner, cmd = s.Spinner.Update(msg) + cmds = append(cmds, cmd) + case EventFlashError: + s.setFlashMessage(FlashTypeError, string(msg)) + case EventFlashSuccess: + s.setFlashMessage(FlashTypeSuccess, string(msg)) + } + return s, tea.Batch(cmds...) +} + +// View implements tea.Model +func (s *Model) View() string { + // Render left and right sections + leftBar := s.joinSections(s.getLeftSections()) + rightBar := s.joinSections(s.getRightSections()) + + // Calculate and handle available space + availableWidth := s.width + leftWidth := lipgloss.Width(leftBar) + rightWidth := lipgloss.Width(rightBar) + spacerWidth := availableWidth - leftWidth - rightWidth + + var statusBar string + // Handle different space scenarios + switch { + case spacerWidth >= 0: + // Enough space for everything + spacer := strings.Repeat(" ", spacerWidth) + statusBar = fmt.Sprintf("%s%s%s", leftBar, spacer, rightBar) + case availableWidth >= leftWidth: + // Only show left bar + statusBar = leftBar + default: + // Not enough space - truncate left bar + statusBar = lipgloss.NewStyle(). + MaxWidth(availableWidth). + Render(leftBar) + } + + // Apply the final bar styling + return s.styles.bar.Width(s.width).Render(statusBar) +} + +// getLeftSections returns the rendered left sections of the status bar +func (s *Model) getLeftSections() []string { + mode := "PLAIN" + if s.markdownMode { + mode = "MARKDOWN" + } + + return []string{ + s.styles.message.Render(fmt.Sprintf("💬 %d", s.messagesCount)), + s.styles.model.Render(fmt.Sprintf("🤖 %s", s.modelName)), + s.styles.mode.Render(mode), + } +} + +// getRightSections returns the rendered right sections of the status bar +func (s *Model) getRightSections() []string { + var sections []string + + // Generation status + if s.isGenerating { + duration := time.Since(s.startTime).Round(time.Millisecond) + sections = append(sections, + s.styles.flash[FlashTypeSuccess].Render( + fmt.Sprintf("⚡ %s %.1fs", s.Spinner.View(), duration.Seconds()), + ), + ) + } + + // Flash message + if s.flashMessage != "" { + var icon string + switch s.flashType { + case FlashTypeError: + icon = "×" + case FlashTypeSuccess: + icon = "✓" + } + sections = append(sections, + s.styles.flash[s.flashType].Render( + fmt.Sprintf("%s %s", icon, s.flashMessage), + ), + ) + } + + return sections +} + +// joinSections joins sections with a dot separator +func (s *Model) joinSections(sections []string) string { + if len(sections) == 0 { + return "" + } + + separator := s.styles.divider.SetString("┃").String() + result := sections[0] + for _, section := range sections[1:] { + result = lipgloss.JoinHorizontal(lipgloss.Center, result, separator, section) + } + return result +} diff --git a/pkg/api/api.go b/pkg/api/api.go new file mode 100644 index 0000000..c580062 --- /dev/null +++ b/pkg/api/api.go @@ -0,0 +1,61 @@ +package api + +import ( + "context" + "errors" + "io" + + "github.com/sashabaranov/go-openai" +) + +// Client wraps OpenAI API client +type Client struct { + client *openai.Client + model string +} + +// NewClient creates a new OpenAI API client +func NewClient(apiKey, model string) *Client { + openaiConfig := openai.DefaultConfig(apiKey) + openaiConfig.BaseURL = "https://api.msh.team/v1" + + return &Client{ + client: openai.NewClientWithConfig(openaiConfig), + model: model, + } +} + +// SetModel changes the model used for requests +func (c *Client) SetModel(model string) { + c.model = model +} + +// SendChatCompletion sends a chat completion request and returns a stream +func (c *Client) SendChatCompletion(messages []openai.ChatCompletionMessage) (*openai.ChatCompletionStream, error) { + req := openai.ChatCompletionRequest{ + Model: c.model, + Messages: messages, + Stream: true, + } + + return c.client.CreateChatCompletionStream(context.Background(), req) +} + +// GetNextResponse gets the next response from a stream +func (c *Client) GetNextResponse(stream *openai.ChatCompletionStream) (openai.ChatCompletionStreamResponse, error) { + resp, err := stream.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + return openai.ChatCompletionStreamResponse{}, io.EOF + } + return openai.ChatCompletionStreamResponse{}, err + } + return resp, nil +} + +// CloseStream closes a stream +func (c *Client) CloseStream(stream *openai.ChatCompletionStream) { + if stream != nil { + _ = stream.Close() + } +} diff --git a/pkg/theme/colors.go b/pkg/theme/colors.go new file mode 100644 index 0000000..e2e0a17 --- /dev/null +++ b/pkg/theme/colors.go @@ -0,0 +1,17 @@ +package theme + +import "github.com/charmbracelet/lipgloss/v2" + +var ( + // Primary colors + Blue = lipgloss.Color("#61AFEF") + Yellow = lipgloss.Color("#E5C07B") + Green = lipgloss.Color("#98C379") + Red = lipgloss.Color("#E06C75") + Gray = lipgloss.Color("#5C6370") + White = lipgloss.Color("#ABB2BF") + + // Background colors + BgDark = lipgloss.Color("#282C34") + BgLight = lipgloss.Color("#3E4451") +)