266 lines
7.7 KiB
Go
266 lines
7.7 KiB
Go
|
|
package usecase
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
app "haixun-backend/internal/library/errors"
|
||
|
|
"haixun-backend/internal/library/errors/code"
|
||
|
|
domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
|
||
|
|
settingdomain "haixun-backend/internal/model/setting/domain/usecase"
|
||
|
|
)
|
||
|
|
|
||
|
|
const keyAiCredentials = "ai.credentials"
|
||
|
|
|
||
|
|
var knownAiProviders = []string{
|
||
|
|
"opencode-go",
|
||
|
|
"xai",
|
||
|
|
"openai",
|
||
|
|
"anthropic",
|
||
|
|
"google",
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *threadsAccountUseCase) GetAiSettings(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.AiSettings, error) {
|
||
|
|
account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
stored, err := u.loadAiCredentials(ctx, ownerUID, account.ID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return toPublicAiSettings(account.ID, stored), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *threadsAccountUseCase) ResolveMemberAiCredential(
|
||
|
|
ctx context.Context,
|
||
|
|
tenantID, ownerUID string,
|
||
|
|
) (*domusecase.WorkerAiCredential, error) {
|
||
|
|
if err := requireActor(tenantID, ownerUID); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
stored, err := u.loadAiCredentials(ctx, ownerUID, "")
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
provider := strings.TrimSpace(stored.Provider)
|
||
|
|
model := strings.TrimSpace(stored.Model)
|
||
|
|
apiKey := strings.TrimSpace(stored.ApiKeys[provider])
|
||
|
|
if provider == "" || apiKey == "" {
|
||
|
|
return nil, app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 AI API key")
|
||
|
|
}
|
||
|
|
return &domusecase.WorkerAiCredential{
|
||
|
|
Provider: provider,
|
||
|
|
Model: model,
|
||
|
|
APIKey: apiKey,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *threadsAccountUseCase) ResolveWorkerAiCredential(
|
||
|
|
ctx context.Context,
|
||
|
|
tenantID, ownerUID, accountID string,
|
||
|
|
) (*domusecase.WorkerAiCredential, error) {
|
||
|
|
account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
stored, err := u.loadAiCredentials(ctx, ownerUID, account.ID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
provider := strings.TrimSpace(stored.ResearchProvider)
|
||
|
|
model := strings.TrimSpace(stored.ResearchModel)
|
||
|
|
if provider == "" {
|
||
|
|
provider = strings.TrimSpace(stored.Provider)
|
||
|
|
}
|
||
|
|
if model == "" {
|
||
|
|
model = strings.TrimSpace(stored.Model)
|
||
|
|
}
|
||
|
|
apiKey := strings.TrimSpace(stored.ApiKeys[provider])
|
||
|
|
if apiKey == "" {
|
||
|
|
return nil, app.For(code.ThreadsAccount).InputMissingRequired("AI API key is not configured for provider " + provider)
|
||
|
|
}
|
||
|
|
return &domusecase.WorkerAiCredential{
|
||
|
|
Provider: provider,
|
||
|
|
Model: model,
|
||
|
|
APIKey: apiKey,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *threadsAccountUseCase) UpdateAiSettings(
|
||
|
|
ctx context.Context,
|
||
|
|
tenantID, ownerUID, accountID string,
|
||
|
|
patch domusecase.AiSettingsPatch,
|
||
|
|
) (*domusecase.AiSettings, error) {
|
||
|
|
account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
current, err := u.loadAiCredentials(ctx, ownerUID, account.ID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
next := applyAiSettingsPatch(current, patch)
|
||
|
|
if err := u.saveAiCredentials(ctx, ownerUID, next); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return toPublicAiSettings(account.ID, next), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type aiCredentials struct {
|
||
|
|
Provider string
|
||
|
|
Model string
|
||
|
|
ResearchProvider string
|
||
|
|
ResearchModel string
|
||
|
|
ApiKeys map[string]string
|
||
|
|
}
|
||
|
|
|
||
|
|
func defaultAiCredentials() aiCredentials {
|
||
|
|
return aiCredentials{
|
||
|
|
Provider: "opencode-go",
|
||
|
|
Model: "deepseek-v4-pro",
|
||
|
|
ResearchProvider: "opencode-go",
|
||
|
|
ResearchModel: "deepseek-v4-flash",
|
||
|
|
ApiKeys: map[string]string{},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *threadsAccountUseCase) loadAiCredentials(ctx context.Context, ownerUID, accountID string) (aiCredentials, error) {
|
||
|
|
defaults := defaultAiCredentials()
|
||
|
|
setting, err := u.settings.Get(ctx, settingScopeUser, ownerUID, keyAiCredentials)
|
||
|
|
if err == nil {
|
||
|
|
return mergeAiCredentials(defaults, setting.Value), nil
|
||
|
|
}
|
||
|
|
if !isSettingNotFound(err) {
|
||
|
|
return defaults, err
|
||
|
|
}
|
||
|
|
// Legacy: per-account storage migrates to user scope on first read.
|
||
|
|
legacy, err := u.settings.Get(ctx, settingScopeAccount, accountID, keyAiCredentials)
|
||
|
|
if err != nil {
|
||
|
|
if isSettingNotFound(err) {
|
||
|
|
return defaults, nil
|
||
|
|
}
|
||
|
|
return defaults, err
|
||
|
|
}
|
||
|
|
creds := mergeAiCredentials(defaults, legacy.Value)
|
||
|
|
if saveErr := u.saveAiCredentials(ctx, ownerUID, creds); saveErr != nil {
|
||
|
|
return creds, saveErr
|
||
|
|
}
|
||
|
|
return creds, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *threadsAccountUseCase) saveAiCredentials(ctx context.Context, ownerUID string, creds aiCredentials) error {
|
||
|
|
_, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{
|
||
|
|
Scope: settingScopeUser,
|
||
|
|
ScopeID: ownerUID,
|
||
|
|
Key: keyAiCredentials,
|
||
|
|
Value: aiCredentialsToMap(creds),
|
||
|
|
})
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
func mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) aiCredentials {
|
||
|
|
if value == nil {
|
||
|
|
return defaults
|
||
|
|
}
|
||
|
|
if v, ok := value["provider"].(string); ok && strings.TrimSpace(v) != "" {
|
||
|
|
defaults.Provider = v
|
||
|
|
}
|
||
|
|
if v, ok := value["model"].(string); ok && strings.TrimSpace(v) != "" {
|
||
|
|
defaults.Model = v
|
||
|
|
}
|
||
|
|
if v, ok := value["research_provider"].(string); ok && strings.TrimSpace(v) != "" {
|
||
|
|
defaults.ResearchProvider = v
|
||
|
|
}
|
||
|
|
if v, ok := value["research_model"].(string); ok && strings.TrimSpace(v) != "" {
|
||
|
|
defaults.ResearchModel = v
|
||
|
|
}
|
||
|
|
if raw, ok := value["api_keys"].(map[string]interface{}); ok {
|
||
|
|
keys := map[string]string{}
|
||
|
|
for provider, item := range raw {
|
||
|
|
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
|
||
|
|
keys[provider] = strings.TrimSpace(s)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
defaults.ApiKeys = keys
|
||
|
|
}
|
||
|
|
return defaults
|
||
|
|
}
|
||
|
|
|
||
|
|
func applyAiSettingsPatch(current aiCredentials, patch domusecase.AiSettingsPatch) aiCredentials {
|
||
|
|
if patch.Provider != nil && strings.TrimSpace(*patch.Provider) != "" {
|
||
|
|
current.Provider = strings.TrimSpace(*patch.Provider)
|
||
|
|
}
|
||
|
|
if patch.Model != nil && strings.TrimSpace(*patch.Model) != "" {
|
||
|
|
current.Model = strings.TrimSpace(*patch.Model)
|
||
|
|
}
|
||
|
|
if patch.ResearchProvider != nil && strings.TrimSpace(*patch.ResearchProvider) != "" {
|
||
|
|
current.ResearchProvider = strings.TrimSpace(*patch.ResearchProvider)
|
||
|
|
}
|
||
|
|
if patch.ResearchModel != nil && strings.TrimSpace(*patch.ResearchModel) != "" {
|
||
|
|
current.ResearchModel = strings.TrimSpace(*patch.ResearchModel)
|
||
|
|
}
|
||
|
|
if len(patch.ApiKeys) > 0 {
|
||
|
|
if current.ApiKeys == nil {
|
||
|
|
current.ApiKeys = map[string]string{}
|
||
|
|
}
|
||
|
|
for provider, value := range patch.ApiKeys {
|
||
|
|
trimmed := strings.TrimSpace(value)
|
||
|
|
if trimmed == "" || isMaskedAPIKey(trimmed) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
current.ApiKeys[provider] = trimmed
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return current
|
||
|
|
}
|
||
|
|
|
||
|
|
func aiCredentialsToMap(creds aiCredentials) map[string]interface{} {
|
||
|
|
keys := map[string]interface{}{}
|
||
|
|
for provider, value := range creds.ApiKeys {
|
||
|
|
keys[provider] = value
|
||
|
|
}
|
||
|
|
return map[string]interface{}{
|
||
|
|
"provider": creds.Provider,
|
||
|
|
"model": creds.Model,
|
||
|
|
"research_provider": creds.ResearchProvider,
|
||
|
|
"research_model": creds.ResearchModel,
|
||
|
|
"api_keys": keys,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func toPublicAiSettings(accountID string, creds aiCredentials) *domusecase.AiSettings {
|
||
|
|
masked := map[string]string{}
|
||
|
|
configured := map[string]bool{}
|
||
|
|
for _, provider := range knownAiProviders {
|
||
|
|
raw := strings.TrimSpace(creds.ApiKeys[provider])
|
||
|
|
configured[provider] = raw != ""
|
||
|
|
if maskedValue := maskAPIKey(raw); maskedValue != "" {
|
||
|
|
masked[provider] = maskedValue
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return &domusecase.AiSettings{
|
||
|
|
AccountID: accountID,
|
||
|
|
Provider: creds.Provider,
|
||
|
|
Model: creds.Model,
|
||
|
|
ResearchProvider: creds.ResearchProvider,
|
||
|
|
ResearchModel: creds.ResearchModel,
|
||
|
|
ApiKeys: masked,
|
||
|
|
ApiKeysConfigured: configured,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func maskAPIKey(key string) string {
|
||
|
|
trimmed := strings.TrimSpace(key)
|
||
|
|
if trimmed == "" {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
if len(trimmed) <= 4 {
|
||
|
|
return "••••"
|
||
|
|
}
|
||
|
|
return "••••" + trimmed[len(trimmed)-4:]
|
||
|
|
}
|
||
|
|
|
||
|
|
func isMaskedAPIKey(value string) bool {
|
||
|
|
return strings.HasPrefix(value, "••••")
|
||
|
|
}
|