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