diff --git a/config.example.yaml b/config.example.yaml index ed955ba6..d9c4a2e2 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -135,8 +135,12 @@ agent: # approval → audit_agent_prompt # review_edit → audit_agent_prompt_review_edit(可改参后放行) hitl: - # 全局默认审批方:human=人工审批,audit_agent=审计 Agent;未选会话时切换会写入本项,重启后仍生效 + # 全局默认人机协同模式:off=关闭,approval=审批模式,review_edit=审查编辑;新建会话无独立配置时沿用 + default_mode: off + # 全局默认审批方:human=人工审批,audit_agent=审计 Agent;新建会话无独立配置时沿用 default_reviewer: human + # 全局默认审批等待时限(秒):300=5分钟,0=不限时;新建会话无独立配置时沿用 + default_timeout_seconds: 300 # 审计 Agent 专用模型;字段留空则复用上方 openai 配置。建议 model 填小模型,用于降低审批成本。 audit_model: provider: "" # openai / claude;留空跟随 openai.provider diff --git a/internal/app/app.go b/internal/app/app.go index 2fa1881e..f0886eeb 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -972,6 +972,8 @@ func setupRoutes( protected.GET("/hitl/tool-whitelist", agentHandler.GetHITLGlobalToolWhitelist) protected.PUT("/hitl/tool-whitelist", agentHandler.SetHITLGlobalToolWhitelist) protected.POST("/hitl/tool-whitelist", agentHandler.MergeHITLGlobalToolWhitelist) + protected.GET("/hitl/default-config", agentHandler.GetHITLDefaultConfig) + protected.PUT("/hitl/default-config", agentHandler.UpdateHITLDefaultConfig) protected.GET("/hitl/default-reviewer", agentHandler.GetHITLDefaultReviewer) protected.PUT("/hitl/default-reviewer", agentHandler.UpdateHITLDefaultReviewer) protected.GET("/hitl/audit-strategy", agentHandler.GetHITLAuditStrategy) diff --git a/internal/config/config.go b/internal/config/config.go index 0a6453c8..0965ec85 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1062,8 +1062,24 @@ type HitlConfig struct { AuditAgentPromptReviewEdit string `yaml:"audit_agent_prompt_review_edit,omitempty" json:"audit_agent_prompt_review_edit,omitempty"` // RetentionDays 已决策审计日志(hitl_interrupts 非 pending)保留天数;省略时默认 90;0 表示不自动清理。 RetentionDays *int `yaml:"retention_days,omitempty" json:"retention_days,omitempty"` - // DefaultReviewer 全局默认审批方(human | audit_agent);未选会话时切换会写入 config.yaml;新建会话无独立配置时沿用。 + // DefaultMode 全局默认人机协同模式(off | approval | review_edit);新建会话无独立配置时沿用。 + DefaultMode string `yaml:"default_mode,omitempty" json:"default_mode,omitempty"` + // DefaultReviewer 全局默认审批方(human | audit_agent);新建会话无独立配置时沿用。 DefaultReviewer string `yaml:"default_reviewer,omitempty" json:"default_reviewer,omitempty"` + // DefaultTimeoutSeconds 全局默认审批等待秒数;nil 表示使用前端历史默认 300 秒,0 表示不限时。 + DefaultTimeoutSeconds *int `yaml:"default_timeout_seconds,omitempty" json:"default_timeout_seconds,omitempty"` +} + +// EffectiveDefaultMode returns off, approval, or review_edit; omitted or unknown values default to off. +func (h HitlConfig) EffectiveDefaultMode() string { + switch strings.ToLower(strings.TrimSpace(h.DefaultMode)) { + case "feedback", "followup": + return "approval" + case "approval", "review_edit": + return strings.ToLower(strings.TrimSpace(h.DefaultMode)) + default: + return "off" + } } // EffectiveDefaultReviewer returns human or audit_agent; omitted or unknown values default to human. @@ -1076,6 +1092,17 @@ func (h HitlConfig) EffectiveDefaultReviewer() string { } } +// EffectiveDefaultTimeoutSeconds returns the default HITL approval timeout; nil defaults to 5 minutes. +func (h HitlConfig) EffectiveDefaultTimeoutSeconds() int { + if h.DefaultTimeoutSeconds == nil { + return 300 + } + if *h.DefaultTimeoutSeconds < 0 { + return 0 + } + return *h.DefaultTimeoutSeconds +} + // RetentionDaysEffective returns retention; 0 means keep forever; omitted defaults to 90. func (h HitlConfig) RetentionDaysEffective() int { if h.RetentionDays == nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a774f8c6..f4f8a9dd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -95,6 +95,29 @@ func TestHitlAuditModelEffectiveFallsBackToMainConfig(t *testing.T) { } } +func TestHitlDefaultConfigEffectiveValues(t *testing.T) { + if got := (HitlConfig{}).EffectiveDefaultMode(); got != "off" { + t.Fatalf("empty default mode = %q, want off", got) + } + if got := (HitlConfig{DefaultMode: "review-edit"}).EffectiveDefaultMode(); got != "off" { + t.Fatalf("unknown default mode = %q, want off", got) + } + if got := (HitlConfig{DefaultMode: "review_edit"}).EffectiveDefaultMode(); got != "review_edit" { + t.Fatalf("review_edit default mode = %q, want review_edit", got) + } + if got := (HitlConfig{}).EffectiveDefaultTimeoutSeconds(); got != 300 { + t.Fatalf("empty default timeout = %d, want 300", got) + } + zero := 0 + if got := (HitlConfig{DefaultTimeoutSeconds: &zero}).EffectiveDefaultTimeoutSeconds(); got != 0 { + t.Fatalf("zero default timeout = %d, want 0", got) + } + neg := -1 + if got := (HitlConfig{DefaultTimeoutSeconds: &neg}).EffectiveDefaultTimeoutSeconds(); got != 0 { + t.Fatalf("negative default timeout = %d, want 0", got) + } +} + func TestLoadUsesAIDefaultChannelAsRuntimeOpenAI(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yaml") diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 072b7141..80b63c92 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -315,12 +315,13 @@ func (h *AgentHandler) SetHitlToolWhitelistSaver(s HitlToolWhitelistSaver) { h.hitlWhitelistSaver = s } -// HitlDefaultReviewerSaver 持久化全局默认审批方到 config.yaml。 +// HitlDefaultReviewerSaver 持久化全局默认人机协同配置到 config.yaml。 type HitlDefaultReviewerSaver interface { + UpdateHitlDefaultConfig(mode, reviewer string, timeoutSeconds int) error UpdateHitlDefaultReviewer(reviewer string) error } -// SetHitlDefaultReviewerSaver 设置 HITL 默认审批方落盘。 +// SetHitlDefaultReviewerSaver 设置 HITL 默认配置落盘。 func (h *AgentHandler) SetHitlDefaultReviewerSaver(s HitlDefaultReviewerSaver) { h.hitlDefaultReviewerSaver = s } @@ -332,6 +333,35 @@ func (h *AgentHandler) hitlEffectiveDefaultReviewer() string { return "human" } +func (h *AgentHandler) hitlEffectiveDefaultMode() string { + if h != nil && h.config != nil { + return normalizeHitlDefaultMode(h.config.Hitl.EffectiveDefaultMode()) + } + return "off" +} + +func (h *AgentHandler) hitlEffectiveDefaultTimeoutSeconds() int { + if h != nil && h.config != nil { + timeout := h.config.Hitl.EffectiveDefaultTimeoutSeconds() + if timeout < 0 { + return 0 + } + return timeout + } + return 300 +} + +func (h *AgentHandler) hitlEffectiveDefaultRequest() *HITLRequest { + mode := h.hitlEffectiveDefaultMode() + return &HITLRequest{ + Enabled: mode != "off", + Mode: mode, + Reviewer: h.hitlEffectiveDefaultReviewer(), + SensitiveTools: []string{}, + TimeoutSeconds: h.hitlEffectiveDefaultTimeoutSeconds(), + } +} + // HITLNeedsToolApproval 供 C2 危险任务门控:与会话侧人机协同及免审批白名单判定一致。 func (h *AgentHandler) HITLNeedsToolApproval(conversationID, toolName string) bool { if h == nil || h.hitlManager == nil { diff --git a/internal/handler/config.go b/internal/handler/config.go index 0ac24f4f..98b4e570 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -891,7 +891,14 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { if req.Hitl != nil { h.config.Hitl.AuditModel = req.Hitl.AuditModel h.config.Hitl.ToolWhitelist = mergeHitlToolWhitelistSlice(nil, req.Hitl.ToolWhitelist) + if strings.TrimSpace(req.Hitl.DefaultMode) != "" { + h.config.Hitl.DefaultMode = req.Hitl.EffectiveDefaultMode() + } h.config.Hitl.DefaultReviewer = req.Hitl.EffectiveDefaultReviewer() + if req.Hitl.DefaultTimeoutSeconds != nil { + v := req.Hitl.EffectiveDefaultTimeoutSeconds() + h.config.Hitl.DefaultTimeoutSeconds = &v + } h.config.Hitl.AuditAgentPrompt = strings.TrimSpace(req.Hitl.AuditAgentPrompt) h.config.Hitl.AuditAgentPromptReviewEdit = strings.TrimSpace(req.Hitl.AuditAgentPromptReviewEdit) if req.Hitl.RetentionDays != nil { @@ -2141,12 +2148,35 @@ func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) { setStringInMap(auditModelNode, "model", cfg.AuditModel.Model) // flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数 setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist) + setStringInMap(hitlNode, "default_mode", cfg.EffectiveDefaultMode()) setStringInMap(hitlNode, "default_reviewer", cfg.EffectiveDefaultReviewer()) + setIntInMap(hitlNode, "default_timeout_seconds", cfg.EffectiveDefaultTimeoutSeconds()) setIntInMap(hitlNode, "retention_days", cfg.RetentionDaysEffective()) setStringInMap(hitlNode, "audit_agent_prompt", cfg.AuditAgentPrompt) setStringInMap(hitlNode, "audit_agent_prompt_review_edit", cfg.AuditAgentPromptReviewEdit) } +// UpdateHitlDefaultConfig 更新全局默认人机协同配置并写入 config.yaml。 +func (h *ConfigHandler) UpdateHitlDefaultConfig(mode, reviewer string, timeoutSeconds int) error { + h.mu.Lock() + defer h.mu.Unlock() + h.config.Hitl.DefaultMode = config.HitlConfig{DefaultMode: mode}.EffectiveDefaultMode() + h.config.Hitl.DefaultReviewer = config.HitlConfig{DefaultReviewer: reviewer}.EffectiveDefaultReviewer() + if timeoutSeconds < 0 { + timeoutSeconds = 0 + } + h.config.Hitl.DefaultTimeoutSeconds = &timeoutSeconds + if err := h.saveConfig(); err != nil { + return err + } + h.logger.Info("HITL 全局默认配置已写入配置文件", + zap.String("default_mode", h.config.Hitl.DefaultMode), + zap.String("default_reviewer", h.config.Hitl.DefaultReviewer), + zap.Int("default_timeout_seconds", timeoutSeconds), + ) + return nil +} + // UpdateHitlDefaultReviewer 更新全局默认审批方并写入 config.yaml。 func (h *ConfigHandler) UpdateHitlDefaultReviewer(reviewer string) error { h.mu.Lock() diff --git a/internal/handler/hitl.go b/internal/handler/hitl.go index c940a6ba..616ac1c7 100644 --- a/internal/handler/hitl.go +++ b/internal/handler/hitl.go @@ -289,6 +289,18 @@ func normalizeHitlMode(mode string) string { } } +func normalizeHitlDefaultMode(mode string) string { + v := strings.ToLower(strings.TrimSpace(mode)) + switch v { + case "feedback", "followup": + return "approval" + case "approval", "review_edit": + return v + default: + return "off" + } +} + func (m *HITLManager) ActivateConversation(conversationID string, req *HITLRequest) { if req == nil || !req.Enabled { m.DeactivateConversation(conversationID) @@ -629,7 +641,7 @@ func (h *AgentHandler) loadHITLConversationConfig(conversationID string) (*HITLR return nil, err } if !has { - cfg.Reviewer = h.hitlEffectiveDefaultReviewer() + return h.hitlEffectiveDefaultRequest(), nil } return cfg, nil } @@ -994,7 +1006,9 @@ func (h *AgentHandler) GetHITLConversationConfig(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "conversationId": conversationID, "hitl": cfg, + "defaultMode": h.hitlEffectiveDefaultMode(), "defaultReviewer": h.hitlEffectiveDefaultReviewer(), + "defaultTimeoutSeconds": h.hitlEffectiveDefaultTimeoutSeconds(), "hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(), }) } @@ -1051,11 +1065,64 @@ type setHitlDefaultReviewerReq struct { Reviewer string `json:"reviewer"` } +type setHitlDefaultConfigReq struct { + Mode string `json:"mode"` + Reviewer string `json:"reviewer"` + TimeoutSeconds int `json:"timeoutSeconds"` +} + +func (h *AgentHandler) hitlDefaultConfigResponse() gin.H { + return gin.H{ + "defaultMode": h.hitlEffectiveDefaultMode(), + "defaultReviewer": h.hitlEffectiveDefaultReviewer(), + "defaultTimeoutSeconds": h.hitlEffectiveDefaultTimeoutSeconds(), + "hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(), + } +} + +// GetHITLDefaultConfig 返回 config.yaml 中的全局默认人机协同配置。 +func (h *AgentHandler) GetHITLDefaultConfig(c *gin.Context) { + c.JSON(http.StatusOK, h.hitlDefaultConfigResponse()) +} + +// UpdateHITLDefaultConfig 将全局默认人机协同配置写入 config.yaml。 +func (h *AgentHandler) UpdateHITLDefaultConfig(c *gin.Context) { + if h.hitlDefaultReviewerSaver == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "HITL 配置持久化不可用"}) + return + } + var req setHitlDefaultConfigReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + mode := normalizeHitlDefaultMode(req.Mode) + reviewer := normalizeHitlReviewer(req.Reviewer) + timeoutSeconds := req.TimeoutSeconds + if timeoutSeconds < 0 { + timeoutSeconds = 0 + } + if err := h.hitlDefaultReviewerSaver.UpdateHitlDefaultConfig(mode, reviewer, timeoutSeconds); err != nil { + h.logger.Warn("写入 HITL 默认配置到 config.yaml 失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if h.config != nil { + h.config.Hitl.DefaultMode = mode + h.config.Hitl.DefaultReviewer = reviewer + h.config.Hitl.DefaultTimeoutSeconds = &timeoutSeconds + } + if h.audit != nil { + h.audit.RecordOK(c, "hitl", "default_config_update", "HITL 全局默认配置更新", "hitl_config", "default", nil) + } + out := h.hitlDefaultConfigResponse() + out["ok"] = true + c.JSON(http.StatusOK, out) +} + // GetHITLDefaultReviewer 返回 config.yaml 中的全局默认审批方。 func (h *AgentHandler) GetHITLDefaultReviewer(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "defaultReviewer": h.hitlEffectiveDefaultReviewer(), - }) + c.JSON(http.StatusOK, h.hitlDefaultConfigResponse()) } // UpdateHITLDefaultReviewer 将全局默认审批方写入 config.yaml(未选会话时切换审批方)。 @@ -1081,10 +1148,9 @@ func (h *AgentHandler) UpdateHITLDefaultReviewer(c *gin.Context) { if h.audit != nil { h.audit.RecordOK(c, "hitl", "default_reviewer_update", "HITL 全局默认审批方更新", "hitl_config", "default_reviewer", nil) } - c.JSON(http.StatusOK, gin.H{ - "ok": true, - "defaultReviewer": reviewer, - }) + out := h.hitlDefaultConfigResponse() + out["ok"] = true + c.JSON(http.StatusOK, out) } // SetHITLGlobalToolWhitelist 整表替换 config.yaml 中的全局免审批工具白名单。 diff --git a/internal/security/rbac_middleware.go b/internal/security/rbac_middleware.go index 77e7c055..2eea54f9 100644 --- a/internal/security/rbac_middleware.go +++ b/internal/security/rbac_middleware.go @@ -217,7 +217,7 @@ func resourceAllowed(c *gin.Context, db *database.DB) bool { return session.Scope == database.RBACScopeAll case strings.HasPrefix(path, "/c2/profiles") && c.Request.Method != http.MethodGet: return session.Scope == database.RBACScopeAll - case (strings.HasPrefix(path, "/hitl/tool-whitelist") || strings.HasPrefix(path, "/hitl/default-reviewer") || strings.HasPrefix(path, "/hitl/audit-strategy")) && c.Request.Method != http.MethodGet: + case (strings.HasPrefix(path, "/hitl/tool-whitelist") || strings.HasPrefix(path, "/hitl/default-config") || strings.HasPrefix(path, "/hitl/default-reviewer") || strings.HasPrefix(path, "/hitl/audit-strategy")) && c.Request.Method != http.MethodGet: return session.Scope == database.RBACScopeAll case isMutationMethod(c.Request.Method) && isProcessGlobalMutationPath(path): // These definitions/configurations are shared by every user and do not diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 56508646..ba8cfea8 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -819,6 +819,7 @@ "hitlWhitelistHint": "Separate with commas or new lines; shown merged with the global allowlist in config.", "hitlApply": "Apply", "hitlApplyOkSync": "HITL settings saved and synced to the server.", + "hitlApplyOkDefaultConfig": "Default HITL settings saved to config.yaml and activated.", "hitlApplyOkWhitelistYaml": "Tool whitelist merged into config.yaml and active. Session settings are saved automatically.", "hitlApplyOkLocal": "Saved in this browser.", "hitlApplyFail": "Failed to sync to server", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 1a1fff99..6c9db80e 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -807,6 +807,7 @@ "hitlWhitelistHint": "白名单内工具免审批;每行一个或逗号分隔,与 config 全局白名单合并。", "hitlApply": "应用", "hitlApplyOkSync": "人机协同配置已保存并同步到服务器。", + "hitlApplyOkDefaultConfig": "人机协同默认配置已写入 config.yaml 并生效。", "hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。", "hitlApplyOkLocal": "已保存到本浏览器。", "hitlApplyFail": "同步到服务器失败", diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 53c97835..e9fa93dc 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -154,9 +154,6 @@ const chatSystemModelCache = new Map(); // 人机协同(HITL)会话级配置 const HITL_STORAGE_PREFIX = 'cyberstrike-chat-hitl'; -const HITL_DRAFT_KEY = 'cyberstrike-chat-hitl-draft'; -/** 跨会话记忆:用户最近一次在侧栏选择的 HITL 偏好(与 hitl.js 中 readHitlGlobalLast 使用同一 key) */ -const HITL_GLOBAL_LAST_KEY = `${HITL_STORAGE_PREFIX}:__last__`; const HITL_MODE_OFF = 'off'; const HITL_MODE_APPROVAL = 'approval'; const HITL_MODE_REVIEW_EDIT = 'review_edit'; @@ -432,14 +429,17 @@ function normalizeHitlTimeoutForChat(value, fallback) { } function defaultHitlConfig() { - const serverReviewer = (typeof window !== 'undefined' && window.csaiHitlDefaultReviewer) + const serverDefault = (typeof window !== 'undefined' && window.csaiHitlDefaultConfig && typeof window.csaiHitlDefaultConfig === 'object') + ? window.csaiHitlDefaultConfig + : {}; + const serverReviewer = serverDefault.reviewer || ((typeof window !== 'undefined' && window.csaiHitlDefaultReviewer) ? window.csaiHitlDefaultReviewer - : 'human'; + : 'human'); return { - mode: HITL_MODE_OFF, + mode: normalizeHitlMode(serverDefault.mode || HITL_MODE_OFF), reviewer: normalizeHitlReviewer(serverReviewer), sensitiveTools: DEFAULT_HITL_SESSION_TOOL_WHITELIST, - timeoutSeconds: DEFAULT_HITL_TIMEOUT_SECONDS, + timeoutSeconds: normalizeHitlTimeoutForChat(serverDefault.timeoutSeconds, DEFAULT_HITL_TIMEOUT_SECONDS), updatedAt: '' }; } @@ -520,70 +520,11 @@ function getHitlModeLabel(mode) { } } -function getHitlLastGlobalConfig() { - const fallback = defaultHitlConfig(); - try { - const raw = localStorage.getItem(HITL_GLOBAL_LAST_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') return null; - return { - mode: normalizeHitlMode(parsed.mode), - reviewer: normalizeHitlReviewer(parsed.reviewer), - sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools, - timeoutSeconds: normalizeHitlTimeoutForChat(parsed.timeoutSeconds, fallback.timeoutSeconds), - updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '' - }; - } catch (e) { - return null; - } -} - -function saveHitlLastGlobalConfig(payload) { - if (!payload || typeof payload !== 'object') return; - try { - localStorage.setItem(HITL_GLOBAL_LAST_KEY, JSON.stringify(payload)); - } catch (e) { - console.warn('saveHitlLastGlobalConfig failed', e); - } -} - function getHitlConfigForConversation(conversationId) { const fallback = defaultHitlConfig(); const cid = conversationId ? String(conversationId).trim() : ''; if (!cid) { - const globalLast = getHitlLastGlobalConfig(); - let draftCfg = null; - try { - const raw = localStorage.getItem(HITL_DRAFT_KEY); - if (raw) { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') { - draftCfg = { - mode: normalizeHitlMode(parsed.mode), - reviewer: normalizeHitlReviewer(parsed.reviewer), - sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools, - timeoutSeconds: normalizeHitlTimeoutForChat(parsed.timeoutSeconds, fallback.timeoutSeconds), - updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '' - }; - } - } - } catch (e) { - draftCfg = null; - } - const g = globalLast ? { - mode: normalizeHitlMode(globalLast.mode), - reviewer: normalizeHitlReviewer(globalLast.reviewer), - sensitiveTools: typeof globalLast.sensitiveTools === 'string' ? globalLast.sensitiveTools : fallback.sensitiveTools, - timeoutSeconds: normalizeHitlTimeoutForChat(globalLast.timeoutSeconds, fallback.timeoutSeconds), - updatedAt: typeof globalLast.updatedAt === 'string' ? globalLast.updatedAt : '' - } : null; - if (!draftCfg && !g) return fallback; - if (!draftCfg) return g; - if (!g) return draftCfg; - const tg = Date.parse(g.updatedAt) || 0; - const td = Date.parse(draftCfg.updatedAt) || 0; - return tg > td ? g : draftCfg; + return fallback; } const key = getHitlStorageKeyByConversation(cid); try { @@ -627,6 +568,8 @@ async function onHitlReviewerChanged(reviewer) { try { if (cid && typeof window.saveHitlConversationConfig === 'function') { await window.saveHitlConversationConfig(cid, cfg); + } else if (typeof window.putHitlDefaultConfig === 'function') { + await window.putHitlDefaultConfig(cfg); } else if (typeof window.putHitlDefaultReviewer === 'function') { await window.putHitlDefaultReviewer(cfg.reviewer); } @@ -652,7 +595,10 @@ function bindHitlReviewerToggleListeners() { } function saveHitlConfigForConversation(conversationId, cfg, opts) { - const syncGlobalLast = !!(opts && opts.syncGlobalLast); + void opts; + if (!conversationId) { + return; + } const payload = { mode: normalizeHitlMode(cfg && cfg.mode), reviewer: normalizeHitlReviewer(cfg && cfg.reviewer), @@ -660,12 +606,9 @@ function saveHitlConfigForConversation(conversationId, cfg, opts) { timeoutSeconds: normalizeHitlTimeoutForChat(cfg && cfg.timeoutSeconds, DEFAULT_HITL_TIMEOUT_SECONDS), updatedAt: typeof (cfg && cfg.updatedAt) === 'string' ? cfg.updatedAt : '' }; - const key = conversationId ? getHitlStorageKeyByConversation(conversationId) : HITL_DRAFT_KEY; + const key = getHitlStorageKeyByConversation(conversationId); try { localStorage.setItem(key, JSON.stringify(payload)); - if (syncGlobalLast) { - saveHitlLastGlobalConfig(payload); - } } catch (e) { console.warn('saveHitlConfigForConversation failed', e); } @@ -752,8 +695,9 @@ async function waitForHitlConfigReady(conversationId) { await hitlConfigSyncPromise; return; } - if (!cid && window.csaiHitlDefaultReviewerReady && typeof window.csaiHitlDefaultReviewerReady.then === 'function') { - await window.csaiHitlDefaultReviewerReady.catch(function () {}); + const defaultReady = window.csaiHitlDefaultConfigReady || window.csaiHitlDefaultReviewerReady; + if (!cid && defaultReady && typeof defaultReady.then === 'function') { + await defaultReady.catch(function () {}); if (!currentConversationId) refreshHitlConfigByCurrentConversation(); } } @@ -818,6 +762,10 @@ async function applyHitlSidebarConfig() { await window.saveHitlConversationConfig(cid, cfg); const ok = typeof window.t === 'function' ? window.t('chat.hitlApplyOkSync') : '人机协同配置已保存并同步到服务器。'; showHitlApplyFeedback(ok, false); + } else if (typeof window.putHitlDefaultConfig === 'function') { + await window.putHitlDefaultConfig(cfg); + const okDefault = typeof window.t === 'function' ? window.t('chat.hitlApplyOkDefaultConfig') : '人机协同默认配置已写入 config.yaml 并生效。'; + showHitlApplyFeedback(okDefault, false); } else if (yamlMerged) { const okYaml = typeof window.t === 'function' ? window.t('chat.hitlApplyOkWhitelistYaml') : '免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。'; showHitlApplyFeedback(okYaml, false); @@ -913,6 +861,11 @@ function applyConversationAgentMode(conversationId, conversation) { if (typeof window !== 'undefined') { window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || []; + window.csaiHitlDefaultConfig = window.csaiHitlDefaultConfig || { + mode: HITL_MODE_OFF, + reviewer: 'human', + timeoutSeconds: DEFAULT_HITL_TIMEOUT_SECONDS + }; window.csaiHitlDefaultReviewer = window.csaiHitlDefaultReviewer || 'human'; window.csaiChatAgentMode = { EINO_MODES: CHAT_AGENT_EINO_MODES, @@ -934,7 +887,6 @@ if (typeof window !== 'undefined') { window.setHitlReviewerUI = setHitlReviewerUI; window.onHitlReviewerChanged = onHitlReviewerChanged; window.bindHitlReviewerToggleListeners = bindHitlReviewerToggleListeners; - window.getHitlLastGlobalConfig = getHitlLastGlobalConfig; window.hitlMergeToolsForDisplay = hitlMergeToolsForDisplay; window.hitlStripGlobalToolsFromFormString = hitlStripGlobalToolsFromFormString; window.hitlToolsSplitToArray = hitlToolsSplitToArray; @@ -5964,13 +5916,6 @@ async function startNewConversation(options = {}) { chatInput.value = ''; adjustTextareaHeight(chatInput); } - // 把当前侧栏人机协同选项写入草稿与「最近应用」记忆,避免刷新时被旧草稿里的「关闭」覆盖 - try { - if (typeof readHitlConfigFromForm === 'function' && typeof saveHitlConfigForConversation === 'function') { - const snap = readHitlConfigFromForm(); - saveHitlConfigForConversation('', snap, { syncGlobalLast: true }); - } - } catch (e) { /* ignore */ } refreshHitlConfigByCurrentConversation(); } diff --git a/web/static/js/hitl.js b/web/static/js/hitl.js index 800763c9..04a2c120 100644 --- a/web/static/js/hitl.js +++ b/web/static/js/hitl.js @@ -248,47 +248,89 @@ async function fetchHitlConversationConfig(conversationId) { if (!data || !data.hitl) return null; return { hitl: data.hitl, + defaultMode: hitlModeNormalize(data.defaultMode || 'off'), defaultReviewer: hitlReviewerNormalize(data.defaultReviewer || 'human'), + defaultTimeoutSeconds: normalizeHitlTimeoutSeconds(data.defaultTimeoutSeconds, 300), hitlGlobalToolWhitelist: Array.isArray(data.hitlGlobalToolWhitelist) ? data.hitlGlobalToolWhitelist : [] }; } function applyHitlDefaultReviewerFromServer(reviewer) { - const v = hitlReviewerNormalize(reviewer); + return applyHitlDefaultConfigFromServer({ defaultReviewer: reviewer }); +} + +function applyHitlDefaultConfigFromServer(data) { + const src = data && typeof data === 'object' ? data : {}; + const mode = hitlModeNormalize(src.defaultMode || src.mode || 'off'); + const reviewer = hitlReviewerNormalize(src.defaultReviewer || src.reviewer || 'human'); + const timeoutSeconds = normalizeHitlTimeoutSeconds( + src.defaultTimeoutSeconds != null ? src.defaultTimeoutSeconds : src.timeoutSeconds, + 300 + ); + const out = { + mode: mode, + reviewer: reviewer, + timeoutSeconds: timeoutSeconds + }; if (typeof window !== 'undefined') { - window.csaiHitlDefaultReviewer = v; + window.csaiHitlDefaultConfig = out; + window.csaiHitlDefaultReviewer = reviewer; + if (Array.isArray(src.hitlGlobalToolWhitelist)) { + window.csaiHitlGlobalToolWhitelist = src.hitlGlobalToolWhitelist; + } } - return v; + return out; +} + +async function fetchHitlDefaultConfig() { + const resp = await hitlApiFetch('/api/hitl/default-config', { credentials: 'same-origin' }); + if (!resp.ok) { + return applyHitlDefaultConfigFromServer({ defaultMode: 'off', defaultReviewer: 'human', defaultTimeoutSeconds: 300 }); + } + const data = await resp.json(); + return applyHitlDefaultConfigFromServer(data); } async function fetchHitlDefaultReviewer() { - const resp = await hitlApiFetch('/api/hitl/default-reviewer', { credentials: 'same-origin' }); - if (!resp.ok) { - return applyHitlDefaultReviewerFromServer('human'); - } - const data = await resp.json(); - return applyHitlDefaultReviewerFromServer(data && data.defaultReviewer); + const cfg = await fetchHitlDefaultConfig(); + return hitlReviewerNormalize(cfg && cfg.reviewer); } -async function putHitlDefaultReviewer(reviewer) { - const normalized = hitlReviewerNormalize(reviewer); - const resp = await hitlApiFetch('/api/hitl/default-reviewer', { +async function putHitlDefaultConfig(config) { + const current = (typeof window !== 'undefined' && window.csaiHitlDefaultConfig && typeof window.csaiHitlDefaultConfig === 'object') + ? window.csaiHitlDefaultConfig + : { mode: 'off', reviewer: 'human', timeoutSeconds: 300 }; + const cfg = config && typeof config === 'object' ? config : {}; + const payload = { + mode: hitlModeNormalize(cfg.mode != null ? cfg.mode : current.mode), + reviewer: hitlReviewerNormalize(cfg.reviewer != null ? cfg.reviewer : current.reviewer), + timeoutSeconds: normalizeHitlTimeoutSeconds( + cfg.timeoutSeconds != null ? cfg.timeoutSeconds : current.timeoutSeconds, + 300 + ) + }; + const resp = await hitlApiFetch('/api/hitl/default-config', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ reviewer: normalized }) + body: JSON.stringify(payload) }); if (!resp.ok) { const msg = await readHitlApiError(resp); throw new Error(msg || ('HTTP ' + resp.status)); } const data = await resp.json(); - return applyHitlDefaultReviewerFromServer(data && data.defaultReviewer); + return applyHitlDefaultConfigFromServer(data); +} + +async function putHitlDefaultReviewer(reviewer) { + const cfg = await putHitlDefaultConfig({ reviewer: reviewer }); + return hitlReviewerNormalize(cfg && cfg.reviewer); } async function initHitlDefaultReviewerFromServer() { try { - await fetchHitlDefaultReviewer(); + await fetchHitlDefaultConfig(); if (!getCurrentConversationIdForHitl() && typeof window.refreshHitlConfigByCurrentConversation === 'function') { window.refreshHitlConfigByCurrentConversation(); } @@ -535,10 +577,13 @@ async function syncHitlConfigFromServer(conversationId) { const pack = await fetchHitlConversationConfig(conversationId); if (!pack || !pack.hitl) return; const cfg = pack.hitl; - if (pack.defaultReviewer) { - applyHitlDefaultReviewerFromServer(pack.defaultReviewer); - } const globalWL = pack.hitlGlobalToolWhitelist || []; + applyHitlDefaultConfigFromServer({ + defaultMode: pack.defaultMode, + defaultReviewer: pack.defaultReviewer, + defaultTimeoutSeconds: pack.defaultTimeoutSeconds, + hitlGlobalToolWhitelist: globalWL + }); if (typeof window !== 'undefined') { window.csaiHitlGlobalToolWhitelist = globalWL; } @@ -1820,7 +1865,8 @@ document.addEventListener('DOMContentLoaded', function () { if (typeof window.bindHitlReviewerToggleListeners === 'function') { window.bindHitlReviewerToggleListeners(); } - window.csaiHitlDefaultReviewerReady = initHitlDefaultReviewerFromServer(); + window.csaiHitlDefaultConfigReady = initHitlDefaultReviewerFromServer(); + window.csaiHitlDefaultReviewerReady = window.csaiHitlDefaultConfigReady; setTimeout(reconcileHitlUiState, 0); }); @@ -1836,6 +1882,8 @@ document.addEventListener('languagechange', function () { window.syncHitlConfigToServerByCurrentConversation = syncHitlConfigToServerByCurrentConversation; window.saveHitlConversationConfig = saveHitlConversationConfig; window.mergeHitlGlobalToolWhitelist = mergeHitlGlobalToolWhitelist; +window.fetchHitlDefaultConfig = fetchHitlDefaultConfig; +window.putHitlDefaultConfig = putHitlDefaultConfig; // 由 chat.js 在 loadConversation 内 await 调用;挂到 window 供其它入口显式触发 window.syncHitlConfigFromServer = syncHitlConfigFromServer;