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:
435
cmd/llm/update.go
Normal file
435
cmd/llm/update.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user