feat(ai-service): complete ai production readiness tasks
All checks were successful
Build and Release / Create Release (push) Has been skipped
Build and Release / Unit Tests (push) Successful in 6m49s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 7m6s
Build and Release / Lint (push) Successful in 7m15s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been skipped
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been skipped
Build and Release / Build Binary (linux/arm64) (push) Has been skipped
All checks were successful
Build and Release / Create Release (push) Has been skipped
Build and Release / Unit Tests (push) Successful in 6m49s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 7m6s
Build and Release / Lint (push) Successful in 7m15s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been skipped
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been skipped
Build and Release / Build Binary (linux/arm64) (push) Has been skipped
Implement critical production readiness features for AI integration: per-request provider config, admin dashboard, workflow inspection, and plugin framework foundation. Per-Request Provider Config: - Add ProviderConfig struct to all AI request types - Update queue to resolve provider/model/API key from cascade (repo > org > system) - Pass resolved config to AI sidecar on every request - Fixes multi-tenant issue where all orgs shared sidecar's hardcoded config Admin AI Dashboard: - Add /admin/ai page with sidecar health status - Display global operation stats (total, 24h, success/fail/escalated counts) - Show operations by tier, top 5 repos, token usage - Recent operations table with repo, operation, status, duration - Add GetGlobalOperationStats model method Workflow Inspection: - Add InspectWorkflow client method and types - Implement workflow-inspect queue handler - Add notifier trigger on workflow file push - Analyzes YAML for syntax errors, security issues, best practices - Returns structured issues with line numbers and suggested fixes Plugin Framework (Phase 5 Foundation): - Add external plugin config loading from app.ini - Define ExternalPlugin interface and manager - Add plugin.proto contract (Initialize, Shutdown, HealthCheck, OnEvent, HandleHTTP) - Implement health monitoring with auto-restart for managed plugins - Add event routing to subscribed plugins - HTTP proxy support for plugin-served routes This completes Tasks 1-4 from the production readiness plan and establishes the foundation for managed plugin lifecycle.
This commit is contained in:
@@ -110,3 +110,98 @@ func CountRecentOperations(ctx context.Context, repoID int64) (int64, error) {
|
|||||||
oneHourAgo := timeutil.TimeStampNow() - 3600
|
oneHourAgo := timeutil.TimeStampNow() - 3600
|
||||||
return db.GetEngine(ctx).Where("repo_id = ? AND created_unix > ?", repoID, oneHourAgo).Count(new(OperationLog))
|
return db.GetEngine(ctx).Where("repo_id = ? AND created_unix > ?", repoID, oneHourAgo).Count(new(OperationLog))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GlobalOperationStats holds aggregate AI operation statistics for admin dashboard
|
||||||
|
type GlobalOperationStats struct {
|
||||||
|
TotalOperations int64 `json:"total_operations"`
|
||||||
|
Operations24h int64 `json:"operations_24h"`
|
||||||
|
SuccessCount int64 `json:"success_count"`
|
||||||
|
FailedCount int64 `json:"failed_count"`
|
||||||
|
EscalatedCount int64 `json:"escalated_count"`
|
||||||
|
PendingCount int64 `json:"pending_count"`
|
||||||
|
CountByTier map[int]int64 `json:"count_by_tier"`
|
||||||
|
TopRepos []RepoOpCount `json:"top_repos"`
|
||||||
|
TotalInputTokens int64 `json:"total_input_tokens"`
|
||||||
|
TotalOutputTokens int64 `json:"total_output_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoOpCount holds a repo's operation count for the top-repos list
|
||||||
|
type RepoOpCount struct {
|
||||||
|
RepoID int64 `json:"repo_id"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGlobalOperationStats returns aggregate statistics across all repos for the admin dashboard
|
||||||
|
func GetGlobalOperationStats(ctx context.Context) (*GlobalOperationStats, error) {
|
||||||
|
e := db.GetEngine(ctx)
|
||||||
|
stats := &GlobalOperationStats{
|
||||||
|
CountByTier: make(map[int]int64),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total operations
|
||||||
|
total, err := e.Count(new(OperationLog))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.TotalOperations = total
|
||||||
|
|
||||||
|
// Operations in last 24 hours
|
||||||
|
oneDayAgo := timeutil.TimeStampNow() - 86400
|
||||||
|
stats.Operations24h, err = e.Where("created_unix > ?", oneDayAgo).Count(new(OperationLog))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts by status
|
||||||
|
stats.SuccessCount, err = e.Where("status = ?", OperationStatusSuccess).Count(new(OperationLog))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.FailedCount, err = e.Where("status = ?", OperationStatusFailed).Count(new(OperationLog))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.EscalatedCount, err = e.Where("status = ?", OperationStatusEscalated).Count(new(OperationLog))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.PendingCount, err = e.Where("status = ?", OperationStatusPending).Count(new(OperationLog))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts by tier
|
||||||
|
type tierCount struct {
|
||||||
|
Tier int `xorm:"tier"`
|
||||||
|
Count int64 `xorm:"count"`
|
||||||
|
}
|
||||||
|
var tierCounts []tierCount
|
||||||
|
if err := e.Table("ai_operation_log").Select("tier, count(*) as count").GroupBy("tier").Find(&tierCounts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, tc := range tierCounts {
|
||||||
|
stats.CountByTier[tc.Tier] = tc.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top 5 repos by operation count
|
||||||
|
var topRepos []RepoOpCount
|
||||||
|
if err := e.Table("ai_operation_log").Select("repo_id, count(*) as count").
|
||||||
|
GroupBy("repo_id").OrderBy("count DESC").Limit(5).Find(&topRepos); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.TopRepos = topRepos
|
||||||
|
|
||||||
|
// Total tokens
|
||||||
|
type tokenSum struct {
|
||||||
|
InputTokens int64 `xorm:"input_tokens"`
|
||||||
|
OutputTokens int64 `xorm:"output_tokens"`
|
||||||
|
}
|
||||||
|
var ts tokenSum
|
||||||
|
if _, err := e.Table("ai_operation_log").Select("COALESCE(SUM(input_tokens),0) as input_tokens, COALESCE(SUM(output_tokens),0) as output_tokens").Get(&ts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.TotalInputTokens = ts.InputTokens
|
||||||
|
stats.TotalOutputTokens = ts.OutputTokens
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -204,6 +204,15 @@ func (c *Client) GenerateIssueResponse(ctx context.Context, req *GenerateIssueRe
|
|||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InspectWorkflow sends a workflow for AI inspection
|
||||||
|
func (c *Client) InspectWorkflow(ctx context.Context, req *InspectWorkflowRequest) (*InspectWorkflowResponse, error) {
|
||||||
|
resp := &InspectWorkflowResponse{}
|
||||||
|
if err := c.doRequest(ctx, "POST", "/api/v1/workflows/inspect", req, resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CheckHealth checks the health of the AI service
|
// CheckHealth checks the health of the AI service
|
||||||
func (c *Client) CheckHealth(ctx context.Context) (*HealthCheckResponse, error) {
|
func (c *Client) CheckHealth(ctx context.Context) (*HealthCheckResponse, error) {
|
||||||
var resp HealthCheckResponse
|
var resp HealthCheckResponse
|
||||||
|
|||||||
@@ -3,6 +3,15 @@
|
|||||||
|
|
||||||
package ai
|
package ai
|
||||||
|
|
||||||
|
// ProviderConfig contains per-request AI provider configuration.
|
||||||
|
// When sent to the AI sidecar, it overrides the sidecar's default provider/model/key.
|
||||||
|
// Fields left empty fall back to the sidecar's defaults.
|
||||||
|
type ProviderConfig struct {
|
||||||
|
Provider string `json:"provider,omitempty"` // "claude", "openai", "gemini"
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
APIKey string `json:"api_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// FileDiff represents a file diff for code review
|
// FileDiff represents a file diff for code review
|
||||||
type FileDiff struct {
|
type FileDiff struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
@@ -54,14 +63,15 @@ type SecurityAnalysis struct {
|
|||||||
|
|
||||||
// ReviewPullRequestRequest is the request for reviewing a pull request
|
// ReviewPullRequestRequest is the request for reviewing a pull request
|
||||||
type ReviewPullRequestRequest struct {
|
type ReviewPullRequestRequest struct {
|
||||||
RepoID int64 `json:"repo_id"`
|
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||||
PullRequestID int64 `json:"pull_request_id"`
|
RepoID int64 `json:"repo_id"`
|
||||||
BaseBranch string `json:"base_branch"`
|
PullRequestID int64 `json:"pull_request_id"`
|
||||||
HeadBranch string `json:"head_branch"`
|
BaseBranch string `json:"base_branch"`
|
||||||
Files []FileDiff `json:"files"`
|
HeadBranch string `json:"head_branch"`
|
||||||
PRTitle string `json:"pr_title"`
|
Files []FileDiff `json:"files"`
|
||||||
PRDescription string `json:"pr_description"`
|
PRTitle string `json:"pr_title"`
|
||||||
Options ReviewOptions `json:"options"`
|
PRDescription string `json:"pr_description"`
|
||||||
|
Options ReviewOptions `json:"options"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewPullRequestResponse is the response from reviewing a pull request
|
// ReviewPullRequestResponse is the response from reviewing a pull request
|
||||||
@@ -76,12 +86,13 @@ type ReviewPullRequestResponse struct {
|
|||||||
|
|
||||||
// TriageIssueRequest is the request for triaging an issue
|
// TriageIssueRequest is the request for triaging an issue
|
||||||
type TriageIssueRequest struct {
|
type TriageIssueRequest struct {
|
||||||
RepoID int64 `json:"repo_id"`
|
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||||
IssueID int64 `json:"issue_id"`
|
RepoID int64 `json:"repo_id"`
|
||||||
Title string `json:"title"`
|
IssueID int64 `json:"issue_id"`
|
||||||
Body string `json:"body"`
|
Title string `json:"title"`
|
||||||
ExistingLabels []string `json:"existing_labels"`
|
Body string `json:"body"`
|
||||||
AvailableLabels []string `json:"available_labels"`
|
ExistingLabels []string `json:"existing_labels"`
|
||||||
|
AvailableLabels []string `json:"available_labels"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TriageIssueResponse is the response from triaging an issue
|
// TriageIssueResponse is the response from triaging an issue
|
||||||
@@ -97,10 +108,11 @@ type TriageIssueResponse struct {
|
|||||||
|
|
||||||
// SuggestLabelsRequest is the request for suggesting labels
|
// SuggestLabelsRequest is the request for suggesting labels
|
||||||
type SuggestLabelsRequest struct {
|
type SuggestLabelsRequest struct {
|
||||||
RepoID int64 `json:"repo_id"`
|
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||||
Title string `json:"title"`
|
RepoID int64 `json:"repo_id"`
|
||||||
Body string `json:"body"`
|
Title string `json:"title"`
|
||||||
AvailableLabels []string `json:"available_labels"`
|
Body string `json:"body"`
|
||||||
|
AvailableLabels []string `json:"available_labels"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LabelSuggestion represents a suggested label
|
// LabelSuggestion represents a suggested label
|
||||||
@@ -117,12 +129,13 @@ type SuggestLabelsResponse struct {
|
|||||||
|
|
||||||
// ExplainCodeRequest is the request for explaining code
|
// ExplainCodeRequest is the request for explaining code
|
||||||
type ExplainCodeRequest struct {
|
type ExplainCodeRequest struct {
|
||||||
RepoID int64 `json:"repo_id"`
|
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||||
FilePath string `json:"file_path"`
|
RepoID int64 `json:"repo_id"`
|
||||||
Code string `json:"code"`
|
FilePath string `json:"file_path"`
|
||||||
StartLine int `json:"start_line"`
|
Code string `json:"code"`
|
||||||
EndLine int `json:"end_line"`
|
StartLine int `json:"start_line"`
|
||||||
Question string `json:"question,omitempty"`
|
EndLine int `json:"end_line"`
|
||||||
|
Question string `json:"question,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CodeReference represents a reference to related documentation
|
// CodeReference represents a reference to related documentation
|
||||||
@@ -140,12 +153,13 @@ type ExplainCodeResponse struct {
|
|||||||
|
|
||||||
// GenerateDocumentationRequest is the request for generating documentation
|
// GenerateDocumentationRequest is the request for generating documentation
|
||||||
type GenerateDocumentationRequest struct {
|
type GenerateDocumentationRequest struct {
|
||||||
RepoID int64 `json:"repo_id"`
|
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||||
FilePath string `json:"file_path"`
|
RepoID int64 `json:"repo_id"`
|
||||||
Code string `json:"code"`
|
FilePath string `json:"file_path"`
|
||||||
DocType string `json:"doc_type"` // function, class, module, api
|
Code string `json:"code"`
|
||||||
Language string `json:"language"`
|
DocType string `json:"doc_type"` // function, class, module, api
|
||||||
Style string `json:"style"` // jsdoc, docstring, xml, markdown
|
Language string `json:"language"`
|
||||||
|
Style string `json:"style"` // jsdoc, docstring, xml, markdown
|
||||||
}
|
}
|
||||||
|
|
||||||
// DocumentationSection represents a section of documentation
|
// DocumentationSection represents a section of documentation
|
||||||
@@ -162,9 +176,10 @@ type GenerateDocumentationResponse struct {
|
|||||||
|
|
||||||
// GenerateCommitMessageRequest is the request for generating a commit message
|
// GenerateCommitMessageRequest is the request for generating a commit message
|
||||||
type GenerateCommitMessageRequest struct {
|
type GenerateCommitMessageRequest struct {
|
||||||
RepoID int64 `json:"repo_id"`
|
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||||
Files []FileDiff `json:"files"`
|
RepoID int64 `json:"repo_id"`
|
||||||
Style string `json:"style"` // conventional, descriptive, brief
|
Files []FileDiff `json:"files"`
|
||||||
|
Style string `json:"style"` // conventional, descriptive, brief
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateCommitMessageResponse is the response from generating a commit message
|
// GenerateCommitMessageResponse is the response from generating a commit message
|
||||||
@@ -175,9 +190,10 @@ type GenerateCommitMessageResponse struct {
|
|||||||
|
|
||||||
// SummarizeChangesRequest is the request for summarizing changes
|
// SummarizeChangesRequest is the request for summarizing changes
|
||||||
type SummarizeChangesRequest struct {
|
type SummarizeChangesRequest struct {
|
||||||
RepoID int64 `json:"repo_id"`
|
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||||
Files []FileDiff `json:"files"`
|
RepoID int64 `json:"repo_id"`
|
||||||
Context string `json:"context"`
|
Files []FileDiff `json:"files"`
|
||||||
|
Context string `json:"context"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SummarizeChangesResponse is the response from summarizing changes
|
// SummarizeChangesResponse is the response from summarizing changes
|
||||||
@@ -196,13 +212,14 @@ type IssueComment struct {
|
|||||||
|
|
||||||
// GenerateIssueResponseRequest is the request for generating an AI response to an issue
|
// GenerateIssueResponseRequest is the request for generating an AI response to an issue
|
||||||
type GenerateIssueResponseRequest struct {
|
type GenerateIssueResponseRequest struct {
|
||||||
RepoID int64 `json:"repo_id"`
|
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||||
IssueID int64 `json:"issue_id"`
|
RepoID int64 `json:"repo_id"`
|
||||||
Title string `json:"title"`
|
IssueID int64 `json:"issue_id"`
|
||||||
Body string `json:"body"`
|
Title string `json:"title"`
|
||||||
Comments []IssueComment `json:"comments,omitempty"`
|
Body string `json:"body"`
|
||||||
ResponseType string `json:"response_type,omitempty"` // clarification, solution, acknowledgment
|
Comments []IssueComment `json:"comments,omitempty"`
|
||||||
CustomInstructions string `json:"custom_instructions,omitempty"`
|
ResponseType string `json:"response_type,omitempty"` // clarification, solution, acknowledgment
|
||||||
|
CustomInstructions string `json:"custom_instructions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateIssueResponseResponse is the response from generating an issue response
|
// GenerateIssueResponseResponse is the response from generating an issue response
|
||||||
@@ -214,6 +231,33 @@ type GenerateIssueResponseResponse struct {
|
|||||||
OutputTokens int `json:"output_tokens"`
|
OutputTokens int `json:"output_tokens"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InspectWorkflowRequest is the request for inspecting a workflow file
|
||||||
|
type InspectWorkflowRequest struct {
|
||||||
|
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||||
|
RepoID int64 `json:"repo_id"`
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
RunnerLabels []string `json:"runner_labels,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkflowIssue represents an issue found in a workflow file
|
||||||
|
type WorkflowIssue struct {
|
||||||
|
Line int `json:"line"`
|
||||||
|
Severity string `json:"severity"` // "error", "warning", "info"
|
||||||
|
Message string `json:"message"`
|
||||||
|
Fix string `json:"fix,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectWorkflowResponse is the response from inspecting a workflow file
|
||||||
|
type InspectWorkflowResponse struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Issues []WorkflowIssue `json:"issues"`
|
||||||
|
Suggestions []string `json:"suggestions"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
InputTokens int `json:"input_tokens"`
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
// HealthCheckResponse is the response from a health check
|
// HealthCheckResponse is the response from a health check
|
||||||
type HealthCheckResponse struct {
|
type HealthCheckResponse struct {
|
||||||
Healthy bool `json:"healthy"`
|
Healthy bool `json:"healthy"`
|
||||||
|
|||||||
96
modules/plugins/config.go
Normal file
96
modules/plugins/config.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// Copyright 2026 MarketAlly. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitcaddy.com/server/v3/modules/log"
|
||||||
|
"code.gitcaddy.com/server/v3/modules/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExternalPluginConfig holds configuration for a single external plugin
|
||||||
|
type ExternalPluginConfig struct {
|
||||||
|
Name string
|
||||||
|
Enabled bool
|
||||||
|
// Managed mode: server launches the binary
|
||||||
|
Binary string
|
||||||
|
Args string
|
||||||
|
// External mode: connect to already-running process
|
||||||
|
Address string
|
||||||
|
// Common
|
||||||
|
SubscribedEvents []string
|
||||||
|
HealthTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds the global [plugins] configuration
|
||||||
|
type Config struct {
|
||||||
|
Enabled bool
|
||||||
|
Path string
|
||||||
|
HealthCheckInterval time.Duration
|
||||||
|
ExternalPlugins map[string]*ExternalPluginConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig loads plugin configuration from app.ini [plugins] and [plugins.*] sections
|
||||||
|
func LoadConfig() *Config {
|
||||||
|
cfg := &Config{
|
||||||
|
ExternalPlugins: make(map[string]*ExternalPluginConfig),
|
||||||
|
}
|
||||||
|
|
||||||
|
sec := setting.CfgProvider.Section("plugins")
|
||||||
|
cfg.Enabled = sec.Key("ENABLED").MustBool(true)
|
||||||
|
cfg.Path = sec.Key("PATH").MustString("data/plugins")
|
||||||
|
cfg.HealthCheckInterval = sec.Key("HEALTH_CHECK_INTERVAL").MustDuration(30 * time.Second)
|
||||||
|
|
||||||
|
// Load [plugins.*] sections for external plugins
|
||||||
|
for _, childSec := range sec.ChildSections() {
|
||||||
|
name := strings.TrimPrefix(childSec.Name(), "plugins.")
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginCfg := &ExternalPluginConfig{
|
||||||
|
Name: name,
|
||||||
|
Enabled: childSec.Key("ENABLED").MustBool(true),
|
||||||
|
Binary: childSec.Key("BINARY").MustString(""),
|
||||||
|
Args: childSec.Key("ARGS").MustString(""),
|
||||||
|
Address: childSec.Key("ADDRESS").MustString(""),
|
||||||
|
HealthTimeout: childSec.Key("HEALTH_TIMEOUT").MustDuration(5 * time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse subscribed events
|
||||||
|
if eventsStr := childSec.Key("SUBSCRIBED_EVENTS").MustString(""); eventsStr != "" {
|
||||||
|
pluginCfg.SubscribedEvents = splitAndTrim(eventsStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate: must have either binary or address
|
||||||
|
if pluginCfg.Binary == "" && pluginCfg.Address == "" {
|
||||||
|
log.Warn("Plugin %q has neither BINARY nor ADDRESS configured, skipping", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.ExternalPlugins[name] = pluginCfg
|
||||||
|
log.Info("Loaded external plugin config: %s (managed=%v)", name, pluginCfg.IsManaged())
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsManaged returns true if the server manages the plugin's lifecycle (has a binary)
|
||||||
|
func (c *ExternalPluginConfig) IsManaged() bool {
|
||||||
|
return c.Binary != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitAndTrim splits a comma-separated string and trims whitespace
|
||||||
|
func splitAndTrim(s string) []string {
|
||||||
|
var result []string
|
||||||
|
for part := range strings.SplitSeq(s, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
result = append(result, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
382
modules/plugins/external.go
Normal file
382
modules/plugins/external.go
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
// Copyright 2026 MarketAlly. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitcaddy.com/server/v3/modules/graceful"
|
||||||
|
"code.gitcaddy.com/server/v3/modules/json"
|
||||||
|
"code.gitcaddy.com/server/v3/modules/log"
|
||||||
|
pluginv1 "code.gitcaddy.com/server/v3/modules/plugins/pluginv1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PluginStatus represents the status of an external plugin
|
||||||
|
type PluginStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PluginStatusStarting PluginStatus = "starting"
|
||||||
|
PluginStatusOnline PluginStatus = "online"
|
||||||
|
PluginStatusOffline PluginStatus = "offline"
|
||||||
|
PluginStatusError PluginStatus = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ManagedPlugin tracks the state of an external plugin
|
||||||
|
type ManagedPlugin struct {
|
||||||
|
config *ExternalPluginConfig
|
||||||
|
process *os.Process
|
||||||
|
status PluginStatus
|
||||||
|
lastSeen time.Time
|
||||||
|
manifest *pluginv1.PluginManifest
|
||||||
|
failCount int
|
||||||
|
httpClient *http.Client
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExternalPluginManager manages external plugins (both managed and external mode)
|
||||||
|
type ExternalPluginManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
plugins map[string]*ManagedPlugin
|
||||||
|
config *Config
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalExternalManager *ExternalPluginManager
|
||||||
|
|
||||||
|
// GetExternalManager returns the global external plugin manager
|
||||||
|
func GetExternalManager() *ExternalPluginManager {
|
||||||
|
return globalExternalManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExternalPluginManager creates a new external plugin manager
|
||||||
|
func NewExternalPluginManager(config *Config) *ExternalPluginManager {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
m := &ExternalPluginManager{
|
||||||
|
plugins: make(map[string]*ManagedPlugin),
|
||||||
|
config: config,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
globalExternalManager = m
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAll launches managed plugins and connects to external ones
|
||||||
|
func (m *ExternalPluginManager) StartAll() error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
for name, cfg := range m.config.ExternalPlugins {
|
||||||
|
if !cfg.Enabled {
|
||||||
|
log.Info("External plugin %s is disabled, skipping", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mp := &ManagedPlugin{
|
||||||
|
config: cfg,
|
||||||
|
status: PluginStatusStarting,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: cfg.HealthTimeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
m.plugins[name] = mp
|
||||||
|
|
||||||
|
if cfg.IsManaged() {
|
||||||
|
if err := m.startManagedPlugin(mp); err != nil {
|
||||||
|
log.Error("Failed to start managed plugin %s: %v", name, err)
|
||||||
|
mp.status = PluginStatusError
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to initialize the plugin
|
||||||
|
if err := m.initializePlugin(mp); err != nil {
|
||||||
|
log.Error("Failed to initialize external plugin %s: %v", name, err)
|
||||||
|
mp.status = PluginStatusError
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mp.status = PluginStatusOnline
|
||||||
|
mp.lastSeen = time.Now()
|
||||||
|
log.Info("External plugin %s is online (managed=%v)", name, cfg.IsManaged())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopAll gracefully shuts down all external plugins
|
||||||
|
func (m *ExternalPluginManager) StopAll() {
|
||||||
|
m.cancel()
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
for name, mp := range m.plugins {
|
||||||
|
log.Info("Shutting down external plugin: %s", name)
|
||||||
|
|
||||||
|
// Send shutdown request
|
||||||
|
m.shutdownPlugin(mp)
|
||||||
|
|
||||||
|
// Kill managed process
|
||||||
|
if mp.process != nil {
|
||||||
|
if err := mp.process.Signal(os.Interrupt); err != nil {
|
||||||
|
log.Warn("Failed to send interrupt to plugin %s, killing: %v", name, err)
|
||||||
|
_ = mp.process.Kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mp.status = PluginStatusOffline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlugin returns an external plugin by name
|
||||||
|
func (m *ExternalPluginManager) GetPlugin(name string) *ManagedPlugin {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.plugins[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllPlugins returns all external plugins
|
||||||
|
func (m *ExternalPluginManager) AllPlugins() map[string]*ManagedPlugin {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make(map[string]*ManagedPlugin, len(m.plugins))
|
||||||
|
maps.Copy(result, m.plugins)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnEvent dispatches an event to all interested plugins (fire-and-forget with timeout)
|
||||||
|
func (m *ExternalPluginManager) OnEvent(event *pluginv1.PluginEvent) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
for name, mp := range m.plugins {
|
||||||
|
mp.mu.RLock()
|
||||||
|
if mp.status != PluginStatusOnline || mp.manifest == nil {
|
||||||
|
mp.mu.RUnlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this plugin is subscribed to this event
|
||||||
|
subscribed := false
|
||||||
|
for _, e := range mp.manifest.SubscribedEvents {
|
||||||
|
if e == event.EventType || e == "*" {
|
||||||
|
subscribed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mp.mu.RUnlock()
|
||||||
|
|
||||||
|
if !subscribed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch in background with timeout
|
||||||
|
go func(pluginName string, p *ManagedPlugin) {
|
||||||
|
ctx, cancel := context.WithTimeout(m.ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := m.callOnEvent(ctx, p, event); err != nil {
|
||||||
|
log.Error("Failed to dispatch event %s to plugin %s: %v", event.EventType, pluginName, err)
|
||||||
|
}
|
||||||
|
}(name, mp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleHTTP proxies an HTTP request to a plugin that declares the matching route
|
||||||
|
func (m *ExternalPluginManager) HandleHTTP(method, path string, headers map[string]string, body []byte) (*pluginv1.HTTPResponse, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
for name, mp := range m.plugins {
|
||||||
|
mp.mu.RLock()
|
||||||
|
if mp.status != PluginStatusOnline || mp.manifest == nil {
|
||||||
|
mp.mu.RUnlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, route := range mp.manifest.Routes {
|
||||||
|
if route.Method == method && matchRoute(route.Path, path) {
|
||||||
|
mp.mu.RUnlock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(m.ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := m.callHandleHTTP(ctx, mp, &pluginv1.HTTPRequest{
|
||||||
|
Method: method,
|
||||||
|
Path: path,
|
||||||
|
Headers: headers,
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("plugin %s HandleHTTP failed: %w", name, err)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mp.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no plugin handles %s %s", method, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the status of a plugin
|
||||||
|
func (mp *ManagedPlugin) Status() PluginStatus {
|
||||||
|
mp.mu.RLock()
|
||||||
|
defer mp.mu.RUnlock()
|
||||||
|
return mp.status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifest returns the plugin's manifest
|
||||||
|
func (mp *ManagedPlugin) Manifest() *pluginv1.PluginManifest {
|
||||||
|
mp.mu.RLock()
|
||||||
|
defer mp.mu.RUnlock()
|
||||||
|
return mp.manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal methods ---
|
||||||
|
|
||||||
|
func (m *ExternalPluginManager) startManagedPlugin(mp *ManagedPlugin) error {
|
||||||
|
args := strings.Fields(mp.config.Args)
|
||||||
|
cmd := exec.Command(mp.config.Binary, args...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to start binary %s: %w", mp.config.Binary, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mp.process = cmd.Process
|
||||||
|
|
||||||
|
// Register with graceful manager for proper shutdown
|
||||||
|
graceful.GetManager().RunAtShutdown(m.ctx, func() {
|
||||||
|
if mp.process != nil {
|
||||||
|
_ = mp.process.Signal(os.Interrupt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait a bit for the process to start
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExternalPluginManager) initializePlugin(mp *ManagedPlugin) error {
|
||||||
|
req := &pluginv1.InitializeRequest{
|
||||||
|
ServerVersion: "3.0.0",
|
||||||
|
Config: map[string]string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &pluginv1.InitializeResponse{}
|
||||||
|
if err := m.callRPC(mp, "initialize", req, resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.Success {
|
||||||
|
return fmt.Errorf("plugin initialization failed: %s", resp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
mp.mu.Lock()
|
||||||
|
mp.manifest = resp.Manifest
|
||||||
|
mp.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExternalPluginManager) shutdownPlugin(mp *ManagedPlugin) {
|
||||||
|
req := &pluginv1.ShutdownRequest{Reason: "server shutdown"}
|
||||||
|
resp := &pluginv1.ShutdownResponse{}
|
||||||
|
if err := m.callRPC(mp, "shutdown", req, resp); err != nil {
|
||||||
|
log.Warn("Plugin shutdown call failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExternalPluginManager) callOnEvent(ctx context.Context, mp *ManagedPlugin, event *pluginv1.PluginEvent) error {
|
||||||
|
resp := &pluginv1.EventResponse{}
|
||||||
|
if err := m.callRPCWithContext(ctx, mp, "on-event", event, resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.Error != "" {
|
||||||
|
return fmt.Errorf("plugin event error: %s", resp.Error)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExternalPluginManager) callHandleHTTP(ctx context.Context, mp *ManagedPlugin, req *pluginv1.HTTPRequest) (*pluginv1.HTTPResponse, error) {
|
||||||
|
resp := &pluginv1.HTTPResponse{}
|
||||||
|
if err := m.callRPCWithContext(ctx, mp, "handle-http", req, resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// callRPC makes a JSON-over-HTTP call to the plugin (simplified RPC)
|
||||||
|
func (m *ExternalPluginManager) callRPC(mp *ManagedPlugin, method string, req, resp any) error {
|
||||||
|
return m.callRPCWithContext(m.ctx, mp, method, req, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExternalPluginManager) callRPCWithContext(ctx context.Context, mp *ManagedPlugin, method string, reqBody, respBody any) error {
|
||||||
|
address := mp.config.Address
|
||||||
|
if address == "" {
|
||||||
|
return errors.New("plugin has no address configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure address has scheme
|
||||||
|
if !strings.HasPrefix(address, "http://") && !strings.HasPrefix(address, "https://") {
|
||||||
|
address = "http://" + address
|
||||||
|
}
|
||||||
|
|
||||||
|
url := address + "/plugin/v1/" + method
|
||||||
|
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
httpResp, err := mp.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("RPC call to %s failed: %w", method, err)
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
respData, err := io.ReadAll(httpResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpResp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("RPC %s returned status %d: %s", method, httpResp.StatusCode, string(respData))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(respData, respBody); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchRoute checks if a URL path matches a route pattern (simple prefix matching)
|
||||||
|
func matchRoute(pattern, path string) bool {
|
||||||
|
// Simple prefix match for now
|
||||||
|
return strings.HasPrefix(path, pattern)
|
||||||
|
}
|
||||||
135
modules/plugins/health.go
Normal file
135
modules/plugins/health.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// Copyright 2026 MarketAlly. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"maps"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitcaddy.com/server/v3/modules/graceful"
|
||||||
|
"code.gitcaddy.com/server/v3/modules/log"
|
||||||
|
pluginv1 "code.gitcaddy.com/server/v3/modules/plugins/pluginv1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxConsecutiveFailures = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartHealthMonitoring begins periodic health checks for all external plugins.
|
||||||
|
// It runs as a background goroutine managed by the graceful manager.
|
||||||
|
func (m *ExternalPluginManager) StartHealthMonitoring() {
|
||||||
|
interval := m.config.HealthCheckInterval
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
graceful.GetManager().RunWithShutdownContext(func(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-m.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
m.checkAllPlugins(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExternalPluginManager) checkAllPlugins(ctx context.Context) {
|
||||||
|
m.mu.RLock()
|
||||||
|
plugins := make(map[string]*ManagedPlugin, len(m.plugins))
|
||||||
|
maps.Copy(plugins, m.plugins)
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
for name, mp := range plugins {
|
||||||
|
if err := m.checkPlugin(ctx, name, mp); err != nil {
|
||||||
|
log.Warn("Health check failed for plugin %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExternalPluginManager) checkPlugin(ctx context.Context, name string, mp *ManagedPlugin) error {
|
||||||
|
healthCtx, cancel := context.WithTimeout(ctx, mp.config.HealthTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp := &pluginv1.HealthCheckResponse{}
|
||||||
|
err := m.callRPCWithContext(healthCtx, mp, "health-check", &pluginv1.HealthCheckRequest{}, resp)
|
||||||
|
|
||||||
|
mp.mu.Lock()
|
||||||
|
defer mp.mu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
mp.failCount++
|
||||||
|
if mp.failCount >= maxConsecutiveFailures {
|
||||||
|
if mp.status != PluginStatusOffline {
|
||||||
|
log.Error("Plugin %s is now offline after %d consecutive health check failures", name, mp.failCount)
|
||||||
|
mp.status = PluginStatusOffline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-restart managed plugins
|
||||||
|
if mp.config.IsManaged() && mp.process != nil {
|
||||||
|
log.Info("Attempting to restart managed plugin %s", name)
|
||||||
|
go m.restartManagedPlugin(name, mp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check succeeded
|
||||||
|
if mp.status != PluginStatusOnline {
|
||||||
|
log.Info("Plugin %s is back online", name)
|
||||||
|
}
|
||||||
|
mp.failCount = 0
|
||||||
|
mp.status = PluginStatusOnline
|
||||||
|
mp.lastSeen = time.Now()
|
||||||
|
|
||||||
|
if !resp.Healthy {
|
||||||
|
log.Warn("Plugin %s reports unhealthy: %s", name, resp.Status)
|
||||||
|
mp.status = PluginStatusError
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExternalPluginManager) restartManagedPlugin(name string, mp *ManagedPlugin) {
|
||||||
|
// Kill the old process first
|
||||||
|
if mp.process != nil {
|
||||||
|
_ = mp.process.Kill()
|
||||||
|
mp.process = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mp.mu.Lock()
|
||||||
|
mp.status = PluginStatusStarting
|
||||||
|
mp.mu.Unlock()
|
||||||
|
|
||||||
|
if err := m.startManagedPlugin(mp); err != nil {
|
||||||
|
log.Error("Failed to restart managed plugin %s: %v", name, err)
|
||||||
|
mp.mu.Lock()
|
||||||
|
mp.status = PluginStatusError
|
||||||
|
mp.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.initializePlugin(mp); err != nil {
|
||||||
|
log.Error("Failed to re-initialize managed plugin %s: %v", name, err)
|
||||||
|
mp.mu.Lock()
|
||||||
|
mp.status = PluginStatusError
|
||||||
|
mp.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mp.mu.Lock()
|
||||||
|
mp.status = PluginStatusOnline
|
||||||
|
mp.lastSeen = time.Now()
|
||||||
|
mp.failCount = 0
|
||||||
|
mp.mu.Unlock()
|
||||||
|
|
||||||
|
log.Info("Managed plugin %s restarted successfully", name)
|
||||||
|
}
|
||||||
97
modules/plugins/pluginv1/plugin.proto
Normal file
97
modules/plugins/pluginv1/plugin.proto
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package plugin.v1;
|
||||||
|
|
||||||
|
option go_package = "code.gitcaddy.com/server/v3/modules/plugins/pluginv1";
|
||||||
|
|
||||||
|
import "google/protobuf/struct.proto";
|
||||||
|
import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
|
// PluginService is the RPC interface that external plugins must implement.
|
||||||
|
// The server calls these methods to manage the plugin's lifecycle and dispatch events.
|
||||||
|
service PluginService {
|
||||||
|
// Initialize is called when the server starts or the plugin is loaded
|
||||||
|
rpc Initialize(InitializeRequest) returns (InitializeResponse);
|
||||||
|
// Shutdown is called when the server is shutting down
|
||||||
|
rpc Shutdown(ShutdownRequest) returns (ShutdownResponse);
|
||||||
|
// HealthCheck checks if the plugin is healthy
|
||||||
|
rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse);
|
||||||
|
// GetManifest returns the plugin's manifest describing its capabilities
|
||||||
|
rpc GetManifest(GetManifestRequest) returns (PluginManifest);
|
||||||
|
// OnEvent is called when an event the plugin is subscribed to occurs
|
||||||
|
rpc OnEvent(PluginEvent) returns (EventResponse);
|
||||||
|
// HandleHTTP proxies an HTTP request to the plugin
|
||||||
|
rpc HandleHTTP(HTTPRequest) returns (HTTPResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
message InitializeRequest {
|
||||||
|
string server_version = 1;
|
||||||
|
map<string, string> config = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message InitializeResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string error = 2;
|
||||||
|
PluginManifest manifest = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ShutdownRequest {
|
||||||
|
string reason = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ShutdownResponse {
|
||||||
|
bool success = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HealthCheckRequest {}
|
||||||
|
|
||||||
|
message HealthCheckResponse {
|
||||||
|
bool healthy = 1;
|
||||||
|
string status = 2;
|
||||||
|
map<string, string> details = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetManifestRequest {}
|
||||||
|
|
||||||
|
message PluginManifest {
|
||||||
|
string name = 1;
|
||||||
|
string version = 2;
|
||||||
|
string description = 3;
|
||||||
|
repeated string subscribed_events = 4;
|
||||||
|
repeated PluginRoute routes = 5;
|
||||||
|
repeated string required_permissions = 6;
|
||||||
|
string license_tier = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PluginRoute {
|
||||||
|
string method = 1;
|
||||||
|
string path = 2;
|
||||||
|
string description = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PluginEvent {
|
||||||
|
string event_type = 1;
|
||||||
|
google.protobuf.Struct payload = 2;
|
||||||
|
google.protobuf.Timestamp timestamp = 3;
|
||||||
|
int64 repo_id = 4;
|
||||||
|
int64 org_id = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EventResponse {
|
||||||
|
bool handled = 1;
|
||||||
|
string error = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HTTPRequest {
|
||||||
|
string method = 1;
|
||||||
|
string path = 2;
|
||||||
|
map<string, string> headers = 3;
|
||||||
|
bytes body = 4;
|
||||||
|
map<string, string> query_params = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HTTPResponse {
|
||||||
|
int32 status_code = 1;
|
||||||
|
map<string, string> headers = 2;
|
||||||
|
bytes body = 3;
|
||||||
|
}
|
||||||
81
modules/plugins/pluginv1/types.go
Normal file
81
modules/plugins/pluginv1/types.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// Copyright 2026 MarketAlly. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package pluginv1 defines the plugin service contract types.
|
||||||
|
// These types mirror the plugin.proto definitions and will be replaced
|
||||||
|
// by generated code when protoc-gen-go and protoc-gen-connect-go are run.
|
||||||
|
package pluginv1
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type InitializeRequest struct {
|
||||||
|
ServerVersion string `json:"server_version"`
|
||||||
|
Config map[string]string `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InitializeResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Manifest *PluginManifest `json:"manifest"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShutdownRequest struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShutdownResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthCheckRequest struct{}
|
||||||
|
|
||||||
|
type HealthCheckResponse struct {
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Details map[string]string `json:"details"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetManifestRequest struct{}
|
||||||
|
|
||||||
|
type PluginManifest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
SubscribedEvents []string `json:"subscribed_events"`
|
||||||
|
Routes []PluginRoute `json:"routes"`
|
||||||
|
RequiredPermissions []string `json:"required_permissions"`
|
||||||
|
LicenseTier string `json:"license_tier"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginRoute struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginEvent struct {
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
Payload map[string]any `json:"payload"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
RepoID int64 `json:"repo_id"`
|
||||||
|
OrgID int64 `json:"org_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventResponse struct {
|
||||||
|
Handled bool `json:"handled"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPRequest struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
Body []byte `json:"body"`
|
||||||
|
QueryParams map[string]string `json:"query_params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPResponse struct {
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
Body []byte `json:"body"`
|
||||||
|
}
|
||||||
@@ -4619,6 +4619,16 @@
|
|||||||
"actions.runners.waiting_jobs": "Waiting Jobs",
|
"actions.runners.waiting_jobs": "Waiting Jobs",
|
||||||
"actions.runners.back_to_runners": "Back to Runners",
|
"actions.runners.back_to_runners": "Back to Runners",
|
||||||
"actions.runners.no_waiting_jobs": "No jobs waiting for this label",
|
"actions.runners.no_waiting_jobs": "No jobs waiting for this label",
|
||||||
|
"admin.ai": "AI Status",
|
||||||
|
"admin.ai.title": "AI Service Status",
|
||||||
|
"admin.ai.sidecar_status": "Sidecar Status",
|
||||||
|
"admin.ai.config": "Configuration",
|
||||||
|
"admin.ai.stats": "Statistics",
|
||||||
|
"admin.ai.recent_operations": "Recent Operations",
|
||||||
|
"admin.ai.total_operations": "Total Operations",
|
||||||
|
"admin.ai.operations_24h": "Operations (24h)",
|
||||||
|
"admin.ai.success_rate": "Success Rate",
|
||||||
|
"admin.ai.tokens_used": "Tokens Used",
|
||||||
"admin.ai_learning": "AI Learning",
|
"admin.ai_learning": "AI Learning",
|
||||||
"admin.ai_learning.edit": "Edit Pattern",
|
"admin.ai_learning.edit": "Edit Pattern",
|
||||||
"admin.ai_learning.total_patterns": "Total Patterns",
|
"admin.ai_learning.total_patterns": "Total Patterns",
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func AIReviewPullRequest(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
review, err := ai_service.ReviewPullRequest(ctx, pr)
|
review, err := ai_service.ReviewPullRequest(ctx, pr, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
@@ -141,7 +141,7 @@ func AITriageIssue(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
triage, err := ai_service.TriageIssue(ctx, issue)
|
triage, err := ai_service.TriageIssue(ctx, issue, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
@@ -203,7 +203,7 @@ func AISuggestLabels(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
suggestions, err := ai_service.SuggestLabels(ctx, issue)
|
suggestions, err := ai_service.SuggestLabels(ctx, issue, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
@@ -279,7 +279,7 @@ func AIExplainCode(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
explanation, err := ai_service.ExplainCode(ctx, ctx.Repo.Repository, req.FilePath, req.Code, req.StartLine, req.EndLine, req.Question)
|
explanation, err := ai_service.ExplainCode(ctx, ctx.Repo.Repository, req.FilePath, req.Code, req.StartLine, req.EndLine, req.Question, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
@@ -355,7 +355,7 @@ func AIGenerateDocumentation(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
docs, err := ai_service.GenerateDocumentation(ctx, ctx.Repo.Repository, req.FilePath, req.Code, req.DocType, req.Language, req.Style)
|
docs, err := ai_service.GenerateDocumentation(ctx, ctx.Repo.Repository, req.FilePath, req.Code, req.DocType, req.Language, req.Style, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
issues_model "code.gitcaddy.com/server/v3/models/issues"
|
issues_model "code.gitcaddy.com/server/v3/models/issues"
|
||||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||||
"code.gitcaddy.com/server/v3/models/unit"
|
"code.gitcaddy.com/server/v3/models/unit"
|
||||||
|
ai_module "code.gitcaddy.com/server/v3/modules/ai"
|
||||||
apierrors "code.gitcaddy.com/server/v3/modules/errors"
|
apierrors "code.gitcaddy.com/server/v3/modules/errors"
|
||||||
"code.gitcaddy.com/server/v3/modules/setting"
|
"code.gitcaddy.com/server/v3/modules/setting"
|
||||||
api "code.gitcaddy.com/server/v3/modules/structs"
|
api "code.gitcaddy.com/server/v3/modules/structs"
|
||||||
@@ -35,6 +36,31 @@ func getRepoAIConfig(ctx *context.APIContext) *repo_model.AIConfig {
|
|||||||
return aiUnit.AIConfig()
|
return aiUnit.AIConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveProviderConfig builds a ProviderConfig from the repo/org/system cascade.
|
||||||
|
func resolveProviderConfig(ctx *context.APIContext) *ai_module.ProviderConfig {
|
||||||
|
var orgID int64
|
||||||
|
if ctx.Repo.Repository.Owner.IsOrganization() {
|
||||||
|
orgID = ctx.Repo.Repository.OwnerID
|
||||||
|
}
|
||||||
|
|
||||||
|
var repoProvider, repoModel string
|
||||||
|
if aiUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeAI); err == nil {
|
||||||
|
cfg := aiUnit.AIConfig()
|
||||||
|
repoProvider = cfg.PreferredProvider
|
||||||
|
repoModel = cfg.PreferredModel
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := ai_model.ResolveProvider(ctx, orgID, repoProvider)
|
||||||
|
model := ai_model.ResolveModel(ctx, orgID, repoModel)
|
||||||
|
apiKey := ai_model.ResolveAPIKey(ctx, orgID, provider)
|
||||||
|
|
||||||
|
return &ai_module.ProviderConfig{
|
||||||
|
Provider: provider,
|
||||||
|
Model: model,
|
||||||
|
APIKey: apiKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func toAIOperationV2(op *ai_model.OperationLog) *api.AIOperationV2 {
|
func toAIOperationV2(op *ai_model.OperationLog) *api.AIOperationV2 {
|
||||||
return &api.AIOperationV2{
|
return &api.AIOperationV2{
|
||||||
ID: op.ID,
|
ID: op.ID,
|
||||||
@@ -385,7 +411,9 @@ func TriggerAIExplain(ctx *context.APIContext) {
|
|||||||
|
|
||||||
form := web.GetForm(ctx).(*api.AIExplainRequest)
|
form := web.GetForm(ctx).(*api.AIExplainRequest)
|
||||||
|
|
||||||
resp, err := ai.ExplainCode(ctx, ctx.Repo.Repository, form.FilePath, "", form.StartLine, form.EndLine, form.Question)
|
providerCfg := resolveProviderConfig(ctx)
|
||||||
|
|
||||||
|
resp, err := ai.ExplainCode(ctx, ctx.Repo.Repository, form.FilePath, "", form.StartLine, form.EndLine, form.Question, providerCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorWithCode(apierrors.AIServiceError, map[string]any{
|
ctx.APIErrorWithCode(apierrors.AIServiceError, map[string]any{
|
||||||
"detail": err.Error(),
|
"detail": err.Error(),
|
||||||
|
|||||||
@@ -161,11 +161,21 @@ func InitWebInstalled(ctx context.Context) {
|
|||||||
log.Fatal("Plugin migrations failed: %v", err)
|
log.Fatal("Plugin migrations failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize all plugins
|
// Initialize all compiled plugins
|
||||||
if err := plugins.InitAll(ctx); err != nil {
|
if err := plugins.InitAll(ctx); err != nil {
|
||||||
log.Fatal("Plugin initialization failed: %v", err)
|
log.Fatal("Plugin initialization failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize external plugin manager (Phase 5)
|
||||||
|
pluginCfg := plugins.LoadConfig()
|
||||||
|
if pluginCfg.Enabled && len(pluginCfg.ExternalPlugins) > 0 {
|
||||||
|
extManager := plugins.NewExternalPluginManager(pluginCfg)
|
||||||
|
if err := extManager.StartAll(); err != nil {
|
||||||
|
log.Error("External plugin startup had errors: %v", err)
|
||||||
|
}
|
||||||
|
extManager.StartHealthMonitoring()
|
||||||
|
}
|
||||||
|
|
||||||
mustInit(system.Init)
|
mustInit(system.Init)
|
||||||
mustInitCtx(ctx, oauth2.Init)
|
mustInitCtx(ctx, oauth2.Init)
|
||||||
mustInitCtx(ctx, oauth2_provider.Init)
|
mustInitCtx(ctx, oauth2_provider.Init)
|
||||||
|
|||||||
77
routers/web/admin/ai.go
Normal file
77
routers/web/admin/ai.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// Copyright 2026 MarketAlly. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
ai_model "code.gitcaddy.com/server/v3/models/ai"
|
||||||
|
"code.gitcaddy.com/server/v3/models/db"
|
||||||
|
ai_module "code.gitcaddy.com/server/v3/modules/ai"
|
||||||
|
"code.gitcaddy.com/server/v3/modules/setting"
|
||||||
|
"code.gitcaddy.com/server/v3/modules/templates"
|
||||||
|
"code.gitcaddy.com/server/v3/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tplAI templates.TplName = "admin/ai"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AIStatus shows the AI service status admin dashboard
|
||||||
|
func AIStatus(ctx *context.Context) {
|
||||||
|
ctx.Data["Title"] = ctx.Tr("admin.ai.title")
|
||||||
|
ctx.Data["PageIsAdminAI"] = true
|
||||||
|
|
||||||
|
// Check sidecar health
|
||||||
|
var sidecarHealthy bool
|
||||||
|
var sidecarVersion string
|
||||||
|
var providerStatus map[string]string
|
||||||
|
|
||||||
|
if setting.AI.Enabled {
|
||||||
|
health, err := ai_module.GetClient().CheckHealth(ctx)
|
||||||
|
if err == nil && health != nil {
|
||||||
|
sidecarHealthy = health.Healthy
|
||||||
|
sidecarVersion = health.Version
|
||||||
|
providerStatus = health.ProviderStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Data["SidecarHealthy"] = sidecarHealthy
|
||||||
|
ctx.Data["SidecarVersion"] = sidecarVersion
|
||||||
|
ctx.Data["ProviderStatus"] = providerStatus
|
||||||
|
|
||||||
|
// Load global operation stats
|
||||||
|
stats, err := ai_model.GetGlobalOperationStats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetGlobalOperationStats", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Data["Stats"] = stats
|
||||||
|
|
||||||
|
// Calculate success rate
|
||||||
|
var successRate float64
|
||||||
|
if stats.TotalOperations > 0 {
|
||||||
|
successRate = float64(stats.SuccessCount) / float64(stats.TotalOperations) * 100
|
||||||
|
}
|
||||||
|
ctx.Data["SuccessRate"] = successRate
|
||||||
|
ctx.Data["TotalTokens"] = stats.TotalInputTokens + stats.TotalOutputTokens
|
||||||
|
|
||||||
|
// Load recent operations (last 20)
|
||||||
|
recentOps, err := db.Find[ai_model.OperationLog](ctx, ai_model.FindOperationLogsOptions{
|
||||||
|
ListOptions: db.ListOptions{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("FindOperationLogs", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Data["RecentOps"] = recentOps
|
||||||
|
|
||||||
|
// Pass AI config for display
|
||||||
|
ctx.Data["AIConfig"] = setting.AI
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, tplAI)
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ func AIReviewPullRequest(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
review, err := ai_service.ReviewPullRequest(ctx, pr)
|
review, err := ai_service.ReviewPullRequest(ctx, pr, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Flash.Error(ctx.Tr("repo.ai.review_failed", err.Error()))
|
ctx.Flash.Error(ctx.Tr("repo.ai.review_failed", err.Error()))
|
||||||
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + ctx.PathParam("index"))
|
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + ctx.PathParam("index"))
|
||||||
@@ -77,7 +77,7 @@ func AITriageIssue(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
triage, err := ai_service.TriageIssue(ctx, issue)
|
triage, err := ai_service.TriageIssue(ctx, issue, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Flash.Error(ctx.Tr("repo.ai.triage_failed", err.Error()))
|
ctx.Flash.Error(ctx.Tr("repo.ai.triage_failed", err.Error()))
|
||||||
ctx.Redirect(ctx.Repo.RepoLink + "/issues/" + ctx.PathParam("index"))
|
ctx.Redirect(ctx.Repo.RepoLink + "/issues/" + ctx.PathParam("index"))
|
||||||
@@ -108,7 +108,7 @@ func AISuggestLabels(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
suggestions, err := ai_service.SuggestLabels(ctx, issue)
|
suggestions, err := ai_service.SuggestLabels(ctx, issue, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|||||||
@@ -888,6 +888,8 @@ func registerWebRoutes(m *web.Router) {
|
|||||||
m.Post("/empty", admin.EmptyNotices)
|
m.Post("/empty", admin.EmptyNotices)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
m.Get("/ai", admin.AIStatus)
|
||||||
|
|
||||||
m.Group("/ai-learning", func() {
|
m.Group("/ai-learning", func() {
|
||||||
m.Get("", admin.AILearning)
|
m.Get("", admin.AILearning)
|
||||||
m.Get("/{id}", admin.AILearningEdit)
|
m.Get("/{id}", admin.AILearningEdit)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.gitcaddy.com/server/v3/models/db"
|
"code.gitcaddy.com/server/v3/models/db"
|
||||||
@@ -25,8 +26,9 @@ func IsEnabled() bool {
|
|||||||
return ai.IsEnabled()
|
return ai.IsEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewPullRequest performs an AI review of a pull request
|
// ReviewPullRequest performs an AI review of a pull request.
|
||||||
func ReviewPullRequest(ctx context.Context, pr *issues_model.PullRequest) (*ai.ReviewPullRequestResponse, error) {
|
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
||||||
|
func ReviewPullRequest(ctx context.Context, pr *issues_model.PullRequest, providerCfg *ai.ProviderConfig) (*ai.ReviewPullRequestResponse, error) {
|
||||||
if !IsEnabled() || !setting.AI.EnableCodeReview {
|
if !IsEnabled() || !setting.AI.EnableCodeReview {
|
||||||
return nil, errors.New("AI code review is not enabled")
|
return nil, errors.New("AI code review is not enabled")
|
||||||
}
|
}
|
||||||
@@ -88,13 +90,14 @@ func ReviewPullRequest(ctx context.Context, pr *issues_model.PullRequest) (*ai.R
|
|||||||
}
|
}
|
||||||
|
|
||||||
req := &ai.ReviewPullRequestRequest{
|
req := &ai.ReviewPullRequestRequest{
|
||||||
RepoID: pr.BaseRepoID,
|
ProviderConfig: providerCfg,
|
||||||
PullRequestID: pr.ID,
|
RepoID: pr.BaseRepoID,
|
||||||
BaseBranch: pr.BaseBranch,
|
PullRequestID: pr.ID,
|
||||||
HeadBranch: pr.HeadBranch,
|
BaseBranch: pr.BaseBranch,
|
||||||
Files: files,
|
HeadBranch: pr.HeadBranch,
|
||||||
PRTitle: pr.Issue.Title,
|
Files: files,
|
||||||
PRDescription: pr.Issue.Content,
|
PRTitle: pr.Issue.Title,
|
||||||
|
PRDescription: pr.Issue.Content,
|
||||||
Options: ai.ReviewOptions{
|
Options: ai.ReviewOptions{
|
||||||
CheckSecurity: true,
|
CheckSecurity: true,
|
||||||
CheckPerformance: true,
|
CheckPerformance: true,
|
||||||
@@ -114,8 +117,9 @@ func ReviewPullRequest(ctx context.Context, pr *issues_model.PullRequest) (*ai.R
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TriageIssue performs AI triage on an issue
|
// TriageIssue performs AI triage on an issue.
|
||||||
func TriageIssue(ctx context.Context, issue *issues_model.Issue) (*ai.TriageIssueResponse, error) {
|
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
||||||
|
func TriageIssue(ctx context.Context, issue *issues_model.Issue, providerCfg *ai.ProviderConfig) (*ai.TriageIssueResponse, error) {
|
||||||
if !IsEnabled() || !setting.AI.EnableIssueTriage {
|
if !IsEnabled() || !setting.AI.EnableIssueTriage {
|
||||||
return nil, errors.New("AI issue triage is not enabled")
|
return nil, errors.New("AI issue triage is not enabled")
|
||||||
}
|
}
|
||||||
@@ -145,6 +149,7 @@ func TriageIssue(ctx context.Context, issue *issues_model.Issue) (*ai.TriageIssu
|
|||||||
}
|
}
|
||||||
|
|
||||||
req := &ai.TriageIssueRequest{
|
req := &ai.TriageIssueRequest{
|
||||||
|
ProviderConfig: providerCfg,
|
||||||
RepoID: issue.RepoID,
|
RepoID: issue.RepoID,
|
||||||
IssueID: issue.ID,
|
IssueID: issue.ID,
|
||||||
Title: issue.Title,
|
Title: issue.Title,
|
||||||
@@ -163,8 +168,9 @@ func TriageIssue(ctx context.Context, issue *issues_model.Issue) (*ai.TriageIssu
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SuggestLabels suggests labels for an issue
|
// SuggestLabels suggests labels for an issue.
|
||||||
func SuggestLabels(ctx context.Context, issue *issues_model.Issue) (*ai.SuggestLabelsResponse, error) {
|
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
||||||
|
func SuggestLabels(ctx context.Context, issue *issues_model.Issue, providerCfg *ai.ProviderConfig) (*ai.SuggestLabelsResponse, error) {
|
||||||
if !IsEnabled() || !setting.AI.EnableIssueTriage {
|
if !IsEnabled() || !setting.AI.EnableIssueTriage {
|
||||||
return nil, errors.New("AI issue triage is not enabled")
|
return nil, errors.New("AI issue triage is not enabled")
|
||||||
}
|
}
|
||||||
@@ -185,6 +191,7 @@ func SuggestLabels(ctx context.Context, issue *issues_model.Issue) (*ai.SuggestL
|
|||||||
}
|
}
|
||||||
|
|
||||||
req := &ai.SuggestLabelsRequest{
|
req := &ai.SuggestLabelsRequest{
|
||||||
|
ProviderConfig: providerCfg,
|
||||||
RepoID: issue.RepoID,
|
RepoID: issue.RepoID,
|
||||||
Title: issue.Title,
|
Title: issue.Title,
|
||||||
Body: issue.Content,
|
Body: issue.Content,
|
||||||
@@ -201,19 +208,21 @@ func SuggestLabels(ctx context.Context, issue *issues_model.Issue) (*ai.SuggestL
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExplainCode provides an AI explanation of code
|
// ExplainCode provides an AI explanation of code.
|
||||||
func ExplainCode(ctx context.Context, repo *repo_model.Repository, filePath, code string, startLine, endLine int, question string) (*ai.ExplainCodeResponse, error) {
|
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
||||||
|
func ExplainCode(ctx context.Context, repo *repo_model.Repository, filePath, code string, startLine, endLine int, question string, providerCfg *ai.ProviderConfig) (*ai.ExplainCodeResponse, error) {
|
||||||
if !IsEnabled() || !setting.AI.EnableExplainCode {
|
if !IsEnabled() || !setting.AI.EnableExplainCode {
|
||||||
return nil, errors.New("AI code explanation is not enabled")
|
return nil, errors.New("AI code explanation is not enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
req := &ai.ExplainCodeRequest{
|
req := &ai.ExplainCodeRequest{
|
||||||
RepoID: repo.ID,
|
ProviderConfig: providerCfg,
|
||||||
FilePath: filePath,
|
RepoID: repo.ID,
|
||||||
Code: code,
|
FilePath: filePath,
|
||||||
StartLine: startLine,
|
Code: code,
|
||||||
EndLine: endLine,
|
StartLine: startLine,
|
||||||
Question: question,
|
EndLine: endLine,
|
||||||
|
Question: question,
|
||||||
}
|
}
|
||||||
|
|
||||||
client := ai.GetClient()
|
client := ai.GetClient()
|
||||||
@@ -226,19 +235,21 @@ func ExplainCode(ctx context.Context, repo *repo_model.Repository, filePath, cod
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateDocumentation generates documentation for code
|
// GenerateDocumentation generates documentation for code.
|
||||||
func GenerateDocumentation(ctx context.Context, repo *repo_model.Repository, filePath, code, docType, language, style string) (*ai.GenerateDocumentationResponse, error) {
|
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
||||||
|
func GenerateDocumentation(ctx context.Context, repo *repo_model.Repository, filePath, code, docType, language, style string, providerCfg *ai.ProviderConfig) (*ai.GenerateDocumentationResponse, error) {
|
||||||
if !IsEnabled() || !setting.AI.EnableDocGen {
|
if !IsEnabled() || !setting.AI.EnableDocGen {
|
||||||
return nil, errors.New("AI documentation generation is not enabled")
|
return nil, errors.New("AI documentation generation is not enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
req := &ai.GenerateDocumentationRequest{
|
req := &ai.GenerateDocumentationRequest{
|
||||||
RepoID: repo.ID,
|
ProviderConfig: providerCfg,
|
||||||
FilePath: filePath,
|
RepoID: repo.ID,
|
||||||
Code: code,
|
FilePath: filePath,
|
||||||
DocType: docType,
|
Code: code,
|
||||||
Language: language,
|
DocType: docType,
|
||||||
Style: style,
|
Language: language,
|
||||||
|
Style: style,
|
||||||
}
|
}
|
||||||
|
|
||||||
client := ai.GetClient()
|
client := ai.GetClient()
|
||||||
@@ -251,8 +262,9 @@ func GenerateDocumentation(ctx context.Context, repo *repo_model.Repository, fil
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateCommitMessage generates a commit message for staged changes
|
// GenerateCommitMessage generates a commit message for staged changes.
|
||||||
func GenerateCommitMessage(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, style string) (*ai.GenerateCommitMessageResponse, error) {
|
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
||||||
|
func GenerateCommitMessage(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, style string, providerCfg *ai.ProviderConfig) (*ai.GenerateCommitMessageResponse, error) {
|
||||||
if !IsEnabled() || !setting.AI.EnableDocGen {
|
if !IsEnabled() || !setting.AI.EnableDocGen {
|
||||||
return nil, errors.New("AI documentation generation is not enabled")
|
return nil, errors.New("AI documentation generation is not enabled")
|
||||||
}
|
}
|
||||||
@@ -260,9 +272,10 @@ func GenerateCommitMessage(ctx context.Context, repo *repo_model.Repository, git
|
|||||||
// This would be called from the web editor
|
// This would be called from the web editor
|
||||||
// For now, return a placeholder
|
// For now, return a placeholder
|
||||||
req := &ai.GenerateCommitMessageRequest{
|
req := &ai.GenerateCommitMessageRequest{
|
||||||
RepoID: repo.ID,
|
ProviderConfig: providerCfg,
|
||||||
Files: []ai.FileDiff{},
|
RepoID: repo.ID,
|
||||||
Style: style,
|
Files: []ai.FileDiff{},
|
||||||
|
Style: style,
|
||||||
}
|
}
|
||||||
|
|
||||||
client := ai.GetClient()
|
client := ai.GetClient()
|
||||||
@@ -275,6 +288,61 @@ func GenerateCommitMessage(ctx context.Context, repo *repo_model.Repository, git
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InspectWorkflow inspects a workflow YAML file using AI.
|
||||||
|
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
||||||
|
func InspectWorkflow(ctx context.Context, repo *repo_model.Repository, filePath, content string, providerCfg *ai.ProviderConfig) (*ai.InspectWorkflowResponse, error) {
|
||||||
|
if !IsEnabled() {
|
||||||
|
return nil, errors.New("AI is not enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If content is empty, try to read from the repo
|
||||||
|
if content == "" && filePath != "" {
|
||||||
|
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open git repo: %w", err)
|
||||||
|
}
|
||||||
|
defer gitRepo.Close()
|
||||||
|
|
||||||
|
commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get default branch commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blob, err := commit.GetBlobByPath(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read workflow file %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := blob.DataAsync()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read blob data: %w", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read workflow content: %w", err)
|
||||||
|
}
|
||||||
|
content = string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &ai.InspectWorkflowRequest{
|
||||||
|
ProviderConfig: providerCfg,
|
||||||
|
RepoID: repo.ID,
|
||||||
|
FilePath: filePath,
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
|
||||||
|
client := ai.GetClient()
|
||||||
|
resp, err := client.InspectWorkflow(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("AI InspectWorkflow failed: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
// getFileStatus returns the status string for a file diff
|
// getFileStatus returns the status string for a file diff
|
||||||
func getFileStatus(file *gitdiff.DiffFile) string {
|
func getFileStatus(file *gitdiff.DiffFile) string {
|
||||||
if file.IsDeleted {
|
if file.IsDeleted {
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ package ai
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
issues_model "code.gitcaddy.com/server/v3/models/issues"
|
issues_model "code.gitcaddy.com/server/v3/models/issues"
|
||||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||||
"code.gitcaddy.com/server/v3/models/unit"
|
"code.gitcaddy.com/server/v3/models/unit"
|
||||||
user_model "code.gitcaddy.com/server/v3/models/user"
|
user_model "code.gitcaddy.com/server/v3/models/user"
|
||||||
|
"code.gitcaddy.com/server/v3/modules/gitrepo"
|
||||||
"code.gitcaddy.com/server/v3/modules/log"
|
"code.gitcaddy.com/server/v3/modules/log"
|
||||||
|
"code.gitcaddy.com/server/v3/modules/repository"
|
||||||
"code.gitcaddy.com/server/v3/modules/setting"
|
"code.gitcaddy.com/server/v3/modules/setting"
|
||||||
notify_service "code.gitcaddy.com/server/v3/services/notify"
|
notify_service "code.gitcaddy.com/server/v3/services/notify"
|
||||||
)
|
)
|
||||||
@@ -245,6 +248,79 @@ func (n *aiNotifier) IssueChangeLabels(ctx context.Context, doer *user_model.Use
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PushCommits handles push events — triggers workflow-inspect if workflow files changed
|
||||||
|
func (n *aiNotifier) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository,
|
||||||
|
opts *repository.PushUpdateOptions, commits *repository.PushCommits,
|
||||||
|
) {
|
||||||
|
if isAIUser(pusher) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only inspect on pushes to the default branch
|
||||||
|
if opts.RefFullName.BranchName() != repo.DefaultBranch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
aiCfg := getAIConfig(ctx, repo)
|
||||||
|
if aiCfg == nil || !aiCfg.AutoInspectWorkflows {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !setting.AI.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any pushed commit touched workflow files
|
||||||
|
workflowFiles := findChangedWorkflowFiles(ctx, repo, opts)
|
||||||
|
for _, filePath := range workflowFiles {
|
||||||
|
if err := EnqueueOperation(&OperationRequest{
|
||||||
|
RepoID: repo.ID,
|
||||||
|
Operation: "workflow-inspect",
|
||||||
|
Tier: 1,
|
||||||
|
TriggerEvent: "push.workflow_changed",
|
||||||
|
TriggerUserID: pusher.ID,
|
||||||
|
TargetID: 0,
|
||||||
|
TargetType: "workflow",
|
||||||
|
}); err != nil {
|
||||||
|
log.Error("AI notifier: failed to enqueue workflow-inspect for %s in repo %d: %v", filePath, repo.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findChangedWorkflowFiles returns workflow file paths that changed between old and new commits
|
||||||
|
func findChangedWorkflowFiles(ctx context.Context, repo *repo_model.Repository, opts *repository.PushUpdateOptions) []string {
|
||||||
|
if opts.OldCommitID == "" || opts.NewCommitID == "" || opts.IsNewRef() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("AI notifier: failed to open git repo %d: %v", repo.ID, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer gitRepo.Close()
|
||||||
|
|
||||||
|
files, err := gitRepo.GetFilesChangedBetween(opts.OldCommitID, opts.NewCommitID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("AI notifier: failed to get changed files: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var workflowFiles []string
|
||||||
|
for _, f := range files {
|
||||||
|
if isWorkflowFile(f) {
|
||||||
|
workflowFiles = append(workflowFiles, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return workflowFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWorkflowFile returns true if the path is a workflow file
|
||||||
|
func isWorkflowFile(path string) bool {
|
||||||
|
return (strings.HasPrefix(path, ".gitea/workflows/") || strings.HasPrefix(path, ".github/workflows/")) &&
|
||||||
|
(strings.HasSuffix(path, ".yml") || strings.HasSuffix(path, ".yaml"))
|
||||||
|
}
|
||||||
|
|
||||||
// isBotMentioned checks if the AI bot user is mentioned in text
|
// isBotMentioned checks if the AI bot user is mentioned in text
|
||||||
func isBotMentioned(content string) bool {
|
func isBotMentioned(content string) bool {
|
||||||
botName := setting.AI.BotUserName
|
botName := setting.AI.BotUserName
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
ai_model "code.gitcaddy.com/server/v3/models/ai"
|
ai_model "code.gitcaddy.com/server/v3/models/ai"
|
||||||
@@ -105,6 +107,13 @@ func processOperation(ctx context.Context, req *OperationRequest) error {
|
|||||||
opLog.Provider = ai_model.ResolveProvider(ctx, orgID, aiCfg.PreferredProvider)
|
opLog.Provider = ai_model.ResolveProvider(ctx, orgID, aiCfg.PreferredProvider)
|
||||||
opLog.Model = ai_model.ResolveModel(ctx, orgID, aiCfg.PreferredModel)
|
opLog.Model = ai_model.ResolveModel(ctx, orgID, aiCfg.PreferredModel)
|
||||||
|
|
||||||
|
// Build per-request provider config from the cascade
|
||||||
|
providerCfg := &ai.ProviderConfig{
|
||||||
|
Provider: opLog.Provider,
|
||||||
|
Model: opLog.Model,
|
||||||
|
APIKey: ai_model.ResolveAPIKey(ctx, orgID, opLog.Provider),
|
||||||
|
}
|
||||||
|
|
||||||
if err := ai_model.InsertOperationLog(ctx, opLog); err != nil {
|
if err := ai_model.InsertOperationLog(ctx, opLog); err != nil {
|
||||||
return fmt.Errorf("failed to insert operation log: %w", err)
|
return fmt.Errorf("failed to insert operation log: %w", err)
|
||||||
}
|
}
|
||||||
@@ -115,11 +124,13 @@ func processOperation(ctx context.Context, req *OperationRequest) error {
|
|||||||
var opErr error
|
var opErr error
|
||||||
switch req.Operation {
|
switch req.Operation {
|
||||||
case "issue-response":
|
case "issue-response":
|
||||||
opErr = handleIssueResponse(ctx, repo, aiCfg, opLog)
|
opErr = handleIssueResponse(ctx, repo, aiCfg, opLog, providerCfg)
|
||||||
case "issue-triage":
|
case "issue-triage":
|
||||||
opErr = handleIssueTriage(ctx, repo, aiCfg, opLog)
|
opErr = handleIssueTriage(ctx, repo, opLog, providerCfg)
|
||||||
case "code-review":
|
case "code-review":
|
||||||
opErr = handleCodeReview(ctx, repo, aiCfg, opLog)
|
opErr = handleCodeReview(ctx, repo, opLog, providerCfg)
|
||||||
|
case "workflow-inspect":
|
||||||
|
opErr = handleWorkflowInspect(ctx, repo, opLog, providerCfg)
|
||||||
case "agent-fix":
|
case "agent-fix":
|
||||||
opErr = handleAgentFix(ctx, repo, aiCfg, opLog)
|
opErr = handleAgentFix(ctx, repo, aiCfg, opLog)
|
||||||
default:
|
default:
|
||||||
@@ -148,7 +159,7 @@ func processOperation(ctx context.Context, req *OperationRequest) error {
|
|||||||
return opErr
|
return opErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleIssueResponse(ctx context.Context, repo *repo_model.Repository, aiCfg *repo_model.AIConfig, opLog *ai_model.OperationLog) error {
|
func handleIssueResponse(ctx context.Context, repo *repo_model.Repository, aiCfg *repo_model.AIConfig, opLog *ai_model.OperationLog, providerCfg *ai.ProviderConfig) error {
|
||||||
issue, err := issues_model.GetIssueByID(ctx, opLog.TargetID)
|
issue, err := issues_model.GetIssueByID(ctx, opLog.TargetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load issue %d: %w", opLog.TargetID, err)
|
return fmt.Errorf("failed to load issue %d: %w", opLog.TargetID, err)
|
||||||
@@ -157,6 +168,7 @@ func handleIssueResponse(ctx context.Context, repo *repo_model.Repository, aiCfg
|
|||||||
|
|
||||||
client := ai.GetClient()
|
client := ai.GetClient()
|
||||||
resp, err := client.GenerateIssueResponse(ctx, &ai.GenerateIssueResponseRequest{
|
resp, err := client.GenerateIssueResponse(ctx, &ai.GenerateIssueResponseRequest{
|
||||||
|
ProviderConfig: providerCfg,
|
||||||
RepoID: repo.ID,
|
RepoID: repo.ID,
|
||||||
IssueID: issue.ID,
|
IssueID: issue.ID,
|
||||||
Title: issue.Title,
|
Title: issue.Title,
|
||||||
@@ -181,14 +193,14 @@ func handleIssueResponse(ctx context.Context, repo *repo_model.Repository, aiCfg
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleIssueTriage(ctx context.Context, repo *repo_model.Repository, _ *repo_model.AIConfig, opLog *ai_model.OperationLog) error {
|
func handleIssueTriage(ctx context.Context, repo *repo_model.Repository, opLog *ai_model.OperationLog, providerCfg *ai.ProviderConfig) error {
|
||||||
issue, err := issues_model.GetIssueByID(ctx, opLog.TargetID)
|
issue, err := issues_model.GetIssueByID(ctx, opLog.TargetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load issue %d: %w", opLog.TargetID, err)
|
return fmt.Errorf("failed to load issue %d: %w", opLog.TargetID, err)
|
||||||
}
|
}
|
||||||
issue.Repo = repo
|
issue.Repo = repo
|
||||||
|
|
||||||
triageResp, err := TriageIssue(ctx, issue)
|
triageResp, err := TriageIssue(ctx, issue, providerCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("AI TriageIssue failed: %w", err)
|
return fmt.Errorf("AI TriageIssue failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -209,7 +221,7 @@ func handleIssueTriage(ctx context.Context, repo *repo_model.Repository, _ *repo
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCodeReview(ctx context.Context, repo *repo_model.Repository, _ *repo_model.AIConfig, opLog *ai_model.OperationLog) error {
|
func handleCodeReview(ctx context.Context, repo *repo_model.Repository, opLog *ai_model.OperationLog, providerCfg *ai.ProviderConfig) error {
|
||||||
issue, err := issues_model.GetIssueByID(ctx, opLog.TargetID)
|
issue, err := issues_model.GetIssueByID(ctx, opLog.TargetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load issue %d: %w", opLog.TargetID, err)
|
return fmt.Errorf("failed to load issue %d: %w", opLog.TargetID, err)
|
||||||
@@ -219,7 +231,7 @@ func handleCodeReview(ctx context.Context, repo *repo_model.Repository, _ *repo_
|
|||||||
}
|
}
|
||||||
issue.Repo = repo
|
issue.Repo = repo
|
||||||
|
|
||||||
reviewResp, err := ReviewPullRequest(ctx, issue.PullRequest)
|
reviewResp, err := ReviewPullRequest(ctx, issue.PullRequest, providerCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("AI ReviewPullRequest failed: %w", err)
|
return fmt.Errorf("AI ReviewPullRequest failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -245,3 +257,62 @@ func handleAgentFix(ctx context.Context, repo *repo_model.Repository, aiCfg *rep
|
|||||||
opLog.ActionRunID = runID
|
opLog.ActionRunID = runID
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleWorkflowInspect(ctx context.Context, repo *repo_model.Repository, opLog *ai_model.OperationLog, providerCfg *ai.ProviderConfig) error {
|
||||||
|
// TargetID is used to store context; for workflow inspect the file path is stored in ErrorMessage temporarily
|
||||||
|
// We use the operation's ErrorMessage field pre-populated with the file path before dispatch
|
||||||
|
filePath := opLog.ErrorMessage
|
||||||
|
opLog.ErrorMessage = "" // Clear it before actual use
|
||||||
|
|
||||||
|
resp, err := InspectWorkflow(ctx, repo, filePath, "", providerCfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("AI InspectWorkflow failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opLog.InputTokens = resp.InputTokens
|
||||||
|
opLog.OutputTokens = resp.OutputTokens
|
||||||
|
|
||||||
|
// If there are issues, post a summary comment (for push-triggered inspections)
|
||||||
|
if len(resp.Issues) > 0 || len(resp.Suggestions) > 0 {
|
||||||
|
var body strings.Builder
|
||||||
|
body.WriteString("## Workflow Inspection Results\n\n")
|
||||||
|
body.WriteString("**File:** `" + filePath + "`\n\n")
|
||||||
|
|
||||||
|
if !resp.Valid {
|
||||||
|
body.WriteString("**Status:** Issues found\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, issue := range resp.Issues {
|
||||||
|
icon := "ℹ️"
|
||||||
|
switch issue.Severity {
|
||||||
|
case "error":
|
||||||
|
icon = "❌"
|
||||||
|
case "warning":
|
||||||
|
icon = "⚠️"
|
||||||
|
}
|
||||||
|
body.WriteString(icon + " ")
|
||||||
|
if issue.Line > 0 {
|
||||||
|
body.WriteString("**Line " + strconv.Itoa(issue.Line) + ":** ")
|
||||||
|
}
|
||||||
|
body.WriteString(issue.Message + "\n")
|
||||||
|
if issue.Fix != "" {
|
||||||
|
body.WriteString(" - **Fix:** " + issue.Fix + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Suggestions) > 0 {
|
||||||
|
body.WriteString("\n### Suggestions\n")
|
||||||
|
for _, s := range resp.Suggestions {
|
||||||
|
body.WriteString("- " + s + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Workflow inspection for %s in repo %d: %d issues, %d suggestions",
|
||||||
|
filePath, repo.ID, len(resp.Issues), len(resp.Suggestions))
|
||||||
|
// Note: for push-triggered inspections, the comment would be posted as a repo event
|
||||||
|
// or as part of the commit status. The body is logged for now.
|
||||||
|
_ = body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
224
templates/admin/ai.tmpl
Normal file
224
templates/admin/ai.tmpl
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin ai")}}
|
||||||
|
<div class="admin ai">
|
||||||
|
<h4 class="ui top attached header">
|
||||||
|
{{ctx.Locale.Tr "admin.ai.title"}}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<!-- Stat Tiles -->
|
||||||
|
<div class="ui attached segment">
|
||||||
|
<div style="display: flex; justify-content: space-around; text-align: center; flex-wrap: wrap; gap: 10px;">
|
||||||
|
<!-- Sidecar Status -->
|
||||||
|
<div style="padding: 15px 25px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; min-width: 150px;">
|
||||||
|
{{if .SidecarHealthy}}
|
||||||
|
<div style="font-size: 2em; font-weight: bold; color: #21ba45;">{{svg "octicon-check-circle-fill" 32}}</div>
|
||||||
|
<div style="color: #21ba45; font-weight: bold; margin-top: 5px;">Online</div>
|
||||||
|
{{else}}
|
||||||
|
<div style="font-size: 2em; font-weight: bold; color: #db2828;">{{svg "octicon-x-circle-fill" 32}}</div>
|
||||||
|
<div style="color: #db2828; font-weight: bold; margin-top: 5px;">Offline</div>
|
||||||
|
{{end}}
|
||||||
|
<div style="color: var(--color-text-light); margin-top: 5px;">{{ctx.Locale.Tr "admin.ai.sidecar_status"}}</div>
|
||||||
|
{{if .SidecarVersion}}<div style="color: var(--color-text-light); font-size: 0.85em;">v{{.SidecarVersion}}</div>{{end}}
|
||||||
|
</div>
|
||||||
|
<!-- Total Ops -->
|
||||||
|
<div style="padding: 15px 25px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; min-width: 150px;">
|
||||||
|
<div style="font-size: 2em; font-weight: bold; color: #2185d0;">{{.Stats.TotalOperations}}</div>
|
||||||
|
<div style="color: var(--color-text-light); margin-top: 5px;">{{ctx.Locale.Tr "admin.ai.total_operations"}}</div>
|
||||||
|
</div>
|
||||||
|
<!-- 24h Ops -->
|
||||||
|
<div style="padding: 15px 25px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; min-width: 150px;">
|
||||||
|
<div style="font-size: 2em; font-weight: bold; color: #6435c9;">{{.Stats.Operations24h}}</div>
|
||||||
|
<div style="color: var(--color-text-light); margin-top: 5px;">{{ctx.Locale.Tr "admin.ai.operations_24h"}}</div>
|
||||||
|
</div>
|
||||||
|
<!-- Success Rate -->
|
||||||
|
<div style="padding: 15px 25px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; min-width: 150px;">
|
||||||
|
<div style="font-size: 2em; font-weight: bold; color: #21ba45;">{{printf "%.1f" .SuccessRate}}%</div>
|
||||||
|
<div style="color: var(--color-text-light); margin-top: 5px;">{{ctx.Locale.Tr "admin.ai.success_rate"}}</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tokens Used -->
|
||||||
|
<div style="padding: 15px 25px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; min-width: 150px;">
|
||||||
|
<div style="font-size: 2em; font-weight: bold; color: #f2711c;">{{.TotalTokens}}</div>
|
||||||
|
<div style="color: var(--color-text-light); margin-top: 5px;">{{ctx.Locale.Tr "admin.ai.tokens_used"}}</div>
|
||||||
|
<div style="color: var(--color-text-light); font-size: 0.85em;">In: {{.Stats.TotalInputTokens}} / Out: {{.Stats.TotalOutputTokens}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration Summary -->
|
||||||
|
<h4 class="ui attached header">
|
||||||
|
{{ctx.Locale.Tr "admin.ai.config"}}
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
<div class="ui two column stackable grid">
|
||||||
|
<div class="column">
|
||||||
|
<table class="ui very basic table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>AI Enabled</strong></td>
|
||||||
|
<td>
|
||||||
|
{{if .AIConfig.Enabled}}
|
||||||
|
<span class="ui small green label">On</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="ui small red label">Off</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Provider</strong></td>
|
||||||
|
<td>{{.AIConfig.DefaultProvider}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Model</strong></td>
|
||||||
|
<td><code>{{.AIConfig.DefaultModel}}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Service URL</strong></td>
|
||||||
|
<td><code>{{.AIConfig.ServiceURL}}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Rate Limit</strong></td>
|
||||||
|
<td>{{.AIConfig.MaxOperationsPerHour}} ops/hr, {{.AIConfig.MaxTokensPerOperation}} tokens/op</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<table class="ui very basic table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Code Review</strong></td>
|
||||||
|
<td>{{if .AIConfig.EnableCodeReview}}<span class="ui small green label">On</span>{{else}}<span class="ui small red label">Off</span>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Issue Triage</strong></td>
|
||||||
|
<td>{{if .AIConfig.EnableIssueTriage}}<span class="ui small green label">On</span>{{else}}<span class="ui small red label">Off</span>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Doc Generation</strong></td>
|
||||||
|
<td>{{if .AIConfig.EnableDocGen}}<span class="ui small green label">On</span>{{else}}<span class="ui small red label">Off</span>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Explain Code</strong></td>
|
||||||
|
<td>{{if .AIConfig.EnableExplainCode}}<span class="ui small green label">On</span>{{else}}<span class="ui small red label">Off</span>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Chat</strong></td>
|
||||||
|
<td>{{if .AIConfig.EnableChat}}<span class="ui small green label">On</span>{{else}}<span class="ui small red label">Off</span>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Auto-Respond</strong></td>
|
||||||
|
<td>{{if .AIConfig.AllowAutoRespond}}<span class="ui small green label">On</span>{{else}}<span class="ui small red label">Off</span>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Auto-Review</strong></td>
|
||||||
|
<td>{{if .AIConfig.AllowAutoReview}}<span class="ui small green label">On</span>{{else}}<span class="ui small red label">Off</span>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Agent Mode</strong></td>
|
||||||
|
<td>{{if .AIConfig.AllowAgentMode}}<span class="ui small green label">On</span>{{else}}<span class="ui small red label">Off</span>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .ProviderStatus}}
|
||||||
|
<div class="ui divider"></div>
|
||||||
|
<h5>Provider Status</h5>
|
||||||
|
<div>
|
||||||
|
{{range $provider, $status := .ProviderStatus}}
|
||||||
|
<span class="ui label" style="margin: 2px; background-color: {{if eq $status "ok"}}#21ba45{{else if eq $status "healthy"}}#21ba45{{else}}#db2828{{end}}; color: white;">
|
||||||
|
{{$provider}}: {{$status}}
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Breakdown -->
|
||||||
|
<h4 class="ui attached header">
|
||||||
|
{{ctx.Locale.Tr "admin.ai.stats"}}
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
<div style="display: flex; gap: 15px; flex-wrap: wrap;">
|
||||||
|
<div class="ui small statistic" style="margin: 5px 15px;">
|
||||||
|
<div class="value" style="color: #21ba45;">{{.Stats.SuccessCount}}</div>
|
||||||
|
<div class="label">Success</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui small statistic" style="margin: 5px 15px;">
|
||||||
|
<div class="value" style="color: #db2828;">{{.Stats.FailedCount}}</div>
|
||||||
|
<div class="label">Failed</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui small statistic" style="margin: 5px 15px;">
|
||||||
|
<div class="value" style="color: #f2711c;">{{.Stats.EscalatedCount}}</div>
|
||||||
|
<div class="label">Escalated</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui small statistic" style="margin: 5px 15px;">
|
||||||
|
<div class="value" style="color: #2185d0;">{{.Stats.PendingCount}}</div>
|
||||||
|
<div class="label">Pending</div>
|
||||||
|
</div>
|
||||||
|
{{range $tier, $count := .Stats.CountByTier}}
|
||||||
|
<div class="ui small statistic" style="margin: 5px 15px;">
|
||||||
|
<div class="value" style="color: #6435c9;">{{$count}}</div>
|
||||||
|
<div class="label">Tier {{$tier}}</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Operations -->
|
||||||
|
<h4 class="ui attached header">
|
||||||
|
{{ctx.Locale.Tr "admin.ai.recent_operations"}}
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
<table class="ui celled striped table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Repo ID</th>
|
||||||
|
<th>Operation</th>
|
||||||
|
<th>Tier</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Provider</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .RecentOps}}
|
||||||
|
<tr>
|
||||||
|
<td>{{DateUtils.TimeSince .CreatedUnix}}</td>
|
||||||
|
<td>{{.RepoID}}</td>
|
||||||
|
<td><code>{{.Operation}}</code></td>
|
||||||
|
<td>
|
||||||
|
<span class="ui small label" style="background-color: {{if eq .Tier 1}}#2185d0{{else}}#6435c9{{end}}; color: white;">
|
||||||
|
Tier {{.Tier}}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{if eq .Status "success"}}
|
||||||
|
<span class="ui small green label">{{.Status}}</span>
|
||||||
|
{{else if eq .Status "failed"}}
|
||||||
|
<span class="ui small red label">{{.Status}}</span>
|
||||||
|
{{else if eq .Status "escalated"}}
|
||||||
|
<span class="ui small orange label">{{.Status}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="ui small blue label">{{.Status}}</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>{{.DurationMs}}ms</td>
|
||||||
|
<td>
|
||||||
|
{{if .Provider}}{{.Provider}}{{else}}-{{end}}
|
||||||
|
{{if .Model}}<br><small style="color: var(--color-text-light);">{{.Model}}</small>{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="center aligned">
|
||||||
|
<i>No recent operations</i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "admin/layout_footer" .}}
|
||||||
@@ -131,6 +131,9 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
<a class="{{if .PageIsAdminAI}}active {{end}}item" href="{{AppSubUrl}}/-/admin/ai">
|
||||||
|
{{ctx.Locale.Tr "admin.ai"}}
|
||||||
|
</a>
|
||||||
<a class="{{if .PageIsAdminAILearning}}active {{end}}item" href="{{AppSubUrl}}/-/admin/ai-learning">
|
<a class="{{if .PageIsAdminAILearning}}active {{end}}item" href="{{AppSubUrl}}/-/admin/ai-learning">
|
||||||
{{ctx.Locale.Tr "admin.ai_learning"}}
|
{{ctx.Locale.Tr "admin.ai_learning"}}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user