mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
201 lines
4.9 KiB
Go
201 lines
4.9 KiB
Go
// PicoClaw - Ultra-lightweight personal AI agent
|
|
// License: MIT
|
|
//
|
|
// Copyright (c) 2026 PicoClaw contributors
|
|
|
|
package ui
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/tview"
|
|
|
|
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
|
)
|
|
|
|
type modelsAPIResponse struct {
|
|
Data []modelEntry `json:"data"`
|
|
}
|
|
|
|
type modelEntry struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive {
|
|
table := tview.NewTable().
|
|
SetBorders(false).
|
|
SetSelectable(true, false).
|
|
SetFixed(0, 0)
|
|
table.SetBorder(true).
|
|
SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).
|
|
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
|
table.SetSelectedStyle(
|
|
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
|
)
|
|
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
|
|
|
var modelIDs []string
|
|
|
|
status := tview.NewTextView().
|
|
SetTextAlign(tview.AlignCenter).
|
|
SetDynamicColors(true).
|
|
SetText("[#ffff00]FETCHING MODELS...[-]")
|
|
status.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
|
|
|
flex := tview.NewFlex().
|
|
SetDirection(tview.FlexRow).
|
|
AddItem(status, 1, 0, false).
|
|
AddItem(table, 0, 1, false)
|
|
|
|
apiKey := a.resolveKey(schemeName, userName)
|
|
|
|
go func() {
|
|
var entries []modelEntry
|
|
var err error
|
|
if apiKey == "" {
|
|
err = fmt.Errorf("key is required")
|
|
} else {
|
|
entries, err = fetchModels(baseURL, apiKey)
|
|
}
|
|
|
|
a.modelCacheMu.Lock()
|
|
if a.modelCache == nil {
|
|
a.modelCache = make(map[string][]modelEntry)
|
|
}
|
|
if err == nil && len(entries) > 0 {
|
|
a.modelCache[cacheKey(schemeName, userName)] = entries
|
|
} else {
|
|
a.modelCache[cacheKey(schemeName, userName)] = nil
|
|
}
|
|
a.modelCacheMu.Unlock()
|
|
|
|
a.tapp.QueueUpdateDraw(func() {
|
|
if err != nil {
|
|
status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error()))
|
|
table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)"))
|
|
a.tapp.SetFocus(table)
|
|
return
|
|
}
|
|
if len(entries) == 0 {
|
|
status.SetText("[#ff2a2a]NO MODELS RETURNED[-]")
|
|
table.SetCell(0, 0, tview.NewTableCell(" (no models available)"))
|
|
a.tapp.SetFocus(table)
|
|
return
|
|
}
|
|
|
|
status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries)))
|
|
for i, m := range entries {
|
|
modelIDs = append(modelIDs, m.ID)
|
|
table.SetCell(i, 0,
|
|
tview.NewTableCell(fmt.Sprintf("%3d", i+1)).
|
|
SetAlign(tview.AlignRight).
|
|
SetTextColor(tcell.NewHexColor(0x808080)).
|
|
SetSelectable(false),
|
|
)
|
|
table.SetCell(i, 1,
|
|
tview.NewTableCell(" "+m.ID).
|
|
SetAlign(tview.AlignLeft).
|
|
SetExpansion(1).
|
|
SetTextColor(tcell.NewHexColor(0xe0e0e0)),
|
|
)
|
|
}
|
|
a.tapp.SetFocus(table)
|
|
})
|
|
}()
|
|
|
|
table.SetSelectedFunc(func(row, _ int) {
|
|
if row < 0 || row >= len(modelIDs) {
|
|
return
|
|
}
|
|
a.cfg.Provider.Current = tuicfg.ProviderCurrent{
|
|
Scheme: schemeName,
|
|
User: userName,
|
|
Model: modelIDs[row],
|
|
}
|
|
a.save()
|
|
|
|
// Trigger model selected callback if set
|
|
if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" {
|
|
scheme := a.cfg.Provider.SchemeByName(schemeName)
|
|
if scheme == nil {
|
|
a.goBack()
|
|
return
|
|
}
|
|
var user tuicfg.User
|
|
for _, u := range a.cfg.Provider.Users {
|
|
if u.Scheme == schemeName && u.Name == userName {
|
|
user = u
|
|
break
|
|
}
|
|
}
|
|
a.OnModelSelected(*scheme, user, modelIDs[row])
|
|
}
|
|
|
|
a.goBack()
|
|
})
|
|
|
|
return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ")
|
|
}
|
|
|
|
func (a *App) resolveKey(schemeName, userName string) string {
|
|
for _, u := range a.cfg.Provider.Users {
|
|
if u.Scheme == schemeName && u.Name == userName {
|
|
return u.Key
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
|
|
url := strings.TrimRight(baseURL, "/") + "/models"
|
|
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build request: %w", err)
|
|
}
|
|
if apiKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response: %w", err)
|
|
}
|
|
|
|
var result modelsAPIResponse
|
|
if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 {
|
|
return result.Data, nil
|
|
}
|
|
|
|
var arr []modelEntry
|
|
if err := json.Unmarshal(body, &arr); err == nil {
|
|
return arr, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf(
|
|
"decode response: unrecognized shape: %s",
|
|
strings.TrimSpace(string(body[:min(len(body), 256)])),
|
|
)
|
|
}
|