- 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.
297 lines
7.5 KiB
Go
297 lines
7.5 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
llm "gitea.stevedudenhoeffer.com/steve/go-llm"
|
|
)
|
|
|
|
// View renders the current state
|
|
func (m Model) View() string {
|
|
switch m.state {
|
|
case StateProviderSelect:
|
|
return m.renderProviderSelect()
|
|
case StateModelSelect:
|
|
return m.renderModelSelect()
|
|
case StateImageInput:
|
|
return m.renderImageInput()
|
|
case StateToolsPanel:
|
|
return m.renderToolsPanel()
|
|
case StateSettings:
|
|
return m.renderSettings()
|
|
case StateAPIKeyInput:
|
|
return m.renderAPIKeyInput()
|
|
default:
|
|
return m.renderChat()
|
|
}
|
|
}
|
|
|
|
// renderChat renders the main chat view
|
|
func (m Model) renderChat() string {
|
|
var b strings.Builder
|
|
|
|
// Header
|
|
provider := m.providerName
|
|
if provider == "" {
|
|
provider = "None"
|
|
}
|
|
model := m.modelName
|
|
if model == "" {
|
|
model = "None"
|
|
}
|
|
|
|
header := headerStyle.Render(fmt.Sprintf("go-llm CLI %s",
|
|
providerBadgeStyle.Render(fmt.Sprintf("%s/%s", provider, model))))
|
|
|
|
b.WriteString(header)
|
|
b.WriteString("\n")
|
|
|
|
// Messages viewport
|
|
if m.viewportReady {
|
|
b.WriteString(m.viewport.View())
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// Image indicator
|
|
if len(m.pendingImages) > 0 {
|
|
b.WriteString(imageIndicatorStyle.Render(fmt.Sprintf(" [%d image(s) attached]", len(m.pendingImages))))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// Error
|
|
if m.err != nil {
|
|
b.WriteString(errorStyle.Render(" Error: " + m.err.Error()))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// Loading
|
|
if m.loading {
|
|
b.WriteString(loadingStyle.Render(" Thinking..."))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// Input
|
|
inputBox := inputStyle.Render(m.input.View())
|
|
b.WriteString(inputBox)
|
|
b.WriteString("\n")
|
|
|
|
// Help
|
|
help := inputHelpStyle.Render("Enter: send | Ctrl+I: image | Ctrl+T: tools | Ctrl+P: provider | Ctrl+M: model | Ctrl+S: settings | Ctrl+N: new | Esc: quit")
|
|
b.WriteString(help)
|
|
|
|
return appStyle.Render(b.String())
|
|
}
|
|
|
|
// renderMessages renders all messages for the viewport
|
|
func (m Model) renderMessages() string {
|
|
var b strings.Builder
|
|
|
|
if len(m.messages) == 0 {
|
|
b.WriteString(systemMsgStyle.Render("[System] " + m.systemPrompt))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Start a conversation by typing a message below."))
|
|
return b.String()
|
|
}
|
|
|
|
b.WriteString(systemMsgStyle.Render("[System] " + m.systemPrompt))
|
|
b.WriteString("\n\n")
|
|
|
|
for _, msg := range m.messages {
|
|
var content string
|
|
var style lipgloss.Style
|
|
|
|
switch msg.Role {
|
|
case llm.RoleUser:
|
|
style = userMsgStyle
|
|
label := roleLabelStyle.Foreground(secondaryColor).Render("[User]")
|
|
content = label + " " + msg.Content
|
|
if msg.Images > 0 {
|
|
content += imageIndicatorStyle.Render(fmt.Sprintf(" [%d image(s)]", msg.Images))
|
|
}
|
|
case llm.RoleAssistant:
|
|
style = assistantMsgStyle
|
|
label := roleLabelStyle.Foreground(lipgloss.Color("255")).Render("[Assistant]")
|
|
content = label + " " + msg.Content
|
|
case llm.Role("tool_call"):
|
|
style = toolCallStyle
|
|
content = " -> Calling: " + msg.Content
|
|
case llm.Role("tool_result"):
|
|
style = toolResultStyle
|
|
content = " <- Result: " + msg.Content
|
|
default:
|
|
style = assistantMsgStyle
|
|
content = msg.Content
|
|
}
|
|
|
|
b.WriteString(style.Render(content))
|
|
b.WriteString("\n\n")
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// renderProviderSelect renders the provider selection view
|
|
func (m Model) renderProviderSelect() string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString(headerStyle.Render("Select Provider"))
|
|
b.WriteString("\n\n")
|
|
|
|
for i, item := range m.listItems {
|
|
cursor := " "
|
|
style := normalItemStyle
|
|
if i == m.listIndex {
|
|
cursor = "> "
|
|
style = selectedItemStyle
|
|
}
|
|
b.WriteString(style.Render(cursor + item))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(helpStyle.Render("Use arrow keys or j/k to navigate, Enter to select, Esc to cancel"))
|
|
|
|
return appStyle.Render(b.String())
|
|
}
|
|
|
|
// renderAPIKeyInput renders the API key input view
|
|
func (m Model) renderAPIKeyInput() string {
|
|
var b strings.Builder
|
|
|
|
provider := m.providers[m.listIndex]
|
|
|
|
b.WriteString(headerStyle.Render(fmt.Sprintf("Enter API Key for %s", provider.Name)))
|
|
b.WriteString("\n\n")
|
|
|
|
b.WriteString(fmt.Sprintf("Environment variable: %s\n\n", provider.EnvVar))
|
|
b.WriteString("Enter your API key below (it will be hidden):\n\n")
|
|
|
|
inputBox := inputStyle.Render(m.apiKeyInput.View())
|
|
b.WriteString(inputBox)
|
|
b.WriteString("\n\n")
|
|
|
|
b.WriteString(helpStyle.Render("Enter to confirm, Esc to cancel"))
|
|
|
|
return appStyle.Render(b.String())
|
|
}
|
|
|
|
// renderModelSelect renders the model selection view
|
|
func (m Model) renderModelSelect() string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString(headerStyle.Render(fmt.Sprintf("Select Model (%s)", m.providerName)))
|
|
b.WriteString("\n\n")
|
|
|
|
for i, item := range m.listItems {
|
|
cursor := " "
|
|
style := normalItemStyle
|
|
if i == m.listIndex {
|
|
cursor = "> "
|
|
style = selectedItemStyle
|
|
}
|
|
if item == m.modelName {
|
|
item += " (current)"
|
|
}
|
|
b.WriteString(style.Render(cursor + item))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(helpStyle.Render("Use arrow keys or j/k to navigate, Enter to select, Esc to cancel"))
|
|
|
|
return appStyle.Render(b.String())
|
|
}
|
|
|
|
// renderImageInput renders the image input view
|
|
func (m Model) renderImageInput() string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString(headerStyle.Render("Add Image"))
|
|
b.WriteString("\n\n")
|
|
|
|
b.WriteString("Enter an image source:\n")
|
|
b.WriteString(" - File path (e.g., /path/to/image.png)\n")
|
|
b.WriteString(" - URL (e.g., https://example.com/image.jpg)\n")
|
|
b.WriteString(" - Base64 data or data URL\n\n")
|
|
|
|
if len(m.pendingImages) > 0 {
|
|
b.WriteString(imageIndicatorStyle.Render(fmt.Sprintf("Currently attached: %d image(s)\n\n", len(m.pendingImages))))
|
|
}
|
|
|
|
inputBox := inputStyle.Render(m.input.View())
|
|
b.WriteString(inputBox)
|
|
b.WriteString("\n\n")
|
|
|
|
b.WriteString(helpStyle.Render("Enter to add image, Esc to cancel"))
|
|
|
|
return appStyle.Render(b.String())
|
|
}
|
|
|
|
// renderToolsPanel renders the tools panel
|
|
func (m Model) renderToolsPanel() string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString(headerStyle.Render("Tools / Function Calling"))
|
|
b.WriteString("\n\n")
|
|
|
|
status := "DISABLED"
|
|
statusStyle := errorStyle
|
|
if m.toolsEnabled {
|
|
status = "ENABLED"
|
|
statusStyle = lipgloss.NewStyle().Foreground(successColor).Bold(true)
|
|
}
|
|
|
|
b.WriteString(settingLabelStyle.Render("Tools Status:"))
|
|
b.WriteString(statusStyle.Render(status))
|
|
b.WriteString("\n\n")
|
|
|
|
b.WriteString("Available tools:\n")
|
|
for _, fn := range m.toolbox.Functions() {
|
|
b.WriteString(fmt.Sprintf(" - %s: %s\n", selectedItemStyle.Render(fn.Name), fn.Description))
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(helpStyle.Render("Press 't' to toggle tools, Enter or 'q' to close"))
|
|
|
|
return appStyle.Render(b.String())
|
|
}
|
|
|
|
// renderSettings renders the settings view
|
|
func (m Model) renderSettings() string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString(headerStyle.Render("Settings"))
|
|
b.WriteString("\n\n")
|
|
|
|
// Temperature
|
|
tempStr := "default"
|
|
if m.temperature != nil {
|
|
tempStr = fmt.Sprintf("%.1f", *m.temperature)
|
|
}
|
|
b.WriteString(settingLabelStyle.Render("Temperature:"))
|
|
b.WriteString(settingValueStyle.Render(tempStr))
|
|
b.WriteString("\n\n")
|
|
|
|
b.WriteString("Press a key to set temperature:\n")
|
|
b.WriteString(" 1 - Default (model decides)\n")
|
|
b.WriteString(" 2 - 0.0 (deterministic)\n")
|
|
b.WriteString(" 3 - 0.5 (balanced)\n")
|
|
b.WriteString(" 4 - 0.7 (creative)\n")
|
|
b.WriteString(" 5 - 1.0 (very creative)\n")
|
|
|
|
b.WriteString("\n")
|
|
|
|
// System prompt
|
|
b.WriteString(settingLabelStyle.Render("System Prompt:"))
|
|
b.WriteString("\n")
|
|
b.WriteString(settingValueStyle.Render(" " + m.systemPrompt))
|
|
b.WriteString("\n\n")
|
|
|
|
b.WriteString(helpStyle.Render("Enter or 'q' to close"))
|
|
|
|
return appStyle.Render(b.String())
|
|
}
|