Implement interactive CLI for LLM providers with chat, tools, and image support

- 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.
This commit is contained in:
2026-01-24 15:53:36 -05:00
parent bf7c86ab2a
commit 97d54c10ae
12 changed files with 1550 additions and 0 deletions

435
cmd/llm/update.go Normal file
View File

@@ -0,0 +1,435 @@
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
}