Compare commits

...
11 Commits
Author SHA1 Message Date
公明andGitHub 41f683ce6c Update config.example.yaml 2026-07-12 13:27:31 +08:00
公明andGitHub ffae94fb2c Add files via upload 2026-07-12 13:21:20 +08:00
公明andGitHub fe3c845ff8 Add files via upload 2026-07-12 13:20:08 +08:00
公明andGitHub 0f1a6ad25a Add files via upload 2026-07-12 13:17:37 +08:00
公明andGitHub 3b3f73461b Add files via upload 2026-07-12 13:15:20 +08:00
公明andGitHub 3ce80fd00f Add files via upload 2026-07-12 13:14:23 +08:00
公明andGitHub 08cb8a68fb Add files via upload 2026-07-12 13:13:00 +08:00
公明andGitHub 2f38693891 Add files via upload 2026-07-12 13:10:56 +08:00
公明andGitHub 6fa17a3093 Add files via upload 2026-07-12 13:09:12 +08:00
公明andGitHub 46cea9459a Add files via upload 2026-07-12 13:07:20 +08:00
公明andGitHub 987bd0a03c Add files via upload 2026-07-11 12:10:57 +08:00
22 changed files with 1019 additions and 175 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.7.1"
version: "v1.7.2"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
+16
View File
@@ -325,6 +325,7 @@ func (db *DB) initTables() error {
session_key TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
role_name TEXT NOT NULL DEFAULT '默认',
agent_mode TEXT NOT NULL DEFAULT 'eino_single',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);`
@@ -750,6 +751,9 @@ func (db *DB) initTables() error {
if _, err := db.Exec(createRobotUserSessionsTable); err != nil {
return fmt.Errorf("创建robot_user_sessions表失败: %w", err)
}
if err := db.migrateRobotUserSessionsTable(); err != nil {
return fmt.Errorf("迁移robot_user_sessions表失败: %w", err)
}
if _, err := db.Exec(createProjectsTable); err != nil {
return fmt.Errorf("创建projects表失败: %w", err)
@@ -873,6 +877,18 @@ func (db *DB) initTables() error {
return nil
}
func (db *DB) migrateRobotUserSessionsTable() error {
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('robot_user_sessions') WHERE name='agent_mode'").Scan(&count); err != nil {
return err
}
if count == 0 {
_, err := db.Exec("ALTER TABLE robot_user_sessions ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'")
return err
}
return nil
}
// migrateMessagesTable 迁移 messages 表,补充 updated_at 字段。
// 语义:updated_at 表示该条消息最后一次被写入/更新的时间(例如助手占位消息在任务结束时更新正文)。
func (db *DB) migrateMessagesTable() error {
+15 -6
View File
@@ -12,6 +12,7 @@ type RobotSessionBinding struct {
SessionKey string
ConversationID string
RoleName string
AgentMode string
UpdatedAt time.Time
}
@@ -24,9 +25,9 @@ func (db *DB) GetRobotSessionBinding(sessionKey string) (*RobotSessionBinding, e
var b RobotSessionBinding
var updatedAt string
err := db.QueryRow(
"SELECT session_key, conversation_id, role_name, updated_at FROM robot_user_sessions WHERE session_key = ?",
"SELECT session_key, conversation_id, role_name, agent_mode, updated_at FROM robot_user_sessions WHERE session_key = ?",
sessionKey,
).Scan(&b.SessionKey, &b.ConversationID, &b.RoleName, &updatedAt)
).Scan(&b.SessionKey, &b.ConversationID, &b.RoleName, &b.AgentMode, &updatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
@@ -43,28 +44,36 @@ func (db *DB) GetRobotSessionBinding(sessionKey string) (*RobotSessionBinding, e
if strings.TrimSpace(b.RoleName) == "" {
b.RoleName = "默认"
}
if strings.TrimSpace(b.AgentMode) == "" {
b.AgentMode = "eino_single"
}
return &b, nil
}
// UpsertRobotSessionBinding 写入或更新机器人会话绑定(包含角色)。
func (db *DB) UpsertRobotSessionBinding(sessionKey, conversationID, roleName string) error {
func (db *DB) UpsertRobotSessionBinding(sessionKey, conversationID, roleName, agentMode string) error {
sessionKey = strings.TrimSpace(sessionKey)
conversationID = strings.TrimSpace(conversationID)
roleName = strings.TrimSpace(roleName)
agentMode = strings.TrimSpace(agentMode)
if sessionKey == "" || conversationID == "" {
return nil
}
if roleName == "" {
roleName = "默认"
}
if agentMode == "" {
agentMode = "eino_single"
}
_, err := db.Exec(`
INSERT INTO robot_user_sessions (session_key, conversation_id, role_name, updated_at)
VALUES (?, ?, ?, ?)
INSERT INTO robot_user_sessions (session_key, conversation_id, role_name, agent_mode, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_key) DO UPDATE SET
conversation_id = excluded.conversation_id,
role_name = excluded.role_name,
agent_mode = excluded.agent_mode,
updated_at = excluded.updated_at
`, sessionKey, conversationID, roleName, time.Now())
`, sessionKey, conversationID, roleName, agentMode, time.Now())
if err != nil {
return fmt.Errorf("写入机器人会话绑定失败: %w", err)
}
+6 -11
View File
@@ -729,7 +729,7 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry(
}
// ProcessMessageForRobot 供机器人(企业微信/钉钉/飞书)调用:Eino 单/多代理执行路径(含 progressCallback、过程详情),仅不发送 SSE,最后返回完整回复
func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform string, principal authctx.Principal, conversationID, message, role string) (response string, convID string, err error) {
func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform string, principal authctx.Principal, conversationID, message, role, agentMode string) (response string, convID string, err error) {
ownerUserID := strings.TrimSpace(principal.UserID)
if ownerUserID == "" {
return "", "", fmt.Errorf("authenticated robot principal is required")
@@ -814,18 +814,14 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform stri
}
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, nil)
robotMode := "eino_single"
if h.config != nil {
robotMode = config.NormalizeRobotAgentMode(h.config.MultiAgent)
}
robotMode := config.NormalizeAgentMode(agentMode)
switch robotMode {
case "eino_single":
return h.runRobotEinoSingleWithRetry(taskCtx, conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus)
case "deep", "plan_execute", "supervisor":
if h.config == nil || !h.config.MultiAgent.Enabled {
h.logger.Warn("机器人配置为多代理模式但未启用 multi_agent,回退 Eino 单代理",
zap.String("robot_mode", robotMode))
return h.runRobotEinoSingleWithRetry(taskCtx, conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus)
taskStatus = "failed"
return "", conversationID, fmt.Errorf("机器人对话模式 %s 需要启用 Eino 多代理", robotMode)
}
return h.runRobotMultiAgentWithRetry(taskCtx, conversationID, finalMessage, robotMode, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus)
}
@@ -1611,9 +1607,8 @@ func (h *AgentHandler) SubscribeAgentTaskEvents(c *gin.Context) {
flusher, _ := c.Writer.(http.Flusher)
ctx := c.Request.Context()
var writeMu sync.Mutex
stopKeepalive := make(chan struct{})
go sseKeepalive(c, stopKeepalive, &writeMu)
defer close(stopKeepalive)
stopKeepalive := runSSEKeepalive(c, &writeMu)
defer stopKeepalive()
for {
select {
+2 -3
View File
@@ -139,9 +139,8 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
"conversationId": conversationID,
})
stopKeepalive := make(chan struct{})
go sseKeepalive(c, stopKeepalive, &sseWriteMu)
defer close(stopKeepalive)
stopKeepalive := runSSEKeepalive(c, &sseWriteMu)
defer stopKeepalive()
if h.config == nil {
taskStatus = "failed"
+1 -1
View File
@@ -69,7 +69,7 @@ func (h *MonitorHandler) SetAgentHandler(ah *AgentHandler) {
h.agentHandler = ah
}
const monitorPageTopTools = 6
const monitorPageTopTools = 3
// MonitorStatsSummary 工具调用汇总
type MonitorStatsSummary struct {
+2 -3
View File
@@ -156,9 +156,8 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
"conversationId": conversationID,
})
stopKeepalive := make(chan struct{})
go sseKeepalive(c, stopKeepalive, &sseWriteMu)
defer close(stopKeepalive)
stopKeepalive := runSSEKeepalive(c, &sseWriteMu)
defer stopKeepalive()
var result *multiagent.RunResult
var runErr error
+402 -63
View File
@@ -41,11 +41,14 @@ const (
robotCmdContinue = "继续"
robotCmdNew = "新对话"
robotCmdClear = "清空"
robotCmdCurrent = "当前"
robotCmdStatus = "状态"
robotCmdStop = "停止"
robotCmdRoles = "角色"
robotCmdRolesList = "角色列表"
robotCmdSwitchRole = "切换角色"
robotCmdModes = "模式"
robotCmdModesList = "模式列表"
robotCmdSwitchMode = "切换模式"
robotCmdDelete = "删除"
robotCmdVersion = "版本"
robotCmdProjects = "项目"
@@ -56,35 +59,51 @@ const (
robotCmdBindUser = "绑定"
robotCmdUnbindUser = "解绑"
robotCmdIdentity = "身份"
robotCmdTask = "任务"
robotCmdRename = "重命名"
robotCmdPermissions = "权限"
robotCmdDoctor = "诊断"
robotCmdConfirm = "确认"
robotCmdCancel = "取消"
robotBindingCodeTTL = 5 * time.Minute
)
type robotPendingConfirmation struct {
Action string
Target string
ExpiresAt time.Time
}
// RobotHandler 企业微信/钉钉/飞书等机器人回调处理
type RobotHandler struct {
config *config.Config
db *database.DB
agentHandler *AgentHandler
logger *zap.Logger
mu sync.RWMutex
sessions map[string]string // key: "platform_userID", value: conversationID
sessionRoles map[string]string // key: "platform_userID", value: roleName(默认"默认"
cancelMu sync.Mutex // 保护 runningCancels
runningCancels map[string]context.CancelFunc // key: "platform_userID", 用于停止命令中断任务
wecomReplay map[string]time.Time
audit *audit.Service
config *config.Config
db *database.DB
agentHandler *AgentHandler
logger *zap.Logger
mu sync.RWMutex
sessions map[string]string // key: "platform_userID", value: conversationID
sessionRoles map[string]string // key: "platform_userID", value: roleName(默认"默认"
sessionModes map[string]string // key: "platform_userID", value: agent mode
cancelMu sync.Mutex // 保护 runningCancels
runningCancels map[string]context.CancelFunc // key: "platform_userID", 用于停止命令中断任务
wecomReplay map[string]time.Time
pendingConfirmations map[string]robotPendingConfirmation
audit *audit.Service
}
// NewRobotHandler 创建机器人处理器
func NewRobotHandler(cfg *config.Config, db *database.DB, agentHandler *AgentHandler, logger *zap.Logger) *RobotHandler {
return &RobotHandler{
config: cfg,
db: db,
agentHandler: agentHandler,
logger: logger,
sessions: make(map[string]string),
sessionRoles: make(map[string]string),
runningCancels: make(map[string]context.CancelFunc),
wecomReplay: make(map[string]time.Time),
config: cfg,
db: db,
agentHandler: agentHandler,
logger: logger,
sessions: make(map[string]string),
sessionRoles: make(map[string]string),
sessionModes: make(map[string]string),
runningCancels: make(map[string]context.CancelFunc),
wecomReplay: make(map[string]time.Time),
pendingConfirmations: make(map[string]robotPendingConfirmation),
}
}
@@ -175,26 +194,26 @@ func (h *RobotHandler) robotAccessDeniedMessage(platform string) string {
return "当前平台账号尚未绑定 CyberStrikeAI 用户。请先在网页端生成绑定码,然后发送:绑定 XXXX-XXXX"
}
func (h *RobotHandler) loadSessionBinding(sk string) (convID, role string) {
func (h *RobotHandler) loadSessionBinding(sk string) (convID, role, agentMode string) {
if h.db == nil || strings.TrimSpace(sk) == "" {
return "", ""
return "", "", ""
}
binding, err := h.db.GetRobotSessionBinding(sk)
if err != nil {
h.logger.Warn("读取机器人会话绑定失败", zap.String("session_key", sk), zap.Error(err))
return "", ""
return "", "", ""
}
if binding == nil {
return "", ""
return "", "", ""
}
return binding.ConversationID, binding.RoleName
return binding.ConversationID, binding.RoleName, binding.AgentMode
}
func (h *RobotHandler) persistSessionBinding(sk, convID, role string) {
func (h *RobotHandler) persistSessionBinding(sk, convID, role, agentMode string) {
if h.db == nil || strings.TrimSpace(sk) == "" || strings.TrimSpace(convID) == "" {
return
}
if err := h.db.UpsertRobotSessionBinding(sk, convID, role); err != nil {
if err := h.db.UpsertRobotSessionBinding(sk, convID, role, agentMode); err != nil {
h.logger.Warn("写入机器人会话绑定失败", zap.String("session_key", sk), zap.Error(err))
}
}
@@ -219,7 +238,7 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string, a
if convID != "" && access.Permissions["chat:read"] && h.db.UserCanAccessResource(ownerID, readScope, "conversation", convID) {
return convID, false
}
if persistedConvID, persistedRole := h.loadSessionBinding(sk); strings.TrimSpace(persistedConvID) != "" {
if persistedConvID, persistedRole, persistedMode := h.loadSessionBinding(sk); strings.TrimSpace(persistedConvID) != "" {
if !access.Permissions["chat:read"] || !h.db.UserCanAccessResource(ownerID, readScope, "conversation", persistedConvID) {
h.deleteSessionBinding(sk)
} else {
@@ -229,6 +248,9 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string, a
if strings.TrimSpace(persistedRole) != "" {
h.sessionRoles[sk] = persistedRole
}
if strings.TrimSpace(persistedMode) != "" {
h.sessionModes[sk] = config.NormalizeAgentMode(persistedMode)
}
h.mu.Unlock()
return persistedConvID, false
}
@@ -256,9 +278,13 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string, a
_ = h.db.SetResourceOwner("conversation", convID, ownerID)
h.mu.Lock()
role := h.sessionRoles[sk]
agentMode := h.sessionModes[sk]
h.sessions[sk] = convID
h.mu.Unlock()
h.persistSessionBinding(sk, convID, role)
if agentMode == "" {
agentMode = config.NormalizeRobotAgentMode(h.config.MultiAgent)
}
h.persistSessionBinding(sk, convID, role, agentMode)
return convID, true
}
@@ -267,9 +293,10 @@ func (h *RobotHandler) setConversation(platform, userID, convID string) {
sk := h.sessionKey(platform, userID)
h.mu.Lock()
role := h.sessionRoles[sk]
agentMode := h.sessionModes[sk]
h.sessions[sk] = convID
h.mu.Unlock()
h.persistSessionBinding(sk, convID, role)
h.persistSessionBinding(sk, convID, role, agentMode)
}
// getRole 获取当前用户使用的角色,未设置时返回"默认"
@@ -281,7 +308,7 @@ func (h *RobotHandler) getRole(platform, userID string) string {
if strings.TrimSpace(role) != "" {
return role
}
if _, persistedRole := h.loadSessionBinding(sk); strings.TrimSpace(persistedRole) != "" {
if _, persistedRole, _ := h.loadSessionBinding(sk); strings.TrimSpace(persistedRole) != "" {
h.mu.Lock()
h.sessionRoles[sk] = persistedRole
h.mu.Unlock()
@@ -296,8 +323,38 @@ func (h *RobotHandler) setRole(platform, userID, roleName string) {
h.mu.Lock()
h.sessionRoles[sk] = roleName
convID := h.sessions[sk]
agentMode := h.sessionModes[sk]
h.mu.Unlock()
h.persistSessionBinding(sk, convID, roleName)
h.persistSessionBinding(sk, convID, roleName, agentMode)
}
func (h *RobotHandler) getAgentMode(platform, userID string) string {
sk := h.sessionKey(platform, userID)
h.mu.RLock()
mode := h.sessionModes[sk]
h.mu.RUnlock()
if mode != "" {
return config.NormalizeAgentMode(mode)
}
if _, _, persistedMode := h.loadSessionBinding(sk); persistedMode != "" {
mode = config.NormalizeAgentMode(persistedMode)
h.mu.Lock()
h.sessionModes[sk] = mode
h.mu.Unlock()
return mode
}
return config.NormalizeRobotAgentMode(h.config.MultiAgent)
}
func (h *RobotHandler) setAgentMode(platform, userID, mode string) {
sk := h.sessionKey(platform, userID)
mode = config.NormalizeAgentMode(mode)
h.mu.Lock()
h.sessionModes[sk] = mode
convID := h.sessions[sk]
role := h.sessionRoles[sk]
h.mu.Unlock()
h.persistSessionBinding(sk, convID, role, mode)
}
// clearConversation 清空当前会话(切换到新对话)
@@ -379,7 +436,8 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin
h.cancelMu.Unlock()
}()
role := h.getRole(platform, userID)
resp, newConvID, err := h.agentHandler.ProcessMessageForRobot(ctx, platform, robotPrincipal(access), convID, text, role)
agentMode := h.getAgentMode(platform, userID)
resp, newConvID, err := h.agentHandler.ProcessMessageForRobot(ctx, platform, robotPrincipal(access), convID, text, role, agentMode)
if err != nil {
h.logger.Warn("机器人 Agent 执行失败", zap.String("platform", platform), zap.String("userID", userID), zap.Error(err))
if errors.Is(err, context.Canceled) {
@@ -401,32 +459,51 @@ func (h *RobotHandler) robotMessageTimeout() time.Duration {
return 10 * time.Hour
}
func (h *RobotHandler) cmdHelp() string {
func (h *RobotHandler) cmdHelp(platform, userID string) string {
access, _ := h.resolveRobotAccess(platform, userID)
can := func(permission string) bool {
return access != nil && access.Permissions[permission]
}
var b strings.Builder
b.WriteString("【CyberStrikeAI 机器人命令】\n\n")
b.WriteString("【通用 General】\n")
b.WriteString("· 帮助 / help — 显示本帮助\n")
b.WriteString("· 版本 / version — 显示当前版本号\n")
b.WriteString("· 绑定 <绑定码> / bind <code> — 绑定网页端 RBAC 用户\n")
b.WriteString("· 解绑 / unbind — 解除当前平台账号绑定\n")
b.WriteString("· 解绑 / unbind — 请求解除账号绑定(需确认)\n")
b.WriteString("· 身份 / whoami — 显示平台发送者、鉴权模式及当前实际 RBAC 身份\n")
b.WriteString("\n【对话 Conversation】\n")
b.WriteString("· 列表 / list — 列出所有对话标题与 ID\n")
b.WriteString("· 切换 <ID> / switch <ID> — 指定对话继续\n")
b.WriteString(新对话 / new — 开启新对话\n")
b.WriteString("· 清空 / clear — 清空当前上下文\n")
b.WriteString("· 当前 / current — 显示当前对话、角色与项目\n")
b.WriteString(停止 / stop — 中断当前任务\n")
b.WriteString("· 删除 <ID> / delete <ID> — 删除指定对话\n")
b.WriteString("\n【角色 Role】\n")
b.WriteString(角色 / roles — 列出所有可用角色\n")
b.WriteString("· 角色 <名> / role <name> — 切换当前角色\n")
if h.projectsEnabled() {
if can("chat:read") || can("chat:write") || can("chat:delete") {
b.WriteString("\n【对话 Conversation】\n")
if can("chat:read") {
b.WriteString(列表 / list — 列出所有对话标题与 ID\n· 切换 <ID> / switch <ID> — 指定对话继续\n· 状态 / status — 汇总当前选择\n· 任务 / task — 查看当前任务状态\n")
}
if can("chat:write") {
b.WriteString(新对话 / new;清空 / clear — 开启新对话\n· 重命名 <名称> / rename <name> — 修改当前对话标题\n")
}
if can("chat:delete") {
b.WriteString(删除 <ID> / delete <ID> — 删除指定对话(需确认)\n")
}
}
if can("roles:read") {
b.WriteString("\n【角色 Role】\n· 角色 / roles — 列出所有可用角色\n· 角色 <名> / role <name> — 切换当前角色\n")
}
if can("agent:execute") {
b.WriteString("\n【模式 Mode】\n· 模式 / modes — 列出对话模式与当前选择\n· 模式 <名称> / mode <name> — 切换对话模式\n· 停止 / stop — 中断当前任务\n")
}
b.WriteString("\n【诊断 Diagnostics】\n")
b.WriteString("· 权限 / permissions — 查看当前业务权限\n")
if can("config:read") {
b.WriteString("· 诊断 / doctor — 检查机器人关键配置状态\n")
}
b.WriteString("· 确认 / confirm;取消 / cancel — 处理高风险操作确认\n")
if h.projectsEnabled() && (can("project:read") || can("project:write")) {
b.WriteString("\n【项目 Project】\n")
b.WriteString("· 项目 / projects — 列出所有项目\n")
b.WriteString(新建项目 <名称> / new project <name> — 创建并绑定当前对话\n")
b.WriteString("· 绑定项目 <ID或名称> / bind project <ID|name> — 绑定到已有项目\n")
b.WriteString("· 解除项目 / unbind project — 解除项目绑定\n")
if can("project:read") {
b.WriteString("· 项目 / projects — 列出所有项目\n")
}
if can("project:write") {
b.WriteString("· 新建项目 <名称> / new project <name> — 创建并绑定当前对话\n· 绑定项目 <ID或名称> / bind project <ID|name> — 绑定已有项目\n· 解除项目 / unbind project — 解除项目绑定\n")
}
}
b.WriteString("\n──────────────\n")
b.WriteString("除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。")
@@ -575,7 +652,7 @@ func (h *RobotHandler) cmdUnbindProject(platform, userID string) string {
convID := h.sessions[sk]
h.mu.RUnlock()
if convID == "" {
if persistedConvID, _ := h.loadSessionBinding(sk); persistedConvID != "" {
if persistedConvID, _, _ := h.loadSessionBinding(sk); persistedConvID != "" {
convID = persistedConvID
}
}
@@ -673,12 +750,10 @@ func (h *RobotHandler) cmdStop(platform, userID string) string {
return "已停止当前任务。"
}
func (h *RobotHandler) cmdCurrent(platform, userID string) string {
h.mu.RLock()
convID := h.sessions[h.sessionKey(platform, userID)]
h.mu.RUnlock()
func (h *RobotHandler) cmdStatus(platform, userID string) string {
convID := h.currentConversationID(platform, userID)
if convID == "" {
return "当前没有进行中的对话。发送任意内容将创建新对话。"
return fmt.Sprintf("当前状态】\n当前对话: 无\n当前角色: %s\n当前模式: %s\n当前项目: 无\n\n发送任意内容将创建新对话。", h.getRole(platform, userID), robotAgentModeLabel(h.getAgentMode(platform, userID)))
}
access, err := h.resolveRobotAccess(platform, userID)
if err != nil {
@@ -692,14 +767,122 @@ func (h *RobotHandler) cmdCurrent(platform, userID string) string {
return "当前对话 ID: " + convID + "(获取标题失败)"
}
role := h.getRole(platform, userID)
reply := fmt.Sprintf("当前对话:「%s\nID: %s\n当前角色: %s", conv.Title, conv.ID, role)
reply := fmt.Sprintf("【当前状态】\n当前对话: %s\n对话 ID: %s\n当前模式: %s\n当前角色: %s", conv.Title, conv.ID, robotAgentModeLabel(h.getAgentMode(platform, userID)), role)
if h.projectsEnabled() {
projectID, _ := h.db.GetConversationProjectID(conv.ID)
reply += "\n当前项目: " + h.formatProjectLabel(projectID)
} else {
reply += "\n当前项目: 未启用"
}
return reply
}
func (h *RobotHandler) currentConversationID(platform, userID string) string {
sk := h.sessionKey(platform, userID)
h.mu.RLock()
convID := h.sessions[sk]
h.mu.RUnlock()
if convID != "" {
return convID
}
persistedConvID, persistedRole, persistedMode := h.loadSessionBinding(sk)
if persistedConvID == "" {
return ""
}
h.mu.Lock()
h.sessions[sk] = persistedConvID
h.sessionRoles[sk] = persistedRole
h.sessionModes[sk] = config.NormalizeAgentMode(persistedMode)
h.mu.Unlock()
return persistedConvID
}
func (h *RobotHandler) cmdTask(platform, userID string) string {
convID := h.currentConversationID(platform, userID)
if convID == "" {
return "【任务状态】\n当前没有对话,也没有正在执行的任务。"
}
if h.agentHandler == nil || h.agentHandler.tasks == nil {
return "任务状态服务不可用。"
}
task := h.agentHandler.tasks.GetTaskSnapshot(convID)
if task == nil {
return "【任务状态】\n状态: 空闲\n当前没有正在执行的任务。"
}
elapsed := time.Since(task.StartedAt).Round(time.Second)
return fmt.Sprintf("【任务状态】\n状态: %s\n已运行: %s\n对话 ID: %s\n模式: %s\n可用操作: 停止 / stop", task.Status, elapsed, convID, robotAgentModeLabel(h.getAgentMode(platform, userID)))
}
func (h *RobotHandler) cmdRename(platform, userID, title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "请指定新标题,例如:重命名 外网资产排查"
}
title = safeTruncateString(title, 100)
convID := h.currentConversationID(platform, userID)
if convID == "" {
return "当前没有对话,无法重命名。"
}
access, err := h.resolveRobotAccess(platform, userID)
if err != nil || !h.db.UserCanAccessResource(access.User.ID, robotPrincipal(access).ScopeFor("chat:write"), "conversation", convID) {
return "当前对话不存在或无权修改。"
}
if err := h.db.UpdateConversationTitle(convID, title); err != nil {
return "重命名失败: " + err.Error()
}
h.recordRobotCommandAudit(access, platform, "conversation_rename", "conversation", convID, "机器人重命名当前对话")
return fmt.Sprintf("已将当前对话重命名为:「%s」", title)
}
func (h *RobotHandler) cmdPermissions(platform, userID string) string {
access, err := h.resolveRobotAccess(platform, userID)
if err != nil {
return h.robotAccessDeniedMessage(platform)
}
allowed := func(permission string) string {
if access.Permissions[permission] {
return "允许"
}
return "不允许"
}
return fmt.Sprintf("【当前权限】\n执行 Agent: %s\n读取对话: %s\n编辑对话: %s\n删除对话: %s\n读取角色: %s\n读取项目: %s\n编辑项目: %s\n资源范围: %s", allowed("agent:execute"), allowed("chat:read"), allowed("chat:write"), allowed("chat:delete"), allowed("roles:read"), allowed("project:read"), allowed("project:write"), access.Scope)
}
func (h *RobotHandler) cmdDoctor() string {
configured := func(ok bool) string {
if ok {
return "正常"
}
return "未配置"
}
enabled := func(ok bool) string {
if ok {
return "已启用"
}
return "已关闭"
}
enabledInternalTools := 0
for _, tool := range h.config.Security.Tools {
if tool.Enabled {
enabledInternalTools++
}
}
enabledExternal := 0
for _, server := range h.config.ExternalMCP.Servers {
if server.ExternalMCPEnable && !server.Disabled {
enabledExternal++
}
}
return fmt.Sprintf("【配置诊断】\n主模型: %s\nEino 多代理: %s\n内置 MCP 工具: %d/%d 个已启用\nHTTP MCP 服务: %s\n外部 MCP: %d 个已启用\n知识库: %s\n项目功能: %s\n说明: 内置工具不依赖 HTTP MCP 服务;此命令只检查配置,不主动探测外部服务。", configured(strings.TrimSpace(h.config.OpenAI.Model) != "" && strings.TrimSpace(h.config.OpenAI.BaseURL) != ""), enabled(h.config.MultiAgent.Enabled), enabledInternalTools, len(h.config.Security.Tools), enabled(h.config.MCP.Enabled), enabledExternal, enabled(h.config.Knowledge.Enabled), enabled(h.config.Project.Enabled))
}
func (h *RobotHandler) recordRobotCommandAudit(access *database.RBACAccess, platform, action, resourceType, resourceID, message string) {
if h.audit == nil || access == nil {
return
}
h.audit.RecordSystem(audit.Entry{Category: "robot", Action: action, Result: "success", Actor: access.User.Username, ResourceType: resourceType, ResourceID: resourceID, Message: message + "" + platform + ""})
}
func (h *RobotHandler) cmdRoles() string {
if h.config.Roles == nil || len(h.config.Roles) == 0 {
return "暂无可用角色。"
@@ -753,6 +936,55 @@ func (h *RobotHandler) cmdSwitchRole(platform, userID, roleName string) string {
return fmt.Sprintf("已切换到角色:「%s」\n%s", roleName, role.Description)
}
func robotAgentModeLabel(mode string) string {
switch config.NormalizeAgentMode(mode) {
case "deep":
return "Deep"
case "plan_execute":
return "Plan-Execute"
case "supervisor":
return "Supervisor"
default:
return "Eino 单代理"
}
}
func parseRobotAgentMode(input string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(input)) {
case "eino_single", "eino-single", "single", "单代理", "eino单代理", "eino 单代理":
return "eino_single", true
case "deep":
return "deep", true
case "plan_execute", "plan-execute", "planexecute", "pe":
return "plan_execute", true
case "supervisor", "super", "sv":
return "supervisor", true
default:
return "", false
}
}
func (h *RobotHandler) cmdModes(platform, userID string) string {
current := h.getAgentMode(platform, userID)
multiStatus := "可用"
if h.config == nil || !h.config.MultiAgent.Enabled {
multiStatus = "不可用(需在系统设置中启用 Eino 多代理)"
}
return fmt.Sprintf("【对话模式】\n· Eino 单代理 — 可用\n· Deep — %s\n· Plan-Execute — %s\n· Supervisor — %s\n\n当前模式: %s\n切换示例:模式 deep", multiStatus, multiStatus, multiStatus, robotAgentModeLabel(current))
}
func (h *RobotHandler) cmdSwitchMode(platform, userID, input string) string {
mode, ok := parseRobotAgentMode(input)
if !ok {
return fmt.Sprintf("不支持的对话模式「%s」。发送「模式」查看可用模式。", strings.TrimSpace(input))
}
if mode != "eino_single" && (h.config == nil || !h.config.MultiAgent.Enabled) {
return fmt.Sprintf("无法切换到 %s:请先在系统设置中启用 Eino 多代理。", robotAgentModeLabel(mode))
}
h.setAgentMode(platform, userID, mode)
return fmt.Sprintf("已切换对话模式:%s\n后续消息和新对话将使用该模式。", robotAgentModeLabel(mode))
}
func (h *RobotHandler) cmdDelete(platform, userID, convID string) string {
if convID == "" {
return "请指定对话 ID,例如:删除 xxx-xxx-xxx"
@@ -764,6 +996,15 @@ func (h *RobotHandler) cmdDelete(platform, userID, convID string) string {
if !h.db.UserCanAccessResource(access.User.ID, robotPrincipal(access).ScopeFor("chat:delete"), "conversation", convID) {
return "对话不存在或无权访问。"
}
h.setPendingConfirmation(platform, userID, "delete_conversation", convID)
return fmt.Sprintf("⚠️ 即将删除对话 ID: %s\n此操作不可撤销。请在 2 分钟内发送「确认」继续,或发送「取消」。", convID)
}
func (h *RobotHandler) executeDelete(platform, userID, convID string) string {
access, err := h.resolveRobotAccess(platform, userID)
if err != nil || !h.db.UserCanAccessResource(access.User.ID, robotPrincipal(access).ScopeFor("chat:delete"), "conversation", convID) {
return "对话不存在或无权删除。"
}
sk := h.sessionKey(platform, userID)
h.mu.RLock()
currentConvID := h.sessions[sk]
@@ -773,6 +1014,7 @@ func (h *RobotHandler) cmdDelete(platform, userID, convID string) string {
h.mu.Lock()
delete(h.sessions, sk)
delete(h.sessionRoles, sk)
delete(h.sessionModes, sk)
h.mu.Unlock()
h.deleteSessionBinding(sk)
}
@@ -782,6 +1024,7 @@ func (h *RobotHandler) cmdDelete(platform, userID, convID string) string {
if err := h.db.DeleteConversation(convID); err != nil {
return "删除失败: " + err.Error()
}
h.recordRobotCommandAudit(access, platform, "conversation_delete", "conversation", convID, "机器人删除对话")
return fmt.Sprintf("已删除对话 ID: %s", convID)
}
@@ -844,9 +1087,10 @@ func robotCommandPermission(text string) (string, bool) {
case text == robotCmdList || text == robotCmdListAlt || text == "list",
strings.HasPrefix(text, robotCmdSwitch+" "), strings.HasPrefix(text, robotCmdContinue+" "),
strings.HasPrefix(text, "switch "), strings.HasPrefix(text, "continue "),
text == robotCmdCurrent || text == "current":
text == robotCmdStatus || text == "status", text == robotCmdTask || text == "task":
return "chat:read", true
case text == robotCmdNew || text == "new", text == robotCmdClear || text == "clear":
case text == robotCmdNew || text == "new", text == robotCmdClear || text == "clear",
strings.HasPrefix(text, robotCmdRename+" "), strings.HasPrefix(text, "rename "):
return "chat:write", true
case strings.HasPrefix(text, robotCmdDelete+" "), strings.HasPrefix(text, "delete "):
return "chat:delete", true
@@ -855,6 +1099,15 @@ func robotCommandPermission(text string) (string, bool) {
case text == robotCmdRoles || text == robotCmdRolesList || text == "roles",
strings.HasPrefix(text, robotCmdRoles+" "), strings.HasPrefix(text, robotCmdSwitchRole+" "), strings.HasPrefix(text, "role "):
return "roles:read", true
case text == robotCmdModes || text == robotCmdModesList || text == "modes",
strings.HasPrefix(text, robotCmdModes+" "), strings.HasPrefix(text, robotCmdSwitchMode+" "), strings.HasPrefix(text, "mode "):
return "agent:execute", true
case text == robotCmdPermissions || text == "permissions":
return "", true
case text == robotCmdConfirm || text == "confirm", text == robotCmdCancel || text == "cancel":
return "", true
case text == robotCmdDoctor || text == "doctor":
return "config:read", true
case text == robotCmdProjects || text == robotCmdProjectsList || text == "projects":
return "project:read", true
case text == robotCmdUnbindProject || text == "unbind project",
@@ -883,6 +1136,7 @@ func (h *RobotHandler) cmdBindUser(platform, userID, code string) string {
h.mu.Lock()
delete(h.sessions, sk)
delete(h.sessionRoles, sk)
delete(h.sessionModes, sk)
h.mu.Unlock()
h.deleteSessionBinding(sk)
name := strings.TrimSpace(user.DisplayName)
@@ -903,6 +1157,15 @@ func (h *RobotHandler) cmdUnbindUser(platform, userID string) string {
if h.config.Robots.AuthorizationFor(platform).EffectiveMode() != config.RobotAuthModeUserBinding {
return "该机器人使用受控服务账号模式,无需用户解绑。"
}
_, accessErr := h.resolveRobotAccess(platform, userID)
if accessErr != nil {
return "当前平台账号尚未绑定。"
}
h.setPendingConfirmation(platform, userID, "unbind_user", "")
return "⚠️ 即将解除当前平台账号绑定。请在 2 分钟内发送「确认」继续,或发送「取消」。"
}
func (h *RobotHandler) executeUnbindUser(platform, userID string) string {
access, accessErr := h.resolveRobotAccess(platform, userID)
if accessErr != nil {
return "当前平台账号尚未绑定。"
@@ -914,6 +1177,7 @@ func (h *RobotHandler) cmdUnbindUser(platform, userID string) string {
h.mu.Lock()
delete(h.sessions, sk)
delete(h.sessionRoles, sk)
delete(h.sessionModes, sk)
h.mu.Unlock()
h.deleteSessionBinding(sk)
if h.audit != nil {
@@ -926,6 +1190,50 @@ func (h *RobotHandler) cmdUnbindUser(platform, userID string) string {
return "已解除当前平台账号与 CyberStrikeAI 用户的绑定。"
}
func (h *RobotHandler) setPendingConfirmation(platform, userID, action, target string) {
sk := h.sessionKey(platform, userID)
now := time.Now()
h.mu.Lock()
for key, pending := range h.pendingConfirmations {
if now.After(pending.ExpiresAt) {
delete(h.pendingConfirmations, key)
}
}
h.pendingConfirmations[sk] = robotPendingConfirmation{Action: action, Target: target, ExpiresAt: now.Add(2 * time.Minute)}
h.mu.Unlock()
}
func (h *RobotHandler) cmdConfirm(platform, userID string) string {
sk := h.sessionKey(platform, userID)
h.mu.Lock()
pending, ok := h.pendingConfirmations[sk]
delete(h.pendingConfirmations, sk)
h.mu.Unlock()
if !ok || time.Now().After(pending.ExpiresAt) {
return "当前没有待确认操作,或确认已超时。"
}
switch pending.Action {
case "delete_conversation":
return h.executeDelete(platform, userID, pending.Target)
case "unbind_user":
return h.executeUnbindUser(platform, userID)
default:
return "待确认操作无效,已取消。"
}
}
func (h *RobotHandler) cmdCancelConfirmation(platform, userID string) string {
sk := h.sessionKey(platform, userID)
h.mu.Lock()
_, ok := h.pendingConfirmations[sk]
delete(h.pendingConfirmations, sk)
h.mu.Unlock()
if !ok {
return "当前没有待确认操作。"
}
return "已取消待确认操作。"
}
// handleRobotCommand 处理机器人内置命令;若匹配到命令返回 (回复内容, true),否则返回 ("", false)
func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string, bool) {
if (strings.HasPrefix(text, robotCmdBindUser+" ") || strings.HasPrefix(text, "bind ")) && !strings.HasPrefix(text, "bind project ") {
@@ -946,9 +1254,13 @@ func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string
}
switch {
case text == robotCmdHelp || text == "help" || text == "" || text == "?":
return h.cmdHelp(), true
return h.cmdHelp(platform, userID), true
case text == robotCmdIdentity || text == "whoami":
return h.cmdIdentity(platform, userID), true
case text == robotCmdConfirm || text == "confirm":
return h.cmdConfirm(platform, userID), true
case text == robotCmdCancel || text == "cancel":
return h.cmdCancelConfirmation(platform, userID), true
case text == robotCmdList || text == robotCmdListAlt || text == "list":
return h.cmdList(platform, userID), true
case strings.HasPrefix(text, robotCmdSwitch+" ") || strings.HasPrefix(text, robotCmdContinue+" ") || strings.HasPrefix(text, "switch ") || strings.HasPrefix(text, "continue "):
@@ -968,8 +1280,18 @@ func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string
return h.cmdNew(platform, userID), true
case text == robotCmdClear || text == "clear":
return h.cmdClear(platform, userID), true
case text == robotCmdCurrent || text == "current":
return h.cmdCurrent(platform, userID), true
case text == robotCmdStatus || text == "status":
return h.cmdStatus(platform, userID), true
case text == robotCmdTask || text == "task":
return h.cmdTask(platform, userID), true
case strings.HasPrefix(text, robotCmdRename+" ") || strings.HasPrefix(text, "rename "):
var title string
if strings.HasPrefix(text, robotCmdRename+" ") {
title = strings.TrimSpace(text[len(robotCmdRename)+1:])
} else {
title = strings.TrimSpace(text[len("rename "):])
}
return h.cmdRename(platform, userID, title), true
case text == robotCmdStop || text == "stop":
return h.cmdStop(platform, userID), true
case text == robotCmdRoles || text == robotCmdRolesList || text == "roles":
@@ -985,6 +1307,23 @@ func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string
roleName = strings.TrimSpace(text[5:])
}
return h.cmdSwitchRole(platform, userID, roleName), true
case text == robotCmdModes || text == robotCmdModesList || text == "modes":
return h.cmdModes(platform, userID), true
case strings.HasPrefix(text, robotCmdModes+" ") || strings.HasPrefix(text, robotCmdSwitchMode+" ") || strings.HasPrefix(text, "mode "):
var mode string
switch {
case strings.HasPrefix(text, robotCmdModes+" "):
mode = strings.TrimSpace(text[len(robotCmdModes)+1:])
case strings.HasPrefix(text, robotCmdSwitchMode+" "):
mode = strings.TrimSpace(text[len(robotCmdSwitchMode)+1:])
default:
mode = strings.TrimSpace(text[5:])
}
return h.cmdSwitchMode(platform, userID, mode), true
case text == robotCmdPermissions || text == "permissions":
return h.cmdPermissions(platform, userID), true
case text == robotCmdDoctor || text == "doctor":
return h.cmdDoctor(), true
case strings.HasPrefix(text, robotCmdDelete+" ") || strings.HasPrefix(text, "delete "):
var convID string
if strings.HasPrefix(text, robotCmdDelete+" ") {
+101
View File
@@ -0,0 +1,101 @@
package handler
import (
"strings"
"testing"
"cyberstrike-ai/internal/config"
"go.uber.org/zap"
)
func TestRobotModeSwitch(t *testing.T) {
h := NewRobotHandler(&config.Config{MultiAgent: config.MultiAgentConfig{Enabled: true}}, nil, nil, zap.NewNop())
if got := h.cmdSwitchMode("lark", "user-1", "plan-execute"); !strings.Contains(got, "Plan-Execute") {
t.Fatalf("unexpected switch response: %s", got)
}
if got := h.getAgentMode("lark", "user-1"); got != "plan_execute" {
t.Fatalf("mode = %q, want plan_execute", got)
}
if got := h.cmdModes("lark", "user-1"); !strings.Contains(got, "当前模式: Plan-Execute") {
t.Fatalf("unexpected modes response: %s", got)
}
}
func TestRobotModeRejectsUnavailableMultiAgent(t *testing.T) {
h := NewRobotHandler(&config.Config{}, nil, nil, zap.NewNop())
if got := h.cmdSwitchMode("lark", "user-1", "deep"); !strings.Contains(got, "启用 Eino 多代理") {
t.Fatalf("unexpected rejection: %s", got)
}
if got := h.getAgentMode("lark", "user-1"); got != "eino_single" {
t.Fatalf("mode changed after rejection: %q", got)
}
}
func TestParseRobotAgentModeRejectsUnknownMode(t *testing.T) {
if mode, ok := parseRobotAgentMode("unknown"); ok || mode != "" {
t.Fatalf("parseRobotAgentMode returned (%q, %v), want empty,false", mode, ok)
}
}
func TestRobotStatusCommandPermission(t *testing.T) {
for _, command := range []string{"状态", "status"} {
permission, recognized := robotCommandPermission(command)
if !recognized || permission != "chat:read" {
t.Fatalf("command %q returned permission=%q recognized=%v", command, permission, recognized)
}
}
for _, removed := range []string{"当前", "current"} {
if _, recognized := robotCommandPermission(removed); recognized {
t.Fatalf("removed command %q is still recognized", removed)
}
}
}
func TestRobotBestPracticeCommandPermissions(t *testing.T) {
cases := map[string]string{
"任务": "chat:read",
"task": "chat:read",
"重命名 新标题": "chat:write",
"rename x": "chat:write",
"诊断": "config:read",
"doctor": "config:read",
}
for command, want := range cases {
permission, recognized := robotCommandPermission(command)
if !recognized || permission != want {
t.Fatalf("command %q returned permission=%q recognized=%v, want %q,true", command, permission, recognized, want)
}
}
}
func TestRobotConfirmationCanBeCancelled(t *testing.T) {
h := NewRobotHandler(&config.Config{}, nil, nil, zap.NewNop())
h.setPendingConfirmation("lark", "user-1", "delete_conversation", "conv-1")
if got := h.cmdCancelConfirmation("lark", "user-1"); got != "已取消待确认操作。" {
t.Fatalf("unexpected cancel response: %s", got)
}
if got := h.cmdConfirm("lark", "user-1"); !strings.Contains(got, "没有待确认操作") {
t.Fatalf("confirmation survived cancellation: %s", got)
}
}
func TestRobotDoctorSeparatesInternalToolsFromHTTPMCP(t *testing.T) {
h := NewRobotHandler(&config.Config{
Security: config.SecurityConfig{Tools: []config.ToolConfig{
{Name: "enabled-tool", Enabled: true},
{Name: "disabled-tool", Enabled: false},
}},
MCP: config.MCPConfig{Enabled: false},
}, nil, nil, zap.NewNop())
got := h.cmdDoctor()
if !strings.Contains(got, "内置 MCP 工具: 1/2 个已启用") {
t.Fatalf("internal tool status missing: %s", got)
}
if !strings.Contains(got, "HTTP MCP 服务: 已关闭") {
t.Fatalf("HTTP MCP status missing: %s", got)
}
}
+46 -16
View File
@@ -1,6 +1,7 @@
package handler
import (
"context"
"fmt"
"net/http"
"sync"
@@ -13,33 +14,51 @@ import (
// some proxies that treat connections as idle; 10s is a reasonable balance with traffic.
const sseKeepaliveInterval = 10 * time.Second
// sseKeepalive sends periodic SSE traffic so proxies (e.g. nginx proxy_read_timeout), NATs,
// runSSEKeepalive starts periodic SSE heartbeats in a background goroutine.
// The returned stop function must be deferred (or called) before the handler returns so the
// goroutine exits before Gin finalizes the ResponseWriter (avoids "Write called after Handler finished").
//
// writeMu must be the same mutex used by the handler's event writes for this request: concurrent
// writes to http.ResponseWriter break chunked transfer encoding (browser: net::ERR_INVALID_CHUNKED_ENCODING).
func runSSEKeepalive(c *gin.Context, writeMu *sync.Mutex) func() {
if writeMu == nil {
return func() {}
}
stop := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
sseKeepaliveLoop(c, stop, writeMu)
}()
var once sync.Once
return func() {
once.Do(func() {
close(stop)
wg.Wait()
})
}
}
// sseKeepaliveLoop sends periodic SSE traffic so proxies (e.g. nginx proxy_read_timeout), NATs,
// and load balancers do not close long-running streams. Some intermediaries ignore comment-only
// lines, so we send both a comment and a minimal data frame (type heartbeat) per tick.
//
// writeMu must be the same mutex used by sendEvent for this request: concurrent writes to
// http.ResponseWriter break chunked transfer encoding (browser: net::ERR_INVALID_CHUNKED_ENCODING).
func sseKeepalive(c *gin.Context, stop <-chan struct{}, writeMu *sync.Mutex) {
if writeMu == nil {
return
}
func sseKeepaliveLoop(c *gin.Context, stop <-chan struct{}, writeMu *sync.Mutex) {
ticker := time.NewTicker(sseKeepaliveInterval)
defer ticker.Stop()
ctx := c.Request.Context()
for {
select {
case <-stop:
return
case <-c.Request.Context().Done():
case <-ctx.Done():
return
case <-ticker.C:
select {
case <-stop:
return
case <-c.Request.Context().Done():
return
default:
}
writeMu.Lock()
if sseShuttingDown(stop, ctx) {
writeMu.Unlock()
return
}
if _, err := fmt.Fprintf(c.Writer, ": keepalive\n\n"); err != nil {
writeMu.Unlock()
return
@@ -56,3 +75,14 @@ func sseKeepalive(c *gin.Context, stop <-chan struct{}, writeMu *sync.Mutex) {
}
}
}
func sseShuttingDown(stop <-chan struct{}, ctx context.Context) bool {
select {
case <-stop:
return true
case <-ctx.Done():
return true
default:
return false
}
}
+61
View File
@@ -0,0 +1,61 @@
package handler
import (
"context"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestRunSSEKeepaliveStopsBeforeHandlerReturns(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/events", nil)
var writeMu sync.Mutex
stop := runSSEKeepalive(c, &writeMu)
stop()
// A second stop must be safe (channel already closed, goroutine already exited).
stop()
}
func TestRunSSEKeepaliveExitsOnClientDisconnect(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
ctx, cancel := context.WithCancel(context.Background())
c.Request = httptest.NewRequest("GET", "/events", nil).WithContext(ctx)
var writeMu sync.Mutex
stop := runSSEKeepalive(c, &writeMu)
cancel()
done := make(chan struct{})
go func() {
stop()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("keepalive stop did not complete after client disconnect")
}
}
func TestRunSSEKeepaliveNilMutexIsNoop(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/events", nil)
stop := runSSEKeepalive(c, nil)
stop()
}
+12
View File
@@ -298,6 +298,18 @@ func (m *AgentTaskManager) GetTask(conversationID string) *AgentTask {
return m.tasks[conversationID]
}
// GetTaskSnapshot 返回运行任务的只读副本,供状态展示使用,避免锁外读取可变任务字段。
func (m *AgentTaskManager) GetTaskSnapshot(conversationID string) *AgentTask {
m.mu.RLock()
defer m.mu.RUnlock()
task := m.tasks[conversationID]
if task == nil {
return nil
}
snapshot := *task
return &snapshot
}
// runStuckCancellingCleanup 定期将长时间处于「取消中」的任务强制结束,避免卡住无法发新消息
func (m *AgentTaskManager) runStuckCancellingCleanup() {
ticker := time.NewTicker(cleanupInterval)
@@ -18,8 +18,9 @@ import (
// 5. soft model-input budget (warn/compact only, never fail locally)
// 6. final tool-call/result reconciliation
// 7. orphan tool prune (defense in depth)
// 8. telemetry
// 9. model-facing trace snapshot
// 8. malformed tool_search history repair
// 9. telemetry
// 10. model-facing trace snapshot
type einoChatModelTailConfig struct {
logger *zap.Logger
phase string
@@ -48,6 +49,7 @@ func appendEinoChatModelTailMiddlewares(handlers []adk.ChatModelAgentMiddleware,
if !cfg.skipOrphanPruner {
handlers = append(handlers, newOrphanToolPrunerMiddleware(cfg.logger, cfg.phase))
}
handlers = append(handlers, newToolSearchResultSanitizerMiddleware(cfg.logger, cfg.phase))
if !cfg.skipTelemetry {
if teleMw := newEinoModelInputTelemetryMiddleware(cfg.logger, cfg.modelName, cfg.conversationID, cfg.phase); teleMw != nil {
handlers = append(handlers, teleMw)
@@ -0,0 +1,76 @@
package multiagent
import (
"context"
"encoding/json"
"strings"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// toolSearchResultSanitizerMiddleware prevents malformed historical tool_search
// results (for example an HTML gateway error page) from crashing Eino's dynamic
// tool loader on every retry. Eino expects every tool_search result to be a JSON
// object containing selectedTools.
type toolSearchResultSanitizerMiddleware struct {
adk.BaseChatModelAgentMiddleware
logger *zap.Logger
phase string
}
func newToolSearchResultSanitizerMiddleware(logger *zap.Logger, phase string) adk.ChatModelAgentMiddleware {
return &toolSearchResultSanitizerMiddleware{logger: logger, phase: phase}
}
type toolSearchResultEnvelope struct {
SelectedTools []string `json:"selectedTools"`
}
func validToolSearchResult(content string) bool {
var result toolSearchResultEnvelope
if err := json.Unmarshal([]byte(content), &result); err != nil {
return false
}
// Reject JSON values such as null. They unmarshal without an error but do not
// satisfy the object-shaped contract used by the toolsearch middleware.
return strings.HasPrefix(strings.TrimSpace(content), "{")
}
func (m *toolSearchResultSanitizerMiddleware) BeforeModelRewriteState(
ctx context.Context,
state *adk.ChatModelAgentState,
_ *adk.ModelContext,
) (context.Context, *adk.ChatModelAgentState, error) {
if m == nil || state == nil || len(state.Messages) == 0 {
return ctx, state, nil
}
var rewritten []adk.Message
repaired := 0
for i, msg := range state.Messages {
if msg == nil || msg.Role != schema.Tool || !IsToolSearchTool(msg.ToolName) || validToolSearchResult(msg.Content) {
continue
}
if rewritten == nil {
rewritten = append([]adk.Message(nil), state.Messages...)
}
clone := *msg
clone.Content = `{"selectedTools":[],"_recovered":true,"reason":"invalid historical tool_search result"}`
rewritten[i] = &clone
repaired++
}
if repaired == 0 {
return ctx, state, nil
}
if m.logger != nil {
m.logger.Warn("invalid historical tool_search results repaired before model call",
zap.String("phase", m.phase),
zap.Int("repaired_count", repaired))
}
ns := *state
ns.Messages = rewritten
return ctx, &ns, nil
}
@@ -0,0 +1,56 @@
package multiagent
import (
"context"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestToolSearchResultSanitizerRepairsMalformedHistory(t *testing.T) {
good := &schema.Message{Role: schema.Tool, ToolName: "tool_search", Content: `{"selectedTools":["grep"]}`}
bad := &schema.Message{Role: schema.Tool, ToolName: "tool_search", Content: "<html>502 Bad Gateway</html>"}
other := &schema.Message{Role: schema.Tool, ToolName: "grep", Content: "plain text is valid for other tools"}
state := &adk.ChatModelAgentState{Messages: []adk.Message{good, bad, other}}
mw := newToolSearchResultSanitizerMiddleware(nil, "test")
_, got, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatalf("BeforeModelRewriteState: %v", err)
}
if got.Messages[0] != good || got.Messages[0].Content != good.Content {
t.Fatal("valid tool_search result was unexpectedly changed")
}
if got.Messages[1] == bad || !validToolSearchResult(got.Messages[1].Content) {
t.Fatalf("malformed result was not safely replaced: %q", got.Messages[1].Content)
}
if got.Messages[2] != other {
t.Fatal("non-tool_search result was unexpectedly changed")
}
if bad.Content != "<html>502 Bad Gateway</html>" {
t.Fatal("middleware mutated the original message")
}
}
func TestToolSearchResultSanitizerFastPath(t *testing.T) {
msg := &schema.Message{Role: schema.Tool, ToolName: "tool_search", Content: `{"selectedTools":[]}`}
state := &adk.ChatModelAgentState{Messages: []adk.Message{msg}}
mw := newToolSearchResultSanitizerMiddleware(nil, "test")
_, got, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatalf("BeforeModelRewriteState: %v", err)
}
if got != state {
t.Fatal("valid history should use the allocation-free fast path")
}
}
func TestValidToolSearchResultRejectsNonObjectJSON(t *testing.T) {
for _, content := range []string{"null", `[]`, `"text"`, `{"selectedTools":"grep"}`} {
if validToolSearchResult(content) {
t.Fatalf("expected invalid tool_search result: %s", content)
}
}
}
+56 -17
View File
@@ -5366,12 +5366,17 @@ html[data-theme="dark"] .user-menu-dropdown {
}
.timeline-live-pruned-marker {
position: sticky;
top: 0;
z-index: 3;
margin: 8px 0;
padding: 8px 12px;
border: 1px dashed rgba(148, 163, 184, 0.35);
border-radius: 6px;
color: var(--text-muted);
background: rgba(148, 163, 184, 0.08);
backdrop-filter: blur(8px);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
font-size: 0.8125rem;
}
@@ -11507,27 +11512,35 @@ html[data-theme="dark"] .robot-binding-one-time-badge {
display: flex;
gap: 6px;
min-width: 0;
overflow: hidden;
overflow-x: auto;
overflow-y: hidden;
flex: 1 1 auto;
flex-wrap: nowrap;
padding: 1px 1px 3px;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
}
.mcp-stats-timeline-moment {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 144px;
min-width: 156px;
max-width: none;
padding: 4px 8px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.08);
color: #1d4ed8;
font-size: 0.6875rem;
font-weight: 600;
flex: 0 0 auto;
flex: 1 0 156px;
box-sizing: border-box;
overflow: hidden;
}
.mcp-stats-timeline-moment__time {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -11544,6 +11557,7 @@ html[data-theme="dark"] .robot-binding-one-time-badge {
color: #fff;
background: #2563eb;
box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0.04);
flex: 0 0 auto;
}
.mcp-stats-timeline-moment__fail {
@@ -11557,12 +11571,16 @@ html[data-theme="dark"] .robot-binding-one-time-badge {
font-size: 0.625rem;
font-weight: 700;
white-space: nowrap;
flex: 0 0 auto;
}
.mcp-stats-timeline-moment--more {
min-width: 42px;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.14);
font-weight: 700;
flex: 0 0 auto;
justify-content: center;
}
.mcp-stats-timeline__chart-wrap {
@@ -11632,7 +11650,8 @@ html[data-theme="dark"] .robot-binding-one-time-badge {
}
.mcp-stats-timeline-moments__list {
flex-wrap: nowrap;
overflow: hidden;
overflow-x: auto;
overflow-y: hidden;
}
}
@@ -30291,7 +30310,7 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
position: relative;
z-index: 20;
margin: 0;
padding: 10px 0 12px;
padding: 6px 0;
background: var(--bg-primary);
border-top: 1px solid var(--border-color);
}
@@ -30351,23 +30370,27 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
}
.projects-graph-legend {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
gap: 8px 14px;
}
.projects-graph-legend-group {
display: inline-flex;
flex-wrap: wrap;
flex-wrap: nowrap;
flex: 0 0 auto;
align-items: center;
gap: 6px 10px;
}
.projects-graph-legend-heading {
flex: 0 0 auto;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #94a3b8;
white-space: nowrap;
}
.projects-graph-legend-divider {
display: inline-block;
@@ -30378,10 +30401,12 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
}
.projects-graph-legend-item {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: #64748b;
white-space: nowrap;
}
.projects-graph-legend-item--edge i {
display: inline-block;
@@ -30619,8 +30644,8 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
color: #64748b;
}
.project-fact-graph-edges-wrap {
flex: 1 1 auto;
min-height: 0;
flex: 0 0 auto;
min-height: min-content;
display: flex;
flex-direction: column;
gap: 6px;
@@ -30643,8 +30668,8 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
overflow-wrap: anywhere;
}
.project-fact-graph-edges-list {
flex: 1 1 auto;
min-height: 0;
flex: 0 1 auto;
max-height: 180px;
overflow-y: auto;
display: flex;
flex-direction: column;
@@ -30715,6 +30740,11 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
cursor: pointer;
flex-shrink: 0;
}
.project-fact-graph-edge-delete svg {
display: block;
flex: 0 0 auto;
pointer-events: none;
}
.project-fact-graph-edge-delete:hover {
background: #fef2f2;
border-color: #f87171;
@@ -30764,6 +30794,7 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
color: #94a3b8;
}
.project-fact-graph-sidebar-actions {
flex: 0 0 auto;
display: flex;
gap: 8px;
flex-wrap: wrap;
@@ -30772,24 +30803,31 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
}
.project-fact-graph-footer {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
justify-content: space-between;
gap: 8px 12px;
margin: 10px 0 0;
gap: 12px;
margin: 0;
flex: 0 0 auto;
flex-shrink: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.project-fact-graph-footer::-webkit-scrollbar {
display: none;
}
.project-fact-graph-stats {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
margin: 0;
flex: 0 1 auto;
flex: 0 0 auto;
}
.projects-graph-stat-badge {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 4px;
font-size: 0.8125rem;
@@ -30798,6 +30836,7 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
border: 1px solid #e2e8f0;
padding: 4px 12px;
border-radius: 999px;
white-space: nowrap;
}
.projects-graph-stat-badge strong {
font-size: 0.9375rem;
+15 -4
View File
@@ -575,6 +575,8 @@
"viewToolDetail": "View details",
"collapseToolDetail": "Collapse",
"liveTimelinePruned": "Collapsed the first {{count}} live process details. View the full record page by page after the task completes.",
"liveTimelinePrunedSingleRound": "main-agent round {{n}}",
"liveTimelinePrunedRoundRange": "main-agent rounds {{from}}{{to}}",
"toolExecutionsCount": "{{n}} tool runs",
"collapseToolExecutions": "Collapse tool runs",
"noProcessDetail": "No process details (execution may be too fast or no detailed events)",
@@ -2658,28 +2660,37 @@
},
"settingsRobotsExtra": {
"botCommandsTitle": "Bot command instructions",
"botCommandsEntryDesc": "View robot commands for identity, authorization, conversations, roles, and projects.",
"botCommandsEntryDesc": "View robot commands for identity, authorization, conversations, roles, modes, projects, and safety diagnostics.",
"viewAllCommands": "View all commands",
"botCommandsDesc": "You can send the following commands in chat (Chinese and English supported):",
"botCmdCategoryGeneral": "General",
"botCmdCategoryIdentity": "Identity and authorization",
"botCmdCategoryConversation": "Conversation",
"botCmdCategoryRole": "Role",
"botCmdCategoryMode": "Mode",
"botCmdCategoryDiagnostics": "Diagnostics and safety",
"botCmdCategoryProject": "Project",
"botCmdHelp": "Show this help",
"botCmdIdentity": "Show the platform sender, authorization mode, and effective RBAC identity",
"botCmdBindUser": "Bind this platform identity to an RBAC user (user-binding mode only)",
"botCmdUnbindUser": "Remove this platform identity binding (user-binding mode only)",
"botCmdUnbindUser": "Remove this platform identity binding (user-binding mode only; confirmation required)",
"botAuthHint": "Service-account mode does not accept bind or unbind commands; only configured allowlisted senders are accepted.",
"botCmdList": "List conversations",
"botCmdSwitch": "Switch to conversation",
"botCmdNew": "Start new conversation",
"botCmdClear": "Clear context",
"botCmdCurrent": "Show current conversation, role and project",
"botCmdStatus": "Show the current conversation, mode, role and project",
"botCmdTask": "Show current task status and elapsed time",
"botCmdRename": "Rename the current conversation",
"botCmdPermissions": "Show effective business permissions",
"botCmdDoctor": "Check key configuration status",
"botCmdConfirmation": "Confirm or cancel a risky action",
"botCmdStop": "Stop running task",
"botCmdRoles": "List roles",
"botCmdRole": "Switch role",
"botCmdDelete": "Delete conversation",
"botCmdModes": "List conversation modes and the current selection",
"botCmdMode": "Switch conversation mode",
"botCmdDelete": "Delete conversation (confirmation required)",
"botCmdVersion": "Show version",
"botCmdProjects": "List projects",
"botCmdNewProject": "Create project and bind current conversation",
+15 -4
View File
@@ -563,6 +563,8 @@
"viewToolDetail": "查看详情",
"collapseToolDetail": "收起",
"liveTimelinePruned": "已收起前 {{count}} 条实时过程详情,任务完成后可按页查看完整记录",
"liveTimelinePrunedSingleRound": "主代理第 {{n}} 轮",
"liveTimelinePrunedRoundRange": "主代理第 {{from}}{{to}} 轮",
"toolExecutionsCount": "{{n}}次工具执行",
"collapseToolExecutions": "收起工具执行",
"noProcessDetail": "暂无过程详情(可能执行过快或未触发详细事件)",
@@ -2646,28 +2648,37 @@
},
"settingsRobotsExtra": {
"botCommandsTitle": "机器人命令说明",
"botCommandsEntryDesc": "查看机器人对话中可用的中英文命令,包括身份鉴权、对话、角色和项目操作。",
"botCommandsEntryDesc": "查看机器人对话中可用的中英文命令,包括身份鉴权、对话、角色、模式、项目和安全诊断。",
"viewAllCommands": "查看全部命令",
"botCommandsDesc": "在对话中可发送以下命令(支持中英文):",
"botCmdCategoryGeneral": "通用",
"botCmdCategoryIdentity": "身份与鉴权",
"botCmdCategoryConversation": "对话",
"botCmdCategoryRole": "角色",
"botCmdCategoryMode": "模式",
"botCmdCategoryDiagnostics": "诊断与安全",
"botCmdCategoryProject": "项目",
"botCmdHelp": "显示本帮助 | Show this help",
"botCmdIdentity": "显示平台发送者、鉴权模式及当前实际 RBAC 身份 | Show effective identity",
"botCmdBindUser": "绑定当前平台账号到 RBAC 用户(仅逐用户绑定模式) | Bind RBAC user",
"botCmdUnbindUser": "解除当前平台账号绑定(仅逐用户绑定模式) | Unbind RBAC user",
"botCmdUnbindUser": "解除当前平台账号绑定(仅逐用户绑定模式,需确认 | Unbind RBAC user",
"botAuthHint": "专用服务账号模式不接受“绑定/解绑”命令,仅允许机器人配置中白名单内的发送者。",
"botCmdList": "列出所有对话标题与 ID | List conversations",
"botCmdSwitch": "指定对话继续 | Switch to conversation",
"botCmdNew": "开启新对话 | Start new conversation",
"botCmdClear": "清空当前上下文 | Clear context",
"botCmdCurrent": "显示当前对话、角色与项目 | Show current conversation",
"botCmdStatus": "汇总当前对话、模式、角色与项目 | Show current status",
"botCmdTask": "查看当前任务状态与运行时长 | Show current task",
"botCmdRename": "修改当前对话标题 | Rename conversation",
"botCmdPermissions": "查看当前业务权限 | Show effective permissions",
"botCmdDoctor": "检查关键配置状态 | Check configuration status",
"botCmdConfirmation": "确认或取消高风险操作 | Confirm or cancel risky action",
"botCmdStop": "中断当前任务 | Stop running task",
"botCmdRoles": "列出所有可用角色 | List roles",
"botCmdRole": "切换当前角色 | Switch role",
"botCmdDelete": "删除指定对话 | Delete conversation",
"botCmdModes": "列出对话模式与当前选择 | List conversation modes",
"botCmdMode": "切换对话模式 | Switch conversation mode",
"botCmdDelete": "删除指定对话(需确认) | Delete conversation",
"botCmdVersion": "显示当前版本号 | Show version",
"botCmdProjects": "列出所有项目 | List projects",
"botCmdNewProject": "创建项目并绑定当前对话 | Create & bind project",
+24 -26
View File
@@ -1,7 +1,13 @@
let currentConversationId = null;
let loadConversationRequestSeq = 0;
/** 轻量会话 LRU 缓存:来回切换已加载会话时避免重复网络 + 全量 DOM 重建 */
/**
* 轻量会话 LRU 缓存
*
* 缓存只用作请求失败时的降级数据不能先于服务端响应直接渲染
* 运行中会话的 process details 会持续写入直接渲染旧快照会让
* UI 暂时回退到旧轮次 task-events 接管后又突然跳到最新轮次
*/
const CONVERSATION_LITE_CACHE_MAX = 12;
const conversationLiteCache = new Map();
@@ -4131,32 +4137,24 @@ async function loadConversation(conversationId) {
const seq = ++loadConversationRequestSeq;
try {
const cachedConversation = getConversationLiteFromCache(conversationId);
const fetchPromise = apiFetch(`/api/conversations/${conversationId}?include_process_details=0`)
.then(async (response) => {
const data = await response.json();
return { response, data };
});
let conversation;
let response;
if (cachedConversation) {
let conversation = null;
let response = null;
try {
response = await apiFetch(`/api/conversations/${conversationId}?include_process_details=0`);
conversation = await response.json();
} catch (fetchError) {
if (!cachedConversation) throw fetchError;
console.warn('加载最新对话失败,使用本地缓存:', fetchError);
conversation = cachedConversation;
fetchPromise.then(({ response: freshResp, data }) => {
if (freshResp.ok && data && seq === loadConversationRequestSeq && currentConversationId === conversationId) {
putConversationLiteCache(conversationId, data);
}
}).catch(() => {});
} else {
const fetched = await fetchPromise;
response = fetched.response;
conversation = fetched.data;
if (seq !== loadConversationRequestSeq) {
return;
}
if (!response.ok) {
showChatToast('加载对话失败: ' + (conversation.error || '未知错误'), 'error');
return;
}
}
if (seq !== loadConversationRequestSeq) {
return;
}
if (response && !response.ok) {
showChatToast('加载对话失败: ' + (conversation.error || '未知错误'), 'error');
return;
}
if (response && response.ok) {
putConversationLiteCache(conversationId, conversation);
}
if (seq !== loadConversationRequestSeq) {
+72 -13
View File
@@ -319,7 +319,10 @@ function finalizeOutstandingToolCallsForProgress(progressId, finalStatus) {
if (!mapping) continue;
if (mapping.progressId != null && String(mapping.progressId) !== pid) continue;
const tcid = mapping.toolCallId || (String(mapKey).includes('::') ? String(mapKey).split('::').slice(1).join('::') : String(mapKey));
updateToolCallStatus(mapping.progressId || progressId, tcid, finalStatus);
// 已收到终态结果的调用仅清理索引,不能在任务收尾时被改写成失败。
if (!mapping.terminalStatus) {
updateToolCallStatus(mapping.progressId || progressId, tcid, finalStatus);
}
toolCallStatusMap.delete(mapKey);
}
}
@@ -1866,15 +1869,30 @@ function handleStreamEvent(event, progressElement, progressId,
? String(prevMainIter.workflowNodeId).trim()
: '';
const curNode = d.workflowNodeId != null ? String(d.workflowNodeId).trim() : '';
const prevAgent = prevMainIter && prevMainIter.einoAgent != null
? String(prevMainIter.einoAgent).trim()
: '';
const curAgent = d.einoAgent != null ? String(d.einoAgent).trim() : '';
const prevOrchestration = prevMainIter && prevMainIter.orchestration != null
? String(prevMainIter.orchestration).trim()
: '';
const curOrchestration = d.orchestration != null ? String(d.orchestration).trim() : '';
const duplicateMainIteration = prevN != null && String(prevN) === String(n) &&
prevNode === curNode && prevAgent === curAgent &&
prevOrchestration === curOrchestration;
mainIterationStateByProgressId.set(String(progressId), {
iteration: n,
orchestration: d.orchestration != null ? d.orchestration : '',
workflowNodeId: curNode
workflowNodeId: curNode,
einoAgent: curAgent
});
// 主通道进入新轮次或图编排切换到新 Agent 节点后,不复用上一段的流式时间线条目
if (prevN != null && (n < prevN || prevN !== n || (curNode && prevNode && curNode !== prevNode))) {
clearTimelineStreamStates(progressId);
}
// 同一主代理可能从“进入模型”和“检测到工具批次”两条路径补发同一轮次。
// 状态缓存仍更新,但时间线只保留一个幂等条目。
if (duplicateMainIteration) break;
}
let iterTitle;
if (d.orchestration === 'plan_execute' && d.einoScope === 'main') {
@@ -2306,7 +2324,9 @@ function handleStreamEvent(event, progressElement, progressId,
const existingItem = document.getElementById(existing.itemId);
if (existingItem) {
// 同一 toolCallId 的重复 tool_call(重试/补发)只更新状态,不重复追加条目。
updateToolCallStatus(progressId, toolCallId, 'running');
if (!existing.terminalStatus) {
updateToolCallStatus(progressId, toolCallId, 'running');
}
break;
}
}
@@ -2359,7 +2379,7 @@ function handleStreamEvent(event, progressElement, progressId,
const mapKey = toolCallMapKey(progressId, resultToolCallId);
if (toolCallStatusMap.has(mapKey)) {
updateToolCallStatus(progressId, resultToolCallId, success ? 'completed' : 'failed');
toolCallStatusMap.delete(mapKey);
toolCallStatusMap.get(mapKey).terminalStatus = success ? 'completed' : 'failed';
}
break;
}
@@ -2367,7 +2387,7 @@ function handleStreamEvent(event, progressElement, progressId,
const mapKey = toolCallMapKey(progressId, resultToolCallId);
if (toolCallStatusMap.has(mapKey)) {
updateToolCallStatus(progressId, resultToolCallId, success ? 'completed' : 'failed');
toolCallStatusMap.delete(mapKey);
toolCallStatusMap.get(mapKey).terminalStatus = success ? 'completed' : 'failed';
}
break;
}
@@ -2375,7 +2395,7 @@ function handleStreamEvent(event, progressElement, progressId,
if (resultToolCallId && toolCallStatusMap.has(toolCallMapKey(progressId, resultToolCallId))) {
updateToolCallStatus(progressId, resultToolCallId, success ? 'completed' : 'failed');
toolCallStatusMap.delete(toolCallMapKey(progressId, resultToolCallId));
toolCallStatusMap.get(toolCallMapKey(progressId, resultToolCallId)).terminalStatus = success ? 'completed' : 'failed';
}
addTimelineItem(timeline, 'tool_result', {
title: timelineAgentBracketPrefix(resultInfo) + statusIcon + ' ' + resultExecText,
@@ -3948,6 +3968,25 @@ function isLiveProgressTimeline(timeline) {
return !!(timeline && timeline.id && /^progress-\d+-\d+-timeline$/.test(timeline.id));
}
function formatLiveTimelinePrunedMarker(marker) {
const count = parseInt(marker.dataset.prunedCount || '0', 10) || 0;
const minIteration = parseInt(marker.dataset.prunedMainIterationMin || '0', 10) || 0;
const maxIteration = parseInt(marker.dataset.prunedMainIterationMax || '0', 10) || 0;
const translate = typeof window.t === 'function' ? window.t : null;
const baseText = translate
? translate('chat.liveTimelinePruned', { count: count })
: ('已收起前 ' + count + ' 条实时过程详情,任务完成后可按页查看完整记录');
if (minIteration <= 0 || maxIteration < minIteration) return baseText;
if (minIteration === maxIteration) {
return baseText + ' · ' + (translate
? translate('chat.liveTimelinePrunedSingleRound', { n: minIteration })
: ('主代理第 ' + minIteration + ' 轮'));
}
return baseText + ' · ' + (translate
? translate('chat.liveTimelinePrunedRoundRange', { from: minIteration, to: maxIteration })
: ('主代理第 ' + minIteration + '' + maxIteration + ' 轮'));
}
function pruneLiveTimelineIfNeeded(timeline) {
if (!isLiveProgressTimeline(timeline)) return;
const items = Array.from(timeline.children).filter(function (el) {
@@ -3970,16 +4009,37 @@ function pruneLiveTimelineIfNeeded(timeline) {
if (!marker) {
marker = document.createElement('div');
marker.className = 'timeline-live-pruned-marker';
marker.setAttribute('role', 'status');
marker.setAttribute('aria-live', 'polite');
timeline.insertBefore(marker, timeline.firstChild);
}
let prunedMainIterationMin = parseInt(marker.dataset.prunedMainIterationMin || '0', 10) || 0;
let prunedMainIterationMax = parseInt(marker.dataset.prunedMainIterationMax || '0', 10) || 0;
for (let i = 0; i < removeCount; i++) {
removable[i].remove();
const removed = removable[i];
if (removed && removed.dataset && removed.dataset.timelineType === 'iteration' &&
removed.dataset.einoScope !== 'sub') {
const iteration = parseInt(removed.dataset.iterationN || '0', 10) || 0;
if (iteration > 0) {
if (prunedMainIterationMin === 0 || iteration < prunedMainIterationMin) {
prunedMainIterationMin = iteration;
}
if (iteration > prunedMainIterationMax) {
prunedMainIterationMax = iteration;
}
}
}
removed.remove();
}
pruned += removeCount;
marker.dataset.prunedCount = String(pruned);
marker.textContent = typeof window.t === 'function'
? window.t('chat.liveTimelinePruned', { count: pruned })
: ('已收起前 ' + pruned + ' 条实时过程详情,任务完成后可按页查看完整记录');
marker.dataset.prunedMainIterationMin = String(prunedMainIterationMin);
marker.dataset.prunedMainIterationMax = String(prunedMainIterationMax);
marker.textContent = formatLiveTimelinePrunedMarker(marker);
// 始终放在已保留详情之前;配合 sticky 样式,查看最新内容时也能感知历史已裁剪。
if (timeline.firstChild !== marker) {
timeline.insertBefore(marker, timeline.firstChild);
}
}
function addTimelineItem(timeline, type, options) {
@@ -4816,7 +4876,7 @@ function updateMonitorTimelineSection() {
}
const MCP_STATS_TOP_N = 6;
const MCP_STATS_TOP_N = 3;
const MCP_TIMELINE_RANGES = ['24h', '7d', '30d'];
function getMcpMonitorTimelineRange() {
@@ -6565,8 +6625,7 @@ function refreshProgressAndTimelineI18n() {
});
document.querySelectorAll('.timeline-live-pruned-marker').forEach(function (marker) {
const count = parseInt(marker.dataset.prunedCount || '0', 10) || 0;
marker.textContent = _t('chat.liveTimelinePruned', { count: count });
marker.textContent = formatLiveTimelinePrunedMarker(marker);
});
// 详情区「展开/收起」按钮
+17 -1
View File
@@ -1178,7 +1178,7 @@ function renderGraphEdgesListHtml(factKey, graphData, selectedEdgeId) {
const synthetic = isSyntheticGraphEdge(e);
const deleteBtn = synthetic
? `<span class="project-fact-graph-edge-synthetic" title="${escapeHtml(tp('projects.graphEdgeSynthetic'))}">—</span>`
: `<button type="button" class="project-fact-graph-edge-delete" data-edge-id="${escapeHtml(e.id)}" onclick="event.stopPropagation(); deleteProjectFactEdge(this.dataset.edgeId)" title="${escapeHtml(tp('projects.graphDeleteEdge'))}">×</button>`;
: `<button type="button" class="project-fact-graph-edge-delete" data-edge-id="${escapeHtml(e.id)}" onclick="event.stopPropagation(); deleteProjectFactEdge(this.dataset.edgeId)" title="${escapeHtml(tp('projects.graphDeleteEdge'))}"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>`;
return `<div class="project-fact-graph-edge-item${selected}" data-edge-id="${escapeHtml(e.id)}" onclick="focusProjectFactGraphEdge(${JSON.stringify(e.id)})">
<span class="project-fact-graph-edge-dir">${escapeHtml(dirLabel)}</span>
<span class="project-fact-graph-edge-type">${escapeHtml(e.type || '')}</span>
@@ -2675,15 +2675,31 @@ function initChatProjectSelector() {
});
}
function initProjectGraphFooterWheelScroll() {
const footer = document.querySelector('.project-fact-graph-footer');
if (!footer || footer.dataset.wheelScrollBound === 'true') return;
footer.dataset.wheelScrollBound = 'true';
footer.addEventListener('wheel', (event) => {
if (footer.scrollWidth <= footer.clientWidth || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
const maxScrollLeft = footer.scrollWidth - footer.clientWidth;
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, footer.scrollLeft + event.deltaY));
if (nextScrollLeft === footer.scrollLeft) return;
footer.scrollLeft = nextScrollLeft;
event.preventDefault();
}, { passive: false });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
initChatProjectSelector();
initProjectListActionMenu();
initProjectGraphFooterWheelScroll();
refreshProjectsFilterSelects();
});
} else {
initChatProjectSelector();
initProjectListActionMenu();
initProjectGraphFooterWheelScroll();
refreshProjectsFilterSelects();
}
+19 -4
View File
@@ -3980,7 +3980,7 @@
<ul class="robot-cmd-list">
<li><code>身份</code> <code>whoami</code><span data-i18n="settingsRobotsExtra.botCmdIdentity">显示平台发送者、鉴权模式及当前实际 RBAC 身份 | Show effective identity</span></li>
<li><code>绑定 &lt;绑定码&gt;</code> <code>bind &lt;code&gt;</code><span data-i18n="settingsRobotsExtra.botCmdBindUser">绑定当前平台账号到 RBAC 用户(仅逐用户绑定模式) | Bind RBAC user</span></li>
<li><code>解绑</code> <code>unbind</code><span data-i18n="settingsRobotsExtra.botCmdUnbindUser">解除当前平台账号绑定(仅逐用户绑定模式) | Unbind RBAC user</span></li>
<li><code>解绑</code> <code>unbind</code><span data-i18n="settingsRobotsExtra.botCmdUnbindUser">解除当前平台账号绑定(仅逐用户绑定模式,需确认 | Unbind RBAC user</span></li>
</ul>
<p class="settings-description" data-i18n="settingsRobotsExtra.botAuthHint">专用服务账号模式不接受“绑定/解绑”命令,仅允许机器人配置中白名单内的发送者。</p>
@@ -3990,9 +3990,11 @@
<li><code>切换 &lt;ID&gt;</code> <code>switch &lt;ID&gt;</code><span data-i18n="settingsRobotsExtra.botCmdSwitch">指定对话继续 | Switch to conversation</span></li>
<li><code>新对话</code> <code>new</code><span data-i18n="settingsRobotsExtra.botCmdNew">开启新对话 | Start new conversation</span></li>
<li><code>清空</code> <code>clear</code><span data-i18n="settingsRobotsExtra.botCmdClear">清空当前上下文 | Clear context</span></li>
<li><code>当前</code> <code>current</code><span data-i18n="settingsRobotsExtra.botCmdCurrent">显示当前对话、角色与项目 | Show current conversation</span></li>
<li><code>状态</code> <code>status</code><span data-i18n="settingsRobotsExtra.botCmdStatus">汇总当前对话、模式、角色与项目 | Show current status</span></li>
<li><code>任务</code> <code>task</code><span data-i18n="settingsRobotsExtra.botCmdTask">查看当前任务状态与运行时长 | Show current task</span></li>
<li><code>重命名 &lt;名称&gt;</code> <code>rename &lt;name&gt;</code><span data-i18n="settingsRobotsExtra.botCmdRename">修改当前对话标题 | Rename conversation</span></li>
<li><code>停止</code> <code>stop</code><span data-i18n="settingsRobotsExtra.botCmdStop">中断当前任务 | Stop running task</span></li>
<li><code>删除 &lt;ID&gt;</code> <code>delete &lt;ID&gt;</code><span data-i18n="settingsRobotsExtra.botCmdDelete">删除指定对话 | Delete conversation</span></li>
<li><code>删除 &lt;ID&gt;</code> <code>delete &lt;ID&gt;</code><span data-i18n="settingsRobotsExtra.botCmdDelete">删除指定对话(需确认) | Delete conversation</span></li>
</ul>
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryRole">角色</p>
@@ -4001,6 +4003,12 @@
<li><code>角色 &lt;&gt;</code> <code>role &lt;name&gt;</code><span data-i18n="settingsRobotsExtra.botCmdRole">切换当前角色 | Switch role</span></li>
</ul>
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryMode">模式</p>
<ul class="robot-cmd-list">
<li><code>模式</code> <code>modes</code><span data-i18n="settingsRobotsExtra.botCmdModes">列出对话模式与当前选择 | List conversation modes</span></li>
<li><code>模式 &lt;名称&gt;</code> <code>mode &lt;name&gt;</code><span data-i18n="settingsRobotsExtra.botCmdMode">切换对话模式 | Switch conversation mode</span></li>
</ul>
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryProject">项目</p>
<ul class="robot-cmd-list">
<li><code>项目</code> <code>projects</code><span data-i18n="settingsRobotsExtra.botCmdProjects">列出所有项目 | List projects</span></li>
@@ -4009,6 +4017,13 @@
<li><code>解除项目</code> <code>unbind project</code><span data-i18n="settingsRobotsExtra.botCmdUnbindProject">解除当前对话的项目绑定 | Unbind project</span></li>
</ul>
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryDiagnostics">诊断与安全</p>
<ul class="robot-cmd-list">
<li><code>权限</code> <code>permissions</code><span data-i18n="settingsRobotsExtra.botCmdPermissions">查看当前业务权限 | Show effective permissions</span></li>
<li><code>诊断</code> <code>doctor</code><span data-i18n="settingsRobotsExtra.botCmdDoctor">检查关键配置状态 | Check configuration status</span></li>
<li><code>确认</code> <code>confirm</code> / <code>取消</code> <code>cancel</code><span data-i18n="settingsRobotsExtra.botCmdConfirmation">确认或取消高风险操作 | Confirm or cancel risky action</span></li>
</ul>
<p class="settings-description robot-cmd-footer" data-i18n="settingsRobotsExtra.botCommandsFooter">除以上命令外,直接输入内容将按绑定用户或服务账号的实时 RBAC 权限发送给 AI。Otherwise, text is sent to AI under the effective RBAC identity.</p>
</div>
</div>
@@ -4017,7 +4032,7 @@
<div class="settings-subsection robot-command-entry">
<div class="robot-command-entry-copy">
<h4 data-i18n="settingsRobotsExtra.botCommandsTitle">机器人命令说明</h4>
<p class="settings-description" data-i18n="settingsRobotsExtra.botCommandsEntryDesc">查看机器人对话中可用的中英文命令,包括身份鉴权、对话、角色和项目操作</p>
<p class="settings-description" data-i18n="settingsRobotsExtra.botCommandsEntryDesc">查看机器人对话中可用的中英文命令,包括身份鉴权、对话、角色、模式、项目和安全诊断</p>
</div>
<button type="button" class="btn-secondary" onclick="openRobotCommandsModal()" data-i18n="settingsRobotsExtra.viewAllCommands">查看全部命令</button>
</div>