Files
browser/platform/service/ai_invocation.go
T
2026-07-20 16:42:33 +08:00

214 lines
7.4 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 {
auditID, auditErr := svc.recordAuditEventWithID(user.ID, "ai.provider.invoke.failed", "ai-provider", provider.ID, domain.AuditResultFailed, "AI provider invocation failed safely")
if auditErr != nil {
return domain.AIInvocationResponse{}, auditErr
}
svc.productionMu.Lock()
_, alertErr := svc.upsertAlert(domain.AlertRecord{SourceKind: "ai-provider", SourceID: provider.ID, RuleKey: "ai.provider.failed", Severity: domain.AlertSeverityWarning, Title: "AI provider invocation failed", Message: "AI provider invocation failed safely", Retryable: false, LastAuditEventID: auditID})
svc.productionMu.Unlock()
if alertErr != nil {
return domain.AIInvocationResponse{}, alertErr
}
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.productionMu.Lock()
preview, persistErr := svc.persistAIConfigDiff(user.ID, provider, request, result)
svc.productionMu.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
}