Files
sm-golem-plugin/console/import_settings.go
Jakub Zych 9f60b6138c fix(14): WR-05 import every key of an object-shaped models repeater
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.
2026-10-04 08:08:44 +02:00

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, &notice):
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
}