A repeater saved with non-sequential keys was read only at keys 0 to len-1, so a model at a higher key was dropped while the import reported success and refused to run again. Every key is now read, numeric keys in ascending order.
264 lines
7.2 KiB
Go
264 lines
7.2 KiB
Go
// Package console holds the golem15.golem operator commands.
|
|
package console
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"math"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/sm-golem-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// SettingsItem is the WinterCMS system_settings item that holds the
|
|
// plugin's AI models repeater.
|
|
const SettingsItem = "golem_settings"
|
|
|
|
// ImportSettings is golem:import-settings: a one-time copy of the
|
|
// WinterCMS plugin's AI models (the models repeater of the system_settings
|
|
// row golem_settings, or of a JSON file given with --file) into
|
|
// golem15_golem_models, in repeater order, each plaintext API key
|
|
// encrypted on write. It refuses to run when the table already has rows,
|
|
// so a second run changes nothing.
|
|
func ImportSettings(app *backpack.App) bonfire.Command {
|
|
return bonfire.Command{
|
|
Name: "golem:import-settings",
|
|
Description: "Import the AI models of a WinterCMS golem_settings row (or a JSON file) into golem15_golem_models, once.",
|
|
Flags: []bonfire.Flag{{
|
|
Name: "file",
|
|
Description: "A JSON file holding the settings value ({\"models\":[...]}) or the models list, read instead of system_settings",
|
|
}},
|
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
|
if app == nil {
|
|
return errors.New("golem:import-settings: app is nil")
|
|
}
|
|
db, ok := app.Lookup[*gorm.DB]()
|
|
if !ok || db == nil {
|
|
return errors.New("golem:import-settings: database is not configured")
|
|
}
|
|
file, _ := in.Flag("file")
|
|
n, err := ImportModels(ctx, db, strings.TrimSpace(file))
|
|
var notice *importNotice
|
|
switch {
|
|
case errors.As(err, ¬ice):
|
|
out.Info(notice.msg)
|
|
return nil
|
|
case err != nil:
|
|
out.Error(err.Error())
|
|
return err
|
|
}
|
|
out.Success(fmt.Sprintf("Imported %d AI models.", n))
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// ErrModelsNotEmpty refuses an import into a table that already has rows.
|
|
var ErrModelsNotEmpty = errors.New("golem15_golem_models already has rows; nothing was imported")
|
|
|
|
// importNotice is a clean stop: there is nothing to import.
|
|
type importNotice struct{ msg string }
|
|
|
|
func (n *importNotice) Error() string { return n.msg }
|
|
|
|
// IsNothingToImport reports an import that stopped because the source
|
|
// holds nothing (no system_settings table, no golem_settings row, no
|
|
// models).
|
|
func IsNothingToImport(err error) bool {
|
|
var n *importNotice
|
|
return errors.As(err, &n)
|
|
}
|
|
|
|
// ImportModels runs the import and returns the number of models written.
|
|
// file, when not empty, is read instead of the system_settings row.
|
|
func ImportModels(ctx context.Context, db *gorm.DB, file string) (int, error) {
|
|
var count int64
|
|
if err := db.WithContext(ctx).Model(&models.AiModel{}).Count(&count).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
if count > 0 {
|
|
return 0, ErrModelsNotEmpty
|
|
}
|
|
raw, err := settingsValue(ctx, db, file)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
items, err := repeaterItems(raw)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(items) == 0 {
|
|
return 0, &importNotice{msg: "The settings hold no AI models: nothing to import."}
|
|
}
|
|
err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
for i, item := range items {
|
|
row := modelFromItem(item)
|
|
row.SortOrder = i
|
|
if err := tx.WithContext(ctx).Create(&row).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return len(items), nil
|
|
}
|
|
|
|
func settingsValue(ctx context.Context, db *gorm.DB, file string) ([]byte, error) {
|
|
if file != "" {
|
|
raw, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("golem:import-settings: read %s: %w", file, err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
if !db.WithContext(ctx).Migrator().HasTable("system_settings") {
|
|
return nil, &importNotice{msg: "No system_settings table: nothing to import."}
|
|
}
|
|
var values []string
|
|
if err := db.WithContext(ctx).Raw(`SELECT value FROM system_settings WHERE item = ?`, SettingsItem).Scan(&values).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(values) == 0 {
|
|
return nil, &importNotice{msg: "No golem_settings row in system_settings: nothing to import."}
|
|
}
|
|
return []byte(values[0]), nil
|
|
}
|
|
|
|
// repeaterItems reads the models list of a settings value
|
|
// ({"models":[...]}) or a bare list.
|
|
func repeaterItems(raw []byte) ([]map[string]any, error) {
|
|
var v any
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
return nil, fmt.Errorf("golem:import-settings: the settings are not JSON: %w", err)
|
|
}
|
|
if m, ok := v.(map[string]any); ok {
|
|
v = m["models"]
|
|
}
|
|
var list []any
|
|
switch t := v.(type) {
|
|
case []any:
|
|
list = t
|
|
case map[string]any:
|
|
// A repeater saved with non-sequential keys decodes as an object
|
|
// ({"0":…,"2":…} after a middle item was deleted). Every key is
|
|
// read, numeric keys in ascending order, then any other key.
|
|
for _, k := range repeaterKeys(t) {
|
|
list = append(list, t[k])
|
|
}
|
|
case nil:
|
|
default:
|
|
return nil, errors.New("golem:import-settings: models is not a list")
|
|
}
|
|
out := make([]map[string]any, 0, len(list))
|
|
for _, item := range list {
|
|
if m, ok := item.(map[string]any); ok {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// repeaterKeys orders an object repeater's keys: integers ascending by
|
|
// value first, then the other keys in byte order.
|
|
func repeaterKeys(t map[string]any) []string {
|
|
keys := slices.Collect(maps.Keys(t))
|
|
slices.SortFunc(keys, func(a, b string) int {
|
|
ai, aerr := strconv.Atoi(a)
|
|
bi, berr := strconv.Atoi(b)
|
|
switch {
|
|
case aerr == nil && berr == nil:
|
|
return cmp.Compare(ai, bi)
|
|
case aerr == nil:
|
|
return -1
|
|
case berr == nil:
|
|
return 1
|
|
}
|
|
return strings.Compare(a, b)
|
|
})
|
|
return keys
|
|
}
|
|
|
|
func modelFromItem(item map[string]any) models.AiModel {
|
|
row := models.AiModel{
|
|
Name: str(item["name"]),
|
|
Adapter: str(item["adapter"]),
|
|
Model: str(item["model"]),
|
|
IsEnabled: truthy(item["is_enabled"]),
|
|
IsDefault: truthy(item["is_default"]),
|
|
GeneratesImages: truthy(item["generates_images"]),
|
|
AcceptsImages: truthy(item["accepts_images"]),
|
|
HasFilesEndpoint: truthy(item["has_files_endpoint"]),
|
|
}
|
|
if row.Adapter == "" {
|
|
row.Adapter = "openai"
|
|
}
|
|
if key := str(item["api_key"]); key != "" {
|
|
row.APIKey = lagoon.NewEncrypted(key)
|
|
}
|
|
if s := str(item["base_url"]); s != "" {
|
|
row.BaseURL = &s
|
|
}
|
|
if s := str(item["system_prompt"]); s != "" {
|
|
row.SystemPrompt = &s
|
|
}
|
|
if n, ok := integer(item["max_completion_tokens"]); ok {
|
|
row.MaxCompletionTokens = &n
|
|
}
|
|
return row
|
|
}
|
|
|
|
func str(v any) string {
|
|
switch t := v.(type) {
|
|
case string:
|
|
return t
|
|
case float64:
|
|
return strconv.FormatFloat(t, 'f', -1, 64)
|
|
case bool:
|
|
if t {
|
|
return "1"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// truthy is PHP's truthiness of a stored switch ("1", true, 1).
|
|
func truthy(v any) bool {
|
|
switch t := v.(type) {
|
|
case bool:
|
|
return t
|
|
case string:
|
|
return t != "" && t != "0"
|
|
case float64:
|
|
return t != 0
|
|
}
|
|
return false
|
|
}
|
|
|
|
func integer(v any) (int, bool) {
|
|
switch t := v.(type) {
|
|
case float64:
|
|
if t == math.Trunc(t) {
|
|
return int(t), true
|
|
}
|
|
case string:
|
|
if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil {
|
|
return n, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|