2
0

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

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:
2026-02-13 01:16:58 -05:00
parent 813e3bcbb4
commit f42c6c39f9
20 changed files with 1603 additions and 95 deletions

View File

@@ -110,3 +110,98 @@ func CountRecentOperations(ctx context.Context, repoID int64) (int64, error) {
oneHourAgo := timeutil.TimeStampNow() - 3600
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
}

View File

@@ -204,6 +204,15 @@ func (c *Client) GenerateIssueResponse(ctx context.Context, req *GenerateIssueRe
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
func (c *Client) CheckHealth(ctx context.Context) (*HealthCheckResponse, error) {
var resp HealthCheckResponse

View File

@@ -3,6 +3,15 @@
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
type FileDiff struct {
Path string `json:"path"`
@@ -54,14 +63,15 @@ type SecurityAnalysis struct {
// ReviewPullRequestRequest is the request for reviewing a pull request
type ReviewPullRequestRequest struct {
RepoID int64 `json:"repo_id"`
PullRequestID int64 `json:"pull_request_id"`
BaseBranch string `json:"base_branch"`
HeadBranch string `json:"head_branch"`
Files []FileDiff `json:"files"`
PRTitle string `json:"pr_title"`
PRDescription string `json:"pr_description"`
Options ReviewOptions `json:"options"`
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
RepoID int64 `json:"repo_id"`
PullRequestID int64 `json:"pull_request_id"`
BaseBranch string `json:"base_branch"`
HeadBranch string `json:"head_branch"`
Files []FileDiff `json:"files"`
PRTitle string `json:"pr_title"`
PRDescription string `json:"pr_description"`
Options ReviewOptions `json:"options"`
}
// ReviewPullRequestResponse is the response from reviewing a pull request
@@ -76,12 +86,13 @@ type ReviewPullRequestResponse struct {
// TriageIssueRequest is the request for triaging an issue
type TriageIssueRequest struct {
RepoID int64 `json:"repo_id"`
IssueID int64 `json:"issue_id"`
Title string `json:"title"`
Body string `json:"body"`
ExistingLabels []string `json:"existing_labels"`
AvailableLabels []string `json:"available_labels"`
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
RepoID int64 `json:"repo_id"`
IssueID int64 `json:"issue_id"`
Title string `json:"title"`
Body string `json:"body"`
ExistingLabels []string `json:"existing_labels"`
AvailableLabels []string `json:"available_labels"`
}
// TriageIssueResponse is the response from triaging an issue
@@ -97,10 +108,11 @@ type TriageIssueResponse struct {
// SuggestLabelsRequest is the request for suggesting labels
type SuggestLabelsRequest struct {
RepoID int64 `json:"repo_id"`
Title string `json:"title"`
Body string `json:"body"`
AvailableLabels []string `json:"available_labels"`
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
RepoID int64 `json:"repo_id"`
Title string `json:"title"`
Body string `json:"body"`
AvailableLabels []string `json:"available_labels"`
}
// LabelSuggestion represents a suggested label
@@ -117,12 +129,13 @@ type SuggestLabelsResponse struct {
// ExplainCodeRequest is the request for explaining code
type ExplainCodeRequest struct {
RepoID int64 `json:"repo_id"`
FilePath string `json:"file_path"`
Code string `json:"code"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
Question string `json:"question,omitempty"`
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
RepoID int64 `json:"repo_id"`
FilePath string `json:"file_path"`
Code string `json:"code"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
Question string `json:"question,omitempty"`
}
// CodeReference represents a reference to related documentation
@@ -140,12 +153,13 @@ type ExplainCodeResponse struct {
// GenerateDocumentationRequest is the request for generating documentation
type GenerateDocumentationRequest struct {
RepoID int64 `json:"repo_id"`
FilePath string `json:"file_path"`
Code string `json:"code"`
DocType string `json:"doc_type"` // function, class, module, api
Language string `json:"language"`
Style string `json:"style"` // jsdoc, docstring, xml, markdown
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
RepoID int64 `json:"repo_id"`
FilePath string `json:"file_path"`
Code string `json:"code"`
DocType string `json:"doc_type"` // function, class, module, api
Language string `json:"language"`
Style string `json:"style"` // jsdoc, docstring, xml, markdown
}
// DocumentationSection represents a section of documentation
@@ -162,9 +176,10 @@ type GenerateDocumentationResponse struct {
// GenerateCommitMessageRequest is the request for generating a commit message
type GenerateCommitMessageRequest struct {
RepoID int64 `json:"repo_id"`
Files []FileDiff `json:"files"`
Style string `json:"style"` // conventional, descriptive, brief
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
RepoID int64 `json:"repo_id"`
Files []FileDiff `json:"files"`
Style string `json:"style"` // conventional, descriptive, brief
}
// GenerateCommitMessageResponse is the response from generating a commit message
@@ -175,9 +190,10 @@ type GenerateCommitMessageResponse struct {
// SummarizeChangesRequest is the request for summarizing changes
type SummarizeChangesRequest struct {
RepoID int64 `json:"repo_id"`
Files []FileDiff `json:"files"`
Context string `json:"context"`
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
RepoID int64 `json:"repo_id"`
Files []FileDiff `json:"files"`
Context string `json:"context"`
}
// 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
type GenerateIssueResponseRequest struct {
RepoID int64 `json:"repo_id"`
IssueID int64 `json:"issue_id"`
Title string `json:"title"`
Body string `json:"body"`
Comments []IssueComment `json:"comments,omitempty"`
ResponseType string `json:"response_type,omitempty"` // clarification, solution, acknowledgment
CustomInstructions string `json:"custom_instructions,omitempty"`
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
RepoID int64 `json:"repo_id"`
IssueID int64 `json:"issue_id"`
Title string `json:"title"`
Body string `json:"body"`
Comments []IssueComment `json:"comments,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
@@ -214,6 +231,33 @@ type GenerateIssueResponseResponse struct {
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
type HealthCheckResponse struct {
Healthy bool `json:"healthy"`

96
modules/plugins/config.go Normal file
View 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
View 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
View 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)
}

View 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;
}

View 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"`
}

View File

@@ -4619,6 +4619,16 @@
"actions.runners.waiting_jobs": "Waiting Jobs",
"actions.runners.back_to_runners": "Back to Runners",
"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.edit": "Edit Pattern",
"admin.ai_learning.total_patterns": "Total Patterns",

View File

@@ -72,7 +72,7 @@ func AIReviewPullRequest(ctx *context.APIContext) {
return
}
review, err := ai_service.ReviewPullRequest(ctx, pr)
review, err := ai_service.ReviewPullRequest(ctx, pr, nil)
if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]string{
"error": err.Error(),
@@ -141,7 +141,7 @@ func AITriageIssue(ctx *context.APIContext) {
return
}
triage, err := ai_service.TriageIssue(ctx, issue)
triage, err := ai_service.TriageIssue(ctx, issue, nil)
if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]string{
"error": err.Error(),
@@ -203,7 +203,7 @@ func AISuggestLabels(ctx *context.APIContext) {
return
}
suggestions, err := ai_service.SuggestLabels(ctx, issue)
suggestions, err := ai_service.SuggestLabels(ctx, issue, nil)
if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]string{
"error": err.Error(),
@@ -279,7 +279,7 @@ func AIExplainCode(ctx *context.APIContext) {
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 {
ctx.JSON(http.StatusInternalServerError, map[string]string{
"error": err.Error(),
@@ -355,7 +355,7 @@ func AIGenerateDocumentation(ctx *context.APIContext) {
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 {
ctx.JSON(http.StatusInternalServerError, map[string]string{
"error": err.Error(),

View File

@@ -11,6 +11,7 @@ import (
issues_model "code.gitcaddy.com/server/v3/models/issues"
repo_model "code.gitcaddy.com/server/v3/models/repo"
"code.gitcaddy.com/server/v3/models/unit"
ai_module "code.gitcaddy.com/server/v3/modules/ai"
apierrors "code.gitcaddy.com/server/v3/modules/errors"
"code.gitcaddy.com/server/v3/modules/setting"
api "code.gitcaddy.com/server/v3/modules/structs"
@@ -35,6 +36,31 @@ func getRepoAIConfig(ctx *context.APIContext) *repo_model.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 {
return &api.AIOperationV2{
ID: op.ID,
@@ -385,7 +411,9 @@ func TriggerAIExplain(ctx *context.APIContext) {
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 {
ctx.APIErrorWithCode(apierrors.AIServiceError, map[string]any{
"detail": err.Error(),

View File

@@ -161,11 +161,21 @@ func InitWebInstalled(ctx context.Context) {
log.Fatal("Plugin migrations failed: %v", err)
}
// Initialize all plugins
// Initialize all compiled plugins
if err := plugins.InitAll(ctx); err != nil {
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)
mustInitCtx(ctx, oauth2.Init)
mustInitCtx(ctx, oauth2_provider.Init)

77
routers/web/admin/ai.go Normal file
View 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)
}

View File

@@ -43,7 +43,7 @@ func AIReviewPullRequest(ctx *context.Context) {
return
}
review, err := ai_service.ReviewPullRequest(ctx, pr)
review, err := ai_service.ReviewPullRequest(ctx, pr, nil)
if err != nil {
ctx.Flash.Error(ctx.Tr("repo.ai.review_failed", err.Error()))
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + ctx.PathParam("index"))
@@ -77,7 +77,7 @@ func AITriageIssue(ctx *context.Context) {
return
}
triage, err := ai_service.TriageIssue(ctx, issue)
triage, err := ai_service.TriageIssue(ctx, issue, nil)
if err != nil {
ctx.Flash.Error(ctx.Tr("repo.ai.triage_failed", err.Error()))
ctx.Redirect(ctx.Repo.RepoLink + "/issues/" + ctx.PathParam("index"))
@@ -108,7 +108,7 @@ func AISuggestLabels(ctx *context.Context) {
return
}
suggestions, err := ai_service.SuggestLabels(ctx, issue)
suggestions, err := ai_service.SuggestLabels(ctx, issue, nil)
if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]string{
"error": err.Error(),

View File

@@ -888,6 +888,8 @@ func registerWebRoutes(m *web.Router) {
m.Post("/empty", admin.EmptyNotices)
})
m.Get("/ai", admin.AIStatus)
m.Group("/ai-learning", func() {
m.Get("", admin.AILearning)
m.Get("/{id}", admin.AILearningEdit)

View File

@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"io"
"strings"
"code.gitcaddy.com/server/v3/models/db"
@@ -25,8 +26,9 @@ func IsEnabled() bool {
return ai.IsEnabled()
}
// ReviewPullRequest performs an AI review of a pull request
func ReviewPullRequest(ctx context.Context, pr *issues_model.PullRequest) (*ai.ReviewPullRequestResponse, error) {
// ReviewPullRequest performs an AI review of a pull request.
// 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 {
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{
RepoID: pr.BaseRepoID,
PullRequestID: pr.ID,
BaseBranch: pr.BaseBranch,
HeadBranch: pr.HeadBranch,
Files: files,
PRTitle: pr.Issue.Title,
PRDescription: pr.Issue.Content,
ProviderConfig: providerCfg,
RepoID: pr.BaseRepoID,
PullRequestID: pr.ID,
BaseBranch: pr.BaseBranch,
HeadBranch: pr.HeadBranch,
Files: files,
PRTitle: pr.Issue.Title,
PRDescription: pr.Issue.Content,
Options: ai.ReviewOptions{
CheckSecurity: true,
CheckPerformance: true,
@@ -114,8 +117,9 @@ func ReviewPullRequest(ctx context.Context, pr *issues_model.PullRequest) (*ai.R
return resp, nil
}
// TriageIssue performs AI triage on an issue
func TriageIssue(ctx context.Context, issue *issues_model.Issue) (*ai.TriageIssueResponse, error) {
// TriageIssue performs AI triage on an issue.
// 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 {
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{
ProviderConfig: providerCfg,
RepoID: issue.RepoID,
IssueID: issue.ID,
Title: issue.Title,
@@ -163,8 +168,9 @@ func TriageIssue(ctx context.Context, issue *issues_model.Issue) (*ai.TriageIssu
return resp, nil
}
// SuggestLabels suggests labels for an issue
func SuggestLabels(ctx context.Context, issue *issues_model.Issue) (*ai.SuggestLabelsResponse, error) {
// SuggestLabels suggests labels for an issue.
// 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 {
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{
ProviderConfig: providerCfg,
RepoID: issue.RepoID,
Title: issue.Title,
Body: issue.Content,
@@ -201,19 +208,21 @@ func SuggestLabels(ctx context.Context, issue *issues_model.Issue) (*ai.SuggestL
return resp, nil
}
// 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) {
// ExplainCode provides an AI explanation of code.
// 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 {
return nil, errors.New("AI code explanation is not enabled")
}
req := &ai.ExplainCodeRequest{
RepoID: repo.ID,
FilePath: filePath,
Code: code,
StartLine: startLine,
EndLine: endLine,
Question: question,
ProviderConfig: providerCfg,
RepoID: repo.ID,
FilePath: filePath,
Code: code,
StartLine: startLine,
EndLine: endLine,
Question: question,
}
client := ai.GetClient()
@@ -226,19 +235,21 @@ func ExplainCode(ctx context.Context, repo *repo_model.Repository, filePath, cod
return resp, nil
}
// GenerateDocumentation generates documentation for code
func GenerateDocumentation(ctx context.Context, repo *repo_model.Repository, filePath, code, docType, language, style string) (*ai.GenerateDocumentationResponse, error) {
// GenerateDocumentation generates documentation for code.
// 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 {
return nil, errors.New("AI documentation generation is not enabled")
}
req := &ai.GenerateDocumentationRequest{
RepoID: repo.ID,
FilePath: filePath,
Code: code,
DocType: docType,
Language: language,
Style: style,
ProviderConfig: providerCfg,
RepoID: repo.ID,
FilePath: filePath,
Code: code,
DocType: docType,
Language: language,
Style: style,
}
client := ai.GetClient()
@@ -251,8 +262,9 @@ func GenerateDocumentation(ctx context.Context, repo *repo_model.Repository, fil
return resp, nil
}
// 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) {
// GenerateCommitMessage generates a commit message for staged changes.
// 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 {
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
// For now, return a placeholder
req := &ai.GenerateCommitMessageRequest{
RepoID: repo.ID,
Files: []ai.FileDiff{},
Style: style,
ProviderConfig: providerCfg,
RepoID: repo.ID,
Files: []ai.FileDiff{},
Style: style,
}
client := ai.GetClient()
@@ -275,6 +288,61 @@ func GenerateCommitMessage(ctx context.Context, repo *repo_model.Repository, git
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
func getFileStatus(file *gitdiff.DiffFile) string {
if file.IsDeleted {

View File

@@ -6,12 +6,15 @@ package ai
import (
"context"
"slices"
"strings"
issues_model "code.gitcaddy.com/server/v3/models/issues"
repo_model "code.gitcaddy.com/server/v3/models/repo"
"code.gitcaddy.com/server/v3/models/unit"
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/repository"
"code.gitcaddy.com/server/v3/modules/setting"
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
func isBotMentioned(content string) bool {
botName := setting.AI.BotUserName

View File

@@ -7,6 +7,8 @@ import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
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.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 {
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
switch req.Operation {
case "issue-response":
opErr = handleIssueResponse(ctx, repo, aiCfg, opLog)
opErr = handleIssueResponse(ctx, repo, aiCfg, opLog, providerCfg)
case "issue-triage":
opErr = handleIssueTriage(ctx, repo, aiCfg, opLog)
opErr = handleIssueTriage(ctx, repo, opLog, providerCfg)
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":
opErr = handleAgentFix(ctx, repo, aiCfg, opLog)
default:
@@ -148,7 +159,7 @@ func processOperation(ctx context.Context, req *OperationRequest) error {
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)
if err != nil {
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()
resp, err := client.GenerateIssueResponse(ctx, &ai.GenerateIssueResponseRequest{
ProviderConfig: providerCfg,
RepoID: repo.ID,
IssueID: issue.ID,
Title: issue.Title,
@@ -181,14 +193,14 @@ func handleIssueResponse(ctx context.Context, repo *repo_model.Repository, aiCfg
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)
if err != nil {
return fmt.Errorf("failed to load issue %d: %w", opLog.TargetID, err)
}
issue.Repo = repo
triageResp, err := TriageIssue(ctx, issue)
triageResp, err := TriageIssue(ctx, issue, providerCfg)
if err != nil {
return fmt.Errorf("AI TriageIssue failed: %w", err)
}
@@ -209,7 +221,7 @@ func handleIssueTriage(ctx context.Context, repo *repo_model.Repository, _ *repo
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)
if err != nil {
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
reviewResp, err := ReviewPullRequest(ctx, issue.PullRequest)
reviewResp, err := ReviewPullRequest(ctx, issue.PullRequest, providerCfg)
if err != nil {
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
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
View 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" .}}

View File

@@ -131,6 +131,9 @@
</a>
</div>
</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">
{{ctx.Locale.Tr "admin.ai_learning"}}
</a>