34119e5a00
Five OpenAI-compatible providers join the library as first-class constructors (llm.DeepSeek, llm.Moonshot, llm.XAI, llm.Groq, llm.Ollama). Their wire-level implementation is shared via a new v2/openaicompat package which is the extracted guts of the old v2/openai provider; each provider supplies its own Rules value to declare per-model constraints (e.g., DeepSeek Reasoner rejects tools and temperature, Moonshot/xAI accept images only on *-vision* models, Groq rejects audio input). v2/openai itself becomes a thin wrapper that sets RestrictTemperature for o-series and gpt-5 models. A new provider registry (v2/registry.go) exposes llm.Providers() and drives the TUI's provider picker so adding a provider in future is a single-file change. The TUI at cmd/llm was migrated from v1 to v2 and moved to v2/cmd/llm. With nothing else depending on v1, the v1 code at the repo root (all .go files, schema/, internal/, provider/, root go.mod/go.sum) is deleted. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
292 lines
7.5 KiB
Go
292 lines
7.5 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
|
|
)
|
|
|
|
// 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
|
|
|
|
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")
|
|
|
|
if m.viewportReady {
|
|
b.WriteString(m.viewport.View())
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
if len(m.pendingImages) > 0 {
|
|
b.WriteString(imageIndicatorStyle.Render(fmt.Sprintf(" [%d image(s) attached]", len(m.pendingImages))))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
if m.err != nil {
|
|
b.WriteString(errorStyle.Render(" Error: " + m.err.Error()))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
if m.loading {
|
|
b.WriteString(loadingStyle.Render(" Thinking..."))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
inputBox := inputStyle.Render(m.input.View())
|
|
b.WriteString(inputBox)
|
|
b.WriteString("\n")
|
|
|
|
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.Info.DisplayName)))
|
|
b.WriteString("\n\n")
|
|
|
|
if provider.Info.EnvKey != "" {
|
|
b.WriteString(fmt.Sprintf("Environment variable: %s\n\n", provider.Info.EnvKey))
|
|
}
|
|
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")
|
|
if m.toolbox != nil {
|
|
for _, t := range m.toolbox.AllTools() {
|
|
b.WriteString(fmt.Sprintf(" - %s: %s\n", selectedItemStyle.Render(t.Name), t.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")
|
|
|
|
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")
|
|
|
|
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())
|
|
}
|