Initilaize
This commit is contained in:
commit
126b128db2
40
README.md
Normal file
40
README.md
Normal 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
37
cmd/lazykimi/main.go
Normal 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
45
go.mod
Normal 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
85
go.sum
Normal 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
68
internal/config/config.go
Normal 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
196
internal/ui/app/app.go
Normal 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())
|
||||
}
|
201
internal/ui/chatarea/model.go
Normal file
201
internal/ui/chatarea/model.go
Normal 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))
|
||||
}
|
74
internal/ui/keymaps/key.go
Normal file
74
internal/ui/keymaps/key.go
Normal 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"),
|
||||
),
|
||||
}
|
255
internal/ui/statusbar/bar.go
Normal file
255
internal/ui/statusbar/bar.go
Normal 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
61
pkg/api/api.go
Normal 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
17
pkg/theme/colors.go
Normal 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")
|
||||
)
|
Loading…
x
Reference in New Issue
Block a user