// 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 }