- Add Bubble Tea-based CLI interface for LLM interactions. - Implement `.env.example` for environment variable setup. - Add provider, model, and tool selection screens. - Include support for API key configuration. - Enable chat interactions with optional image and tool support. - Introduce core utility functions: image handling, tool execution, chat request management, and response rendering. - Implement style customization with Lip Gloss.
436 lines
9.6 KiB
Go
436 lines
9.6 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
llm "gitea.stevedudenhoeffer.com/steve/go-llm"
|
|
)
|
|
|
|
// pendingRequest stores the request being processed for follow-up
|
|
var pendingRequest llm.Request
|
|
var pendingResponse llm.ResponseChoice
|
|
|
|
// Update handles messages and updates the model
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var cmd tea.Cmd
|
|
var cmds []tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
return m.handleKeyMsg(msg)
|
|
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
|
|
headerHeight := 3
|
|
footerHeight := 4
|
|
verticalMargins := headerHeight + footerHeight
|
|
|
|
if !m.viewportReady {
|
|
m.viewport = viewport.New(msg.Width-4, msg.Height-verticalMargins)
|
|
m.viewport.HighPerformanceRendering = false
|
|
m.viewportReady = true
|
|
} else {
|
|
m.viewport.Width = msg.Width - 4
|
|
m.viewport.Height = msg.Height - verticalMargins
|
|
}
|
|
|
|
m.input.Width = msg.Width - 6
|
|
m.apiKeyInput.Width = msg.Width - 6
|
|
|
|
m.viewport.SetContent(m.renderMessages())
|
|
|
|
case ChatResponseMsg:
|
|
m.loading = false
|
|
if msg.Err != nil {
|
|
m.err = msg.Err
|
|
return m, nil
|
|
}
|
|
|
|
if len(msg.Response.Choices) == 0 {
|
|
m.err = fmt.Errorf("no response choices returned")
|
|
return m, nil
|
|
}
|
|
|
|
choice := msg.Response.Choices[0]
|
|
|
|
// Check for tool calls
|
|
if len(choice.Calls) > 0 && m.toolsEnabled {
|
|
// Store for follow-up
|
|
pendingResponse = choice
|
|
|
|
// Add assistant's response to conversation if there's content
|
|
if choice.Content != "" {
|
|
m.addAssistantMessage(choice.Content)
|
|
}
|
|
|
|
// Display tool calls
|
|
for _, call := range choice.Calls {
|
|
m.addToolCallMessage(call.FunctionCall.Name, call.FunctionCall.Arguments)
|
|
}
|
|
|
|
m.viewport.SetContent(m.renderMessages())
|
|
m.viewport.GotoBottom()
|
|
|
|
// Execute tools
|
|
m.loading = true
|
|
return m, executeTools(m.toolbox, pendingRequest, choice)
|
|
}
|
|
|
|
// Regular response - add to conversation and display
|
|
m.conversation = append(m.conversation, choice)
|
|
m.addAssistantMessage(choice.Content)
|
|
|
|
m.viewport.SetContent(m.renderMessages())
|
|
m.viewport.GotoBottom()
|
|
|
|
case ToolExecutionMsg:
|
|
if msg.Err != nil {
|
|
m.loading = false
|
|
m.err = msg.Err
|
|
return m, nil
|
|
}
|
|
|
|
// Display tool results
|
|
for i, result := range msg.Results {
|
|
name := pendingResponse.Calls[i].FunctionCall.Name
|
|
resultStr := fmt.Sprintf("%v", result.Result)
|
|
if result.Error != nil {
|
|
resultStr = "Error: " + result.Error.Error()
|
|
}
|
|
m.addToolResultMessage(name, resultStr)
|
|
}
|
|
|
|
// Add tool call responses to conversation
|
|
for _, result := range msg.Results {
|
|
m.conversation = append(m.conversation, result)
|
|
}
|
|
|
|
// Add the assistant's response to conversation
|
|
m.conversation = append(m.conversation, pendingResponse)
|
|
|
|
m.viewport.SetContent(m.renderMessages())
|
|
m.viewport.GotoBottom()
|
|
|
|
// Send follow-up request
|
|
followUp := buildFollowUpRequest(&m, pendingRequest, pendingResponse, msg.Results)
|
|
pendingRequest = followUp
|
|
return m, sendChatRequest(m.chat, followUp)
|
|
|
|
case ImageLoadedMsg:
|
|
if msg.Err != nil {
|
|
m.err = msg.Err
|
|
m.state = m.previousState
|
|
return m, nil
|
|
}
|
|
|
|
m.pendingImages = append(m.pendingImages, msg.Image)
|
|
m.state = m.previousState
|
|
m.err = nil
|
|
|
|
default:
|
|
// Update text input
|
|
if m.state == StateChat {
|
|
m.input, cmd = m.input.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
} else if m.state == StateAPIKeyInput {
|
|
m.apiKeyInput, cmd = m.apiKeyInput.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
}
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
// handleKeyMsg handles keyboard input
|
|
func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
// Global key handling
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
|
|
case "esc":
|
|
if m.state != StateChat {
|
|
m.state = StateChat
|
|
m.input.Focus()
|
|
return m, nil
|
|
}
|
|
return m, tea.Quit
|
|
}
|
|
|
|
// State-specific key handling
|
|
switch m.state {
|
|
case StateChat:
|
|
return m.handleChatKeys(msg)
|
|
case StateProviderSelect:
|
|
return m.handleProviderSelectKeys(msg)
|
|
case StateModelSelect:
|
|
return m.handleModelSelectKeys(msg)
|
|
case StateImageInput:
|
|
return m.handleImageInputKeys(msg)
|
|
case StateToolsPanel:
|
|
return m.handleToolsPanelKeys(msg)
|
|
case StateSettings:
|
|
return m.handleSettingsKeys(msg)
|
|
case StateAPIKeyInput:
|
|
return m.handleAPIKeyInputKeys(msg)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// handleChatKeys handles keys in chat state
|
|
func (m Model) handleChatKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "enter":
|
|
if m.loading {
|
|
return m, nil
|
|
}
|
|
|
|
text := strings.TrimSpace(m.input.Value())
|
|
if text == "" {
|
|
return m, nil
|
|
}
|
|
|
|
if m.chat == nil {
|
|
m.err = fmt.Errorf("no model selected - press Ctrl+P to select a provider")
|
|
return m, nil
|
|
}
|
|
|
|
// Build and send request
|
|
req := buildRequest(&m, text)
|
|
pendingRequest = req
|
|
|
|
// Add user message to display
|
|
m.addUserMessage(text, m.pendingImages)
|
|
|
|
// Clear input and pending images
|
|
m.input.Reset()
|
|
m.pendingImages = nil
|
|
m.err = nil
|
|
m.loading = true
|
|
|
|
m.viewport.SetContent(m.renderMessages())
|
|
m.viewport.GotoBottom()
|
|
|
|
return m, sendChatRequest(m.chat, req)
|
|
|
|
case "ctrl+i":
|
|
m.previousState = StateChat
|
|
m.state = StateImageInput
|
|
m.input.SetValue("")
|
|
m.input.Placeholder = "Enter image path or URL..."
|
|
return m, nil
|
|
|
|
case "ctrl+t":
|
|
m.state = StateToolsPanel
|
|
return m, nil
|
|
|
|
case "ctrl+p":
|
|
m.state = StateProviderSelect
|
|
m.listIndex = m.providerIndex
|
|
return m, nil
|
|
|
|
case "ctrl+m":
|
|
if m.provider == nil {
|
|
m.err = fmt.Errorf("select a provider first")
|
|
return m, nil
|
|
}
|
|
m.state = StateModelSelect
|
|
m.listItems = m.providers[m.providerIndex].Models
|
|
m.listIndex = m.providers[m.providerIndex].ModelIndex
|
|
return m, nil
|
|
|
|
case "ctrl+s":
|
|
m.state = StateSettings
|
|
return m, nil
|
|
|
|
case "ctrl+n":
|
|
m.newConversation()
|
|
m.viewport.SetContent(m.renderMessages())
|
|
return m, nil
|
|
|
|
case "up", "down", "pgup", "pgdown":
|
|
var cmd tea.Cmd
|
|
m.viewport, cmd = m.viewport.Update(msg)
|
|
return m, cmd
|
|
|
|
default:
|
|
var cmd tea.Cmd
|
|
m.input, cmd = m.input.Update(msg)
|
|
return m, cmd
|
|
}
|
|
}
|
|
|
|
// handleProviderSelectKeys handles keys in provider selection state
|
|
func (m Model) handleProviderSelectKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "up", "k":
|
|
if m.listIndex > 0 {
|
|
m.listIndex--
|
|
}
|
|
case "down", "j":
|
|
if m.listIndex < len(m.providers)-1 {
|
|
m.listIndex++
|
|
}
|
|
case "enter":
|
|
p := m.providers[m.listIndex]
|
|
if !p.HasAPIKey {
|
|
// Need to get API key
|
|
m.state = StateAPIKeyInput
|
|
m.apiKeyInput.Focus()
|
|
m.apiKeyInput.SetValue("")
|
|
return m, textinput.Blink
|
|
}
|
|
|
|
err := m.selectProvider(m.listIndex)
|
|
if err != nil {
|
|
m.err = err
|
|
return m, nil
|
|
}
|
|
|
|
m.state = StateChat
|
|
m.input.Focus()
|
|
m.newConversation()
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// handleAPIKeyInputKeys handles keys in API key input state
|
|
func (m Model) handleAPIKeyInputKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "enter":
|
|
key := strings.TrimSpace(m.apiKeyInput.Value())
|
|
if key == "" {
|
|
return m, nil
|
|
}
|
|
|
|
// Store the API key
|
|
p := m.providers[m.listIndex]
|
|
m.apiKeys[p.Name] = key
|
|
m.providers[m.listIndex].HasAPIKey = true
|
|
|
|
// Update list items
|
|
for i, prov := range m.providers {
|
|
status := " (no key)"
|
|
if prov.HasAPIKey {
|
|
status = " (ready)"
|
|
}
|
|
m.listItems[i] = prov.Name + status
|
|
}
|
|
|
|
// Select the provider
|
|
err := m.selectProvider(m.listIndex)
|
|
if err != nil {
|
|
m.err = err
|
|
return m, nil
|
|
}
|
|
|
|
m.state = StateChat
|
|
m.input.Focus()
|
|
m.newConversation()
|
|
return m, nil
|
|
|
|
default:
|
|
var cmd tea.Cmd
|
|
m.apiKeyInput, cmd = m.apiKeyInput.Update(msg)
|
|
return m, cmd
|
|
}
|
|
}
|
|
|
|
// handleModelSelectKeys handles keys in model selection state
|
|
func (m Model) handleModelSelectKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "up", "k":
|
|
if m.listIndex > 0 {
|
|
m.listIndex--
|
|
}
|
|
case "down", "j":
|
|
if m.listIndex < len(m.listItems)-1 {
|
|
m.listIndex++
|
|
}
|
|
case "enter":
|
|
err := m.selectModel(m.listIndex)
|
|
if err != nil {
|
|
m.err = err
|
|
return m, nil
|
|
}
|
|
m.state = StateChat
|
|
m.input.Focus()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// handleImageInputKeys handles keys in image input state
|
|
func (m Model) handleImageInputKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "enter":
|
|
input := strings.TrimSpace(m.input.Value())
|
|
if input == "" {
|
|
m.state = m.previousState
|
|
m.input.Placeholder = "Type your message..."
|
|
return m, nil
|
|
}
|
|
|
|
m.input.Placeholder = "Type your message..."
|
|
|
|
// Determine input type and load
|
|
if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") {
|
|
return m, loadImageFromURL(input)
|
|
} else if strings.HasPrefix(input, "data:") || len(input) > 100 && !strings.Contains(input, "/") && !strings.Contains(input, "\\") {
|
|
return m, loadImageFromBase64(input)
|
|
} else {
|
|
return m, loadImageFromPath(input)
|
|
}
|
|
|
|
default:
|
|
var cmd tea.Cmd
|
|
m.input, cmd = m.input.Update(msg)
|
|
return m, cmd
|
|
}
|
|
}
|
|
|
|
// handleToolsPanelKeys handles keys in tools panel state
|
|
func (m Model) handleToolsPanelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "t":
|
|
m.toolsEnabled = !m.toolsEnabled
|
|
case "enter", "q":
|
|
m.state = StateChat
|
|
m.input.Focus()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// handleSettingsKeys handles keys in settings state
|
|
func (m Model) handleSettingsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "1":
|
|
// Set temperature to nil (default)
|
|
m.temperature = nil
|
|
case "2":
|
|
t := 0.0
|
|
m.temperature = &t
|
|
case "3":
|
|
t := 0.5
|
|
m.temperature = &t
|
|
case "4":
|
|
t := 0.7
|
|
m.temperature = &t
|
|
case "5":
|
|
t := 1.0
|
|
m.temperature = &t
|
|
case "enter", "q":
|
|
m.state = StateChat
|
|
m.input.Focus()
|
|
}
|
|
return m, nil
|
|
}
|