204 lines
6.7 KiB
Go
204 lines
6.7 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"browser.local/platform/domain"
|
|
"browser.local/platform/repo"
|
|
"browser.local/platform/validator"
|
|
)
|
|
|
|
type AIProviderClient interface {
|
|
Invoke(provider domain.AIProvider, request domain.AIInvocationRequest) (domain.AIProviderInvocationResult, error)
|
|
}
|
|
|
|
type MockAIProviderClient struct{}
|
|
|
|
func (MockAIProviderClient) Invoke(provider domain.AIProvider, request domain.AIInvocationRequest) (domain.AIProviderInvocationResult, error) {
|
|
model := request.Model
|
|
if model == "" {
|
|
model = provider.DefaultModel
|
|
}
|
|
if model == "" && len(provider.Models) > 0 {
|
|
model = provider.Models[0]
|
|
}
|
|
recommendation := "Mock AI recommendation for " + request.Purpose + ": review the proposed change before dispatch."
|
|
result := domain.AIProviderInvocationResult{
|
|
Recommendation: recommendation,
|
|
Usage: domain.AIInvocationUsage{
|
|
ProviderID: provider.ID,
|
|
Model: model,
|
|
InputTokens: boundedTokenEstimate(request.Prompt + request.CurrentConfig),
|
|
OutputTokens: boundedTokenEstimate(recommendation),
|
|
Mocked: true,
|
|
},
|
|
}
|
|
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
|
|
result.SuggestedConfig = buildSuggestedConfig(request.CurrentConfig, request.Prompt)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIInvocationRequest) (domain.AIInvocationResponse, error) {
|
|
request = domain.CopyAIInvocationRequest(request)
|
|
if err := validator.ValidateAIInvocationRequest(request); err != nil {
|
|
return domain.AIInvocationResponse{}, err
|
|
}
|
|
user, err := svc.GetCurrentUser(sessionID)
|
|
if err != nil {
|
|
return domain.AIInvocationResponse{}, err
|
|
}
|
|
if request.ServerInstanceID != "" {
|
|
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.AIInvocationResponse{}, err
|
|
}
|
|
if request.PluginID != "" && instance.PluginID != request.PluginID {
|
|
return safeAIDenial(request, "plugin scope does not match server instance"), nil
|
|
}
|
|
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
|
|
config, err := svc.GetServerConfigForSession(sessionID, instance.ID)
|
|
if err != nil {
|
|
return domain.AIInvocationResponse{}, err
|
|
}
|
|
request.CurrentConfig = config.Content
|
|
}
|
|
}
|
|
if request.PluginID != "" {
|
|
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
|
|
if err != nil {
|
|
return domain.AIInvocationResponse{}, err
|
|
}
|
|
authorization, err := validator.AuthorizePluginBridgeAction(plugin, domain.PluginBridgeAuthorizeRequest{
|
|
PluginID: request.PluginID,
|
|
RouteKey: request.RouteKey,
|
|
ServerInstanceID: request.ServerInstanceID,
|
|
Action: domain.PluginBridgeActionAIInvoke,
|
|
AIPurpose: request.Purpose,
|
|
})
|
|
if err != nil {
|
|
return domain.AIInvocationResponse{}, err
|
|
}
|
|
if !authorization.Allowed {
|
|
return safeAIDenial(request, authorization.Reason), nil
|
|
}
|
|
}
|
|
provider, err := svc.selectAIProvider(request.ProviderID)
|
|
if err != nil {
|
|
return domain.AIInvocationResponse{}, err
|
|
}
|
|
result, err := svc.aiProviderClient.Invoke(provider, request)
|
|
if err != nil {
|
|
return domain.CopyAIInvocationResponse(domain.AIInvocationResponse{
|
|
RequestID: request.RequestID,
|
|
Purpose: request.Purpose,
|
|
ProviderID: provider.ID,
|
|
Model: safeModel(request.Model, provider),
|
|
Status: "error",
|
|
Usage: domain.AIInvocationUsage{ProviderID: provider.ID, Model: safeModel(request.Model, provider)},
|
|
Error: &domain.AIInvocationSafeError{Code: "provider_failed", Message: "AI provider invocation failed safely"},
|
|
}), nil
|
|
}
|
|
response := domain.AIInvocationResponse{
|
|
RequestID: request.RequestID,
|
|
Purpose: request.Purpose,
|
|
ProviderID: provider.ID,
|
|
Model: result.Usage.Model,
|
|
Status: "ok",
|
|
Recommendation: result.Recommendation,
|
|
Usage: result.Usage,
|
|
}
|
|
if result.SuggestedConfig != "" {
|
|
if request.ServerInstanceID == "" {
|
|
return domain.AIInvocationResponse{}, validationError("serverInstanceId is required for AI config recommendations")
|
|
}
|
|
svc.pluginOperationsMu.Lock()
|
|
preview, persistErr := svc.persistAIConfigDiff(user.ID, provider, request, result)
|
|
svc.pluginOperationsMu.Unlock()
|
|
if persistErr != nil {
|
|
return domain.AIInvocationResponse{}, persistErr
|
|
}
|
|
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: preview.Key, SuggestedConfig: preview.ProposedConfig, DiffSummary: preview.DiffSummary, DiffID: preview.ID, ExpiresAt: preview.ExpiresAt.Format(time.RFC3339)}
|
|
}
|
|
if err := validator.ValidateAIInvocationResponse(response); err != nil {
|
|
return domain.AIInvocationResponse{}, err
|
|
}
|
|
return domain.CopyAIInvocationResponse(response), nil
|
|
}
|
|
|
|
func (svc *CoreService) selectAIProvider(id string) (domain.AIProvider, error) {
|
|
if id != "" {
|
|
provider, err := svc.store.AIProviders().Get(id)
|
|
if err != nil {
|
|
return domain.AIProvider{}, err
|
|
}
|
|
if provider.Status != domain.AIProviderStatusActive {
|
|
return domain.AIProvider{}, ErrForbidden
|
|
}
|
|
return provider, nil
|
|
}
|
|
providers, err := svc.store.AIProviders().List(domain.AIProviderFilter{Status: domain.AIProviderStatusActive})
|
|
if err != nil {
|
|
return domain.AIProvider{}, err
|
|
}
|
|
if len(providers) == 0 {
|
|
return domain.AIProvider{}, repo.ErrNotFound
|
|
}
|
|
return providers[0], nil
|
|
}
|
|
|
|
func safeAIDenial(request domain.AIInvocationRequest, reason string) domain.AIInvocationResponse {
|
|
return domain.CopyAIInvocationResponse(domain.AIInvocationResponse{
|
|
RequestID: request.RequestID,
|
|
Purpose: request.Purpose,
|
|
Status: "denied",
|
|
Error: &domain.AIInvocationSafeError{Code: "permission_denied", Message: safeBridgeReason(reason)},
|
|
})
|
|
}
|
|
|
|
func safeModel(model string, provider domain.AIProvider) string {
|
|
if strings.TrimSpace(model) != "" {
|
|
return model
|
|
}
|
|
if provider.DefaultModel != "" {
|
|
return provider.DefaultModel
|
|
}
|
|
if len(provider.Models) > 0 {
|
|
return provider.Models[0]
|
|
}
|
|
return "mock-model"
|
|
}
|
|
|
|
func buildSuggestedConfig(currentConfig string, prompt string) string {
|
|
base := strings.TrimRight(currentConfig, "\n")
|
|
if base == "" {
|
|
base = "# generated server config"
|
|
}
|
|
if strings.Contains(strings.ToLower(prompt), "pvp") && !strings.Contains(base, "pvp=") {
|
|
base += "\npvp=false"
|
|
}
|
|
return base + "\n# ai.recommendation=review-required\n"
|
|
}
|
|
|
|
func boundedTokenEstimate(value string) int {
|
|
count := len([]rune(value)) / 4
|
|
if count < 1 {
|
|
return 1
|
|
}
|
|
if count > 4096 {
|
|
return 4096
|
|
}
|
|
return count
|
|
}
|
|
|
|
type failingAIProviderClient struct{ err error }
|
|
|
|
func (client failingAIProviderClient) Invoke(domain.AIProvider, domain.AIInvocationRequest) (domain.AIProviderInvocationResult, error) {
|
|
if client.err == nil {
|
|
return domain.AIProviderInvocationResult{}, errors.New("provider failed")
|
|
}
|
|
return domain.AIProviderInvocationResult{}, client.err
|
|
}
|