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

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)
}