feat: persist hitl default config

This commit is contained in:
temp
2026-08-24 19:45:07 +08:00
parent e0a2f01427
commit 21c6ad9bdf
12 changed files with 291 additions and 114 deletions
+5 -1
View File
@@ -135,8 +135,12 @@ agent:
# approval → audit_agent_prompt # approval → audit_agent_prompt
# review_edit → audit_agent_prompt_review_edit(可改参后放行) # review_edit → audit_agent_prompt_review_edit(可改参后放行)
hitl: hitl:
# 全局默认审批方:human=人工审批,audit_agent=审计 Agent;未选会话时切换会写入本项,重启后仍生效 # 全局默认人机协同模式:off=关闭,approval=审批模式,review_edit=审查编辑;新建会话无独立配置时沿用
default_mode: off
# 全局默认审批方:human=人工审批,audit_agent=审计 Agent;新建会话无独立配置时沿用
default_reviewer: human default_reviewer: human
# 全局默认审批等待时限(秒):300=5分钟,0=不限时;新建会话无独立配置时沿用
default_timeout_seconds: 300
# 审计 Agent 专用模型;字段留空则复用上方 openai 配置。建议 model 填小模型,用于降低审批成本。 # 审计 Agent 专用模型;字段留空则复用上方 openai 配置。建议 model 填小模型,用于降低审批成本。
audit_model: audit_model:
provider: "" # openai / claude;留空跟随 openai.provider provider: "" # openai / claude;留空跟随 openai.provider
+2
View File
@@ -972,6 +972,8 @@ func setupRoutes(
protected.GET("/hitl/tool-whitelist", agentHandler.GetHITLGlobalToolWhitelist) protected.GET("/hitl/tool-whitelist", agentHandler.GetHITLGlobalToolWhitelist)
protected.PUT("/hitl/tool-whitelist", agentHandler.SetHITLGlobalToolWhitelist) protected.PUT("/hitl/tool-whitelist", agentHandler.SetHITLGlobalToolWhitelist)
protected.POST("/hitl/tool-whitelist", agentHandler.MergeHITLGlobalToolWhitelist) 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.GET("/hitl/default-reviewer", agentHandler.GetHITLDefaultReviewer)
protected.PUT("/hitl/default-reviewer", agentHandler.UpdateHITLDefaultReviewer) protected.PUT("/hitl/default-reviewer", agentHandler.UpdateHITLDefaultReviewer)
protected.GET("/hitl/audit-strategy", agentHandler.GetHITLAuditStrategy) protected.GET("/hitl/audit-strategy", agentHandler.GetHITLAuditStrategy)
+28 -1
View File
@@ -1062,8 +1062,24 @@ type HitlConfig struct {
AuditAgentPromptReviewEdit string `yaml:"audit_agent_prompt_review_edit,omitempty" json:"audit_agent_prompt_review_edit,omitempty"` AuditAgentPromptReviewEdit string `yaml:"audit_agent_prompt_review_edit,omitempty" json:"audit_agent_prompt_review_edit,omitempty"`
// RetentionDays 已决策审计日志(hitl_interrupts 非 pending)保留天数;省略时默认 90;0 表示不自动清理。 // RetentionDays 已决策审计日志(hitl_interrupts 非 pending)保留天数;省略时默认 90;0 表示不自动清理。
RetentionDays *int `yaml:"retention_days,omitempty" json:"retention_days,omitempty"` 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"` 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. // 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. // RetentionDaysEffective returns retention; 0 means keep forever; omitted defaults to 90.
func (h HitlConfig) RetentionDaysEffective() int { func (h HitlConfig) RetentionDaysEffective() int {
if h.RetentionDays == nil { if h.RetentionDays == nil {
+23
View File
@@ -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) { func TestLoadUsesAIDefaultChannelAsRuntimeOpenAI(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "config.yaml") path := filepath.Join(dir, "config.yaml")
+32 -2
View File
@@ -315,12 +315,13 @@ func (h *AgentHandler) SetHitlToolWhitelistSaver(s HitlToolWhitelistSaver) {
h.hitlWhitelistSaver = s h.hitlWhitelistSaver = s
} }
// HitlDefaultReviewerSaver 持久化全局默认审批方到 config.yaml。 // HitlDefaultReviewerSaver 持久化全局默认人机协同配置到 config.yaml。
type HitlDefaultReviewerSaver interface { type HitlDefaultReviewerSaver interface {
UpdateHitlDefaultConfig(mode, reviewer string, timeoutSeconds int) error
UpdateHitlDefaultReviewer(reviewer string) error UpdateHitlDefaultReviewer(reviewer string) error
} }
// SetHitlDefaultReviewerSaver 设置 HITL 默认审批方落盘。 // SetHitlDefaultReviewerSaver 设置 HITL 默认配置落盘。
func (h *AgentHandler) SetHitlDefaultReviewerSaver(s HitlDefaultReviewerSaver) { func (h *AgentHandler) SetHitlDefaultReviewerSaver(s HitlDefaultReviewerSaver) {
h.hitlDefaultReviewerSaver = s h.hitlDefaultReviewerSaver = s
} }
@@ -332,6 +333,35 @@ func (h *AgentHandler) hitlEffectiveDefaultReviewer() string {
return "human" 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 危险任务门控:与会话侧人机协同及免审批白名单判定一致。 // HITLNeedsToolApproval 供 C2 危险任务门控:与会话侧人机协同及免审批白名单判定一致。
func (h *AgentHandler) HITLNeedsToolApproval(conversationID, toolName string) bool { func (h *AgentHandler) HITLNeedsToolApproval(conversationID, toolName string) bool {
if h == nil || h.hitlManager == nil { if h == nil || h.hitlManager == nil {
+30
View File
@@ -891,7 +891,14 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
if req.Hitl != nil { if req.Hitl != nil {
h.config.Hitl.AuditModel = req.Hitl.AuditModel h.config.Hitl.AuditModel = req.Hitl.AuditModel
h.config.Hitl.ToolWhitelist = mergeHitlToolWhitelistSlice(nil, req.Hitl.ToolWhitelist) 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() 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.AuditAgentPrompt = strings.TrimSpace(req.Hitl.AuditAgentPrompt)
h.config.Hitl.AuditAgentPromptReviewEdit = strings.TrimSpace(req.Hitl.AuditAgentPromptReviewEdit) h.config.Hitl.AuditAgentPromptReviewEdit = strings.TrimSpace(req.Hitl.AuditAgentPromptReviewEdit)
if req.Hitl.RetentionDays != nil { if req.Hitl.RetentionDays != nil {
@@ -2141,12 +2148,35 @@ func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) {
setStringInMap(auditModelNode, "model", cfg.AuditModel.Model) setStringInMap(auditModelNode, "model", cfg.AuditModel.Model)
// flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数 // flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数
setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist) setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist)
setStringInMap(hitlNode, "default_mode", cfg.EffectiveDefaultMode())
setStringInMap(hitlNode, "default_reviewer", cfg.EffectiveDefaultReviewer()) setStringInMap(hitlNode, "default_reviewer", cfg.EffectiveDefaultReviewer())
setIntInMap(hitlNode, "default_timeout_seconds", cfg.EffectiveDefaultTimeoutSeconds())
setIntInMap(hitlNode, "retention_days", cfg.RetentionDaysEffective()) setIntInMap(hitlNode, "retention_days", cfg.RetentionDaysEffective())
setStringInMap(hitlNode, "audit_agent_prompt", cfg.AuditAgentPrompt) setStringInMap(hitlNode, "audit_agent_prompt", cfg.AuditAgentPrompt)
setStringInMap(hitlNode, "audit_agent_prompt_review_edit", cfg.AuditAgentPromptReviewEdit) 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。 // UpdateHitlDefaultReviewer 更新全局默认审批方并写入 config.yaml。
func (h *ConfigHandler) UpdateHitlDefaultReviewer(reviewer string) error { func (h *ConfigHandler) UpdateHitlDefaultReviewer(reviewer string) error {
h.mu.Lock() h.mu.Lock()
+74 -8
View File
@@ -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) { func (m *HITLManager) ActivateConversation(conversationID string, req *HITLRequest) {
if req == nil || !req.Enabled { if req == nil || !req.Enabled {
m.DeactivateConversation(conversationID) m.DeactivateConversation(conversationID)
@@ -629,7 +641,7 @@ func (h *AgentHandler) loadHITLConversationConfig(conversationID string) (*HITLR
return nil, err return nil, err
} }
if !has { if !has {
cfg.Reviewer = h.hitlEffectiveDefaultReviewer() return h.hitlEffectiveDefaultRequest(), nil
} }
return cfg, nil return cfg, nil
} }
@@ -994,7 +1006,9 @@ func (h *AgentHandler) GetHITLConversationConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"conversationId": conversationID, "conversationId": conversationID,
"hitl": cfg, "hitl": cfg,
"defaultMode": h.hitlEffectiveDefaultMode(),
"defaultReviewer": h.hitlEffectiveDefaultReviewer(), "defaultReviewer": h.hitlEffectiveDefaultReviewer(),
"defaultTimeoutSeconds": h.hitlEffectiveDefaultTimeoutSeconds(),
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(), "hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
}) })
} }
@@ -1051,11 +1065,64 @@ type setHitlDefaultReviewerReq struct {
Reviewer string `json:"reviewer"` 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 中的全局默认审批方。 // GetHITLDefaultReviewer 返回 config.yaml 中的全局默认审批方。
func (h *AgentHandler) GetHITLDefaultReviewer(c *gin.Context) { func (h *AgentHandler) GetHITLDefaultReviewer(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, h.hitlDefaultConfigResponse())
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
})
} }
// UpdateHITLDefaultReviewer 将全局默认审批方写入 config.yaml(未选会话时切换审批方)。 // UpdateHITLDefaultReviewer 将全局默认审批方写入 config.yaml(未选会话时切换审批方)。
@@ -1081,10 +1148,9 @@ func (h *AgentHandler) UpdateHITLDefaultReviewer(c *gin.Context) {
if h.audit != nil { if h.audit != nil {
h.audit.RecordOK(c, "hitl", "default_reviewer_update", "HITL 全局默认审批方更新", "hitl_config", "default_reviewer", nil) h.audit.RecordOK(c, "hitl", "default_reviewer_update", "HITL 全局默认审批方更新", "hitl_config", "default_reviewer", nil)
} }
c.JSON(http.StatusOK, gin.H{ out := h.hitlDefaultConfigResponse()
"ok": true, out["ok"] = true
"defaultReviewer": reviewer, c.JSON(http.StatusOK, out)
})
} }
// SetHITLGlobalToolWhitelist 整表替换 config.yaml 中的全局免审批工具白名单。 // SetHITLGlobalToolWhitelist 整表替换 config.yaml 中的全局免审批工具白名单。
+1 -1
View File
@@ -217,7 +217,7 @@ func resourceAllowed(c *gin.Context, db *database.DB) bool {
return session.Scope == database.RBACScopeAll return session.Scope == database.RBACScopeAll
case strings.HasPrefix(path, "/c2/profiles") && c.Request.Method != http.MethodGet: case strings.HasPrefix(path, "/c2/profiles") && c.Request.Method != http.MethodGet:
return session.Scope == database.RBACScopeAll 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 return session.Scope == database.RBACScopeAll
case isMutationMethod(c.Request.Method) && isProcessGlobalMutationPath(path): case isMutationMethod(c.Request.Method) && isProcessGlobalMutationPath(path):
// These definitions/configurations are shared by every user and do not // These definitions/configurations are shared by every user and do not
+1
View File
@@ -819,6 +819,7 @@
"hitlWhitelistHint": "Separate with commas or new lines; shown merged with the global allowlist in config.", "hitlWhitelistHint": "Separate with commas or new lines; shown merged with the global allowlist in config.",
"hitlApply": "Apply", "hitlApply": "Apply",
"hitlApplyOkSync": "HITL settings saved and synced to the server.", "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.", "hitlApplyOkWhitelistYaml": "Tool whitelist merged into config.yaml and active. Session settings are saved automatically.",
"hitlApplyOkLocal": "Saved in this browser.", "hitlApplyOkLocal": "Saved in this browser.",
"hitlApplyFail": "Failed to sync to server", "hitlApplyFail": "Failed to sync to server",
+1
View File
@@ -807,6 +807,7 @@
"hitlWhitelistHint": "白名单内工具免审批;每行一个或逗号分隔,与 config 全局白名单合并。", "hitlWhitelistHint": "白名单内工具免审批;每行一个或逗号分隔,与 config 全局白名单合并。",
"hitlApply": "应用", "hitlApply": "应用",
"hitlApplyOkSync": "人机协同配置已保存并同步到服务器。", "hitlApplyOkSync": "人机协同配置已保存并同步到服务器。",
"hitlApplyOkDefaultConfig": "人机协同默认配置已写入 config.yaml 并生效。",
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。", "hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。",
"hitlApplyOkLocal": "已保存到本浏览器。", "hitlApplyOkLocal": "已保存到本浏览器。",
"hitlApplyFail": "同步到服务器失败", "hitlApplyFail": "同步到服务器失败",
+27 -82
View File
@@ -154,9 +154,6 @@ const chatSystemModelCache = new Map();
// 人机协同(HITL)会话级配置 // 人机协同(HITL)会话级配置
const HITL_STORAGE_PREFIX = 'cyberstrike-chat-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_OFF = 'off';
const HITL_MODE_APPROVAL = 'approval'; const HITL_MODE_APPROVAL = 'approval';
const HITL_MODE_REVIEW_EDIT = 'review_edit'; const HITL_MODE_REVIEW_EDIT = 'review_edit';
@@ -432,14 +429,17 @@ function normalizeHitlTimeoutForChat(value, fallback) {
} }
function defaultHitlConfig() { 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 ? window.csaiHitlDefaultReviewer
: 'human'; : 'human');
return { return {
mode: HITL_MODE_OFF, mode: normalizeHitlMode(serverDefault.mode || HITL_MODE_OFF),
reviewer: normalizeHitlReviewer(serverReviewer), reviewer: normalizeHitlReviewer(serverReviewer),
sensitiveTools: DEFAULT_HITL_SESSION_TOOL_WHITELIST, sensitiveTools: DEFAULT_HITL_SESSION_TOOL_WHITELIST,
timeoutSeconds: DEFAULT_HITL_TIMEOUT_SECONDS, timeoutSeconds: normalizeHitlTimeoutForChat(serverDefault.timeoutSeconds, DEFAULT_HITL_TIMEOUT_SECONDS),
updatedAt: '' 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) { function getHitlConfigForConversation(conversationId) {
const fallback = defaultHitlConfig(); const fallback = defaultHitlConfig();
const cid = conversationId ? String(conversationId).trim() : ''; const cid = conversationId ? String(conversationId).trim() : '';
if (!cid) { if (!cid) {
const globalLast = getHitlLastGlobalConfig(); return fallback;
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;
} }
const key = getHitlStorageKeyByConversation(cid); const key = getHitlStorageKeyByConversation(cid);
try { try {
@@ -627,6 +568,8 @@ async function onHitlReviewerChanged(reviewer) {
try { try {
if (cid && typeof window.saveHitlConversationConfig === 'function') { if (cid && typeof window.saveHitlConversationConfig === 'function') {
await window.saveHitlConversationConfig(cid, cfg); await window.saveHitlConversationConfig(cid, cfg);
} else if (typeof window.putHitlDefaultConfig === 'function') {
await window.putHitlDefaultConfig(cfg);
} else if (typeof window.putHitlDefaultReviewer === 'function') { } else if (typeof window.putHitlDefaultReviewer === 'function') {
await window.putHitlDefaultReviewer(cfg.reviewer); await window.putHitlDefaultReviewer(cfg.reviewer);
} }
@@ -652,7 +595,10 @@ function bindHitlReviewerToggleListeners() {
} }
function saveHitlConfigForConversation(conversationId, cfg, opts) { function saveHitlConfigForConversation(conversationId, cfg, opts) {
const syncGlobalLast = !!(opts && opts.syncGlobalLast); void opts;
if (!conversationId) {
return;
}
const payload = { const payload = {
mode: normalizeHitlMode(cfg && cfg.mode), mode: normalizeHitlMode(cfg && cfg.mode),
reviewer: normalizeHitlReviewer(cfg && cfg.reviewer), reviewer: normalizeHitlReviewer(cfg && cfg.reviewer),
@@ -660,12 +606,9 @@ function saveHitlConfigForConversation(conversationId, cfg, opts) {
timeoutSeconds: normalizeHitlTimeoutForChat(cfg && cfg.timeoutSeconds, DEFAULT_HITL_TIMEOUT_SECONDS), timeoutSeconds: normalizeHitlTimeoutForChat(cfg && cfg.timeoutSeconds, DEFAULT_HITL_TIMEOUT_SECONDS),
updatedAt: typeof (cfg && cfg.updatedAt) === 'string' ? cfg.updatedAt : '' updatedAt: typeof (cfg && cfg.updatedAt) === 'string' ? cfg.updatedAt : ''
}; };
const key = conversationId ? getHitlStorageKeyByConversation(conversationId) : HITL_DRAFT_KEY; const key = getHitlStorageKeyByConversation(conversationId);
try { try {
localStorage.setItem(key, JSON.stringify(payload)); localStorage.setItem(key, JSON.stringify(payload));
if (syncGlobalLast) {
saveHitlLastGlobalConfig(payload);
}
} catch (e) { } catch (e) {
console.warn('saveHitlConfigForConversation failed', e); console.warn('saveHitlConfigForConversation failed', e);
} }
@@ -752,8 +695,9 @@ async function waitForHitlConfigReady(conversationId) {
await hitlConfigSyncPromise; await hitlConfigSyncPromise;
return; return;
} }
if (!cid && window.csaiHitlDefaultReviewerReady && typeof window.csaiHitlDefaultReviewerReady.then === 'function') { const defaultReady = window.csaiHitlDefaultConfigReady || window.csaiHitlDefaultReviewerReady;
await window.csaiHitlDefaultReviewerReady.catch(function () {}); if (!cid && defaultReady && typeof defaultReady.then === 'function') {
await defaultReady.catch(function () {});
if (!currentConversationId) refreshHitlConfigByCurrentConversation(); if (!currentConversationId) refreshHitlConfigByCurrentConversation();
} }
} }
@@ -818,6 +762,10 @@ async function applyHitlSidebarConfig() {
await window.saveHitlConversationConfig(cid, cfg); await window.saveHitlConversationConfig(cid, cfg);
const ok = typeof window.t === 'function' ? window.t('chat.hitlApplyOkSync') : '人机协同配置已保存并同步到服务器。'; const ok = typeof window.t === 'function' ? window.t('chat.hitlApplyOkSync') : '人机协同配置已保存并同步到服务器。';
showHitlApplyFeedback(ok, false); 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) { } else if (yamlMerged) {
const okYaml = typeof window.t === 'function' ? window.t('chat.hitlApplyOkWhitelistYaml') : '免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。'; const okYaml = typeof window.t === 'function' ? window.t('chat.hitlApplyOkWhitelistYaml') : '免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。';
showHitlApplyFeedback(okYaml, false); showHitlApplyFeedback(okYaml, false);
@@ -913,6 +861,11 @@ function applyConversationAgentMode(conversationId, conversation) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || []; 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.csaiHitlDefaultReviewer = window.csaiHitlDefaultReviewer || 'human';
window.csaiChatAgentMode = { window.csaiChatAgentMode = {
EINO_MODES: CHAT_AGENT_EINO_MODES, EINO_MODES: CHAT_AGENT_EINO_MODES,
@@ -934,7 +887,6 @@ if (typeof window !== 'undefined') {
window.setHitlReviewerUI = setHitlReviewerUI; window.setHitlReviewerUI = setHitlReviewerUI;
window.onHitlReviewerChanged = onHitlReviewerChanged; window.onHitlReviewerChanged = onHitlReviewerChanged;
window.bindHitlReviewerToggleListeners = bindHitlReviewerToggleListeners; window.bindHitlReviewerToggleListeners = bindHitlReviewerToggleListeners;
window.getHitlLastGlobalConfig = getHitlLastGlobalConfig;
window.hitlMergeToolsForDisplay = hitlMergeToolsForDisplay; window.hitlMergeToolsForDisplay = hitlMergeToolsForDisplay;
window.hitlStripGlobalToolsFromFormString = hitlStripGlobalToolsFromFormString; window.hitlStripGlobalToolsFromFormString = hitlStripGlobalToolsFromFormString;
window.hitlToolsSplitToArray = hitlToolsSplitToArray; window.hitlToolsSplitToArray = hitlToolsSplitToArray;
@@ -5964,13 +5916,6 @@ async function startNewConversation(options = {}) {
chatInput.value = ''; chatInput.value = '';
adjustTextareaHeight(chatInput); adjustTextareaHeight(chatInput);
} }
// 把当前侧栏人机协同选项写入草稿与「最近应用」记忆,避免刷新时被旧草稿里的「关闭」覆盖
try {
if (typeof readHitlConfigFromForm === 'function' && typeof saveHitlConfigForConversation === 'function') {
const snap = readHitlConfigFromForm();
saveHitlConfigForConversation('', snap, { syncGlobalLast: true });
}
} catch (e) { /* ignore */ }
refreshHitlConfigByCurrentConversation(); refreshHitlConfigByCurrentConversation();
} }
+67 -19
View File
@@ -248,47 +248,89 @@ async function fetchHitlConversationConfig(conversationId) {
if (!data || !data.hitl) return null; if (!data || !data.hitl) return null;
return { return {
hitl: data.hitl, hitl: data.hitl,
defaultMode: hitlModeNormalize(data.defaultMode || 'off'),
defaultReviewer: hitlReviewerNormalize(data.defaultReviewer || 'human'), defaultReviewer: hitlReviewerNormalize(data.defaultReviewer || 'human'),
defaultTimeoutSeconds: normalizeHitlTimeoutSeconds(data.defaultTimeoutSeconds, 300),
hitlGlobalToolWhitelist: Array.isArray(data.hitlGlobalToolWhitelist) ? data.hitlGlobalToolWhitelist : [] hitlGlobalToolWhitelist: Array.isArray(data.hitlGlobalToolWhitelist) ? data.hitlGlobalToolWhitelist : []
}; };
} }
function applyHitlDefaultReviewerFromServer(reviewer) { 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') { 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() { async function fetchHitlDefaultReviewer() {
const resp = await hitlApiFetch('/api/hitl/default-reviewer', { credentials: 'same-origin' }); const cfg = await fetchHitlDefaultConfig();
if (!resp.ok) { return hitlReviewerNormalize(cfg && cfg.reviewer);
return applyHitlDefaultReviewerFromServer('human');
}
const data = await resp.json();
return applyHitlDefaultReviewerFromServer(data && data.defaultReviewer);
} }
async function putHitlDefaultReviewer(reviewer) { async function putHitlDefaultConfig(config) {
const normalized = hitlReviewerNormalize(reviewer); const current = (typeof window !== 'undefined' && window.csaiHitlDefaultConfig && typeof window.csaiHitlDefaultConfig === 'object')
const resp = await hitlApiFetch('/api/hitl/default-reviewer', { ? 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', method: 'PUT',
credentials: 'same-origin', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reviewer: normalized }) body: JSON.stringify(payload)
}); });
if (!resp.ok) { if (!resp.ok) {
const msg = await readHitlApiError(resp); const msg = await readHitlApiError(resp);
throw new Error(msg || ('HTTP ' + resp.status)); throw new Error(msg || ('HTTP ' + resp.status));
} }
const data = await resp.json(); 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() { async function initHitlDefaultReviewerFromServer() {
try { try {
await fetchHitlDefaultReviewer(); await fetchHitlDefaultConfig();
if (!getCurrentConversationIdForHitl() && typeof window.refreshHitlConfigByCurrentConversation === 'function') { if (!getCurrentConversationIdForHitl() && typeof window.refreshHitlConfigByCurrentConversation === 'function') {
window.refreshHitlConfigByCurrentConversation(); window.refreshHitlConfigByCurrentConversation();
} }
@@ -535,10 +577,13 @@ async function syncHitlConfigFromServer(conversationId) {
const pack = await fetchHitlConversationConfig(conversationId); const pack = await fetchHitlConversationConfig(conversationId);
if (!pack || !pack.hitl) return; if (!pack || !pack.hitl) return;
const cfg = pack.hitl; const cfg = pack.hitl;
if (pack.defaultReviewer) {
applyHitlDefaultReviewerFromServer(pack.defaultReviewer);
}
const globalWL = pack.hitlGlobalToolWhitelist || []; const globalWL = pack.hitlGlobalToolWhitelist || [];
applyHitlDefaultConfigFromServer({
defaultMode: pack.defaultMode,
defaultReviewer: pack.defaultReviewer,
defaultTimeoutSeconds: pack.defaultTimeoutSeconds,
hitlGlobalToolWhitelist: globalWL
});
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.csaiHitlGlobalToolWhitelist = globalWL; window.csaiHitlGlobalToolWhitelist = globalWL;
} }
@@ -1820,7 +1865,8 @@ document.addEventListener('DOMContentLoaded', function () {
if (typeof window.bindHitlReviewerToggleListeners === 'function') { if (typeof window.bindHitlReviewerToggleListeners === 'function') {
window.bindHitlReviewerToggleListeners(); window.bindHitlReviewerToggleListeners();
} }
window.csaiHitlDefaultReviewerReady = initHitlDefaultReviewerFromServer(); window.csaiHitlDefaultConfigReady = initHitlDefaultReviewerFromServer();
window.csaiHitlDefaultReviewerReady = window.csaiHitlDefaultConfigReady;
setTimeout(reconcileHitlUiState, 0); setTimeout(reconcileHitlUiState, 0);
}); });
@@ -1836,6 +1882,8 @@ document.addEventListener('languagechange', function () {
window.syncHitlConfigToServerByCurrentConversation = syncHitlConfigToServerByCurrentConversation; window.syncHitlConfigToServerByCurrentConversation = syncHitlConfigToServerByCurrentConversation;
window.saveHitlConversationConfig = saveHitlConversationConfig; window.saveHitlConversationConfig = saveHitlConversationConfig;
window.mergeHitlGlobalToolWhitelist = mergeHitlGlobalToolWhitelist; window.mergeHitlGlobalToolWhitelist = mergeHitlGlobalToolWhitelist;
window.fetchHitlDefaultConfig = fetchHitlDefaultConfig;
window.putHitlDefaultConfig = putHitlDefaultConfig;
// 由 chat.js 在 loadConversation 内 await 调用;挂到 window 供其它入口显式触发 // 由 chat.js 在 loadConversation 内 await 调用;挂到 window 供其它入口显式触发
window.syncHitlConfigFromServer = syncHitlConfigFromServer; window.syncHitlConfigFromServer = syncHitlConfigFromServer;