- golem15_golem_models (encrypted write-only api_key) with its cabana list and form under golem15.golem.access_settings, and the Settings lookups (Models, DefaultModel with PHP's position-0 quirk, Vision, Image, File, ByName), every one Trusted - golem:import-settings copies the WinterCMS golem_settings repeater once - AnthropicAdapter; SendStream, SendFile/SendFilePath/SendFileReader, SendWithModel, SendToImageModel, SendToVisionModel, GenerateImage, Ask - PromptFactory; README documents the config keys, the environment variable, the command and the admin screen
241 lines
6.6 KiB
Go
241 lines
6.6 KiB
Go
// Package console holds the golem15.golem operator commands.
|
|
package console
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"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.
|
|
for i := 0; i < len(t); i++ {
|
|
if item, ok := t[strconv.Itoa(i)]; ok {
|
|
list = append(list, item)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|