chore: initial commit (sources + docs, ignore build artifacts and secrets)
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LLMProfile is safe for rendering in the local Wails UI. APIKey never leaves the controller vault.
|
||||
type LLMProfile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
Model string `json:"model"`
|
||||
SupportsImages bool `json:"supportsImages"`
|
||||
HasAPIKey bool `json:"hasAPIKey"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// LLMProfileInput is accepted only from the local Wails UI.
|
||||
type LLMProfileInput struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
Model string `json:"model"`
|
||||
SupportsImages bool `json:"supportsImages"`
|
||||
APIKey string `json:"apiKey"`
|
||||
}
|
||||
|
||||
// AgentLLMStatus exposes deployment state without exposing any secret.
|
||||
type AgentLLMStatus struct {
|
||||
AgentID string `json:"agentID"`
|
||||
AgentName string `json:"agentName"`
|
||||
ProfileID string `json:"profileID"`
|
||||
ProfileName string `json:"profileName"`
|
||||
ConfigVersion int `json:"configVersion"`
|
||||
ApplyStatus string `json:"applyStatus"`
|
||||
LastError string `json:"lastError"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (c *ControlPlane) LLMProfiles() ([]LLMProfile, error) {
|
||||
rows, err := c.db.Query("SELECT profile_id, name, provider, base_url, model, supports_images, secret_id, updated_at FROM llm_profiles ORDER BY name")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法读取 LLM 配置: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
profiles := make([]LLMProfile, 0)
|
||||
for rows.Next() {
|
||||
var profile LLMProfile
|
||||
var secretID sql.NullString
|
||||
if err := rows.Scan(&profile.ID, &profile.Name, &profile.Provider, &profile.BaseURL, &profile.Model, &profile.SupportsImages, &secretID, &profile.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profile.HasAPIKey = secretID.Valid && secretID.String != ""
|
||||
profiles = append(profiles, profile)
|
||||
}
|
||||
return profiles, rows.Err()
|
||||
}
|
||||
|
||||
func (c *ControlPlane) SaveLLMProfile(input LLMProfileInput) (LLMProfile, error) {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
provider := strings.TrimSpace(input.Provider)
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(input.BaseURL), "/")
|
||||
model := strings.TrimSpace(input.Model)
|
||||
if name == "" || provider == "" || baseURL == "" || model == "" {
|
||||
return LLMProfile{}, errors.New("请完整填写名称、供应商、API 地址和模型名称")
|
||||
}
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
|
||||
return LLMProfile{}, errors.New("API 地址必须是有效的 http:// 或 https:// 地址")
|
||||
}
|
||||
if len([]rune(name)) > 80 || len([]rune(model)) > 160 || len(baseURL) > 500 {
|
||||
return LLMProfile{}, errors.New("LLM 配置内容过长")
|
||||
}
|
||||
profileID := input.ID
|
||||
if profileID == "" {
|
||||
profileID, err = randomHex(16)
|
||||
if err != nil {
|
||||
return LLMProfile{}, err
|
||||
}
|
||||
}
|
||||
var existingSecretID string
|
||||
_ = c.db.QueryRow("SELECT COALESCE(secret_id, '') FROM llm_profiles WHERE profile_id = ?", profileID).Scan(&existingSecretID)
|
||||
secretID := existingSecretID
|
||||
if strings.TrimSpace(input.APIKey) != "" {
|
||||
encrypted, protectErr := protectSecret(strings.TrimSpace(input.APIKey))
|
||||
if protectErr != nil {
|
||||
return LLMProfile{}, protectErr
|
||||
}
|
||||
if secretID == "" {
|
||||
secretID, err = randomHex(16)
|
||||
if err != nil {
|
||||
return LLMProfile{}, err
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := c.db.Exec(
|
||||
"INSERT INTO llm_secrets(secret_id, encrypted_value, updated_at) VALUES (?, ?, ?) ON CONFLICT(secret_id) DO UPDATE SET encrypted_value = excluded.encrypted_value, updated_at = excluded.updated_at",
|
||||
secretID, encrypted, now,
|
||||
); err != nil {
|
||||
return LLMProfile{}, fmt.Errorf("无法保存受保护的 API Key: %w", err)
|
||||
}
|
||||
}
|
||||
if secretID == "" {
|
||||
return LLMProfile{}, errors.New("请填写 API Key")
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := c.db.Exec(
|
||||
"INSERT INTO llm_profiles(profile_id, name, provider, base_url, model, supports_images, secret_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(profile_id) DO UPDATE SET name = excluded.name, provider = excluded.provider, base_url = excluded.base_url, model = excluded.model, supports_images = excluded.supports_images, secret_id = excluded.secret_id, updated_at = excluded.updated_at",
|
||||
profileID, name, provider, baseURL, model, input.SupportsImages, secretID, now,
|
||||
); err != nil {
|
||||
return LLMProfile{}, fmt.Errorf("无法保存 LLM 配置: %w", err)
|
||||
}
|
||||
c.recordAudit("llm.profile_saved", "controller", "llm-profile:"+profileID, "completed", "已安全保存 LLM 配置档;密钥未写入审计。")
|
||||
return LLMProfile{ID: profileID, Name: name, Provider: provider, BaseURL: baseURL, Model: model, SupportsImages: input.SupportsImages, HasAPIKey: true, UpdatedAt: now}, nil
|
||||
}
|
||||
|
||||
func (c *ControlPlane) DeployLLMProfile(profileID string, agentIDs []string) error {
|
||||
if profileID == "" || len(agentIDs) == 0 {
|
||||
return errors.New("请选择 LLM 配置和至少一个 Agent")
|
||||
}
|
||||
var exists int
|
||||
if err := c.db.QueryRow("SELECT COUNT(*) FROM llm_profiles WHERE profile_id = ?", profileID).Scan(&exists); err != nil || exists != 1 {
|
||||
return errors.New("未找到要下发的 LLM 配置")
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
for _, agentID := range agentIDs {
|
||||
if strings.TrimSpace(agentID) == "" {
|
||||
return errors.New("Agent 标识无效")
|
||||
}
|
||||
var agentExists int
|
||||
if err := c.db.QueryRow("SELECT COUNT(*) FROM managed_agents WHERE agent_id = ?", agentID).Scan(&agentExists); err != nil || agentExists != 1 {
|
||||
return errors.New("目标 Agent 不存在")
|
||||
}
|
||||
if _, err := c.db.Exec(
|
||||
"INSERT INTO agent_llm_assignments(agent_id, profile_id, config_version, apply_status, last_error, updated_at) VALUES (?, ?, 1, 'pending', '', ?) ON CONFLICT(agent_id) DO UPDATE SET profile_id = excluded.profile_id, config_version = agent_llm_assignments.config_version + 1, apply_status = 'pending', last_error = '', updated_at = excluded.updated_at",
|
||||
agentID, profileID, now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("无法保存 Agent LLM 下发任务: %w", err)
|
||||
}
|
||||
c.recordAudit("llm.deployment_requested", "controller", "agent:"+agentID, "pending", "已请求下发 LLM 配置。")
|
||||
c.pushLLMConfig(agentID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ControlPlane) AgentLLMStatuses() ([]AgentLLMStatus, error) {
|
||||
rows, err := c.db.Query("SELECT a.agent_id, a.name, x.profile_id, p.name, x.config_version, x.apply_status, x.last_error, x.updated_at FROM agent_llm_assignments x JOIN managed_agents a ON a.agent_id = x.agent_id JOIN llm_profiles p ON p.profile_id = x.profile_id ORDER BY a.name")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法读取 Agent LLM 状态: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
statuses := make([]AgentLLMStatus, 0)
|
||||
for rows.Next() {
|
||||
var status AgentLLMStatus
|
||||
if err := rows.Scan(&status.AgentID, &status.AgentName, &status.ProfileID, &status.ProfileName, &status.ConfigVersion, &status.ApplyStatus, &status.LastError, &status.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statuses = append(statuses, status)
|
||||
}
|
||||
return statuses, rows.Err()
|
||||
}
|
||||
|
||||
func (c *ControlPlane) pushLLMConfig(agentID string) {
|
||||
client := c.clientFor(agentID)
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
var profileID, provider, baseURL, model, secretID string
|
||||
var supportsImages bool
|
||||
var version int
|
||||
err := c.db.QueryRow(
|
||||
"SELECT x.profile_id, x.config_version, p.provider, p.base_url, p.model, p.supports_images, p.secret_id FROM agent_llm_assignments x JOIN llm_profiles p ON p.profile_id = x.profile_id WHERE x.agent_id = ?",
|
||||
agentID,
|
||||
).Scan(&profileID, &version, &provider, &baseURL, &model, &supportsImages, &secretID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var encrypted []byte
|
||||
if err := c.db.QueryRow("SELECT encrypted_value FROM llm_secrets WHERE secret_id = ?", secretID).Scan(&encrypted); err != nil {
|
||||
c.markLLMApplyFailed(agentID, version, "总控未找到 LLM API Key")
|
||||
return
|
||||
}
|
||||
apiKey, err := unprotectSecret(encrypted)
|
||||
if err != nil {
|
||||
c.markLLMApplyFailed(agentID, version, "无法读取总控保存的 LLM API Key")
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"type": "config_snapshot", "schema_version": protocolVersion, "config_version": version,
|
||||
"llm": map[string]any{"profile_id": profileID, "provider": provider, "base_url": baseURL, "model": model, "supports_images": supportsImages, "api_key": apiKey},
|
||||
}
|
||||
if err := client.send(payload); err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = c.db.Exec("UPDATE agent_llm_assignments SET apply_status = 'sent', updated_at = ? WHERE agent_id = ? AND config_version = ?", time.Now().UTC().Format(time.RFC3339Nano), agentID, version)
|
||||
c.recordAudit("llm.snapshot_sent", "controller", "agent:"+agentID, "sent", "已通过认证 WSS 下发 LLM 配置。")
|
||||
}
|
||||
|
||||
func (c *ControlPlane) applyLLMConfigAck(agentID string, message map[string]any) {
|
||||
versionNumber, ok := message["config_version"].(float64)
|
||||
status, _ := message["status"].(string)
|
||||
errorText, _ := message["error"].(string)
|
||||
if !ok || versionNumber < 1 || status != "applied" && status != "failed" {
|
||||
return
|
||||
}
|
||||
_, _ = c.db.Exec(
|
||||
"UPDATE agent_llm_assignments SET apply_status = ?, last_error = ?, updated_at = ? WHERE agent_id = ? AND config_version = ?",
|
||||
status, limit(sanitizeDisplayText(errorText), 1000), time.Now().UTC().Format(time.RFC3339Nano), agentID, int(versionNumber),
|
||||
)
|
||||
c.recordAudit("llm.deployment_ack", "agent:"+agentID, "llm-config", status, errorText)
|
||||
}
|
||||
|
||||
func (c *ControlPlane) markLLMApplyFailed(agentID string, version int, message string) {
|
||||
_, _ = c.db.Exec(
|
||||
"UPDATE agent_llm_assignments SET apply_status = 'failed', last_error = ?, updated_at = ? WHERE agent_id = ? AND config_version = ?",
|
||||
limit(sanitizeDisplayText(message), 1000), time.Now().UTC().Format(time.RFC3339Nano), agentID, version,
|
||||
)
|
||||
c.recordAudit("llm.deployment_ack", "controller", "agent:"+agentID, "failed", message)
|
||||
}
|
||||
Reference in New Issue
Block a user