Files

216 lines
7.4 KiB
Go

package service
import (
"errors"
"strings"
"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 + ": configuration changes are dispatched automatically."
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
}
if _, err := svc.GetCurrentUser(sessionID); err != nil {
return domain.AIInvocationResponse{}, err
}
var currentConfig domain.ServerConfig
var hasCurrentConfig bool
var err error
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
}
currentConfig = config
hasCurrentConfig = true
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 == "" || !hasCurrentConfig {
return domain.AIInvocationResponse{}, validationError("serverInstanceId is required for AI config recommendations")
}
idempotencyKey := aiConfigWriteIdempotencyKey(request.RequestID, request.ServerInstanceID)
svc.pluginOperationsMu.Lock()
dispatch, dispatchErr := svc.ApproveServerConfigWriteForSession(sessionID, domain.ServerConfigWriteApproval{
ServerInstanceID: request.ServerInstanceID,
ExpectedConfigVersion: currentConfig.ConfigVersion,
ExpectedChecksum: currentConfig.Checksum,
Key: currentConfig.Key,
ProposedContent: result.SuggestedConfig,
IdempotencyKey: idempotencyKey,
})
svc.pluginOperationsMu.Unlock()
if dispatchErr != nil {
return domain.AIInvocationResponse{}, dispatchErr
}
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: currentConfig.Key, SuggestedConfig: result.SuggestedConfig, DiffSummary: "AI 配置建议已直接派发 config.write 任务"}
response.ConfigExecution = &domain.AIConfigExecution{Status: dispatch.Status, Job: dispatch.Job}
}
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=auto-applied\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
}