mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
3ca95b294d
* feat: integrate NOFXi agent into dev * Enhance NOFXi agent workflow and diagnostics
319 lines
9.3 KiB
Go
319 lines
9.3 KiB
Go
package store
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"nofx/crypto"
|
|
"nofx/logger"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// AIModelStore AI model storage
|
|
type AIModelStore struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// AIModel AI model configuration
|
|
type AIModel struct {
|
|
ID string `gorm:"primaryKey" json:"id"`
|
|
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
|
|
Name string `gorm:"not null" json:"name"`
|
|
Provider string `gorm:"not null" json:"provider"`
|
|
Enabled bool `gorm:"default:false" json:"enabled"`
|
|
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
|
|
CustomAPIURL string `gorm:"column:custom_api_url;default:''" json:"customApiUrl"`
|
|
CustomModelName string `gorm:"column:custom_model_name;default:''" json:"customModelName"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
func (AIModel) TableName() string { return "ai_models" }
|
|
|
|
// NewAIModelStore creates a new AIModelStore
|
|
func NewAIModelStore(db *gorm.DB) *AIModelStore {
|
|
return &AIModelStore{db: db}
|
|
}
|
|
|
|
func (s *AIModelStore) initTables() error {
|
|
// For PostgreSQL with existing table, skip AutoMigrate
|
|
if s.db.Dialector.Name() == "postgres" {
|
|
var tableExists int64
|
|
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'ai_models'`).Scan(&tableExists)
|
|
if tableExists > 0 {
|
|
return nil
|
|
}
|
|
}
|
|
return s.db.AutoMigrate(&AIModel{})
|
|
}
|
|
|
|
func (s *AIModelStore) initDefaultData() error {
|
|
// No longer pre-populate AI models - create on demand when user configures
|
|
return nil
|
|
}
|
|
|
|
// FindOrphanClaw402 finds a claw402 model whose user_id no longer exists in the users table.
|
|
// Used to recover wallets after account reset.
|
|
func (s *AIModelStore) FindOrphanClaw402() (*AIModel, error) {
|
|
var model AIModel
|
|
err := s.db.Where("provider = ? AND api_key != '' AND user_id NOT IN (SELECT id FROM users)", "claw402").
|
|
First(&model).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &model, nil
|
|
}
|
|
|
|
// AdoptModel re-assigns an existing model to a new user.
|
|
func (s *AIModelStore) AdoptModel(modelID, newUserID string) error {
|
|
return s.db.Model(&AIModel{}).Where("id = ?", modelID).
|
|
Update("user_id", newUserID).Error
|
|
}
|
|
|
|
// List retrieves user's AI model list
|
|
func (s *AIModelStore) List(userID string) ([]*AIModel, error) {
|
|
var models []*AIModel
|
|
err := s.db.Where("user_id = ?", userID).Order("id").Find(&models).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return models, nil
|
|
}
|
|
|
|
// Get retrieves a single AI model
|
|
func (s *AIModelStore) Get(userID, modelID string) (*AIModel, error) {
|
|
if modelID == "" {
|
|
return nil, fmt.Errorf("model ID cannot be empty")
|
|
}
|
|
|
|
candidates := []string{}
|
|
if userID != "" {
|
|
candidates = append(candidates, userID)
|
|
}
|
|
if userID != "default" {
|
|
candidates = append(candidates, "default")
|
|
}
|
|
if len(candidates) == 0 {
|
|
candidates = append(candidates, "default")
|
|
}
|
|
|
|
for _, uid := range candidates {
|
|
var model AIModel
|
|
err := s.db.Where("user_id = ? AND id = ?", uid, modelID).First(&model).Error
|
|
if err == nil {
|
|
return &model, nil
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
}
|
|
return nil, gorm.ErrRecordNotFound
|
|
}
|
|
|
|
// GetByID retrieves an AI model by ID only
|
|
func (s *AIModelStore) GetByID(modelID string) (*AIModel, error) {
|
|
if modelID == "" {
|
|
return nil, fmt.Errorf("model ID cannot be empty")
|
|
}
|
|
|
|
var model AIModel
|
|
err := s.db.Where("id = ?", modelID).First(&model).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &model, nil
|
|
}
|
|
|
|
// GetDefault retrieves the default enabled AI model
|
|
func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) {
|
|
if userID == "" {
|
|
userID = "default"
|
|
}
|
|
model, err := s.firstEnabledUsable(userID)
|
|
if err == nil {
|
|
return model, nil
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
if userID != "default" {
|
|
return s.firstEnabledUsable("default")
|
|
}
|
|
return nil, fmt.Errorf("please configure an available AI model in the system first")
|
|
}
|
|
|
|
func (s *AIModelStore) firstEnabledUsable(userID string) (*AIModel, error) {
|
|
var model AIModel
|
|
err := s.db.Where("user_id = ? AND enabled = ? AND api_key != ''", userID, true).
|
|
Order("updated_at DESC, id ASC").
|
|
First(&model).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &model, nil
|
|
}
|
|
|
|
// GetAnyEnabled returns the first enabled AI model across all users.
|
|
// Used by single-user features (e.g. Telegram bot) that need any working LLM client.
|
|
func (s *AIModelStore) GetAnyEnabled() (*AIModel, error) {
|
|
var model AIModel
|
|
err := s.db.Where("enabled = ? AND api_key != ''", true).
|
|
Order("updated_at DESC, id ASC").
|
|
First(&model).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &model, nil
|
|
}
|
|
|
|
// Update updates AI model, creates if not exists
|
|
// IMPORTANT: If apiKey is empty string, the existing API key will be preserved (not overwritten)
|
|
func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error {
|
|
// Try exact ID match first
|
|
var existingModel AIModel
|
|
err := s.db.Where("user_id = ? AND id = ?", userID, id).First(&existingModel).Error
|
|
if err == nil {
|
|
// Update existing model
|
|
updates := map[string]interface{}{
|
|
"enabled": enabled,
|
|
"custom_api_url": customAPIURL,
|
|
"custom_model_name": customModelName,
|
|
"updated_at": time.Now().UTC(),
|
|
}
|
|
// If apiKey is not empty, update it (encryption handled by crypto.EncryptedString)
|
|
if apiKey != "" {
|
|
updates["api_key"] = crypto.EncryptedString(apiKey)
|
|
}
|
|
return s.db.Model(&existingModel).Updates(updates).Error
|
|
}
|
|
|
|
// Try legacy logic compatibility: use id as provider to search
|
|
provider := id
|
|
err = s.db.Where("user_id = ? AND provider = ?", userID, provider).First(&existingModel).Error
|
|
if err == nil {
|
|
logger.Warnf("⚠️ Using legacy provider matching to update model: %s -> %s", provider, existingModel.ID)
|
|
updates := map[string]interface{}{
|
|
"enabled": enabled,
|
|
"custom_api_url": customAPIURL,
|
|
"custom_model_name": customModelName,
|
|
"updated_at": time.Now().UTC(),
|
|
}
|
|
if apiKey != "" {
|
|
updates["api_key"] = crypto.EncryptedString(apiKey)
|
|
}
|
|
return s.db.Model(&existingModel).Updates(updates).Error
|
|
}
|
|
|
|
// Create new record
|
|
if provider == id && (provider == "deepseek" || provider == "qwen") {
|
|
provider = id
|
|
} else {
|
|
parts := strings.Split(id, "_")
|
|
if len(parts) >= 2 {
|
|
provider = parts[len(parts)-1]
|
|
} else {
|
|
provider = id
|
|
}
|
|
}
|
|
|
|
// Try to get name from existing model with same provider
|
|
var refModel AIModel
|
|
var name string
|
|
if err := s.db.Where("provider = ?", provider).First(&refModel).Error; err == nil {
|
|
name = refModel.Name
|
|
} else {
|
|
if provider == "deepseek" {
|
|
name = "DeepSeek AI"
|
|
} else if provider == "qwen" {
|
|
name = "Qwen AI"
|
|
} else {
|
|
name = provider + " AI"
|
|
}
|
|
}
|
|
|
|
newModelID := id
|
|
if id == provider {
|
|
newModelID = fmt.Sprintf("%s_%s", userID, provider)
|
|
}
|
|
|
|
logger.Infof("✓ Creating new AI model configuration: ID=%s, Provider=%s, Name=%s", newModelID, provider, name)
|
|
newModel := &AIModel{
|
|
ID: newModelID,
|
|
UserID: userID,
|
|
Name: name,
|
|
Provider: provider,
|
|
Enabled: enabled,
|
|
APIKey: crypto.EncryptedString(apiKey),
|
|
CustomAPIURL: customAPIURL,
|
|
CustomModelName: customModelName,
|
|
}
|
|
return s.db.Create(newModel).Error
|
|
}
|
|
|
|
// Create creates an AI model
|
|
// ResolveClaw402WalletKey returns the claw402 wallet private key for a user.
|
|
// If preferredModelID is non-empty and points to a claw402 model, its key is returned first.
|
|
// Otherwise the first enabled claw402 model in the user's model list is used.
|
|
// Returns ("", nil) when no claw402 model is configured — callers should treat this as
|
|
// "no paid data routing" rather than an error.
|
|
func (s *AIModelStore) ResolveClaw402WalletKey(userID, preferredModelID string) (string, error) {
|
|
if preferredModelID != "" {
|
|
model, err := s.Get(userID, preferredModelID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to load selected AI model")
|
|
}
|
|
if model.Provider == "claw402" {
|
|
walletKey := string(model.APIKey)
|
|
if walletKey == "" {
|
|
return "", fmt.Errorf("selected claw402 model is missing wallet private key")
|
|
}
|
|
return walletKey, nil
|
|
}
|
|
}
|
|
|
|
models, err := s.List(userID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to load AI models")
|
|
}
|
|
|
|
for _, model := range models {
|
|
if model == nil || model.Provider != "claw402" {
|
|
continue
|
|
}
|
|
if walletKey := string(model.APIKey); walletKey != "" {
|
|
return walletKey, nil
|
|
}
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
func (s *AIModelStore) Create(userID, id, name, provider string, enabled bool, apiKey, customAPIURL string) error {
|
|
model := &AIModel{
|
|
ID: id,
|
|
UserID: userID,
|
|
Name: name,
|
|
Provider: provider,
|
|
Enabled: enabled,
|
|
APIKey: crypto.EncryptedString(apiKey),
|
|
CustomAPIURL: customAPIURL,
|
|
}
|
|
// Use FirstOrCreate to ignore if already exists
|
|
return s.db.Where("id = ?", id).FirstOrCreate(model).Error
|
|
}
|
|
|
|
// Delete removes a user-owned AI model configuration.
|
|
func (s *AIModelStore) Delete(userID, id string) error {
|
|
result := s.db.Where("user_id = ? AND id = ?", userID, id).Delete(&AIModel{})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return fmt.Errorf("ai model not found: id=%s, userID=%s", id, userID)
|
|
}
|
|
logger.Infof("🗑️ Deleted AI model: id=%s, userID=%s", id, userID)
|
|
return nil
|
|
}
|