You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
226 lines
9.8 KiB
226 lines
9.8 KiB
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)
|
|
}
|
|
|