mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
943385105f
io.ReadAll errors were silently discarded with `body, _ := io.ReadAll(...)`, which could cause empty or partial data to be used for JSON unmarshaling or error messages. This adds proper error checks for all instances.
348 lines
9.3 KiB
Go
348 lines
9.3 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, 2+len(s.config.ModelList))
|
|
items = append(items,
|
|
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
|
MenuItem{
|
|
Label: "Add model",
|
|
Description: "Append a new model entry",
|
|
Action: func() {
|
|
s.addModel(
|
|
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
|
)
|
|
s.push(
|
|
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
|
s.modelForm(len(s.config.ModelList)-1),
|
|
)
|
|
},
|
|
},
|
|
)
|
|
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
|
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))
|
|
},
|
|
})
|
|
}
|
|
|
|
menu := NewMenu("Models", items)
|
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
if event.Key() == tcell.KeyEsc {
|
|
s.pop()
|
|
return nil
|
|
}
|
|
if event.Rune() == 'q' {
|
|
s.pop()
|
|
return nil
|
|
}
|
|
if event.Rune() == ' ' {
|
|
row, _ := menu.GetSelection()
|
|
if row > 0 && row <= len(s.config.ModelList) {
|
|
model := s.config.ModelList[row-1]
|
|
if !isModelValid(model) {
|
|
s.showMessage(
|
|
"Invalid model",
|
|
"Select a model with api_key or oauth auth_method",
|
|
)
|
|
return nil
|
|
}
|
|
s.config.Agents.Defaults.Model = model.ModelName
|
|
s.dirty = true
|
|
refreshModelMenu(menu, s.config.Agents.Defaults.Model, 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))
|
|
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
|
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
|
|
|
addInput(form, "Model Name", model.ModelName, func(value string) {
|
|
model.ModelName = value
|
|
s.dirty = true
|
|
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() {
|
|
s.deleteModel(index)
|
|
})
|
|
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 + 1
|
|
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, 2+len(s.config.ModelList))
|
|
items = append(items,
|
|
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
|
MenuItem{
|
|
Label: "Add model",
|
|
Description: "Append a new model entry",
|
|
Action: func() {
|
|
s.addModel(
|
|
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
|
)
|
|
s.push(
|
|
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
|
s.modelForm(len(s.config.ModelList)-1),
|
|
)
|
|
},
|
|
},
|
|
)
|
|
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
|
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))
|
|
},
|
|
})
|
|
}
|
|
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) 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))),
|
|
)
|
|
}
|