Initilaize

This commit is contained in:
NEO 2025-04-15 19:58:39 +08:00
commit 126b128db2
11 changed files with 1079 additions and 0 deletions

40
README.md Normal file
View File

@ -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

37
cmd/lazykimi/main.go Normal file
View File

@ -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)
}
}

45
go.mod Normal file
View File

@ -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
)

85
go.sum Normal file
View File

@ -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=

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

@ -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)
}

196
internal/ui/app/app.go Normal file
View File

@ -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())
}

View File

@ -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))
}

View File

@ -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"),
),
}

View File

@ -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
}

61
pkg/api/api.go Normal file
View File

@ -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()
}
}

17
pkg/theme/colors.go Normal file
View File

@ -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")
)