picoclaw/cmd/picoclaw-launcher-tui/internal/ui/model.go
2026-03-19 18:04:58 +08:00

399 lines
11 KiB
Go

package ui
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
)
func (s *appState) modelMenu() tview.Primitive {
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
currentModel := strings.TrimSpace(s.config.Agents.Defaults.ModelName)
for i := range s.config.ModelList {
index := i
model := s.config.ModelList[i]
isValid := isModelValid(model)
desc := model.APIBase
if desc == "" {
desc = model.AuthMethod
}
if desc == "" {
desc = "api_key required"
}
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
if model.ModelName == currentModel && currentModel != "" {
label = "* " + label
}
isSelected := model.ModelName == currentModel && currentModel != ""
items = append(items, MenuItem{
Label: label,
Description: desc,
MainColor: modelStatusColor(isValid, isSelected),
Action: func() {
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
},
})
}
// Add model entry appended at the end so the models map to rows 1..N
items = append(items,
MenuItem{
Label: "**Add model**",
Description: "Append a new model entry",
Action: func() {
newName := s.nextAvailableModelName("new-model")
s.addModel(
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
)
s.push(
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
s.modelForm(len(s.config.ModelList)-1),
)
},
},
)
menu := NewMenu("Models", items)
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pop()
return nil
}
if event.Rune() == ' ' {
row, _ := menu.GetSelection()
if row >= 0 && row < len(s.config.ModelList) {
model := s.config.ModelList[row]
if !isModelValid(model) {
s.showMessage(
"Invalid model",
"Select a model with api_key or oauth auth_method",
)
return nil
}
s.config.Agents.Defaults.ModelName = model.ModelName
s.dirty = true
refreshModelMenu(menu, s.config.Agents.Defaults.GetModelName(), s.config.ModelList)
refreshMainMenuIfPresent(s)
}
return nil
}
return event
})
return menu
}
func (s *appState) modelForm(index int) tview.Primitive {
model := &s.config.ModelList[index]
form := tview.NewForm()
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
addInput(form, "Model Name", model.ModelName, func(value string) {
if value == "" {
s.showMessage("Invalid model name", "Model Name cannot be empty")
return
}
if s.modelNameExists(value, index) {
s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value))
return
}
oldName := model.ModelName
model.ModelName = value
if s.config.Agents.Defaults.ModelName == oldName {
s.config.Agents.Defaults.ModelName = value
}
s.dirty = true
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "Model", model.Model, func(value string) {
model.Model = value
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "API Base", model.APIBase, func(value string) {
model.APIBase = value
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "API Key", model.APIKey, func(value string) {
model.APIKey = value
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "Proxy", model.Proxy, func(value string) {
model.Proxy = value
})
addInput(form, "Auth Method", model.AuthMethod, func(value string) {
model.AuthMethod = value
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "Connect Mode", model.ConnectMode, func(value string) {
model.ConnectMode = value
})
addInput(form, "Workspace", model.Workspace, func(value string) {
model.Workspace = value
})
addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) {
model.MaxTokensField = value
})
addIntInput(form, "RPM", model.RPM, func(value int) {
model.RPM = value
})
addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) {
model.RequestTimeout = value
})
form.AddButton("Delete", func() {
pageName := "confirm-delete-model"
if s.pages.HasPage(pageName) {
return
}
modal := tview.NewModal().
SetText("Are you sure you want to delete this model?").
AddButtons([]string{"Cancel", "Delete"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
s.pages.RemovePage(pageName)
if buttonLabel == "Delete" {
s.deleteModel(index)
}
})
modal.SetTitle("Confirm Delete").SetBorder(true)
s.pages.AddPage(pageName, modal, true, true)
})
form.AddButton("Test", func() {
s.testModel(model)
})
form.AddButton("Back", func() {
s.pop()
})
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pop()
return nil
}
return event
})
return form
}
func addInput(form *tview.Form, label, value string, onChange func(string)) {
form.AddInputField(label, value, 128, nil, func(text string) {
onChange(strings.TrimSpace(text))
})
}
func addIntInput(form *tview.Form, label string, value int, onChange func(int)) {
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
var parsed int
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
onChange(parsed)
}
})
}
func (s *appState) addModel(model picoclawconfig.ModelConfig) {
s.config.ModelList = append(s.config.ModelList, model)
}
func (s *appState) deleteModel(index int) {
if index < 0 || index >= len(s.config.ModelList) {
return
}
s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...)
s.pop()
}
func modelStatusColor(valid bool, selected bool) *tcell.Color {
if valid {
color := tview.Styles.PrimaryTextColor
return &color
}
color := tcell.ColorGray
return &color
}
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
for i, model := range models {
row := i
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
isValid := isModelValid(model)
if model.ModelName == currentModel && currentModel != "" {
label = "* " + label
}
cell := menu.GetCell(row, 0)
if cell != nil {
cell.SetText(label)
isSelected := model.ModelName == currentModel && currentModel != ""
color := modelStatusColor(isValid, isSelected)
if color != nil {
cell.SetTextColor(*color)
}
}
}
}
func refreshModelMenuFromState(menu *Menu, s *appState) {
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
currentModel := strings.TrimSpace(s.config.Agents.Defaults.ModelName)
for i := range s.config.ModelList {
index := i
model := s.config.ModelList[i]
isValid := isModelValid(model)
desc := model.APIBase
if desc == "" {
desc = model.AuthMethod
}
if desc == "" {
desc = "api_key required"
}
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
if model.ModelName == currentModel && currentModel != "" {
label = "* " + label
}
isSelected := model.ModelName == currentModel && currentModel != ""
items = append(items, MenuItem{
Label: label,
Description: desc,
MainColor: modelStatusColor(isValid, isSelected),
Action: func() {
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
},
})
}
items = append(items,
MenuItem{
Label: "**Add Model**",
Description: "Append a new model entry",
Action: func() {
newName := s.nextAvailableModelName("new-model")
s.addModel(
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
)
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
},
},
)
menu.applyItems(items)
}
func isModelValid(model picoclawconfig.ModelConfig) bool {
hasKey := strings.TrimSpace(model.APIKey) != "" ||
strings.TrimSpace(model.AuthMethod) == "oauth"
hasModel := strings.TrimSpace(model.Model) != ""
return hasKey && hasModel
}
func (s *appState) modelNameExists(name string, excludeIndex int) bool {
target := strings.TrimSpace(name)
if target == "" {
return false
}
for i := range s.config.ModelList {
if i == excludeIndex {
continue
}
if strings.TrimSpace(s.config.ModelList[i].ModelName) == target {
return true
}
}
return false
}
func (s *appState) nextAvailableModelName(base string) string {
name := strings.TrimSpace(base)
if name == "" {
name = "new-model"
}
if !s.modelNameExists(name, -1) {
return name
}
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s-%d", name, i)
if !s.modelNameExists(candidate, -1) {
return candidate
}
}
}
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
if model == nil {
return
}
if strings.TrimSpace(model.APIKey) == "" {
s.showMessage("Missing API Key", "Set api_key before testing")
return
}
base := strings.TrimSpace(model.APIBase)
if base == "" {
s.showMessage("Missing API Base", "Set api_base before testing")
return
}
modelID := strings.TrimSpace(model.Model)
if modelID == "" {
s.showMessage("Missing Model", "Set model before testing")
return
}
if !strings.HasPrefix(modelID, "openai/") {
s.showMessage("Unsupported model", "Only openai/* models are supported for test")
return
}
modelName := strings.TrimPrefix(modelID, "openai/")
endpoint := strings.TrimRight(base, "/") + "/chat/completions"
payload := fmt.Sprintf(
`{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`,
modelName,
)
client := &http.Client{Timeout: 10 * time.Second}
request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload))
if err != nil {
s.showMessage("Test failed", err.Error())
return
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey))
resp, err := client.Do(request)
if err != nil {
s.showMessage("Test failed", err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
s.showMessage("Test OK", resp.Status)
return
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
if err != nil {
s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err))
return
}
s.showMessage(
"Test failed",
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
)
}