mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-01 16:38:48 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41f683ce6c | ||
|
|
ffae94fb2c | ||
|
|
fe3c845ff8 | ||
|
|
0f1a6ad25a | ||
|
|
3b3f73461b | ||
|
|
3ce80fd00f | ||
|
|
08cb8a68fb | ||
|
|
2f38693891 | ||
|
|
6fa17a3093 | ||
|
|
46cea9459a | ||
|
|
987bd0a03c | ||
|
|
6529c13ffb | ||
|
|
f2ba5093d7 | ||
|
|
3c6cf633e1 | ||
|
|
142977413e | ||
|
|
62efc81993 | ||
|
|
9ac5fd33ec | ||
|
|
d1d67b07d3 | ||
|
|
211c36654a | ||
|
|
3894ba6054 | ||
|
|
fa0dd6c721 | ||
|
|
1cf10981ef | ||
|
|
79d162ccb4 | ||
|
|
722806797f | ||
|
|
2fcda3a57d | ||
|
|
bec296ae3f | ||
|
|
612015b6d7 |
@@ -212,7 +212,7 @@ The `run.sh` script will automatically:
|
|||||||
model: "gpt-4o" # or deepseek-chat, claude-3-opus, etc.
|
model: "gpt-4o" # or deepseek-chat, claude-3-opus, etc.
|
||||||
```
|
```
|
||||||
- Or edit `config.yaml` directly before launching
|
- Or edit `config.yaml` directly before launching
|
||||||
2. **Login** - Use the auto-generated password shown in the console (or set `auth.password` in `config.yaml`)
|
2. **Login** - On first startup the console prints an auto-generated initial `admin` password; create accounts from **Platform permissions → User management**
|
||||||
3. **Install security tools (optional)** - Install tools from `tools/` as needed; missing tools are skipped or substituted at runtime. Common examples:
|
3. **Install security tools (optional)** - Install tools from `tools/` as needed; missing tools are skipped or substituted at runtime. Common examples:
|
||||||
|
|
||||||
**macOS (Homebrew):**
|
**macOS (Homebrew):**
|
||||||
@@ -281,7 +281,7 @@ Requirements / tips:
|
|||||||
|
|
||||||
### Built-in Safeguards
|
### Built-in Safeguards
|
||||||
- Required-field validation prevents accidental blank API credentials.
|
- Required-field validation prevents accidental blank API credentials.
|
||||||
- Auto-generated strong passwords when `auth.password` is empty.
|
- Auto-generated 24-character initial `admin` password on first startup when no RBAC users exist (stored in the database only, not in `config.yaml`).
|
||||||
- Unified auth middleware for every web/API call (Bearer token flow).
|
- Unified auth middleware for every web/API call (Bearer token flow).
|
||||||
- Timeout and sandbox guards per tool, plus structured logging for triage.
|
- Timeout and sandbox guards per tool, plus structured logging for triage.
|
||||||
|
|
||||||
@@ -537,7 +537,6 @@ A test SSE MCP server is available at `cmd/test-sse-mcp-server/` for validation
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
auth:
|
auth:
|
||||||
password: "change-me"
|
|
||||||
session_duration_hours: 12
|
session_duration_hours: 12
|
||||||
server:
|
server:
|
||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
|
|||||||
+2
-3
@@ -211,7 +211,7 @@ chmod +x run.sh && ./run.sh
|
|||||||
model: "gpt-4o" # 或 deepseek-chat, claude-3-opus 等
|
model: "gpt-4o" # 或 deepseek-chat, claude-3-opus 等
|
||||||
```
|
```
|
||||||
- 或启动前直接编辑 `config.yaml` 文件
|
- 或启动前直接编辑 `config.yaml` 文件
|
||||||
2. **登录系统** - 使用控制台显示的自动生成密码(或在 `config.yaml` 中设置 `auth.password`)
|
2. **登录系统** - 首次启动时控制台会显示自动生成的 `admin` 初始密码;也可在「平台权限 → 用户管理」中创建账号
|
||||||
3. **安装安全工具(可选)** - 按需安装 `tools/` 目录中的工具;未安装的工具在执行时会自动跳过或改用替代方案。常用示例:
|
3. **安装安全工具(可选)** - 按需安装 `tools/` 目录中的工具;未安装的工具在执行时会自动跳过或改用替代方案。常用示例:
|
||||||
|
|
||||||
**macOS(Homebrew):**
|
**macOS(Homebrew):**
|
||||||
@@ -279,7 +279,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
|||||||
|
|
||||||
### 默认安全措施
|
### 默认安全措施
|
||||||
- 设置面板内置必填校验,防止漏配 API Key/Base URL/模型。
|
- 设置面板内置必填校验,防止漏配 API Key/Base URL/模型。
|
||||||
- `auth.password` 为空时自动生成 24 位强口令并写回 `config.yaml`。
|
- 首次启动且无 RBAC 用户时,自动生成 24 位 `admin` 初始密码并在控制台输出(仅存于数据库,不再写入 `config.yaml`)。
|
||||||
- 所有 API(除登录外)都需携带 Bearer Token,统一鉴权中间件拦截。
|
- 所有 API(除登录外)都需携带 Bearer Token,统一鉴权中间件拦截。
|
||||||
- 每个工具执行都带有超时、日志和错误隔离。
|
- 每个工具执行都带有超时、日志和错误隔离。
|
||||||
|
|
||||||
@@ -535,7 +535,6 @@ CyberStrikeAI 支持通过三种传输模式连接外部 MCP 服务器:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
auth:
|
auth:
|
||||||
password: "change-me"
|
|
||||||
session_duration_hours: 12
|
session_duration_hours: 12
|
||||||
server:
|
server:
|
||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
|
|||||||
+8
-11
@@ -5,6 +5,7 @@ import (
|
|||||||
"cyberstrike-ai/internal/app"
|
"cyberstrike-ai/internal/app"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/logger"
|
"cyberstrike-ai/internal/logger"
|
||||||
|
"cyberstrike-ai/internal/termout"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -47,8 +48,7 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if localConfig.Created {
|
if localConfig.Created {
|
||||||
cfg.Auth.GeneratedPassword = localConfig.GeneratedPassword
|
termout.PrintConfigCreated()
|
||||||
cfg.Auth.GeneratedPasswordPersisted = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if *httpsBootstrap {
|
if *httpsBootstrap {
|
||||||
@@ -63,15 +63,12 @@ func main() {
|
|||||||
if config.MainWebUIUsesHTTPS(&cfg.Server) {
|
if config.MainWebUIUsesHTTPS(&cfg.Server) {
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
fmt.Println()
|
termout.PrintStartupWebUI(termout.StartupWebUIOptions{
|
||||||
fmt.Printf("→ Web 界面: %s://127.0.0.1:%d/\n", scheme, port)
|
Scheme: scheme,
|
||||||
if scheme == "https" && cfg.Server.TLSAutoSelfSign {
|
Port: port,
|
||||||
fmt.Println(" (内存自签证书:浏览器首次需确认「继续访问」)")
|
SelfSigned: scheme == "https" && cfg.Server.TLSAutoSelfSign,
|
||||||
}
|
HTTPRedirect: scheme == "https" && config.ServerHTTPRedirectEnabled(&cfg.Server),
|
||||||
if scheme == "https" && config.ServerHTTPRedirectEnabled(&cfg.Server) {
|
})
|
||||||
fmt.Printf(" (http://127.0.0.1:%d/ 将自动跳转到 HTTPS)\n", port)
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
|
// MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
|
||||||
if err := config.EnsureMCPAuth(cp, cfg); err != nil {
|
if err := config.EnsureMCPAuth(cp, cfg); err != nil {
|
||||||
|
|||||||
+2
-2
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.7.0"
|
version: "v1.7.2"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
@@ -28,7 +28,6 @@ server:
|
|||||||
tls_auto_self_sign: true
|
tls_auto_self_sign: true
|
||||||
# 认证配置
|
# 认证配置
|
||||||
auth:
|
auth:
|
||||||
password: # Web 登录密码,请修改为强密码
|
|
||||||
session_duration_hours: 12 # 登录有效期(小时),超时后需重新登录
|
session_duration_hours: 12 # 登录有效期(小时),超时后需重新登录
|
||||||
# 日志配置
|
# 日志配置
|
||||||
log:
|
log:
|
||||||
@@ -210,6 +209,7 @@ multi_agent:
|
|||||||
reduction_clear_exclude: [] # 不参与「清理阶段」的工具名额外列表(会与 task/transfer/exit 等内置排除项合并);需要时用 YAML 列表填写
|
reduction_clear_exclude: [] # 不参与「清理阶段」的工具名额外列表(会与 task/transfer/exit 等内置排除项合并);需要时用 YAML 列表填写
|
||||||
reduction_sub_agents: true # true:子代理也挂 reduction;false:仅编排主代理使用 reduction
|
reduction_sub_agents: true # true:子代理也挂 reduction;false:仅编排主代理使用 reduction
|
||||||
summarization_trigger_ratio: 0.8 # summarization 触发比例(max_total_tokens * ratio),建议 0.75~0.85
|
summarization_trigger_ratio: 0.8 # summarization 触发比例(max_total_tokens * ratio),建议 0.75~0.85
|
||||||
|
summarization_output_reserve_tokens: 8192 # 摘要模型输出预留 token;摘要输入预算 = 触发阈值 - 该值
|
||||||
summarization_emit_internal_events: true # true:发出 summarization 内部事件(便于诊断)
|
summarization_emit_internal_events: true # true:发出 summarization 内部事件(便于诊断)
|
||||||
summarization_user_intent_ledger_max_runes: 96000 # 压缩后注入模型上下文的「原始用户输入与约束账本」总字符上限;DB 原始消息不裁剪
|
summarization_user_intent_ledger_max_runes: 96000 # 压缩后注入模型上下文的「原始用户输入与约束账本」总字符上限;DB 原始消息不裁剪
|
||||||
summarization_user_intent_ledger_entry_max_runes: 16000 # 账本中单条用户消息的字符上限;超出仅裁剪模型可见账本,不影响 DB 原文
|
summarization_user_intent_ledger_entry_max_runes: 16000 # 账本中单条用户消息的字符上限;超出仅裁剪模型可见账本,不影响 DB 原文
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ server:
|
|||||||
tls_enabled: true
|
tls_enabled: true
|
||||||
tls_auto_self_sign: true
|
tls_auto_self_sign: true
|
||||||
auth:
|
auth:
|
||||||
password: "dev-only-change-me"
|
session_duration_hours: 12
|
||||||
audit:
|
audit:
|
||||||
enabled: true
|
enabled: true
|
||||||
retention_days: 7
|
retention_days: 7
|
||||||
@@ -45,7 +45,7 @@ server:
|
|||||||
port: 8080
|
port: 8080
|
||||||
tls_enabled: false
|
tls_enabled: false
|
||||||
auth:
|
auth:
|
||||||
password: "<long-random-password>"
|
session_duration_hours: 12
|
||||||
audit:
|
audit:
|
||||||
enabled: true
|
enabled: true
|
||||||
retention_days: 30
|
retention_days: 30
|
||||||
@@ -90,7 +90,6 @@ Goal: long-running production red-team or security platform.
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
auth:
|
auth:
|
||||||
password: "<managed-secret>"
|
|
||||||
session_duration_hours: 8
|
session_duration_hours: 8
|
||||||
audit:
|
audit:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ server:
|
|||||||
port: 8080
|
port: 8080
|
||||||
tls_enabled: true
|
tls_enabled: true
|
||||||
auth:
|
auth:
|
||||||
password: "change-me"
|
|
||||||
session_duration_hours: 12
|
session_duration_hours: 12
|
||||||
openai:
|
openai:
|
||||||
provider: openai
|
provider: openai
|
||||||
@@ -24,7 +23,7 @@ agent:
|
|||||||
tool_timeout_minutes: 60
|
tool_timeout_minutes: 60
|
||||||
```
|
```
|
||||||
|
|
||||||
Change the default password immediately. Use HTTPS or a trusted reverse proxy in any shared environment.
|
Change the initial `admin` password from the Web UI after first login. Use HTTPS or a trusted reverse proxy in any shared environment.
|
||||||
|
|
||||||
## Hot-Apply Boundaries
|
## Hot-Apply Boundaries
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ config.yaml
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
auth:
|
auth:
|
||||||
password: "<long-random-password>"
|
session_duration_hours: 12
|
||||||
server:
|
server:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This checklist covers pre-production and continuous hardening for CyberStrikeAI.
|
|||||||
|
|
||||||
## Before Going Live
|
## Before Going Live
|
||||||
|
|
||||||
- Change `auth.password` to a long random secret.
|
- Change the initial `admin` password from the Web UI after first login.
|
||||||
- Use HTTPS or a trusted reverse proxy.
|
- Use HTTPS or a trusted reverse proxy.
|
||||||
- Restrict access by IP, VPN, or bastion.
|
- Restrict access by IP, VPN, or bastion.
|
||||||
- Enable `audit.enabled`.
|
- Enable `audit.enabled`.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ Page inaccessible:
|
|||||||
|
|
||||||
Login fails:
|
Login fails:
|
||||||
|
|
||||||
- wrong `auth.password`;
|
- wrong RBAC user password;
|
||||||
- config not applied/restarted;
|
- config not applied/restarted;
|
||||||
- stale cookie;
|
- stale cookie;
|
||||||
- audit throttling repeated failures.
|
- audit throttling repeated failures.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ server:
|
|||||||
tls_enabled: true
|
tls_enabled: true
|
||||||
tls_auto_self_sign: true
|
tls_auto_self_sign: true
|
||||||
auth:
|
auth:
|
||||||
password: "dev-only-change-me"
|
session_duration_hours: 12
|
||||||
audit:
|
audit:
|
||||||
enabled: true
|
enabled: true
|
||||||
retention_days: 7
|
retention_days: 7
|
||||||
@@ -54,7 +54,7 @@ server:
|
|||||||
port: 8080
|
port: 8080
|
||||||
tls_enabled: false
|
tls_enabled: false
|
||||||
auth:
|
auth:
|
||||||
password: "<long-random-password>"
|
session_duration_hours: 12
|
||||||
audit:
|
audit:
|
||||||
enabled: true
|
enabled: true
|
||||||
retention_days: 30
|
retention_days: 30
|
||||||
@@ -107,7 +107,6 @@ multi_agent:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
auth:
|
auth:
|
||||||
password: "<managed-secret>"
|
|
||||||
session_duration_hours: 8
|
session_duration_hours: 8
|
||||||
audit:
|
audit:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ server:
|
|||||||
tls_enabled: true
|
tls_enabled: true
|
||||||
tls_auto_self_sign: true
|
tls_auto_self_sign: true
|
||||||
auth:
|
auth:
|
||||||
password: "change-me"
|
|
||||||
session_duration_hours: 12
|
session_duration_hours: 12
|
||||||
log:
|
log:
|
||||||
level: info
|
level: info
|
||||||
@@ -22,7 +21,7 @@ log:
|
|||||||
- `version`:前端展示版本。
|
- `version`:前端展示版本。
|
||||||
- `server.host/port`:Web 服务监听地址和端口。
|
- `server.host/port`:Web 服务监听地址和端口。
|
||||||
- `server.tls_*`:HTTPS 配置。生产环境建议使用 `tls_cert_path` 和 `tls_key_path`。
|
- `server.tls_*`:HTTPS 配置。生产环境建议使用 `tls_cert_path` 和 `tls_key_path`。
|
||||||
- `auth.password`:Web 登录密码,必须改为强密码。
|
- `auth.session_duration_hours`:登录会话有效期(小时)。登录密码由 RBAC 用户管理,首次启动时在控制台输出 `admin` 初始密码。
|
||||||
- `auth.session_duration_hours`:登录会话有效期。
|
- `auth.session_duration_hours`:登录会话有效期。
|
||||||
- `log.output`:可以是 `stdout`、`stderr` 或文件路径。
|
- `log.output`:可以是 `stdout`、`stderr` 或文件路径。
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ config.yaml
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
auth:
|
auth:
|
||||||
password: "<long-random-password>"
|
session_duration_hours: 12
|
||||||
server:
|
server:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## 上线前必做
|
## 上线前必做
|
||||||
|
|
||||||
- 修改 `auth.password` 为长随机密码。
|
- 首次部署后立即修改 `admin` 初始密码(Web 界面或平台权限 → 用户管理)。
|
||||||
- 使用 HTTPS,或放在可信反向代理之后。
|
- 使用 HTTPS,或放在可信反向代理之后。
|
||||||
- 限制来源 IP、VPN 或堡垒机访问。
|
- 限制来源 IP、VPN 或堡垒机访问。
|
||||||
- 开启 `audit.enabled`。
|
- 开启 `audit.enabled`。
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ CyberStrikeAI 面向授权安全测试场景,内置命令执行、MCP 工具
|
|||||||
|
|
||||||
## 认证与会话
|
## 认证与会话
|
||||||
|
|
||||||
`auth.password` 是 Web 登录密码。建议:
|
Web 登录凭据由 RBAC 用户管理(默认内置 `admin` 账号)。建议:
|
||||||
|
|
||||||
- 首次部署立即修改默认密码。
|
- 首次部署后立即修改 `admin` 初始密码(控制台首次启动会输出)。
|
||||||
- 使用长随机密码,并限制分享范围。
|
- 使用长随机密码,并限制分享范围。
|
||||||
- 将服务放在内网、VPN、堡垒机或反向代理认证后面。
|
- 将服务放在内网、VPN、堡垒机或反向代理认证后面。
|
||||||
- 生产环境开启 HTTPS,避免明文传输 Cookie。
|
- 生产环境开启 HTTPS,避免明文传输 Cookie。
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ https://127.0.0.1:8080/
|
|||||||
|
|
||||||
检查:
|
检查:
|
||||||
|
|
||||||
- `config.yaml` 中的 `auth.password`。
|
- RBAC 用户密码是否正确(默认 `admin`;首次启动密码见控制台输出)。
|
||||||
- 是否修改后未重启或未应用配置。
|
- 是否修改密码后旧会话已失效,需重新登录。
|
||||||
- 浏览器 Cookie 是否异常,可尝试无痕窗口。
|
- 浏览器 Cookie 是否异常,可尝试无痕窗口。
|
||||||
- 审计日志中是否有登录失败节流。
|
- 审计日志中是否有登录失败节流。
|
||||||
|
|
||||||
生产环境忘记密码时,需要在服务器上修改 `config.yaml` 并重启服务。
|
生产环境忘记密码时,需在服务器上通过 RBAC 用户管理重置,或直接更新数据库中的用户密码哈希。
|
||||||
|
|
||||||
## 模型无响应
|
## 模型无响应
|
||||||
|
|
||||||
|
|||||||
@@ -661,7 +661,7 @@ func (a *Agent) UpdateToolDescriptionMode(mode string) {
|
|||||||
mode = "short"
|
mode = "short"
|
||||||
}
|
}
|
||||||
a.toolDescriptionMode = mode
|
a.toolDescriptionMode = mode
|
||||||
a.logger.Info("Agent工具描述模式已更新", zap.String("tool_description_mode", mode))
|
a.logger.Debug("Agent工具描述模式已更新", zap.String("tool_description_mode", mode))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RepairOrphanToolMessages 清理失去配对的tool消息和未完成的tool_calls,避免OpenAI报错
|
// RepairOrphanToolMessages 清理失去配对的tool消息和未完成的tool_calls,避免OpenAI报错
|
||||||
|
|||||||
+17
-22
@@ -101,13 +101,12 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
return nil, fmt.Errorf("初始化数据库失败: %w", err)
|
return nil, fmt.Errorf("初始化数据库失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 认证管理器(数据库初始化后挂载 RBAC,以兼容旧的单密码配置)
|
// 认证管理器(数据库初始化后挂载 RBAC)
|
||||||
authManager, err := security.NewAuthManager(cfg.Auth.Password, cfg.Auth.SessionDurationHours)
|
authManager := security.NewAuthManager(cfg.Auth.SessionDurationHours)
|
||||||
if err != nil {
|
if generatedPassword, err := authManager.AttachRBACStore(db); err != nil {
|
||||||
return nil, fmt.Errorf("初始化认证失败: %w", err)
|
|
||||||
}
|
|
||||||
if err := authManager.AttachRBACStore(db); err != nil {
|
|
||||||
return nil, fmt.Errorf("初始化RBAC失败: %w", err)
|
return nil, fmt.Errorf("初始化RBAC失败: %w", err)
|
||||||
|
} else if generatedPassword != "" {
|
||||||
|
config.PrintBootstrapAdminPassword(generatedPassword)
|
||||||
}
|
}
|
||||||
for platform, userID := range cfg.Robots.ServiceAccountUserIDs() {
|
for platform, userID := range cfg.Robots.ServiceAccountUserIDs() {
|
||||||
user, userErr := db.GetRBACUserByID(userID)
|
user, userErr := db.GetRBACUserByID(userID)
|
||||||
@@ -125,6 +124,9 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
monitorRetention.PurgeExpired()
|
monitorRetention.PurgeExpired()
|
||||||
monitor.StartRetentionLoop(monitorRetention, log.Logger)
|
monitor.StartRetentionLoop(monitorRetention, log.Logger)
|
||||||
|
|
||||||
|
if err := handler.NewHITLManager(db, log.Logger).EnsureSchema(); err != nil {
|
||||||
|
log.Logger.Warn("初始化 HITL 表失败", zap.Error(err))
|
||||||
|
}
|
||||||
hitlRetention := hitl.NewService(db, cfg, log.Logger)
|
hitlRetention := hitl.NewService(db, cfg, log.Logger)
|
||||||
hitlRetention.PurgeExpired()
|
hitlRetention.PurgeExpired()
|
||||||
hitl.StartRetentionLoop(hitlRetention, log.Logger)
|
hitl.StartRetentionLoop(hitlRetention, log.Logger)
|
||||||
@@ -146,13 +148,6 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
registerProjectFactTools(mcpServer, db, cfg, log.Logger)
|
registerProjectFactTools(mcpServer, db, cfg, log.Logger)
|
||||||
registerVisionTools(mcpServer, cfg, log.Logger)
|
registerVisionTools(mcpServer, cfg, log.Logger)
|
||||||
|
|
||||||
if cfg.Auth.GeneratedPassword != "" {
|
|
||||||
config.PrintGeneratedPasswordWarning(cfg.Auth.GeneratedPassword, cfg.Auth.GeneratedPasswordPersisted, cfg.Auth.GeneratedPasswordPersistErr)
|
|
||||||
cfg.Auth.GeneratedPassword = ""
|
|
||||||
cfg.Auth.GeneratedPasswordPersisted = false
|
|
||||||
cfg.Auth.GeneratedPasswordPersistErr = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建外部MCP管理器(使用与内部MCP服务器相同的存储)
|
// 创建外部MCP管理器(使用与内部MCP服务器相同的存储)
|
||||||
externalMCPMgr := mcp.NewExternalMCPManagerWithStorage(log.Logger, db)
|
externalMCPMgr := mcp.NewExternalMCPManagerWithStorage(log.Logger, db)
|
||||||
externalMCPMgr.SetToolAuthorizer(externalMCPToolAuthorizer())
|
externalMCPMgr.SetToolAuthorizer(externalMCPToolAuthorizer())
|
||||||
@@ -181,7 +176,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
var knowledgeHandler *handler.KnowledgeHandler
|
var knowledgeHandler *handler.KnowledgeHandler
|
||||||
|
|
||||||
var knowledgeDBConn *database.DB
|
var knowledgeDBConn *database.DB
|
||||||
log.Logger.Info("检查知识库配置", zap.Bool("enabled", cfg.Knowledge.Enabled))
|
log.Logger.Debug("检查知识库配置", zap.Bool("enabled", cfg.Knowledge.Enabled))
|
||||||
if cfg.Knowledge.Enabled {
|
if cfg.Knowledge.Enabled {
|
||||||
// 确定知识库数据库路径
|
// 确定知识库数据库路径
|
||||||
knowledgeDBPath := cfg.Database.KnowledgeDBPath
|
knowledgeDBPath := cfg.Database.KnowledgeDBPath
|
||||||
@@ -324,7 +319,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
skillsDir := skillpackage.SkillsRootFromConfig(cfg.SkillsDir, configPath)
|
skillsDir := skillpackage.SkillsRootFromConfig(cfg.SkillsDir, configPath)
|
||||||
log.Logger.Info("Skills 目录(Eino ADK skill 中间件 + Web 管理 API)", zap.String("skillsDir", skillsDir))
|
log.Logger.Debug("Skills 目录(Eino ADK skill 中间件 + Web 管理 API)", zap.String("skillsDir", skillsDir))
|
||||||
configDir := filepath.Dir(configPath)
|
configDir := filepath.Dir(configPath)
|
||||||
plantaskRel := strings.TrimSpace(cfg.MultiAgent.EinoMiddleware.PlantaskRelDir)
|
plantaskRel := strings.TrimSpace(cfg.MultiAgent.EinoMiddleware.PlantaskRelDir)
|
||||||
if plantaskRel == "" {
|
if plantaskRel == "" {
|
||||||
@@ -350,7 +345,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
}
|
}
|
||||||
markdownAgentsHandler := handler.NewMarkdownAgentsHandler(agentsDir)
|
markdownAgentsHandler := handler.NewMarkdownAgentsHandler(agentsDir)
|
||||||
markdownAgentsHandler.SetAudit(auditSvc)
|
markdownAgentsHandler.SetAudit(auditSvc)
|
||||||
log.Logger.Info("多代理 Markdown 子 Agent 目录", zap.String("agentsDir", agentsDir))
|
log.Logger.Debug("多代理 Markdown 子 Agent 目录", zap.String("agentsDir", agentsDir))
|
||||||
|
|
||||||
// 创建处理器
|
// 创建处理器
|
||||||
agentHandler := handler.NewAgentHandler(agent, db, cfg, log.Logger)
|
agentHandler := handler.NewAgentHandler(agent, db, cfg, log.Logger)
|
||||||
@@ -635,20 +630,20 @@ func (a *App) RunWithContext(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
switch tlsMode {
|
switch tlsMode {
|
||||||
case mainTLSFromFiles:
|
case mainTLSFromFiles:
|
||||||
a.logger.Info("启动 HTTPS 主服务(已启用 HTTP/2 协商)",
|
a.logger.Debug("启动 HTTPS 主服务(已启用 HTTP/2 协商)",
|
||||||
zap.String("address", addr),
|
zap.String("address", addr),
|
||||||
zap.String("cert", certFile),
|
zap.String("cert", certFile),
|
||||||
)
|
)
|
||||||
case mainTLSInMemorySelfSigned:
|
case mainTLSInMemorySelfSigned:
|
||||||
a.logger.Info("启动 HTTPS 主服务(内存自签证书,仅测试;已启用 HTTP/2 协商)",
|
a.logger.Debug("启动 HTTPS 主服务(内存自签证书,仅测试;已启用 HTTP/2 协商)",
|
||||||
zap.String("address", addr),
|
zap.String("address", addr),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if httpRedirect {
|
if httpRedirect {
|
||||||
a.logger.Info("已启用 HTTP→HTTPS 自动跳转(同端口嗅探分流)", zap.String("address", addr))
|
a.logger.Debug("已启用 HTTP→HTTPS 自动跳转(同端口嗅探分流)", zap.String("address", addr))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
a.logger.Info("启动 HTTP 主服务", zap.String("address", addr))
|
a.logger.Debug("启动 HTTP 主服务", zap.String("address", addr))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听 context 取消,优雅关闭 HTTP 服务器
|
// 监听 context 取消,优雅关闭 HTTP 服务器
|
||||||
@@ -1508,7 +1503,7 @@ func registerWebshellTools(mcpServer *mcp.Server, db *database.DB, webshellHandl
|
|||||||
}
|
}
|
||||||
mcpServer.RegisterTool(writeTool, writeHandler)
|
mcpServer.RegisterTool(writeTool, writeHandler)
|
||||||
|
|
||||||
logger.Info("WebShell 工具注册成功")
|
logger.Debug("WebShell 工具注册成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerWebshellManagementTools 注册 WebShell 连接管理 MCP 工具
|
// registerWebshellManagementTools 注册 WebShell 连接管理 MCP 工具
|
||||||
@@ -1879,7 +1874,7 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
|||||||
}
|
}
|
||||||
mcpServer.RegisterTool(testTool, testHandler)
|
mcpServer.RegisterTool(testTool, testHandler)
|
||||||
|
|
||||||
logger.Info("WebShell 管理工具注册成功")
|
logger.Debug("WebShell 管理工具注册成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
// initializeKnowledge 初始化知识库组件(用于动态初始化)
|
// initializeKnowledge 初始化知识库组件(用于动态初始化)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func registerC2Tools(mcpServer *mcp.Server, c2Manager *c2.Manager, logger *zap.L
|
|||||||
registerC2EventTool(mcpServer, c2Manager, logger)
|
registerC2EventTool(mcpServer, c2Manager, logger)
|
||||||
registerC2ProfileTool(mcpServer, c2Manager, logger)
|
registerC2ProfileTool(mcpServer, c2Manager, logger)
|
||||||
registerC2FileTool(mcpServer, c2Manager, logger)
|
registerC2FileTool(mcpServer, c2Manager, logger)
|
||||||
logger.Info("C2 MCP tools registered (8 unified tools)")
|
logger.Debug("C2 MCP tools registered (8 unified tools)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeC2Result(data interface{}, err error) (*mcp.ToolResult, error) {
|
func makeC2Result(data interface{}, err error) (*mcp.ToolResult, error) {
|
||||||
|
|||||||
@@ -21,11 +21,15 @@ func TestStandaloneMCPPrefersUserRBACAndDisablesGlobalTokenByDefault(t *testing.
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = db.Close() })
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
auth, err := security.NewAuthManager("admin-secret", 12)
|
auth := security.NewAuthManager(12)
|
||||||
|
if _, err := auth.AttachRBACStore(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, err := security.HashPassword("admin-secret")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := auth.AttachRBACStore(db); err != nil {
|
if err := db.UpdateRBACAdminPassword(hash); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
token, _, err := auth.Authenticate("admin", "admin-secret")
|
token, _, err := auth.Authenticate("admin", "admin-secret")
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi
|
|||||||
"description": "可选:关联的漏洞记录 ID",
|
"description": "可选:关联的漏洞记录 ID",
|
||||||
},
|
},
|
||||||
"links": map[string]interface{}{
|
"links": map[string]interface{}{
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "可选:关系边(from → 当前 fact)。finding 至少 1 条 {from:target/*, type:discovered_on};finding 上记录 exploit 用 {from:exploit/*, type:exploits}。省略保留已有边;传 [] 清空全部关系边。",
|
"description": "可选:关系边(from → 当前 fact)。finding 至少 1 条 {from:target/*, type:discovered_on};finding 上记录 exploit 用 {from:exploit/*, type:exploits}。省略保留已有边;传 [] 清空全部关系边。",
|
||||||
"items": map[string]interface{}{
|
"items": map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -357,7 +357,7 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi
|
|||||||
})
|
})
|
||||||
|
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
logger.Info("项目黑板 MCP 工具注册成功")
|
logger.Debug("项目黑板 MCP 工具注册成功")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ func registerVulnerabilityTools(mcpServer *mcp.Server, db *database.DB, logger *
|
|||||||
registerListVulnerabilitiesTool(mcpServer, db, logger)
|
registerListVulnerabilitiesTool(mcpServer, db, logger)
|
||||||
registerGetVulnerabilityTool(mcpServer, db, logger)
|
registerGetVulnerabilityTool(mcpServer, db, logger)
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
logger.Info("漏洞 MCP 工具注册成功", zap.Strings("tools", []string{
|
logger.Debug("漏洞 MCP 工具注册成功", zap.Strings("tools", []string{
|
||||||
builtin.ToolRecordVulnerability,
|
builtin.ToolRecordVulnerability,
|
||||||
builtin.ToolListVulnerabilities,
|
builtin.ToolListVulnerabilities,
|
||||||
builtin.ToolGetVulnerability,
|
builtin.ToolGetVulnerability,
|
||||||
|
|||||||
+20
-198
@@ -2,7 +2,6 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,6 +11,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/termout"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,9 +44,8 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EnsureLocalConfigResult struct {
|
type EnsureLocalConfigResult struct {
|
||||||
Created bool
|
Created bool
|
||||||
GeneratedPassword string
|
ExamplePath string
|
||||||
ExamplePath string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -54,6 +54,7 @@ const (
|
|||||||
DefaultLatestUserMessageMaxRunes = 48000
|
DefaultLatestUserMessageMaxRunes = 48000
|
||||||
DefaultLatestUserMessageHeadRunes = 24000
|
DefaultLatestUserMessageHeadRunes = 24000
|
||||||
DefaultLatestUserMessageTailRunes = 24000
|
DefaultLatestUserMessageTailRunes = 24000
|
||||||
|
DefaultSummarizationOutputReserveTokens = 8192
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProjectConfig 项目黑板(跨对话共享事实)配置。
|
// ProjectConfig 项目黑板(跨对话共享事实)配置。
|
||||||
@@ -268,6 +269,8 @@ type MultiAgentEinoMiddlewareConfig struct {
|
|||||||
ReductionSubAgents bool `yaml:"reduction_sub_agents,omitempty" json:"reduction_sub_agents,omitempty"` // also attach to sub-agents
|
ReductionSubAgents bool `yaml:"reduction_sub_agents,omitempty" json:"reduction_sub_agents,omitempty"` // also attach to sub-agents
|
||||||
// SummarizationTriggerRatio controls summarization trigger threshold as max_total_tokens * ratio (default 0.8).
|
// SummarizationTriggerRatio controls summarization trigger threshold as max_total_tokens * ratio (default 0.8).
|
||||||
SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"`
|
SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"`
|
||||||
|
// SummarizationOutputReserveTokens reserves completion headroom for the summarization model call (default 8192).
|
||||||
|
SummarizationOutputReserveTokens int `yaml:"summarization_output_reserve_tokens,omitempty" json:"summarization_output_reserve_tokens,omitempty"`
|
||||||
// SummarizationEmitInternalEvents controls middleware internal event emission (default true).
|
// SummarizationEmitInternalEvents controls middleware internal event emission (default true).
|
||||||
SummarizationEmitInternalEvents *bool `yaml:"summarization_emit_internal_events,omitempty" json:"summarization_emit_internal_events,omitempty"`
|
SummarizationEmitInternalEvents *bool `yaml:"summarization_emit_internal_events,omitempty" json:"summarization_emit_internal_events,omitempty"`
|
||||||
// SummarizationUserIntentLedgerMaxRunes caps the DB-backed immutable user input ledger injected into model context.
|
// SummarizationUserIntentLedgerMaxRunes caps the DB-backed immutable user input ledger injected into model context.
|
||||||
@@ -320,6 +323,13 @@ func (c MultiAgentEinoMiddlewareConfig) SummarizationTriggerRatioEffective() flo
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c MultiAgentEinoMiddlewareConfig) SummarizationOutputReserveTokensEffective() int {
|
||||||
|
if c.SummarizationOutputReserveTokens > 0 {
|
||||||
|
return c.SummarizationOutputReserveTokens
|
||||||
|
}
|
||||||
|
return DefaultSummarizationOutputReserveTokens
|
||||||
|
}
|
||||||
|
|
||||||
func (c MultiAgentEinoMiddlewareConfig) SummarizationEmitInternalEventsEffective() bool {
|
func (c MultiAgentEinoMiddlewareConfig) SummarizationEmitInternalEventsEffective() bool {
|
||||||
if c.SummarizationEmitInternalEvents != nil {
|
if c.SummarizationEmitInternalEvents != nil {
|
||||||
return *c.SummarizationEmitInternalEvents
|
return *c.SummarizationEmitInternalEvents
|
||||||
@@ -995,11 +1005,7 @@ func normalizeHitlModeForPrompt(mode string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
Password string `yaml:"password" json:"password"`
|
SessionDurationHours int `yaml:"session_duration_hours" json:"session_duration_hours"`
|
||||||
SessionDurationHours int `yaml:"session_duration_hours" json:"session_duration_hours"`
|
|
||||||
GeneratedPassword string `yaml:"-" json:"-"`
|
|
||||||
GeneratedPasswordPersisted bool `yaml:"-" json:"-"`
|
|
||||||
GeneratedPasswordPersistErr string `yaml:"-" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MonitorConfig MCP 状态监控(tool_executions)保留策略。
|
// MonitorConfig MCP 状态监控(tool_executions)保留策略。
|
||||||
@@ -1159,23 +1165,6 @@ func Load(path string) (*Config, error) {
|
|||||||
if cfg.Audit.MaxDetailBytes <= 0 {
|
if cfg.Audit.MaxDetailBytes <= 0 {
|
||||||
cfg.Audit.MaxDetailBytes = 8192
|
cfg.Audit.MaxDetailBytes = 8192
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(cfg.Auth.Password) == "" {
|
|
||||||
password, err := generateStrongPassword(24)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("生成默认密码失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Auth.Password = password
|
|
||||||
cfg.Auth.GeneratedPassword = password
|
|
||||||
|
|
||||||
if err := PersistAuthPassword(path, password); err != nil {
|
|
||||||
cfg.Auth.GeneratedPasswordPersisted = false
|
|
||||||
cfg.Auth.GeneratedPasswordPersistErr = err.Error()
|
|
||||||
} else {
|
|
||||||
cfg.Auth.GeneratedPasswordPersisted = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果配置了工具目录,从目录加载工具配置
|
// 如果配置了工具目录,从目录加载工具配置
|
||||||
if cfg.Security.ToolsDir != "" {
|
if cfg.Security.ToolsDir != "" {
|
||||||
inlineTools := append([]ToolConfig(nil), cfg.Security.Tools...)
|
inlineTools := append([]ToolConfig(nil), cfg.Security.Tools...)
|
||||||
@@ -1246,7 +1235,7 @@ func EnsureLocalConfig(path string) (EnsureLocalConfigResult, error) {
|
|||||||
|
|
||||||
if _, err := os.Stat(path); err == nil {
|
if _, err := os.Stat(path); err == nil {
|
||||||
return EnsureLocalConfigResult{}, nil
|
return EnsureLocalConfigResult{}, nil
|
||||||
} else if err != nil && !os.IsNotExist(err) {
|
} else if !os.IsNotExist(err) {
|
||||||
return EnsureLocalConfigResult{}, fmt.Errorf("检查配置文件失败: %w", err)
|
return EnsureLocalConfigResult{}, fmt.Errorf("检查配置文件失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1281,181 +1270,14 @@ func EnsureLocalConfig(path string) (EnsureLocalConfigResult, error) {
|
|||||||
return EnsureLocalConfigResult{}, fmt.Errorf("创建配置文件失败: %w", err)
|
return EnsureLocalConfigResult{}, fmt.Errorf("创建配置文件失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
password, err := generateStrongPassword(24)
|
|
||||||
if err != nil {
|
|
||||||
return EnsureLocalConfigResult{}, fmt.Errorf("生成默认密码失败: %w", err)
|
|
||||||
}
|
|
||||||
if err := PersistAuthPassword(path, password); err != nil {
|
|
||||||
return EnsureLocalConfigResult{}, fmt.Errorf("写入默认密码失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return EnsureLocalConfigResult{
|
return EnsureLocalConfigResult{
|
||||||
Created: true,
|
Created: true,
|
||||||
GeneratedPassword: password,
|
ExamplePath: examplePath,
|
||||||
ExamplePath: examplePath,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateStrongPassword(length int) (string, error) {
|
func PrintBootstrapAdminPassword(password string) {
|
||||||
if length <= 0 {
|
termout.PrintBootstrapAdminCredentials(password)
|
||||||
length = 24
|
|
||||||
}
|
|
||||||
|
|
||||||
bytesLen := length
|
|
||||||
randomBytes := make([]byte, bytesLen)
|
|
||||||
if _, err := rand.Read(randomBytes); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
password := base64.RawURLEncoding.EncodeToString(randomBytes)
|
|
||||||
if len(password) > length {
|
|
||||||
password = password[:length]
|
|
||||||
}
|
|
||||||
return password, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func PersistAuthPassword(path, password string) error {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := strings.Split(string(data), "\n")
|
|
||||||
inAuthBlock := false
|
|
||||||
authIndent := -1
|
|
||||||
|
|
||||||
for i, line := range lines {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if !inAuthBlock {
|
|
||||||
if strings.HasPrefix(trimmed, "auth:") {
|
|
||||||
inAuthBlock = true
|
|
||||||
authIndent = len(line) - len(strings.TrimLeft(line, " "))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
leadingSpaces := len(line) - len(strings.TrimLeft(line, " "))
|
|
||||||
if leadingSpaces <= authIndent {
|
|
||||||
// 离开 auth 块
|
|
||||||
inAuthBlock = false
|
|
||||||
authIndent = -1
|
|
||||||
// 继续寻找其它 auth 块(理论上没有)
|
|
||||||
if strings.HasPrefix(trimmed, "auth:") {
|
|
||||||
inAuthBlock = true
|
|
||||||
authIndent = leadingSpaces
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(strings.TrimSpace(line), "password:") {
|
|
||||||
prefix := line[:len(line)-len(strings.TrimLeft(line, " "))]
|
|
||||||
comment := ""
|
|
||||||
if idx := yamlLineCommentIndex(line); idx >= 0 {
|
|
||||||
comment = strings.TrimRight(line[idx:], " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
newLine := fmt.Sprintf("%spassword: %s", prefix, quoteYAMLString(password))
|
|
||||||
if comment != "" {
|
|
||||||
if !strings.HasPrefix(comment, " ") {
|
|
||||||
newLine += " "
|
|
||||||
}
|
|
||||||
newLine += comment
|
|
||||||
}
|
|
||||||
lines[i] = newLine
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
func quoteYAMLString(value string) string {
|
|
||||||
node := yaml.Node{
|
|
||||||
Kind: yaml.ScalarNode,
|
|
||||||
Tag: "!!str",
|
|
||||||
Style: yaml.DoubleQuotedStyle,
|
|
||||||
Value: value,
|
|
||||||
}
|
|
||||||
data, err := yaml.Marshal(&node)
|
|
||||||
if err != nil {
|
|
||||||
return strconv.Quote(value)
|
|
||||||
}
|
|
||||||
return strings.TrimSuffix(string(data), "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func yamlLineCommentIndex(line string) int {
|
|
||||||
inSingleQuote := false
|
|
||||||
inDoubleQuote := false
|
|
||||||
escaped := false
|
|
||||||
|
|
||||||
for i, r := range line {
|
|
||||||
if inDoubleQuote {
|
|
||||||
if escaped {
|
|
||||||
escaped = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r == '\\' {
|
|
||||||
escaped = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r == '"' {
|
|
||||||
inDoubleQuote = false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if inSingleQuote {
|
|
||||||
if r == '\'' {
|
|
||||||
inSingleQuote = false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch r {
|
|
||||||
case '"':
|
|
||||||
inDoubleQuote = true
|
|
||||||
case '\'':
|
|
||||||
inSingleQuote = true
|
|
||||||
case '#':
|
|
||||||
if i == 0 || isYAMLWhitespace(line[i-1]) {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
func isYAMLWhitespace(b byte) bool {
|
|
||||||
return b == ' ' || b == '\t'
|
|
||||||
}
|
|
||||||
|
|
||||||
func PrintGeneratedPasswordWarning(password string, persisted bool, persistErr string) {
|
|
||||||
if strings.TrimSpace(password) == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if persisted {
|
|
||||||
fmt.Println("[CyberStrikeAI] ✅ 已为您自动生成并写入 Web 登录密码。")
|
|
||||||
} else {
|
|
||||||
if persistErr != "" {
|
|
||||||
fmt.Printf("[CyberStrikeAI] ⚠️ 无法自动写入配置文件中的密码: %s\n", persistErr)
|
|
||||||
} else {
|
|
||||||
fmt.Println("[CyberStrikeAI] ⚠️ 无法自动写入配置文件中的密码。")
|
|
||||||
}
|
|
||||||
fmt.Println("请手动将以下随机密码写入 config.yaml 的 auth.password:")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("----------------------------------------------------------------")
|
|
||||||
fmt.Println("CyberStrikeAI Auto-Generated Web Password")
|
|
||||||
fmt.Printf("Password: %s\n", password)
|
|
||||||
fmt.Println("WARNING: Anyone with this password can fully control CyberStrikeAI.")
|
|
||||||
fmt.Println("Please store it securely and change it in config.yaml as soon as possible.")
|
|
||||||
fmt.Println("警告:持有此密码的人将拥有对 CyberStrikeAI 的完全控制权限。")
|
|
||||||
fmt.Println("请妥善保管,并尽快在 config.yaml 中修改 auth.password!")
|
|
||||||
fmt.Println("----------------------------------------------------------------")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateRandomToken 生成用于 MCP 鉴权的随机字符串(64 位十六进制)
|
// generateRandomToken 生成用于 MCP 鉴权的随机字符串(64 位十六进制)
|
||||||
|
|||||||
@@ -7,78 +7,12 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPersistAuthPasswordQuotesYAMLSpecialCharacters(t *testing.T) {
|
func TestEnsureLocalConfigCreatesFromExample(t *testing.T) {
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.yaml")
|
|
||||||
initial := strings.Join([]string{
|
|
||||||
"server:",
|
|
||||||
" host: 0.0.0.0",
|
|
||||||
"auth:",
|
|
||||||
" password: old-password # Web 登录密码",
|
|
||||||
" session_duration_hours: 12",
|
|
||||||
"log:",
|
|
||||||
" level: info",
|
|
||||||
"",
|
|
||||||
}, "\n")
|
|
||||||
if err := os.WriteFile(path, []byte(initial), 0644); err != nil {
|
|
||||||
t.Fatalf("write config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := `@abc:def # still password`
|
|
||||||
if err := PersistAuthPassword(path, want); err != nil {
|
|
||||||
t.Fatalf("PersistAuthPassword: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read config: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(data), `password: "@abc:def # still password" # Web 登录密码`) {
|
|
||||||
t.Fatalf("password was not safely quoted or comment was not preserved:\n%s", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := Load(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load after PersistAuthPassword: %v", err)
|
|
||||||
}
|
|
||||||
if cfg.Auth.Password != want {
|
|
||||||
t.Fatalf("Auth.Password = %q, want %q", cfg.Auth.Password, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPersistAuthPasswordDoesNotTreatQuotedHashAsComment(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.yaml")
|
|
||||||
initial := strings.Join([]string{
|
|
||||||
"auth:",
|
|
||||||
` password: "old#password"`,
|
|
||||||
" session_duration_hours: 12",
|
|
||||||
"",
|
|
||||||
}, "\n")
|
|
||||||
if err := os.WriteFile(path, []byte(initial), 0644); err != nil {
|
|
||||||
t.Fatalf("write config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := PersistAuthPassword(path, "new-password"); err != nil {
|
|
||||||
t.Fatalf("PersistAuthPassword: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read config: %v", err)
|
|
||||||
}
|
|
||||||
if strings.Contains(string(data), "#password") {
|
|
||||||
t.Fatalf("old quoted password fragment was incorrectly preserved as a comment:\n%s", data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureLocalConfigCreatesFromExampleWithGeneratedPassword(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
examplePath := filepath.Join(dir, "config.example.yaml")
|
examplePath := filepath.Join(dir, "config.example.yaml")
|
||||||
configPath := filepath.Join(dir, "config.yaml")
|
configPath := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
example := []byte(`auth:
|
example := []byte(`auth:
|
||||||
password: "change-me-use-a-long-random-password"
|
|
||||||
session_duration_hours: 12
|
session_duration_hours: 12
|
||||||
server:
|
server:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
@@ -95,9 +29,6 @@ server:
|
|||||||
if !result.Created {
|
if !result.Created {
|
||||||
t.Fatal("Created = false, want true")
|
t.Fatal("Created = false, want true")
|
||||||
}
|
}
|
||||||
if result.GeneratedPassword == "" {
|
|
||||||
t.Fatal("GeneratedPassword is empty")
|
|
||||||
}
|
|
||||||
if result.ExamplePath != examplePath {
|
if result.ExamplePath != examplePath {
|
||||||
t.Fatalf("ExamplePath = %q, want %q", result.ExamplePath, examplePath)
|
t.Fatalf("ExamplePath = %q, want %q", result.ExamplePath, examplePath)
|
||||||
}
|
}
|
||||||
@@ -106,11 +37,8 @@ server:
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load generated config: %v", err)
|
t.Fatalf("Load generated config: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.Auth.Password == "change-me-use-a-long-random-password" {
|
if cfg.Auth.SessionDurationHours != 12 {
|
||||||
t.Fatal("auth.password still contains the template placeholder")
|
t.Fatalf("SessionDurationHours = %d, want 12", cfg.Auth.SessionDurationHours)
|
||||||
}
|
|
||||||
if cfg.Auth.Password != result.GeneratedPassword {
|
|
||||||
t.Fatalf("Auth.Password = %q, want generated password %q", cfg.Auth.Password, result.GeneratedPassword)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
second, err := EnsureLocalConfig(configPath)
|
second, err := EnsureLocalConfig(configPath)
|
||||||
@@ -122,6 +50,31 @@ server:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadIgnoresLegacyAuthPasswordField(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
initial := strings.Join([]string{
|
||||||
|
"auth:",
|
||||||
|
` password: "legacy-password"`,
|
||||||
|
" session_duration_hours: 12",
|
||||||
|
"server:",
|
||||||
|
" host: 127.0.0.1",
|
||||||
|
" port: 8080",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
if err := os.WriteFile(path, []byte(initial), 0644); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Auth.SessionDurationHours != 12 {
|
||||||
|
t.Fatalf("SessionDurationHours = %d, want 12", cfg.Auth.SessionDurationHours)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHitlAuditModelEffectiveFallsBackToMainConfig(t *testing.T) {
|
func TestHitlAuditModelEffectiveFallsBackToMainConfig(t *testing.T) {
|
||||||
main := OpenAIConfig{
|
main := OpenAIConfig{
|
||||||
Provider: "openai",
|
Provider: "openai",
|
||||||
@@ -163,6 +116,17 @@ func TestSummarizationUserIntentLedgerRunesEffective(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSummarizationOutputReserveTokensEffective(t *testing.T) {
|
||||||
|
var zero MultiAgentEinoMiddlewareConfig
|
||||||
|
if got := zero.SummarizationOutputReserveTokensEffective(); got != DefaultSummarizationOutputReserveTokens {
|
||||||
|
t.Fatalf("default output reserve = %d, want %d", got, DefaultSummarizationOutputReserveTokens)
|
||||||
|
}
|
||||||
|
custom := MultiAgentEinoMiddlewareConfig{SummarizationOutputReserveTokens: 4096}
|
||||||
|
if got := custom.SummarizationOutputReserveTokensEffective(); got != 4096 {
|
||||||
|
t.Fatalf("custom output reserve = %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLatestUserMessageRunesEffective(t *testing.T) {
|
func TestLatestUserMessageRunesEffective(t *testing.T) {
|
||||||
var zero MultiAgentEinoMiddlewareConfig
|
var zero MultiAgentEinoMiddlewareConfig
|
||||||
if got := zero.LatestUserMessageMaxRunesEffective(); got != DefaultLatestUserMessageMaxRunes {
|
if got := zero.LatestUserMessageMaxRunesEffective(); got != DefaultLatestUserMessageMaxRunes {
|
||||||
|
|||||||
@@ -113,10 +113,10 @@ func (db *DB) runPassiveCheckpoint(trigger string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if busy > 0 {
|
if busy > 0 {
|
||||||
db.logger.Info("SQLite PASSIVE checkpoint 完成(部分推进)", fields...)
|
db.logger.Debug("SQLite PASSIVE checkpoint 完成(部分推进)", fields...)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
db.logger.Info("SQLite PASSIVE checkpoint 完成(成功)", fields...)
|
db.logger.Debug("SQLite PASSIVE checkpoint 完成(成功)", fields...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDB 创建数据库连接
|
// NewDB 创建数据库连接
|
||||||
@@ -325,6 +325,7 @@ func (db *DB) initTables() error {
|
|||||||
session_key TEXT PRIMARY KEY,
|
session_key TEXT PRIMARY KEY,
|
||||||
conversation_id TEXT NOT NULL,
|
conversation_id TEXT NOT NULL,
|
||||||
role_name TEXT NOT NULL DEFAULT '默认',
|
role_name TEXT NOT NULL DEFAULT '默认',
|
||||||
|
agent_mode TEXT NOT NULL DEFAULT 'eino_single',
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
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 {
|
if _, err := db.Exec(createRobotUserSessionsTable); err != nil {
|
||||||
return fmt.Errorf("创建robot_user_sessions表失败: %w", err)
|
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 {
|
if _, err := db.Exec(createProjectsTable); err != nil {
|
||||||
return fmt.Errorf("创建projects表失败: %w", err)
|
return fmt.Errorf("创建projects表失败: %w", err)
|
||||||
@@ -869,7 +873,19 @@ func (db *DB) initTables() error {
|
|||||||
return fmt.Errorf("创建索引失败: %w", err)
|
return fmt.Errorf("创建索引失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
db.logger.Info("数据库表初始化完成")
|
db.logger.Debug("数据库表初始化完成")
|
||||||
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -227,11 +227,32 @@ func (db *DB) addColumnIfMissing(table, name, stmt string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RBACNeedsAdminPassword reports whether the built-in admin account still needs an initial password.
|
||||||
|
func (db *DB) RBACNeedsAdminPassword() (bool, error) {
|
||||||
|
var userCount int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM rbac_users`).Scan(&userCount); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if userCount == 0 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
var hash sql.NullString
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT password_hash FROM rbac_users
|
||||||
|
WHERE username = 'admin' AND is_builtin = 1
|
||||||
|
LIMIT 1
|
||||||
|
`).Scan(&hash)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return !hash.Valid || strings.TrimSpace(hash.String) == "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// BootstrapRBAC seeds the local admin account and system roles.
|
// BootstrapRBAC seeds the local admin account and system roles.
|
||||||
func (db *DB) BootstrapRBAC(adminPasswordHash string, permissions map[string]string) error {
|
func (db *DB) BootstrapRBAC(adminPasswordHash string, permissions map[string]string) error {
|
||||||
if strings.TrimSpace(adminPasswordHash) == "" {
|
|
||||||
return errors.New("admin password hash is required")
|
|
||||||
}
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -302,6 +323,9 @@ func (db *DB) BootstrapRBAC(adminPasswordHash string, permissions map[string]str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if userCount == 0 {
|
if userCount == 0 {
|
||||||
|
if strings.TrimSpace(adminPasswordHash) == "" {
|
||||||
|
return errors.New("admin password hash is required for initial bootstrap")
|
||||||
|
}
|
||||||
if _, err := tx.Exec(`
|
if _, err := tx.Exec(`
|
||||||
INSERT INTO rbac_users (id, username, display_name, password_hash, enabled, is_builtin, created_at, updated_at)
|
INSERT INTO rbac_users (id, username, display_name, password_hash, enabled, is_builtin, created_at, updated_at)
|
||||||
VALUES (?, 'admin', '管理员', ?, 1, 1, ?, ?)
|
VALUES (?, 'admin', '管理员', ?, 1, 1, ?, ?)
|
||||||
@@ -311,7 +335,7 @@ func (db *DB) BootstrapRBAC(adminPasswordHash string, permissions map[string]str
|
|||||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO rbac_user_roles (user_id, role_id, created_at) VALUES ('admin', ?, ?)`, RBACSystemRoleAdmin, now); err != nil {
|
if _, err := tx.Exec(`INSERT OR IGNORE INTO rbac_user_roles (user_id, role_id, created_at) VALUES ('admin', ?, ?)`, RBACSystemRoleAdmin, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else if strings.TrimSpace(adminPasswordHash) != "" {
|
||||||
if _, err := tx.Exec(`UPDATE rbac_users SET password_hash = ?, updated_at = ? WHERE username = 'admin' AND is_builtin = 1 AND (password_hash = '' OR password_hash IS NULL)`, adminPasswordHash, now); err != nil {
|
if _, err := tx.Exec(`UPDATE rbac_users SET password_hash = ?, updated_at = ? WHERE username = 'admin' AND is_builtin = 1 AND (password_hash = '' OR password_hash IS NULL)`, adminPasswordHash, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type RobotSessionBinding struct {
|
|||||||
SessionKey string
|
SessionKey string
|
||||||
ConversationID string
|
ConversationID string
|
||||||
RoleName string
|
RoleName string
|
||||||
|
AgentMode string
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,9 +25,9 @@ func (db *DB) GetRobotSessionBinding(sessionKey string) (*RobotSessionBinding, e
|
|||||||
var b RobotSessionBinding
|
var b RobotSessionBinding
|
||||||
var updatedAt string
|
var updatedAt string
|
||||||
err := db.QueryRow(
|
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,
|
sessionKey,
|
||||||
).Scan(&b.SessionKey, &b.ConversationID, &b.RoleName, &updatedAt)
|
).Scan(&b.SessionKey, &b.ConversationID, &b.RoleName, &b.AgentMode, &updatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -43,28 +44,36 @@ func (db *DB) GetRobotSessionBinding(sessionKey string) (*RobotSessionBinding, e
|
|||||||
if strings.TrimSpace(b.RoleName) == "" {
|
if strings.TrimSpace(b.RoleName) == "" {
|
||||||
b.RoleName = "默认"
|
b.RoleName = "默认"
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(b.AgentMode) == "" {
|
||||||
|
b.AgentMode = "eino_single"
|
||||||
|
}
|
||||||
return &b, nil
|
return &b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpsertRobotSessionBinding 写入或更新机器人会话绑定(包含角色)。
|
// UpsertRobotSessionBinding 写入或更新机器人会话绑定(包含角色)。
|
||||||
func (db *DB) UpsertRobotSessionBinding(sessionKey, conversationID, roleName string) error {
|
func (db *DB) UpsertRobotSessionBinding(sessionKey, conversationID, roleName, agentMode string) error {
|
||||||
sessionKey = strings.TrimSpace(sessionKey)
|
sessionKey = strings.TrimSpace(sessionKey)
|
||||||
conversationID = strings.TrimSpace(conversationID)
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
roleName = strings.TrimSpace(roleName)
|
roleName = strings.TrimSpace(roleName)
|
||||||
|
agentMode = strings.TrimSpace(agentMode)
|
||||||
if sessionKey == "" || conversationID == "" {
|
if sessionKey == "" || conversationID == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if roleName == "" {
|
if roleName == "" {
|
||||||
roleName = "默认"
|
roleName = "默认"
|
||||||
}
|
}
|
||||||
|
if agentMode == "" {
|
||||||
|
agentMode = "eino_single"
|
||||||
|
}
|
||||||
_, err := db.Exec(`
|
_, err := db.Exec(`
|
||||||
INSERT INTO robot_user_sessions (session_key, conversation_id, role_name, updated_at)
|
INSERT INTO robot_user_sessions (session_key, conversation_id, role_name, agent_mode, updated_at)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(session_key) DO UPDATE SET
|
ON CONFLICT(session_key) DO UPDATE SET
|
||||||
conversation_id = excluded.conversation_id,
|
conversation_id = excluded.conversation_id,
|
||||||
role_name = excluded.role_name,
|
role_name = excluded.role_name,
|
||||||
|
agent_mode = excluded.agent_mode,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
`, sessionKey, conversationID, roleName, time.Now())
|
`, sessionKey, conversationID, roleName, agentMode, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("写入机器人会话绑定失败: %w", err)
|
return fmt.Errorf("写入机器人会话绑定失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -729,7 +729,7 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProcessMessageForRobot 供机器人(企业微信/钉钉/飞书)调用:Eino 单/多代理执行路径(含 progressCallback、过程详情),仅不发送 SSE,最后返回完整回复
|
// 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)
|
ownerUserID := strings.TrimSpace(principal.UserID)
|
||||||
if ownerUserID == "" {
|
if ownerUserID == "" {
|
||||||
return "", "", fmt.Errorf("authenticated robot principal is required")
|
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)
|
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, nil)
|
||||||
|
|
||||||
robotMode := "eino_single"
|
robotMode := config.NormalizeAgentMode(agentMode)
|
||||||
if h.config != nil {
|
|
||||||
robotMode = config.NormalizeRobotAgentMode(h.config.MultiAgent)
|
|
||||||
}
|
|
||||||
switch robotMode {
|
switch robotMode {
|
||||||
case "eino_single":
|
case "eino_single":
|
||||||
return h.runRobotEinoSingleWithRetry(taskCtx, conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus)
|
return h.runRobotEinoSingleWithRetry(taskCtx, conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus)
|
||||||
case "deep", "plan_execute", "supervisor":
|
case "deep", "plan_execute", "supervisor":
|
||||||
if h.config == nil || !h.config.MultiAgent.Enabled {
|
if h.config == nil || !h.config.MultiAgent.Enabled {
|
||||||
h.logger.Warn("机器人配置为多代理模式但未启用 multi_agent,回退 Eino 单代理",
|
taskStatus = "failed"
|
||||||
zap.String("robot_mode", robotMode))
|
return "", conversationID, fmt.Errorf("机器人对话模式 %s 需要启用 Eino 多代理", robotMode)
|
||||||
return h.runRobotEinoSingleWithRetry(taskCtx, conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus)
|
|
||||||
}
|
}
|
||||||
return h.runRobotMultiAgentWithRetry(taskCtx, conversationID, finalMessage, robotMode, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus)
|
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)
|
flusher, _ := c.Writer.(http.Flusher)
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
var writeMu sync.Mutex
|
var writeMu sync.Mutex
|
||||||
stopKeepalive := make(chan struct{})
|
stopKeepalive := runSSEKeepalive(c, &writeMu)
|
||||||
go sseKeepalive(c, stopKeepalive, &writeMu)
|
defer stopKeepalive()
|
||||||
defer close(stopKeepalive)
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -170,28 +170,10 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if session.UserID == "" || session.UserID == "admin" {
|
if session.UserID == "" {
|
||||||
if err := config.PersistAuthPassword(h.configPath, newPassword); err != nil {
|
session.UserID = "admin"
|
||||||
if h.logger != nil {
|
}
|
||||||
h.logger.Error("保存新密码失败", zap.Error(err))
|
if err := h.manager.UpdateUserPassword(session.UserID, newPassword); err != nil {
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存新密码失败,请重试"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.manager.UpdateConfig(newPassword, h.config.Auth.SessionDurationHours); err != nil {
|
|
||||||
if h.logger != nil {
|
|
||||||
h.logger.Error("更新认证配置失败", zap.Error(err))
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新认证配置失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.config.Auth.Password = newPassword
|
|
||||||
h.config.Auth.GeneratedPassword = ""
|
|
||||||
h.config.Auth.GeneratedPasswordPersisted = false
|
|
||||||
h.config.Auth.GeneratedPasswordPersistErr = ""
|
|
||||||
} else if err := h.manager.UpdateUserPassword(session.UserID, newPassword); err != nil {
|
|
||||||
if h.logger != nil {
|
if h.logger != nil {
|
||||||
h.logger.Error("更新用户密码失败", zap.Error(err))
|
h.logger.Error("更新用户密码失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -664,7 +664,7 @@ schedule_mode 为 cron 时必须提供有效 cron_expr;为 manual 时会清除
|
|||||||
return batchMCPJSONResult(queue)
|
return batchMCPJSONResult(queue)
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.Info("批量任务 MCP 工具已注册", zap.Int("count", 12))
|
logger.Debug("批量任务 MCP 工具已注册", zap.Int("count", 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- batch_task_list 精简结构(避免把每条子任务的 result 等大段文本塞进列表上下文) ---
|
// --- batch_task_list 精简结构(避免把每条子任务的 result 等大段文本塞进列表上下文) ---
|
||||||
|
|||||||
@@ -1362,7 +1362,7 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "初始化知识库失败: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "初始化知识库失败: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.logger.Info("知识库动态初始化完成,工具已注册")
|
h.logger.Debug("知识库动态初始化完成,工具已注册")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查嵌入模型配置是否变更(需要在锁外执行,避免阻塞)
|
// 检查嵌入模型配置是否变更(需要在锁外执行,避免阻塞)
|
||||||
@@ -1441,10 +1441,10 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "重新加载工具配置失败: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "重新加载工具配置失败: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.logger.Info("已从 tools 目录重新加载工具配置", zap.Int("tools_count", len(h.config.Security.Tools)))
|
h.logger.Debug("已从 tools 目录重新加载工具配置", zap.Int("tools_count", len(h.config.Security.Tools)))
|
||||||
|
|
||||||
// 重新注册工具(根据新的启用状态)
|
// 重新注册工具(根据新的启用状态)
|
||||||
h.logger.Info("重新注册工具")
|
h.logger.Debug("重新注册工具")
|
||||||
|
|
||||||
// 清空MCP服务器中的工具
|
// 清空MCP服务器中的工具
|
||||||
h.mcpServer.ClearTools()
|
h.mcpServer.ClearTools()
|
||||||
|
|||||||
@@ -139,9 +139,8 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
})
|
})
|
||||||
|
|
||||||
stopKeepalive := make(chan struct{})
|
stopKeepalive := runSSEKeepalive(c, &sseWriteMu)
|
||||||
go sseKeepalive(c, stopKeepalive, &sseWriteMu)
|
defer stopKeepalive()
|
||||||
defer close(stopKeepalive)
|
|
||||||
|
|
||||||
if h.config == nil {
|
if h.config == nil {
|
||||||
taskStatus = "failed"
|
taskStatus = "failed"
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func (h *MonitorHandler) SetAgentHandler(ah *AgentHandler) {
|
|||||||
h.agentHandler = ah
|
h.agentHandler = ah
|
||||||
}
|
}
|
||||||
|
|
||||||
const monitorPageTopTools = 6
|
const monitorPageTopTools = 3
|
||||||
|
|
||||||
// MonitorStatsSummary 工具调用汇总
|
// MonitorStatsSummary 工具调用汇总
|
||||||
type MonitorStatsSummary struct {
|
type MonitorStatsSummary struct {
|
||||||
|
|||||||
@@ -156,9 +156,8 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
})
|
})
|
||||||
|
|
||||||
stopKeepalive := make(chan struct{})
|
stopKeepalive := runSSEKeepalive(c, &sseWriteMu)
|
||||||
go sseKeepalive(c, stopKeepalive, &sseWriteMu)
|
defer stopKeepalive()
|
||||||
defer close(stopKeepalive)
|
|
||||||
|
|
||||||
var result *multiagent.RunResult
|
var result *multiagent.RunResult
|
||||||
var runErr error
|
var runErr error
|
||||||
|
|||||||
+402
-63
@@ -41,11 +41,14 @@ const (
|
|||||||
robotCmdContinue = "继续"
|
robotCmdContinue = "继续"
|
||||||
robotCmdNew = "新对话"
|
robotCmdNew = "新对话"
|
||||||
robotCmdClear = "清空"
|
robotCmdClear = "清空"
|
||||||
robotCmdCurrent = "当前"
|
robotCmdStatus = "状态"
|
||||||
robotCmdStop = "停止"
|
robotCmdStop = "停止"
|
||||||
robotCmdRoles = "角色"
|
robotCmdRoles = "角色"
|
||||||
robotCmdRolesList = "角色列表"
|
robotCmdRolesList = "角色列表"
|
||||||
robotCmdSwitchRole = "切换角色"
|
robotCmdSwitchRole = "切换角色"
|
||||||
|
robotCmdModes = "模式"
|
||||||
|
robotCmdModesList = "模式列表"
|
||||||
|
robotCmdSwitchMode = "切换模式"
|
||||||
robotCmdDelete = "删除"
|
robotCmdDelete = "删除"
|
||||||
robotCmdVersion = "版本"
|
robotCmdVersion = "版本"
|
||||||
robotCmdProjects = "项目"
|
robotCmdProjects = "项目"
|
||||||
@@ -56,35 +59,51 @@ const (
|
|||||||
robotCmdBindUser = "绑定"
|
robotCmdBindUser = "绑定"
|
||||||
robotCmdUnbindUser = "解绑"
|
robotCmdUnbindUser = "解绑"
|
||||||
robotCmdIdentity = "身份"
|
robotCmdIdentity = "身份"
|
||||||
|
robotCmdTask = "任务"
|
||||||
|
robotCmdRename = "重命名"
|
||||||
|
robotCmdPermissions = "权限"
|
||||||
|
robotCmdDoctor = "诊断"
|
||||||
|
robotCmdConfirm = "确认"
|
||||||
|
robotCmdCancel = "取消"
|
||||||
robotBindingCodeTTL = 5 * time.Minute
|
robotBindingCodeTTL = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type robotPendingConfirmation struct {
|
||||||
|
Action string
|
||||||
|
Target string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// RobotHandler 企业微信/钉钉/飞书等机器人回调处理
|
// RobotHandler 企业微信/钉钉/飞书等机器人回调处理
|
||||||
type RobotHandler struct {
|
type RobotHandler struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
db *database.DB
|
db *database.DB
|
||||||
agentHandler *AgentHandler
|
agentHandler *AgentHandler
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
sessions map[string]string // key: "platform_userID", value: conversationID
|
sessions map[string]string // key: "platform_userID", value: conversationID
|
||||||
sessionRoles map[string]string // key: "platform_userID", value: roleName(默认"默认")
|
sessionRoles map[string]string // key: "platform_userID", value: roleName(默认"默认")
|
||||||
cancelMu sync.Mutex // 保护 runningCancels
|
sessionModes map[string]string // key: "platform_userID", value: agent mode
|
||||||
runningCancels map[string]context.CancelFunc // key: "platform_userID", 用于停止命令中断任务
|
cancelMu sync.Mutex // 保护 runningCancels
|
||||||
wecomReplay map[string]time.Time
|
runningCancels map[string]context.CancelFunc // key: "platform_userID", 用于停止命令中断任务
|
||||||
audit *audit.Service
|
wecomReplay map[string]time.Time
|
||||||
|
pendingConfirmations map[string]robotPendingConfirmation
|
||||||
|
audit *audit.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRobotHandler 创建机器人处理器
|
// NewRobotHandler 创建机器人处理器
|
||||||
func NewRobotHandler(cfg *config.Config, db *database.DB, agentHandler *AgentHandler, logger *zap.Logger) *RobotHandler {
|
func NewRobotHandler(cfg *config.Config, db *database.DB, agentHandler *AgentHandler, logger *zap.Logger) *RobotHandler {
|
||||||
return &RobotHandler{
|
return &RobotHandler{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
db: db,
|
db: db,
|
||||||
agentHandler: agentHandler,
|
agentHandler: agentHandler,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
sessions: make(map[string]string),
|
sessions: make(map[string]string),
|
||||||
sessionRoles: make(map[string]string),
|
sessionRoles: make(map[string]string),
|
||||||
runningCancels: make(map[string]context.CancelFunc),
|
sessionModes: make(map[string]string),
|
||||||
wecomReplay: make(map[string]time.Time),
|
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"
|
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) == "" {
|
if h.db == nil || strings.TrimSpace(sk) == "" {
|
||||||
return "", ""
|
return "", "", ""
|
||||||
}
|
}
|
||||||
binding, err := h.db.GetRobotSessionBinding(sk)
|
binding, err := h.db.GetRobotSessionBinding(sk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Warn("读取机器人会话绑定失败", zap.String("session_key", sk), zap.Error(err))
|
h.logger.Warn("读取机器人会话绑定失败", zap.String("session_key", sk), zap.Error(err))
|
||||||
return "", ""
|
return "", "", ""
|
||||||
}
|
}
|
||||||
if binding == nil {
|
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) == "" {
|
if h.db == nil || strings.TrimSpace(sk) == "" || strings.TrimSpace(convID) == "" {
|
||||||
return
|
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))
|
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) {
|
if convID != "" && access.Permissions["chat:read"] && h.db.UserCanAccessResource(ownerID, readScope, "conversation", convID) {
|
||||||
return convID, false
|
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) {
|
if !access.Permissions["chat:read"] || !h.db.UserCanAccessResource(ownerID, readScope, "conversation", persistedConvID) {
|
||||||
h.deleteSessionBinding(sk)
|
h.deleteSessionBinding(sk)
|
||||||
} else {
|
} else {
|
||||||
@@ -229,6 +248,9 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string, a
|
|||||||
if strings.TrimSpace(persistedRole) != "" {
|
if strings.TrimSpace(persistedRole) != "" {
|
||||||
h.sessionRoles[sk] = persistedRole
|
h.sessionRoles[sk] = persistedRole
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(persistedMode) != "" {
|
||||||
|
h.sessionModes[sk] = config.NormalizeAgentMode(persistedMode)
|
||||||
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
return persistedConvID, false
|
return persistedConvID, false
|
||||||
}
|
}
|
||||||
@@ -256,9 +278,13 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string, a
|
|||||||
_ = h.db.SetResourceOwner("conversation", convID, ownerID)
|
_ = h.db.SetResourceOwner("conversation", convID, ownerID)
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
role := h.sessionRoles[sk]
|
role := h.sessionRoles[sk]
|
||||||
|
agentMode := h.sessionModes[sk]
|
||||||
h.sessions[sk] = convID
|
h.sessions[sk] = convID
|
||||||
h.mu.Unlock()
|
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
|
return convID, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,9 +293,10 @@ func (h *RobotHandler) setConversation(platform, userID, convID string) {
|
|||||||
sk := h.sessionKey(platform, userID)
|
sk := h.sessionKey(platform, userID)
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
role := h.sessionRoles[sk]
|
role := h.sessionRoles[sk]
|
||||||
|
agentMode := h.sessionModes[sk]
|
||||||
h.sessions[sk] = convID
|
h.sessions[sk] = convID
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
h.persistSessionBinding(sk, convID, role)
|
h.persistSessionBinding(sk, convID, role, agentMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRole 获取当前用户使用的角色,未设置时返回"默认"
|
// getRole 获取当前用户使用的角色,未设置时返回"默认"
|
||||||
@@ -281,7 +308,7 @@ func (h *RobotHandler) getRole(platform, userID string) string {
|
|||||||
if strings.TrimSpace(role) != "" {
|
if strings.TrimSpace(role) != "" {
|
||||||
return role
|
return role
|
||||||
}
|
}
|
||||||
if _, persistedRole := h.loadSessionBinding(sk); strings.TrimSpace(persistedRole) != "" {
|
if _, persistedRole, _ := h.loadSessionBinding(sk); strings.TrimSpace(persistedRole) != "" {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.sessionRoles[sk] = persistedRole
|
h.sessionRoles[sk] = persistedRole
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
@@ -296,8 +323,38 @@ func (h *RobotHandler) setRole(platform, userID, roleName string) {
|
|||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.sessionRoles[sk] = roleName
|
h.sessionRoles[sk] = roleName
|
||||||
convID := h.sessions[sk]
|
convID := h.sessions[sk]
|
||||||
|
agentMode := h.sessionModes[sk]
|
||||||
h.mu.Unlock()
|
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 清空当前会话(切换到新对话)
|
// clearConversation 清空当前会话(切换到新对话)
|
||||||
@@ -379,7 +436,8 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin
|
|||||||
h.cancelMu.Unlock()
|
h.cancelMu.Unlock()
|
||||||
}()
|
}()
|
||||||
role := h.getRole(platform, userID)
|
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 {
|
if err != nil {
|
||||||
h.logger.Warn("机器人 Agent 执行失败", zap.String("platform", platform), zap.String("userID", userID), zap.Error(err))
|
h.logger.Warn("机器人 Agent 执行失败", zap.String("platform", platform), zap.String("userID", userID), zap.Error(err))
|
||||||
if errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.Canceled) {
|
||||||
@@ -401,32 +459,51 @@ func (h *RobotHandler) robotMessageTimeout() time.Duration {
|
|||||||
return 10 * time.Hour
|
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
|
var b strings.Builder
|
||||||
b.WriteString("【CyberStrikeAI 机器人命令】\n\n")
|
b.WriteString("【CyberStrikeAI 机器人命令】\n\n")
|
||||||
b.WriteString("【通用 General】\n")
|
b.WriteString("【通用 General】\n")
|
||||||
b.WriteString("· 帮助 / help — 显示本帮助\n")
|
b.WriteString("· 帮助 / help — 显示本帮助\n")
|
||||||
b.WriteString("· 版本 / version — 显示当前版本号\n")
|
b.WriteString("· 版本 / version — 显示当前版本号\n")
|
||||||
b.WriteString("· 绑定 <绑定码> / bind <code> — 绑定网页端 RBAC 用户\n")
|
b.WriteString("· 绑定 <绑定码> / bind <code> — 绑定网页端 RBAC 用户\n")
|
||||||
b.WriteString("· 解绑 / unbind — 解除当前平台账号绑定\n")
|
b.WriteString("· 解绑 / unbind — 请求解除账号绑定(需确认)\n")
|
||||||
b.WriteString("· 身份 / whoami — 显示平台发送者、鉴权模式及当前实际 RBAC 身份\n")
|
b.WriteString("· 身份 / whoami — 显示平台发送者、鉴权模式及当前实际 RBAC 身份\n")
|
||||||
b.WriteString("\n【对话 Conversation】\n")
|
if can("chat:read") || can("chat:write") || can("chat:delete") {
|
||||||
b.WriteString("· 列表 / list — 列出所有对话标题与 ID\n")
|
b.WriteString("\n【对话 Conversation】\n")
|
||||||
b.WriteString("· 切换 <ID> / switch <ID> — 指定对话继续\n")
|
if can("chat:read") {
|
||||||
b.WriteString("· 新对话 / new — 开启新对话\n")
|
b.WriteString("· 列表 / list — 列出所有对话标题与 ID\n· 切换 <ID> / switch <ID> — 指定对话继续\n· 状态 / status — 汇总当前选择\n· 任务 / task — 查看当前任务状态\n")
|
||||||
b.WriteString("· 清空 / clear — 清空当前上下文\n")
|
}
|
||||||
b.WriteString("· 当前 / current — 显示当前对话、角色与项目\n")
|
if can("chat:write") {
|
||||||
b.WriteString("· 停止 / stop — 中断当前任务\n")
|
b.WriteString("· 新对话 / new;清空 / clear — 开启新对话\n· 重命名 <名称> / rename <name> — 修改当前对话标题\n")
|
||||||
b.WriteString("· 删除 <ID> / delete <ID> — 删除指定对话\n")
|
}
|
||||||
b.WriteString("\n【角色 Role】\n")
|
if can("chat:delete") {
|
||||||
b.WriteString("· 角色 / roles — 列出所有可用角色\n")
|
b.WriteString("· 删除 <ID> / delete <ID> — 删除指定对话(需确认)\n")
|
||||||
b.WriteString("· 角色 <名> / role <name> — 切换当前角色\n")
|
}
|
||||||
if h.projectsEnabled() {
|
}
|
||||||
|
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("\n【项目 Project】\n")
|
||||||
b.WriteString("· 项目 / projects — 列出所有项目\n")
|
if can("project:read") {
|
||||||
b.WriteString("· 新建项目 <名称> / new project <name> — 创建并绑定当前对话\n")
|
b.WriteString("· 项目 / projects — 列出所有项目\n")
|
||||||
b.WriteString("· 绑定项目 <ID或名称> / bind project <ID|name> — 绑定到已有项目\n")
|
}
|
||||||
b.WriteString("· 解除项目 / unbind project — 解除项目绑定\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("\n──────────────\n")
|
||||||
b.WriteString("除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。")
|
b.WriteString("除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。")
|
||||||
@@ -575,7 +652,7 @@ func (h *RobotHandler) cmdUnbindProject(platform, userID string) string {
|
|||||||
convID := h.sessions[sk]
|
convID := h.sessions[sk]
|
||||||
h.mu.RUnlock()
|
h.mu.RUnlock()
|
||||||
if convID == "" {
|
if convID == "" {
|
||||||
if persistedConvID, _ := h.loadSessionBinding(sk); persistedConvID != "" {
|
if persistedConvID, _, _ := h.loadSessionBinding(sk); persistedConvID != "" {
|
||||||
convID = persistedConvID
|
convID = persistedConvID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -673,12 +750,10 @@ func (h *RobotHandler) cmdStop(platform, userID string) string {
|
|||||||
return "已停止当前任务。"
|
return "已停止当前任务。"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RobotHandler) cmdCurrent(platform, userID string) string {
|
func (h *RobotHandler) cmdStatus(platform, userID string) string {
|
||||||
h.mu.RLock()
|
convID := h.currentConversationID(platform, userID)
|
||||||
convID := h.sessions[h.sessionKey(platform, userID)]
|
|
||||||
h.mu.RUnlock()
|
|
||||||
if convID == "" {
|
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)
|
access, err := h.resolveRobotAccess(platform, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -692,14 +767,122 @@ func (h *RobotHandler) cmdCurrent(platform, userID string) string {
|
|||||||
return "当前对话 ID: " + convID + "(获取标题失败)"
|
return "当前对话 ID: " + convID + "(获取标题失败)"
|
||||||
}
|
}
|
||||||
role := h.getRole(platform, userID)
|
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() {
|
if h.projectsEnabled() {
|
||||||
projectID, _ := h.db.GetConversationProjectID(conv.ID)
|
projectID, _ := h.db.GetConversationProjectID(conv.ID)
|
||||||
reply += "\n当前项目: " + h.formatProjectLabel(projectID)
|
reply += "\n当前项目: " + h.formatProjectLabel(projectID)
|
||||||
|
} else {
|
||||||
|
reply += "\n当前项目: 未启用"
|
||||||
}
|
}
|
||||||
return reply
|
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 {
|
func (h *RobotHandler) cmdRoles() string {
|
||||||
if h.config.Roles == nil || len(h.config.Roles) == 0 {
|
if h.config.Roles == nil || len(h.config.Roles) == 0 {
|
||||||
return "暂无可用角色。"
|
return "暂无可用角色。"
|
||||||
@@ -753,6 +936,55 @@ func (h *RobotHandler) cmdSwitchRole(platform, userID, roleName string) string {
|
|||||||
return fmt.Sprintf("已切换到角色:「%s」\n%s", roleName, role.Description)
|
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 {
|
func (h *RobotHandler) cmdDelete(platform, userID, convID string) string {
|
||||||
if convID == "" {
|
if convID == "" {
|
||||||
return "请指定对话 ID,例如:删除 xxx-xxx-xxx"
|
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) {
|
if !h.db.UserCanAccessResource(access.User.ID, robotPrincipal(access).ScopeFor("chat:delete"), "conversation", convID) {
|
||||||
return "对话不存在或无权访问。"
|
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)
|
sk := h.sessionKey(platform, userID)
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
currentConvID := h.sessions[sk]
|
currentConvID := h.sessions[sk]
|
||||||
@@ -773,6 +1014,7 @@ func (h *RobotHandler) cmdDelete(platform, userID, convID string) string {
|
|||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
delete(h.sessions, sk)
|
delete(h.sessions, sk)
|
||||||
delete(h.sessionRoles, sk)
|
delete(h.sessionRoles, sk)
|
||||||
|
delete(h.sessionModes, sk)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
h.deleteSessionBinding(sk)
|
h.deleteSessionBinding(sk)
|
||||||
}
|
}
|
||||||
@@ -782,6 +1024,7 @@ func (h *RobotHandler) cmdDelete(platform, userID, convID string) string {
|
|||||||
if err := h.db.DeleteConversation(convID); err != nil {
|
if err := h.db.DeleteConversation(convID); err != nil {
|
||||||
return "删除失败: " + err.Error()
|
return "删除失败: " + err.Error()
|
||||||
}
|
}
|
||||||
|
h.recordRobotCommandAudit(access, platform, "conversation_delete", "conversation", convID, "机器人删除对话")
|
||||||
return fmt.Sprintf("已删除对话 ID: %s", convID)
|
return fmt.Sprintf("已删除对话 ID: %s", convID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,9 +1087,10 @@ func robotCommandPermission(text string) (string, bool) {
|
|||||||
case text == robotCmdList || text == robotCmdListAlt || text == "list",
|
case text == robotCmdList || text == robotCmdListAlt || text == "list",
|
||||||
strings.HasPrefix(text, robotCmdSwitch+" "), strings.HasPrefix(text, robotCmdContinue+" "),
|
strings.HasPrefix(text, robotCmdSwitch+" "), strings.HasPrefix(text, robotCmdContinue+" "),
|
||||||
strings.HasPrefix(text, "switch "), strings.HasPrefix(text, "continue "),
|
strings.HasPrefix(text, "switch "), strings.HasPrefix(text, "continue "),
|
||||||
text == robotCmdCurrent || text == "current":
|
text == robotCmdStatus || text == "status", text == robotCmdTask || text == "task":
|
||||||
return "chat:read", true
|
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
|
return "chat:write", true
|
||||||
case strings.HasPrefix(text, robotCmdDelete+" "), strings.HasPrefix(text, "delete "):
|
case strings.HasPrefix(text, robotCmdDelete+" "), strings.HasPrefix(text, "delete "):
|
||||||
return "chat:delete", true
|
return "chat:delete", true
|
||||||
@@ -855,6 +1099,15 @@ func robotCommandPermission(text string) (string, bool) {
|
|||||||
case text == robotCmdRoles || text == robotCmdRolesList || text == "roles",
|
case text == robotCmdRoles || text == robotCmdRolesList || text == "roles",
|
||||||
strings.HasPrefix(text, robotCmdRoles+" "), strings.HasPrefix(text, robotCmdSwitchRole+" "), strings.HasPrefix(text, "role "):
|
strings.HasPrefix(text, robotCmdRoles+" "), strings.HasPrefix(text, robotCmdSwitchRole+" "), strings.HasPrefix(text, "role "):
|
||||||
return "roles:read", true
|
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":
|
case text == robotCmdProjects || text == robotCmdProjectsList || text == "projects":
|
||||||
return "project:read", true
|
return "project:read", true
|
||||||
case text == robotCmdUnbindProject || text == "unbind project",
|
case text == robotCmdUnbindProject || text == "unbind project",
|
||||||
@@ -883,6 +1136,7 @@ func (h *RobotHandler) cmdBindUser(platform, userID, code string) string {
|
|||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
delete(h.sessions, sk)
|
delete(h.sessions, sk)
|
||||||
delete(h.sessionRoles, sk)
|
delete(h.sessionRoles, sk)
|
||||||
|
delete(h.sessionModes, sk)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
h.deleteSessionBinding(sk)
|
h.deleteSessionBinding(sk)
|
||||||
name := strings.TrimSpace(user.DisplayName)
|
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 {
|
if h.config.Robots.AuthorizationFor(platform).EffectiveMode() != config.RobotAuthModeUserBinding {
|
||||||
return "该机器人使用受控服务账号模式,无需用户解绑。"
|
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)
|
access, accessErr := h.resolveRobotAccess(platform, userID)
|
||||||
if accessErr != nil {
|
if accessErr != nil {
|
||||||
return "当前平台账号尚未绑定。"
|
return "当前平台账号尚未绑定。"
|
||||||
@@ -914,6 +1177,7 @@ func (h *RobotHandler) cmdUnbindUser(platform, userID string) string {
|
|||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
delete(h.sessions, sk)
|
delete(h.sessions, sk)
|
||||||
delete(h.sessionRoles, sk)
|
delete(h.sessionRoles, sk)
|
||||||
|
delete(h.sessionModes, sk)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
h.deleteSessionBinding(sk)
|
h.deleteSessionBinding(sk)
|
||||||
if h.audit != nil {
|
if h.audit != nil {
|
||||||
@@ -926,6 +1190,50 @@ func (h *RobotHandler) cmdUnbindUser(platform, userID string) string {
|
|||||||
return "已解除当前平台账号与 CyberStrikeAI 用户的绑定。"
|
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)
|
// handleRobotCommand 处理机器人内置命令;若匹配到命令返回 (回复内容, true),否则返回 ("", false)
|
||||||
func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string, bool) {
|
func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string, bool) {
|
||||||
if (strings.HasPrefix(text, robotCmdBindUser+" ") || strings.HasPrefix(text, "bind ")) && !strings.HasPrefix(text, "bind project ") {
|
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 {
|
switch {
|
||||||
case text == robotCmdHelp || text == "help" || text == "?" || text == "?":
|
case text == robotCmdHelp || text == "help" || text == "?" || text == "?":
|
||||||
return h.cmdHelp(), true
|
return h.cmdHelp(platform, userID), true
|
||||||
case text == robotCmdIdentity || text == "whoami":
|
case text == robotCmdIdentity || text == "whoami":
|
||||||
return h.cmdIdentity(platform, userID), true
|
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":
|
case text == robotCmdList || text == robotCmdListAlt || text == "list":
|
||||||
return h.cmdList(platform, userID), true
|
return h.cmdList(platform, userID), true
|
||||||
case strings.HasPrefix(text, robotCmdSwitch+" ") || strings.HasPrefix(text, robotCmdContinue+" ") || strings.HasPrefix(text, "switch ") || strings.HasPrefix(text, "continue "):
|
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
|
return h.cmdNew(platform, userID), true
|
||||||
case text == robotCmdClear || text == "clear":
|
case text == robotCmdClear || text == "clear":
|
||||||
return h.cmdClear(platform, userID), true
|
return h.cmdClear(platform, userID), true
|
||||||
case text == robotCmdCurrent || text == "current":
|
case text == robotCmdStatus || text == "status":
|
||||||
return h.cmdCurrent(platform, userID), true
|
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":
|
case text == robotCmdStop || text == "stop":
|
||||||
return h.cmdStop(platform, userID), true
|
return h.cmdStop(platform, userID), true
|
||||||
case text == robotCmdRoles || text == robotCmdRolesList || text == "roles":
|
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:])
|
roleName = strings.TrimSpace(text[5:])
|
||||||
}
|
}
|
||||||
return h.cmdSwitchRole(platform, userID, roleName), true
|
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 "):
|
case strings.HasPrefix(text, robotCmdDelete+" ") || strings.HasPrefix(text, "delete "):
|
||||||
var convID string
|
var convID string
|
||||||
if strings.HasPrefix(text, robotCmdDelete+" ") {
|
if strings.HasPrefix(text, robotCmdDelete+" ") {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -13,33 +14,51 @@ import (
|
|||||||
// some proxies that treat connections as idle; 10s is a reasonable balance with traffic.
|
// some proxies that treat connections as idle; 10s is a reasonable balance with traffic.
|
||||||
const sseKeepaliveInterval = 10 * time.Second
|
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
|
// 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.
|
// lines, so we send both a comment and a minimal data frame (type heartbeat) per tick.
|
||||||
//
|
func sseKeepaliveLoop(c *gin.Context, stop <-chan struct{}, writeMu *sync.Mutex) {
|
||||||
// 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
|
|
||||||
}
|
|
||||||
ticker := time.NewTicker(sseKeepaliveInterval)
|
ticker := time.NewTicker(sseKeepaliveInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
ctx := c.Request.Context()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-stop:
|
case <-stop:
|
||||||
return
|
return
|
||||||
case <-c.Request.Context().Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
case <-c.Request.Context().Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
writeMu.Lock()
|
writeMu.Lock()
|
||||||
|
if sseShuttingDown(stop, ctx) {
|
||||||
|
writeMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
if _, err := fmt.Fprintf(c.Writer, ": keepalive\n\n"); err != nil {
|
if _, err := fmt.Fprintf(c.Writer, ": keepalive\n\n"); err != nil {
|
||||||
writeMu.Unlock()
|
writeMu.Unlock()
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -298,6 +298,18 @@ func (m *AgentTaskManager) GetTask(conversationID string) *AgentTask {
|
|||||||
return m.tasks[conversationID]
|
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 定期将长时间处于「取消中」的任务强制结束,避免卡住无法发新消息
|
// runStuckCancellingCleanup 定期将长时间处于「取消中」的任务强制结束,避免卡住无法发新消息
|
||||||
func (m *AgentTaskManager) runStuckCancellingCleanup() {
|
func (m *AgentTaskManager) runStuckCancellingCleanup() {
|
||||||
ticker := time.NewTicker(cleanupInterval)
|
ticker := time.NewTicker(cleanupInterval)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func RegisterKnowledgeTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
mcpServer.RegisterTool(listRiskTypesTool, listRiskTypesHandler)
|
mcpServer.RegisterTool(listRiskTypesTool, listRiskTypesHandler)
|
||||||
logger.Info("风险类型列表工具已注册", zap.String("toolName", listRiskTypesTool.Name))
|
logger.Debug("风险类型列表工具已注册", zap.String("toolName", listRiskTypesTool.Name))
|
||||||
|
|
||||||
// 注册第二个工具:搜索知识库(保持原有功能)
|
// 注册第二个工具:搜索知识库(保持原有功能)
|
||||||
searchTool := mcp.Tool{
|
searchTool := mcp.Tool{
|
||||||
@@ -271,7 +271,7 @@ func RegisterKnowledgeTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
mcpServer.RegisterTool(searchTool, searchHandler)
|
mcpServer.RegisterTool(searchTool, searchHandler)
|
||||||
logger.Info("知识检索工具已注册", zap.String("toolName", searchTool.Name))
|
logger.Debug("知识检索工具已注册", zap.String("toolName", searchTool.Name))
|
||||||
}
|
}
|
||||||
|
|
||||||
// contains 检查切片是否包含元素
|
// contains 检查切片是否包含元素
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/adk/middlewares/summarization"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
toolOutputTruncationMarker = "\n\n...[tool output truncated; full text persisted in reduction cache or summarization transcript]...\n\n"
|
||||||
|
aggressiveToolTruncDivisor = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// isEinoContextOverflowError reports API-side context window rejections.
|
||||||
|
func isEinoContextOverflowError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||||
|
if msg == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
markers := []string{
|
||||||
|
"context length",
|
||||||
|
"context_length",
|
||||||
|
"maximum context",
|
||||||
|
"max context",
|
||||||
|
"context window",
|
||||||
|
"context overflow",
|
||||||
|
"too many tokens",
|
||||||
|
"token limit",
|
||||||
|
"tokens exceed",
|
||||||
|
"exceeds the context",
|
||||||
|
"input is too long",
|
||||||
|
"prompt is too long",
|
||||||
|
"request too large",
|
||||||
|
}
|
||||||
|
for _, m := range markers {
|
||||||
|
if strings.Contains(msg, m) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateBytesWithMarker(content string, maxBytes int, marker string) string {
|
||||||
|
if maxBytes <= 0 || len(content) <= maxBytes {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
if marker == "" {
|
||||||
|
marker = toolOutputTruncationMarker
|
||||||
|
}
|
||||||
|
budget := maxBytes - len(marker)
|
||||||
|
if budget <= 0 {
|
||||||
|
if len(marker) > maxBytes {
|
||||||
|
return marker[:maxBytes]
|
||||||
|
}
|
||||||
|
return marker
|
||||||
|
}
|
||||||
|
head := budget / 2
|
||||||
|
tail := budget - head
|
||||||
|
for head > 0 && !utf8.RuneStart(content[head]) {
|
||||||
|
head--
|
||||||
|
}
|
||||||
|
tailStart := len(content) - tail
|
||||||
|
for tailStart < len(content) && !utf8.RuneStart(content[tailStart]) {
|
||||||
|
tailStart++
|
||||||
|
}
|
||||||
|
return content[:head] + marker + content[tailStart:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMessage(msg adk.Message) adk.Message {
|
||||||
|
if msg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := *msg
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateMessageToolContent(msg adk.Message, maxBytes int, spillRef string) adk.Message {
|
||||||
|
if msg == nil || maxBytes <= 0 {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
out := cloneMessage(msg)
|
||||||
|
marker := toolOutputTruncationMarker
|
||||||
|
if spillRef != "" {
|
||||||
|
marker = fmt.Sprintf("\n\n...[tool output truncated; retrieve full text via: %s]...\n\n", spillRef)
|
||||||
|
}
|
||||||
|
switch out.Role {
|
||||||
|
case schema.Tool:
|
||||||
|
out.Content = truncateBytesWithMarker(out.Content, maxBytes, marker)
|
||||||
|
case schema.Assistant:
|
||||||
|
if out.ReasoningContent != "" {
|
||||||
|
out.ReasoningContent = truncateBytesWithMarker(out.ReasoningContent, maxBytes, marker)
|
||||||
|
}
|
||||||
|
if out.Content != "" {
|
||||||
|
out.Content = truncateBytesWithMarker(out.Content, maxBytes, marker)
|
||||||
|
}
|
||||||
|
case schema.User:
|
||||||
|
if out.Content != "" {
|
||||||
|
out.Content = truncateBytesWithMarker(out.Content, maxBytes, marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func countMessagesTokens(
|
||||||
|
ctx context.Context,
|
||||||
|
msgs []adk.Message,
|
||||||
|
counter summarization.TokenCounterFunc,
|
||||||
|
tools []*schema.ToolInfo,
|
||||||
|
) (int, error) {
|
||||||
|
if counter == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
n, err := counter(ctx, &summarization.TokenCounterInput{Messages: msgs, Tools: tools})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateRoundMessagesToTokenBudget(
|
||||||
|
ctx context.Context,
|
||||||
|
round messageRound,
|
||||||
|
tokenBudget int,
|
||||||
|
counter summarization.TokenCounterFunc,
|
||||||
|
toolMaxBytes int,
|
||||||
|
spillRef string,
|
||||||
|
) ([]adk.Message, error) {
|
||||||
|
if tokenBudget <= 0 || len(round.messages) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
msgs := append([]adk.Message(nil), round.messages...)
|
||||||
|
if n, err := countMessagesTokens(ctx, msgs, counter, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if n <= tokenBudget {
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
if toolMaxBytes <= 0 {
|
||||||
|
toolMaxBytes = 12000
|
||||||
|
}
|
||||||
|
for pass := 0; pass < 8 && toolMaxBytes >= 32; pass++ {
|
||||||
|
out := make([]adk.Message, 0, len(msgs))
|
||||||
|
for _, msg := range msgs {
|
||||||
|
switch {
|
||||||
|
case msg != nil && msg.Role == schema.Tool:
|
||||||
|
out = append(out, truncateMessageToolContent(msg, toolMaxBytes, spillRef))
|
||||||
|
case msg != nil && msg.Role == schema.Assistant:
|
||||||
|
out = append(out, truncateMessageToolContent(msg, toolMaxBytes, spillRef))
|
||||||
|
default:
|
||||||
|
out = append(out, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n, err := countMessagesTokens(ctx, out, counter, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if n <= tokenBudget {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
msgs = out
|
||||||
|
toolMaxBytes /= 2
|
||||||
|
}
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type compactMessagesOpts struct {
|
||||||
|
maxTokens int
|
||||||
|
counter summarization.TokenCounterFunc
|
||||||
|
toolMaxBytes int
|
||||||
|
spillRef string
|
||||||
|
aggressive bool
|
||||||
|
logger *zap.Logger
|
||||||
|
phase string
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactMessagesByDroppingRounds(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []adk.Message,
|
||||||
|
opts compactMessagesOpts,
|
||||||
|
) ([]adk.Message, bool) {
|
||||||
|
if opts.maxTokens <= 0 || len(messages) == 0 || opts.counter == nil {
|
||||||
|
return messages, false
|
||||||
|
}
|
||||||
|
before, err := countMessagesTokens(ctx, messages, opts.counter, nil)
|
||||||
|
if err != nil || before <= opts.maxTokens {
|
||||||
|
return messages, false
|
||||||
|
}
|
||||||
|
|
||||||
|
systems := make([]adk.Message, 0, 1)
|
||||||
|
contextMsgs := make([]adk.Message, 0, len(messages))
|
||||||
|
for _, msg := range messages {
|
||||||
|
if msg != nil && msg.Role == schema.System && len(contextMsgs) == 0 {
|
||||||
|
systems = append(systems, msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if msg != nil {
|
||||||
|
contextMsgs = append(contextMsgs, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rounds := splitMessagesIntoRounds(contextMsgs)
|
||||||
|
if len(rounds) == 0 {
|
||||||
|
return messages, false
|
||||||
|
}
|
||||||
|
|
||||||
|
startIdx := 0
|
||||||
|
if opts.aggressive {
|
||||||
|
startIdx = len(rounds) - 1
|
||||||
|
if startIdx < 0 {
|
||||||
|
startIdx = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dropped := 0
|
||||||
|
for len(rounds) > 1 || (opts.aggressive && len(rounds) == 1) {
|
||||||
|
if !opts.aggressive && len(rounds) <= 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if opts.aggressive && len(rounds) == 1 {
|
||||||
|
// Fall through to latest-round truncation below.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rounds = rounds[1:]
|
||||||
|
dropped++
|
||||||
|
candidate := append([]adk.Message(nil), systems...)
|
||||||
|
for _, round := range rounds {
|
||||||
|
candidate = append(candidate, round.messages...)
|
||||||
|
}
|
||||||
|
after, countErr := countMessagesTokens(ctx, candidate, opts.counter, nil)
|
||||||
|
if countErr != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if after <= opts.maxTokens {
|
||||||
|
if opts.logger != nil {
|
||||||
|
opts.logger.Warn("eino context compacted by dropping older rounds",
|
||||||
|
zap.String("phase", opts.phase),
|
||||||
|
zap.Int("tokens_before", before),
|
||||||
|
zap.Int("tokens_after", after),
|
||||||
|
zap.Int("max_tokens", opts.maxTokens),
|
||||||
|
zap.Int("dropped_rounds", dropped),
|
||||||
|
zap.Bool("aggressive", opts.aggressive),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return candidate, true
|
||||||
|
}
|
||||||
|
if opts.aggressive {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rounds) == 0 {
|
||||||
|
return messages, false
|
||||||
|
}
|
||||||
|
latest := rounds[len(rounds)-1]
|
||||||
|
truncated, truncErr := truncateRoundMessagesToTokenBudget(
|
||||||
|
ctx, latest, opts.maxTokens, opts.counter, opts.toolMaxBytes, opts.spillRef,
|
||||||
|
)
|
||||||
|
if truncErr != nil || len(truncated) == 0 {
|
||||||
|
if opts.logger != nil {
|
||||||
|
opts.logger.Warn("eino context still above budget after round compaction; passing through without local error",
|
||||||
|
zap.String("phase", opts.phase),
|
||||||
|
zap.Int("tokens_before", before),
|
||||||
|
zap.Int("max_tokens", opts.maxTokens),
|
||||||
|
zap.Bool("aggressive", opts.aggressive),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return messages, false
|
||||||
|
}
|
||||||
|
candidate := append([]adk.Message(nil), systems...)
|
||||||
|
if dropped > 0 || startIdx > 0 {
|
||||||
|
for _, round := range rounds[:len(rounds)-1] {
|
||||||
|
candidate = append(candidate, round.messages...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidate = append(candidate, truncated...)
|
||||||
|
after, countErr := countMessagesTokens(ctx, candidate, opts.counter, nil)
|
||||||
|
if countErr != nil {
|
||||||
|
return messages, false
|
||||||
|
}
|
||||||
|
if opts.logger != nil {
|
||||||
|
opts.logger.Warn("eino context compacted by truncating latest round tool output",
|
||||||
|
zap.String("phase", opts.phase),
|
||||||
|
zap.Int("tokens_before", before),
|
||||||
|
zap.Int("tokens_after", after),
|
||||||
|
zap.Int("max_tokens", opts.maxTokens),
|
||||||
|
zap.Int("dropped_rounds", dropped),
|
||||||
|
zap.Bool("aggressive", opts.aggressive),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return candidate, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func aggressiveCompactMessagesForOverflow(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []adk.Message,
|
||||||
|
maxTotalTokens int,
|
||||||
|
modelName string,
|
||||||
|
toolMaxBytes int,
|
||||||
|
phase string,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) []adk.Message {
|
||||||
|
if len(messages) == 0 || maxTotalTokens <= 0 {
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
budget := maxTotalTokens * 70 / 100
|
||||||
|
if budget < 4096 {
|
||||||
|
budget = 4096
|
||||||
|
}
|
||||||
|
aggressiveToolMax := toolMaxBytes / aggressiveToolTruncDivisor
|
||||||
|
if aggressiveToolMax < 2048 {
|
||||||
|
aggressiveToolMax = 2048
|
||||||
|
}
|
||||||
|
out, _ := compactMessagesByDroppingRounds(ctx, messages, compactMessagesOpts{
|
||||||
|
maxTokens: budget,
|
||||||
|
counter: einoSummarizationTokenCounter(modelName),
|
||||||
|
toolMaxBytes: aggressiveToolMax,
|
||||||
|
aggressive: true,
|
||||||
|
logger: logger,
|
||||||
|
phase: phase,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsEinoContextOverflowError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{nil, false},
|
||||||
|
{errors.New("context length exceeded"), true},
|
||||||
|
{errors.New("maximum context length"), true},
|
||||||
|
{errors.New("input is too long for model"), true},
|
||||||
|
{errors.New("HTTP 429 Too Many Requests"), false},
|
||||||
|
{errors.New("invalid api key"), false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := isEinoContextOverflowError(tc.err); got != tc.want {
|
||||||
|
t.Fatalf("isEinoContextOverflowError(%v) = %v, want %v", tc.err, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateRoundMessagesToTokenBudget(t *testing.T) {
|
||||||
|
huge := strings.Repeat("x", 8000)
|
||||||
|
round := messageRound{messages: []adk.Message{
|
||||||
|
assistantToolCallsMsg("", "c1"),
|
||||||
|
schema.ToolMessage(huge, "c1"),
|
||||||
|
}}
|
||||||
|
out, err := truncateRoundMessagesToTokenBudget(
|
||||||
|
context.Background(), round, 256, einoSummarizationTokenCounter("gpt-4o"), 512, "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, msg := range out {
|
||||||
|
if msg != nil && msg.Role == schema.Tool && len(msg.Content) >= len(huge) {
|
||||||
|
t.Fatalf("expected truncated tool output, got len=%d", len(msg.Content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBudgetedSummarizationModelInputTruncatesOversizedLatestRound(t *testing.T) {
|
||||||
|
huge := strings.Repeat("x", 8000)
|
||||||
|
msgs := []adk.Message{
|
||||||
|
assistantToolCallsMsg("", "call-latest"),
|
||||||
|
schema.ToolMessage(huge, "call-latest"),
|
||||||
|
}
|
||||||
|
counter := einoSummarizationTokenCounter("gpt-4o")
|
||||||
|
input, dropped, err := buildBudgetedSummarizationModelInput(
|
||||||
|
context.Background(),
|
||||||
|
schema.SystemMessage("sys"),
|
||||||
|
schema.UserMessage("instr"),
|
||||||
|
msgs,
|
||||||
|
counter,
|
||||||
|
512,
|
||||||
|
summarizationInputBudgetOpts{toolMaxBytes: 256},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dropped != 0 {
|
||||||
|
t.Fatalf("expected no dropped rounds, got %d", dropped)
|
||||||
|
}
|
||||||
|
toolContent := ""
|
||||||
|
for _, msg := range input {
|
||||||
|
if msg != nil && msg.Role == schema.Tool {
|
||||||
|
toolContent = msg.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(toolContent) >= len(huge) {
|
||||||
|
t.Fatalf("expected oversized tool output to be compacted, got len=%d", len(toolContent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelInputSoftBudgetNeverErrors(t *testing.T) {
|
||||||
|
mw := &modelInputSoftBudgetMiddleware{
|
||||||
|
maxTokens: 4,
|
||||||
|
toolMaxBytes: 16,
|
||||||
|
counter: fixedTokenCounter(4),
|
||||||
|
phase: "test",
|
||||||
|
}
|
||||||
|
state := &adk.ChatModelAgentState{Messages: []adk.Message{
|
||||||
|
schema.UserMessage("u"),
|
||||||
|
assistantToolCallsMsg("", "c1"),
|
||||||
|
schema.ToolMessage(strings.Repeat("t", 200), "c1"),
|
||||||
|
}}
|
||||||
|
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("soft budget must not error: %v", err)
|
||||||
|
}
|
||||||
|
if out == nil || len(out.Messages) == 0 {
|
||||||
|
t.Fatal("expected compacted messages")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,6 +105,11 @@ type einoADKRunLoopArgs struct {
|
|||||||
|
|
||||||
// EinoCallbacks 可选:为 ADK Runner 注入 eino [callbacks] 全链路观测(见 internal/einoobserve)。
|
// EinoCallbacks 可选:为 ADK Runner 注入 eino [callbacks] 全链路观测(见 internal/einoobserve)。
|
||||||
EinoCallbacks *config.MultiAgentEinoCallbacksConfig
|
EinoCallbacks *config.MultiAgentEinoCallbacksConfig
|
||||||
|
|
||||||
|
// MaxTotalTokens / ToolMaxBytes / ModelName 用于 context overflow 时的激进压缩续跑。
|
||||||
|
MaxTotalTokens int
|
||||||
|
ToolMaxBytes int
|
||||||
|
ModelName string
|
||||||
}
|
}
|
||||||
|
|
||||||
func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs []adk.Message) (*RunResult, error) {
|
func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs []adk.Message) (*RunResult, error) {
|
||||||
@@ -439,6 +444,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
iter = startRunnerIter(msgs)
|
iter = startRunnerIter(msgs)
|
||||||
}
|
}
|
||||||
transientRetrier := newEinoTransientRunRetrier(einoTransientRunRetryPolicyFromArgs(args))
|
transientRetrier := newEinoTransientRunRetrier(einoTransientRunRetryPolicyFromArgs(args))
|
||||||
|
var contextOverflowRetried bool
|
||||||
handleRunErr := func(runErr error) error {
|
handleRunErr := func(runErr error) error {
|
||||||
if runErr == nil {
|
if runErr == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -495,6 +501,31 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
if runErr == nil {
|
if runErr == nil {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
if isEinoContextOverflowError(runErr) && !contextOverflowRetried {
|
||||||
|
contextOverflowRetried = true
|
||||||
|
restartMsgs, ctxSource := einoMessagesForRunRestart(args, baseMsgs, runAccumulatedMsgs, baseAccumulatedCount)
|
||||||
|
restartMsgs = aggressiveCompactMessagesForOverflow(
|
||||||
|
ctx, restartMsgs, args.MaxTotalTokens, args.ModelName, args.ToolMaxBytes, orchMode, logger,
|
||||||
|
)
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("eino context overflow, retrying with aggressive compaction",
|
||||||
|
zap.Error(runErr),
|
||||||
|
zap.String("orchestration", orchMode),
|
||||||
|
zap.String("contextSource", string(ctxSource)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if progress != nil {
|
||||||
|
progress("eino_context_overflow_retry", "上下文超限,正在激进压缩后重试…", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
|
"orchestration": orchMode,
|
||||||
|
"contextSource": string(ctxSource),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
msgs = restartMsgs
|
||||||
|
iter = startRunnerIter(msgs)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
if !isEinoTransientRunError(runErr) {
|
if !isEinoTransientRunError(runErr) {
|
||||||
return false, handleRunErr(runErr)
|
return false, handleRunErr(runErr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package multiagent
|
package multiagent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
"github.com/cloudwego/eino/adk"
|
"github.com/cloudwego/eino/adk"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -13,17 +15,19 @@ import (
|
|||||||
// 2. continuation user dedup — drop stale session-resume injections
|
// 2. continuation user dedup — drop stale session-resume injections
|
||||||
// 3. pre-summarization tool-call/result reconciliation
|
// 3. pre-summarization tool-call/result reconciliation
|
||||||
// 4. summarization
|
// 4. summarization
|
||||||
// 5. total model-input hard budget
|
// 5. soft model-input budget (warn/compact only, never fail locally)
|
||||||
// 6. final tool-call/result reconciliation
|
// 6. final tool-call/result reconciliation
|
||||||
// 7. orphan tool prune (defense in depth)
|
// 7. orphan tool prune (defense in depth)
|
||||||
// 8. telemetry
|
// 8. malformed tool_search history repair
|
||||||
// 9. model-facing trace snapshot
|
// 9. telemetry
|
||||||
|
// 10. model-facing trace snapshot
|
||||||
type einoChatModelTailConfig struct {
|
type einoChatModelTailConfig struct {
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
phase string
|
phase string
|
||||||
summarization adk.ChatModelAgentMiddleware
|
summarization adk.ChatModelAgentMiddleware
|
||||||
modelName string
|
modelName string
|
||||||
maxTotalTokens int
|
maxTotalTokens int
|
||||||
|
toolMaxBytes int
|
||||||
conversationID string
|
conversationID string
|
||||||
trace *modelFacingTraceHolder
|
trace *modelFacingTraceHolder
|
||||||
skipOrphanPruner bool
|
skipOrphanPruner bool
|
||||||
@@ -40,11 +44,12 @@ func appendEinoChatModelTailMiddlewares(handlers []adk.ChatModelAgentMiddleware,
|
|||||||
handlers = append(handlers, newToolPairReconcilerMiddleware(cfg.logger, cfg.phase+"_pre_summarization"))
|
handlers = append(handlers, newToolPairReconcilerMiddleware(cfg.logger, cfg.phase+"_pre_summarization"))
|
||||||
handlers = append(handlers, cfg.summarization)
|
handlers = append(handlers, cfg.summarization)
|
||||||
}
|
}
|
||||||
handlers = append(handlers, newModelInputBudgetMiddleware(cfg.maxTotalTokens, cfg.modelName, cfg.logger, cfg.phase))
|
handlers = append(handlers, newModelInputSoftBudgetMiddleware(cfg.maxTotalTokens, cfg.toolMaxBytes, cfg.modelName, cfg.logger, cfg.phase))
|
||||||
handlers = append(handlers, newToolPairReconcilerMiddleware(cfg.logger, cfg.phase))
|
handlers = append(handlers, newToolPairReconcilerMiddleware(cfg.logger, cfg.phase))
|
||||||
if !cfg.skipOrphanPruner {
|
if !cfg.skipOrphanPruner {
|
||||||
handlers = append(handlers, newOrphanToolPrunerMiddleware(cfg.logger, cfg.phase))
|
handlers = append(handlers, newOrphanToolPrunerMiddleware(cfg.logger, cfg.phase))
|
||||||
}
|
}
|
||||||
|
handlers = append(handlers, newToolSearchResultSanitizerMiddleware(cfg.logger, cfg.phase))
|
||||||
if !cfg.skipTelemetry {
|
if !cfg.skipTelemetry {
|
||||||
if teleMw := newEinoModelInputTelemetryMiddleware(cfg.logger, cfg.modelName, cfg.conversationID, cfg.phase); teleMw != nil {
|
if teleMw := newEinoModelInputTelemetryMiddleware(cfg.logger, cfg.modelName, cfg.conversationID, cfg.phase); teleMw != nil {
|
||||||
handlers = append(handlers, teleMw)
|
handlers = append(handlers, teleMw)
|
||||||
@@ -57,3 +62,10 @@ func appendEinoChatModelTailMiddlewares(handlers []adk.ChatModelAgentMiddleware,
|
|||||||
}
|
}
|
||||||
return handlers
|
return handlers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toolMaxBytesFromMW(mwCfg *config.MultiAgentEinoMiddlewareConfig) int {
|
||||||
|
if mwCfg != nil {
|
||||||
|
return mwCfg.ReductionMaxLengthForTruncEffective()
|
||||||
|
}
|
||||||
|
return config.MultiAgentEinoMiddlewareConfig{}.ReductionMaxLengthForTruncEffective()
|
||||||
|
}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ func buildPlanExecuteExecutorHandlers(ctx context.Context, a *PlanExecuteRootArg
|
|||||||
summarization: sumMw,
|
summarization: sumMw,
|
||||||
modelName: a.ModelName,
|
modelName: a.ModelName,
|
||||||
maxTotalTokens: a.AppCfg.OpenAI.MaxTotalTokens,
|
maxTotalTokens: a.AppCfg.OpenAI.MaxTotalTokens,
|
||||||
|
toolMaxBytes: toolMaxBytesFromMW(a.MwCfg),
|
||||||
conversationID: a.ConversationID,
|
conversationID: a.ConversationID,
|
||||||
trace: a.ModelFacingTrace,
|
trace: a.ModelFacingTrace,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ func RunEinoSingleChatModelAgent(
|
|||||||
summarization: mainSumMw,
|
summarization: mainSumMw,
|
||||||
modelName: appCfg.OpenAI.Model,
|
modelName: appCfg.OpenAI.Model,
|
||||||
maxTotalTokens: appCfg.OpenAI.MaxTotalTokens,
|
maxTotalTokens: appCfg.OpenAI.MaxTotalTokens,
|
||||||
|
toolMaxBytes: toolMaxBytesFromMW(&ma.EinoMiddleware),
|
||||||
conversationID: conversationID,
|
conversationID: conversationID,
|
||||||
trace: modelFacingTrace,
|
trace: modelFacingTrace,
|
||||||
})
|
})
|
||||||
@@ -235,6 +236,9 @@ func RunEinoSingleChatModelAgent(
|
|||||||
DA: chatAgent,
|
DA: chatAgent,
|
||||||
ModelFacingTrace: modelFacingTrace,
|
ModelFacingTrace: modelFacingTrace,
|
||||||
EinoCallbacks: &ma.EinoCallbacks,
|
EinoCallbacks: &ma.EinoCallbacks,
|
||||||
|
MaxTotalTokens: appCfg.OpenAI.MaxTotalTokens,
|
||||||
|
ToolMaxBytes: toolMaxBytesFromMW(&ma.EinoMiddleware),
|
||||||
|
ModelName: appCfg.OpenAI.Model,
|
||||||
EmptyResponseMessage: "(Eino ADK single-agent session completed but no assistant text was captured. Check process details or logs.) " +
|
EmptyResponseMessage: "(Eino ADK single-agent session completed but no assistant text was captured. Check process details or logs.) " +
|
||||||
"(Eino ADK 单代理会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
|
"(Eino ADK 单代理会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
|
||||||
}, baseMsgs)
|
}, baseMsgs)
|
||||||
|
|||||||
@@ -98,17 +98,21 @@ func newEinoSummarizationMiddleware(
|
|||||||
}
|
}
|
||||||
triggerRatio := 0.8
|
triggerRatio := 0.8
|
||||||
emitInternalEvents := true
|
emitInternalEvents := true
|
||||||
|
outputReserve := config.DefaultSummarizationOutputReserveTokens
|
||||||
userLedgerMaxRunes := config.DefaultSummarizationUserIntentLedgerMaxRunes
|
userLedgerMaxRunes := config.DefaultSummarizationUserIntentLedgerMaxRunes
|
||||||
userLedgerEntryMaxRunes := config.DefaultSummarizationUserIntentLedgerEntryMaxRunes
|
userLedgerEntryMaxRunes := config.DefaultSummarizationUserIntentLedgerEntryMaxRunes
|
||||||
|
toolMaxBytes := config.MultiAgentEinoMiddlewareConfig{}.ReductionMaxLengthForTruncEffective()
|
||||||
if mwCfg != nil {
|
if mwCfg != nil {
|
||||||
triggerRatio = mwCfg.SummarizationTriggerRatioEffective()
|
triggerRatio = mwCfg.SummarizationTriggerRatioEffective()
|
||||||
emitInternalEvents = mwCfg.SummarizationEmitInternalEventsEffective()
|
emitInternalEvents = mwCfg.SummarizationEmitInternalEventsEffective()
|
||||||
|
outputReserve = mwCfg.SummarizationOutputReserveTokensEffective()
|
||||||
userLedgerMaxRunes = mwCfg.SummarizationUserIntentLedgerMaxRunesEffective()
|
userLedgerMaxRunes = mwCfg.SummarizationUserIntentLedgerMaxRunesEffective()
|
||||||
userLedgerEntryMaxRunes = mwCfg.SummarizationUserIntentLedgerEntryMaxRunesEffective()
|
userLedgerEntryMaxRunes = mwCfg.SummarizationUserIntentLedgerEntryMaxRunesEffective()
|
||||||
|
toolMaxBytes = mwCfg.ReductionMaxLengthForTruncEffective()
|
||||||
}
|
}
|
||||||
// The ledger is merged into the leading system message and cannot be removed as
|
// The ledger is merged into the leading system message and cannot be removed as
|
||||||
// an ordinary conversation round. Bound it relative to the configured window so
|
// an ordinary conversation round. Bound it relative to the configured window so
|
||||||
// it cannot crowd out the summary/latest turn or trip the final fail-closed guard.
|
// it cannot crowd out the summary/latest turn.
|
||||||
ledgerWindowCap := modelFacingRuneBudget(maxTotal, 0.20)
|
ledgerWindowCap := modelFacingRuneBudget(maxTotal, 0.20)
|
||||||
userLedgerMaxRunes = minPositiveInt(userLedgerMaxRunes, ledgerWindowCap)
|
userLedgerMaxRunes = minPositiveInt(userLedgerMaxRunes, ledgerWindowCap)
|
||||||
userLedgerEntryMaxRunes = minPositiveInt(userLedgerEntryMaxRunes, userLedgerMaxRunes)
|
userLedgerEntryMaxRunes = minPositiveInt(userLedgerEntryMaxRunes, userLedgerMaxRunes)
|
||||||
@@ -137,11 +141,11 @@ func newEinoSummarizationMiddleware(
|
|||||||
if recentTrailMax > trigger/2 {
|
if recentTrailMax > trigger/2 {
|
||||||
recentTrailMax = trigger / 2
|
recentTrailMax = trigger / 2
|
||||||
}
|
}
|
||||||
// The summarization request itself needs output headroom. A trigger is not a hard
|
// Summarization input aligns with the trigger threshold, minus explicit output reserve.
|
||||||
// request limit: one large turn can jump far beyond it. Bound the actual summary
|
summaryInputMax := trigger - outputReserve
|
||||||
// model input to 60% of the configured context window and keep complete recent
|
if summaryInputMax < 4096 {
|
||||||
// rounds so tool_call/tool_result pairs are never split.
|
summaryInputMax = trigger * 80 / 100
|
||||||
summaryInputMax := int(float64(maxTotal) * 0.6)
|
}
|
||||||
if summaryInputMax < 4096 {
|
if summaryInputMax < 4096 {
|
||||||
summaryInputMax = 4096
|
summaryInputMax = 4096
|
||||||
}
|
}
|
||||||
@@ -153,6 +157,9 @@ func newEinoSummarizationMiddleware(
|
|||||||
baseRoot = filepath.Join(filepath.Dir(dbPath), "conversation_artifacts", sanitizeEinoPathSegment(conv), "summarization")
|
baseRoot = filepath.Join(filepath.Dir(dbPath), "conversation_artifacts", sanitizeEinoPathSegment(conv), "summarization")
|
||||||
}
|
}
|
||||||
base := baseRoot
|
base := baseRoot
|
||||||
|
if abs, err := filepath.Abs(base); err == nil {
|
||||||
|
base = abs
|
||||||
|
}
|
||||||
if mkErr := os.MkdirAll(base, 0o755); mkErr == nil {
|
if mkErr := os.MkdirAll(base, 0o755); mkErr == nil {
|
||||||
transcriptPath = filepath.Join(base, "transcript.txt")
|
transcriptPath = filepath.Join(base, "transcript.txt")
|
||||||
}
|
}
|
||||||
@@ -160,6 +167,7 @@ func newEinoSummarizationMiddleware(
|
|||||||
|
|
||||||
retryPolicy := einoTransientRunRetryPolicyFromMW(mwCfg)
|
retryPolicy := einoTransientRunRetryPolicyFromMW(mwCfg)
|
||||||
retryMax := retryPolicy.maxAttempts
|
retryMax := retryPolicy.maxAttempts
|
||||||
|
var summaryOverflowRetries int
|
||||||
|
|
||||||
// ModelOptions apply only to summarization Generate (same ChatModel instance as the agent).
|
// ModelOptions apply only to summarization Generate (same ChatModel instance as the agent).
|
||||||
// Strip thinking/reasoning on this call path; mark requests for empty-choices diagnostics.
|
// Strip thinking/reasoning on this call path; mark requests for empty-choices diagnostics.
|
||||||
@@ -189,13 +197,29 @@ func newEinoSummarizationMiddleware(
|
|||||||
zap.String("path", transcriptPath), zap.Error(werr))
|
zap.String("path", transcriptPath), zap.Error(werr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
budget := summaryInputMax
|
||||||
|
aggressive := summaryOverflowRetries > 0
|
||||||
|
if aggressive {
|
||||||
|
budget = summaryInputMax * 70 / 100
|
||||||
|
if budget < 4096 {
|
||||||
|
budget = 4096
|
||||||
|
}
|
||||||
|
}
|
||||||
input, dropped, berr := buildBudgetedSummarizationModelInput(
|
input, dropped, berr := buildBudgetedSummarizationModelInput(
|
||||||
ctx, sysInstruction, userInstruction, originalMsgs, tokenCounter, summaryInputMax,
|
ctx, sysInstruction, userInstruction, originalMsgs, tokenCounter, budget,
|
||||||
|
summarizationInputBudgetOpts{
|
||||||
|
toolMaxBytes: toolMaxBytes,
|
||||||
|
spillRef: transcriptPath,
|
||||||
|
aggressive: aggressive,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if logger != nil && (berr != nil || dropped > 0) {
|
if logger != nil && (berr != nil || dropped > 0 || aggressive) {
|
||||||
fields := []zap.Field{
|
fields := []zap.Field{
|
||||||
zap.Int("max_input_tokens", summaryInputMax),
|
zap.Int("max_input_tokens", budget),
|
||||||
|
zap.Int("trigger_context_tokens", trigger),
|
||||||
|
zap.Int("output_reserve_tokens", outputReserve),
|
||||||
zap.Int("dropped_rounds", dropped),
|
zap.Int("dropped_rounds", dropped),
|
||||||
|
zap.Bool("aggressive", aggressive),
|
||||||
}
|
}
|
||||||
if berr != nil {
|
if berr != nil {
|
||||||
fields = append(fields, zap.Error(berr))
|
fields = append(fields, zap.Error(berr))
|
||||||
@@ -220,6 +244,15 @@ func newEinoSummarizationMiddleware(
|
|||||||
Retry: &summarization.RetryConfig{
|
Retry: &summarization.RetryConfig{
|
||||||
MaxRetries: &retryMax,
|
MaxRetries: &retryMax,
|
||||||
ShouldRetry: func(_ context.Context, _ adk.Message, err error) bool {
|
ShouldRetry: func(_ context.Context, _ adk.Message, err error) bool {
|
||||||
|
if isEinoContextOverflowError(err) && summaryOverflowRetries < 1 {
|
||||||
|
summaryOverflowRetries++
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("eino summarization context overflow, retrying with aggressive compaction",
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
retry := isEinoTransientRunError(err)
|
retry := isEinoTransientRunError(err)
|
||||||
if retry && logger != nil {
|
if retry && logger != nil {
|
||||||
logger.Warn("eino summarization generate transient error, will retry if attempts remain",
|
logger.Warn("eino summarization generate transient error, will retry if attempts remain",
|
||||||
@@ -275,6 +308,13 @@ func newEinoSummarizationMiddleware(
|
|||||||
return mw, nil
|
return mw, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// summarizationInputBudgetOpts controls spill/truncation behavior when a round alone exceeds budget.
|
||||||
|
type summarizationInputBudgetOpts struct {
|
||||||
|
toolMaxBytes int
|
||||||
|
spillRef string
|
||||||
|
aggressive bool
|
||||||
|
}
|
||||||
|
|
||||||
// buildBudgetedSummarizationModelInput builds the exact payload sent to the summary model.
|
// buildBudgetedSummarizationModelInput builds the exact payload sent to the summary model.
|
||||||
// It retains the newest complete conversation rounds within budget and emits an explicit
|
// It retains the newest complete conversation rounds within budget and emits an explicit
|
||||||
// marker when older rounds are omitted. The full pre-compaction transcript is persisted
|
// marker when older rounds are omitted. The full pre-compaction transcript is persisted
|
||||||
@@ -286,6 +326,7 @@ func buildBudgetedSummarizationModelInput(
|
|||||||
originalMsgs []adk.Message,
|
originalMsgs []adk.Message,
|
||||||
tokenCounter summarization.TokenCounterFunc,
|
tokenCounter summarization.TokenCounterFunc,
|
||||||
maxTokens int,
|
maxTokens int,
|
||||||
|
opts summarizationInputBudgetOpts,
|
||||||
) ([]adk.Message, int, error) {
|
) ([]adk.Message, int, error) {
|
||||||
base := []adk.Message{sysInstruction, userInstruction}
|
base := []adk.Message{sysInstruction, userInstruction}
|
||||||
baseTokens, err := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: base})
|
baseTokens, err := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: base})
|
||||||
@@ -312,12 +353,36 @@ func buildBudgetedSummarizationModelInput(
|
|||||||
rounds := splitMessagesIntoRounds(contextMsgs)
|
rounds := splitMessagesIntoRounds(contextMsgs)
|
||||||
selectedReverse := make([]messageRound, 0, len(rounds))
|
selectedReverse := make([]messageRound, 0, len(rounds))
|
||||||
used := 0
|
used := 0
|
||||||
|
toolMaxBytes := opts.toolMaxBytes
|
||||||
|
if toolMaxBytes <= 0 {
|
||||||
|
toolMaxBytes = 12000
|
||||||
|
}
|
||||||
|
if opts.aggressive {
|
||||||
|
toolMaxBytes /= aggressiveToolTruncDivisor
|
||||||
|
if toolMaxBytes < 2048 {
|
||||||
|
toolMaxBytes = 2048
|
||||||
|
}
|
||||||
|
}
|
||||||
for i := len(rounds) - 1; i >= 0; i-- {
|
for i := len(rounds) - 1; i >= 0; i-- {
|
||||||
n, countErr := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: rounds[i].messages})
|
n, countErr := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: rounds[i].messages})
|
||||||
if countErr != nil {
|
if countErr != nil {
|
||||||
return nil, 0, countErr
|
return nil, 0, countErr
|
||||||
}
|
}
|
||||||
if used+n > remaining {
|
if used+n > remaining {
|
||||||
|
if len(selectedReverse) == 0 {
|
||||||
|
slot := remaining - used
|
||||||
|
if slot > 0 {
|
||||||
|
truncated, truncErr := truncateRoundMessagesToTokenBudget(
|
||||||
|
ctx, rounds[i], slot, tokenCounter, toolMaxBytes, opts.spillRef,
|
||||||
|
)
|
||||||
|
if truncErr != nil {
|
||||||
|
return nil, 0, truncErr
|
||||||
|
}
|
||||||
|
if len(truncated) > 0 {
|
||||||
|
selectedReverse = append(selectedReverse, messageRound{messages: truncated})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
used += n
|
used += n
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func TestBuildBudgetedSummarizationModelInputKeepsRecentCompleteRounds(t *testin
|
|||||||
}
|
}
|
||||||
input, dropped, err := buildBudgetedSummarizationModelInput(
|
input, dropped, err := buildBudgetedSummarizationModelInput(
|
||||||
context.Background(), schema.SystemMessage("summary-system"), schema.UserMessage("summary-instruction"),
|
context.Background(), schema.SystemMessage("summary-system"), schema.UserMessage("summary-instruction"),
|
||||||
msgs, fixedTokenCounter(2), 7,
|
msgs, fixedTokenCounter(2), 7, summarizationInputBudgetOpts{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ func isEinoTransientRunError(err error) bool {
|
|||||||
if msg == "" {
|
if msg == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if strings.Contains(msg, "model input exceeds configured hard budget") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
transientMarkers := []string{
|
transientMarkers := []string{
|
||||||
"406",
|
"406",
|
||||||
"429",
|
"429",
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ func TestIsEinoTransientRunError(t *testing.T) {
|
|||||||
{"iteration limit", errors.New("max iteration reached"), false},
|
{"iteration limit", errors.New("max iteration reached"), false},
|
||||||
{"canceled", context.Canceled, false},
|
{"canceled", context.Canceled, false},
|
||||||
{"deadline", context.DeadlineExceeded, false},
|
{"deadline", context.DeadlineExceeded, false},
|
||||||
{"model hard budget containing 500", errors.New("model input exceeds configured hard budget after preserving the latest round: tokens=20500 max=19500 phase=test"), false},
|
|
||||||
{"auth", errors.New("invalid api key"), false},
|
{"auth", errors.New("invalid api key"), false},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
package multiagent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/cloudwego/eino/adk"
|
|
||||||
"github.com/cloudwego/eino/adk/middlewares/summarization"
|
|
||||||
"github.com/cloudwego/eino/schema"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// modelInputBudgetMiddleware is the final deterministic guard before a normal model call.
|
|
||||||
// Summarization remains the primary compaction strategy; this middleware only removes the
|
|
||||||
// oldest complete rounds if the finalized state still exceeds 65% of the configured
|
|
||||||
// window, leaving serialization/tokenizer headroom for the outbound HTTP guard.
|
|
||||||
type modelInputBudgetMiddleware struct {
|
|
||||||
adk.BaseChatModelAgentMiddleware
|
|
||||||
maxTokens int
|
|
||||||
counter summarization.TokenCounterFunc
|
|
||||||
logger *zap.Logger
|
|
||||||
phase string
|
|
||||||
}
|
|
||||||
|
|
||||||
func newModelInputBudgetMiddleware(maxTotalTokens int, modelName string, logger *zap.Logger, phase string) adk.ChatModelAgentMiddleware {
|
|
||||||
if maxTotalTokens <= 0 {
|
|
||||||
maxTotalTokens = 120000
|
|
||||||
}
|
|
||||||
limit := int(float64(maxTotalTokens) * 0.65)
|
|
||||||
if limit < 4096 {
|
|
||||||
limit = 4096
|
|
||||||
}
|
|
||||||
return &modelInputBudgetMiddleware{
|
|
||||||
maxTokens: limit,
|
|
||||||
counter: einoSummarizationTokenCounter(modelName),
|
|
||||||
logger: logger,
|
|
||||||
phase: phase,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *modelInputBudgetMiddleware) BeforeModelRewriteState(
|
|
||||||
ctx context.Context,
|
|
||||||
state *adk.ChatModelAgentState,
|
|
||||||
mc *adk.ModelContext,
|
|
||||||
) (context.Context, *adk.ChatModelAgentState, error) {
|
|
||||||
if m == nil || state == nil || len(state.Messages) == 0 {
|
|
||||||
return ctx, state, nil
|
|
||||||
}
|
|
||||||
count := func(msgs []adk.Message) (int, error) {
|
|
||||||
input := &summarization.TokenCounterInput{Messages: msgs}
|
|
||||||
if mc != nil {
|
|
||||||
input.Tools = mc.Tools
|
|
||||||
}
|
|
||||||
return m.counter(ctx, input)
|
|
||||||
}
|
|
||||||
before, err := count(state.Messages)
|
|
||||||
if err != nil {
|
|
||||||
return ctx, state, err
|
|
||||||
}
|
|
||||||
if before <= m.maxTokens {
|
|
||||||
return ctx, state, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
systems := make([]adk.Message, 0, 1)
|
|
||||||
contextMsgs := make([]adk.Message, 0, len(state.Messages))
|
|
||||||
for _, msg := range state.Messages {
|
|
||||||
if msg != nil && msg.Role == schema.System && len(contextMsgs) == 0 {
|
|
||||||
systems = append(systems, msg)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if msg != nil {
|
|
||||||
contextMsgs = append(contextMsgs, msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rounds := splitMessagesIntoRounds(contextMsgs)
|
|
||||||
dropped := 0
|
|
||||||
var candidate []adk.Message
|
|
||||||
for len(rounds) > 1 {
|
|
||||||
rounds = rounds[1:]
|
|
||||||
dropped++
|
|
||||||
candidate = append(candidate[:0], systems...)
|
|
||||||
for _, round := range rounds {
|
|
||||||
candidate = append(candidate, round.messages...)
|
|
||||||
}
|
|
||||||
after, countErr := count(candidate)
|
|
||||||
if countErr != nil {
|
|
||||||
return ctx, state, countErr
|
|
||||||
}
|
|
||||||
if after <= m.maxTokens {
|
|
||||||
out := *state
|
|
||||||
out.Messages = append([]adk.Message(nil), candidate...)
|
|
||||||
if m.logger != nil {
|
|
||||||
m.logger.Warn("eino model input hard budget applied",
|
|
||||||
zap.String("phase", m.phase), zap.Int("tokens_before", before),
|
|
||||||
zap.Int("tokens_after", after), zap.Int("max_tokens", m.maxTokens),
|
|
||||||
zap.Int("dropped_rounds", dropped))
|
|
||||||
}
|
|
||||||
return ctx, &out, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ctx, state, fmt.Errorf(
|
|
||||||
"model input exceeds configured hard budget after preserving the latest round: tokens=%d max=%d phase=%s",
|
|
||||||
before, m.maxTokens, m.phase,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package multiagent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/cloudwego/eino/adk"
|
|
||||||
"github.com/cloudwego/eino/schema"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestModelInputBudgetDropsOldestCompleteRoundsAndPersistsLatest(t *testing.T) {
|
|
||||||
mw := &modelInputBudgetMiddleware{
|
|
||||||
maxTokens: 7,
|
|
||||||
counter: fixedTokenCounter(4),
|
|
||||||
phase: "test",
|
|
||||||
}
|
|
||||||
state := &adk.ChatModelAgentState{Messages: []adk.Message{
|
|
||||||
schema.SystemMessage("system"),
|
|
||||||
schema.UserMessage("old-user"),
|
|
||||||
schema.AssistantMessage("old-answer", nil),
|
|
||||||
schema.UserMessage("latest-user"),
|
|
||||||
assistantToolCallsMsg("", "latest-call"),
|
|
||||||
schema.ToolMessage("latest-result", "latest-call"),
|
|
||||||
}}
|
|
||||||
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
joined := formatSummarizationTranscript(out.Messages)
|
|
||||||
if strings.Contains(joined, "old-user") || strings.Contains(joined, "old-answer") {
|
|
||||||
t.Fatalf("old rounds retained after hard budget: %s", joined)
|
|
||||||
}
|
|
||||||
if !strings.Contains(joined, "latest-user") || !strings.Contains(joined, "latest-result") {
|
|
||||||
t.Fatalf("latest rounds lost after hard budget: %s", joined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestModelInputBudgetFailsLocallyWhenLatestRoundAloneCannotFit(t *testing.T) {
|
|
||||||
mw := &modelInputBudgetMiddleware{maxTokens: 2, counter: fixedTokenCounter(4), phase: "test"}
|
|
||||||
state := &adk.ChatModelAgentState{Messages: []adk.Message{
|
|
||||||
schema.SystemMessage("system"),
|
|
||||||
assistantToolCallsMsg("", "latest-call"),
|
|
||||||
schema.ToolMessage("latest-result", "latest-call"),
|
|
||||||
}}
|
|
||||||
_, _, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "hard budget") {
|
|
||||||
t.Fatalf("expected local hard-budget error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/adk/middlewares/summarization"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// modelInputSoftBudgetMiddleware is the final guard before a normal model call.
|
||||||
|
// It drops oldest complete rounds and truncates oversized tool output in the latest
|
||||||
|
// round, but never fails locally — API context limits are handled by overflow retry.
|
||||||
|
type modelInputSoftBudgetMiddleware struct {
|
||||||
|
adk.BaseChatModelAgentMiddleware
|
||||||
|
maxTokens int
|
||||||
|
toolMaxBytes int
|
||||||
|
counter summarization.TokenCounterFunc
|
||||||
|
logger *zap.Logger
|
||||||
|
phase string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newModelInputSoftBudgetMiddleware(
|
||||||
|
maxTotalTokens int,
|
||||||
|
toolMaxBytes int,
|
||||||
|
modelName string,
|
||||||
|
logger *zap.Logger,
|
||||||
|
phase string,
|
||||||
|
) adk.ChatModelAgentMiddleware {
|
||||||
|
if maxTotalTokens <= 0 {
|
||||||
|
maxTotalTokens = 120000
|
||||||
|
}
|
||||||
|
if toolMaxBytes <= 0 {
|
||||||
|
toolMaxBytes = 12000
|
||||||
|
}
|
||||||
|
return &modelInputSoftBudgetMiddleware{
|
||||||
|
maxTokens: maxTotalTokens,
|
||||||
|
toolMaxBytes: toolMaxBytes,
|
||||||
|
counter: einoSummarizationTokenCounter(modelName),
|
||||||
|
logger: logger,
|
||||||
|
phase: phase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modelInputSoftBudgetMiddleware) BeforeModelRewriteState(
|
||||||
|
ctx context.Context,
|
||||||
|
state *adk.ChatModelAgentState,
|
||||||
|
mc *adk.ModelContext,
|
||||||
|
) (context.Context, *adk.ChatModelAgentState, error) {
|
||||||
|
if m == nil || state == nil || len(state.Messages) == 0 {
|
||||||
|
return ctx, state, nil
|
||||||
|
}
|
||||||
|
compacted, changed := compactMessagesByDroppingRounds(ctx, state.Messages, compactMessagesOpts{
|
||||||
|
maxTokens: m.maxTokens,
|
||||||
|
counter: m.counter,
|
||||||
|
toolMaxBytes: m.toolMaxBytes,
|
||||||
|
phase: m.phase,
|
||||||
|
logger: m.logger,
|
||||||
|
})
|
||||||
|
if !changed {
|
||||||
|
return ctx, state, nil
|
||||||
|
}
|
||||||
|
out := *state
|
||||||
|
out.Messages = compacted
|
||||||
|
return ctx, &out, nil
|
||||||
|
}
|
||||||
@@ -252,6 +252,7 @@ func RunDeepAgent(
|
|||||||
summarization: subSumMw,
|
summarization: subSumMw,
|
||||||
modelName: appCfg.OpenAI.Model,
|
modelName: appCfg.OpenAI.Model,
|
||||||
maxTotalTokens: appCfg.OpenAI.MaxTotalTokens,
|
maxTotalTokens: appCfg.OpenAI.MaxTotalTokens,
|
||||||
|
toolMaxBytes: toolMaxBytesFromMW(&ma.EinoMiddleware),
|
||||||
conversationID: conversationID,
|
conversationID: conversationID,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -413,6 +414,7 @@ func RunDeepAgent(
|
|||||||
summarization: mainSumMw,
|
summarization: mainSumMw,
|
||||||
modelName: appCfg.OpenAI.Model,
|
modelName: appCfg.OpenAI.Model,
|
||||||
maxTotalTokens: appCfg.OpenAI.MaxTotalTokens,
|
maxTotalTokens: appCfg.OpenAI.MaxTotalTokens,
|
||||||
|
toolMaxBytes: toolMaxBytesFromMW(&ma.EinoMiddleware),
|
||||||
conversationID: conversationID,
|
conversationID: conversationID,
|
||||||
trace: modelFacingTrace,
|
trace: modelFacingTrace,
|
||||||
})
|
})
|
||||||
@@ -430,6 +432,7 @@ func RunDeepAgent(
|
|||||||
summarization: mainSumMw,
|
summarization: mainSumMw,
|
||||||
modelName: appCfg.OpenAI.Model,
|
modelName: appCfg.OpenAI.Model,
|
||||||
maxTotalTokens: appCfg.OpenAI.MaxTotalTokens,
|
maxTotalTokens: appCfg.OpenAI.MaxTotalTokens,
|
||||||
|
toolMaxBytes: toolMaxBytesFromMW(&ma.EinoMiddleware),
|
||||||
conversationID: conversationID,
|
conversationID: conversationID,
|
||||||
trace: modelFacingTrace,
|
trace: modelFacingTrace,
|
||||||
})
|
})
|
||||||
@@ -505,6 +508,7 @@ func RunDeepAgent(
|
|||||||
summarization: mainSumMw,
|
summarization: mainSumMw,
|
||||||
modelName: appCfg.OpenAI.Model,
|
modelName: appCfg.OpenAI.Model,
|
||||||
maxTotalTokens: appCfg.OpenAI.MaxTotalTokens,
|
maxTotalTokens: appCfg.OpenAI.MaxTotalTokens,
|
||||||
|
toolMaxBytes: toolMaxBytesFromMW(&ma.EinoMiddleware),
|
||||||
conversationID: conversationID,
|
conversationID: conversationID,
|
||||||
skipTrace: true,
|
skipTrace: true,
|
||||||
}),
|
}),
|
||||||
@@ -608,6 +612,9 @@ func RunDeepAgent(
|
|||||||
DA: da,
|
DA: da,
|
||||||
ModelFacingTrace: modelFacingTrace,
|
ModelFacingTrace: modelFacingTrace,
|
||||||
EinoCallbacks: &ma.EinoCallbacks,
|
EinoCallbacks: &ma.EinoCallbacks,
|
||||||
|
MaxTotalTokens: appCfg.OpenAI.MaxTotalTokens,
|
||||||
|
ToolMaxBytes: toolMaxBytesFromMW(&ma.EinoMiddleware),
|
||||||
|
ModelName: appCfg.OpenAI.Model,
|
||||||
EmptyResponseMessage: "(Eino multi-agent orchestration completed but no assistant text was captured. Check process details or logs.) " +
|
EmptyResponseMessage: "(Eino multi-agent orchestration completed but no assistant text was captured. Check process details or logs.) " +
|
||||||
"(Eino 多代理编排已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
|
"(Eino 多代理编排已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
|
||||||
}, baseMsgs)
|
}, baseMsgs)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,6 @@ type Session struct {
|
|||||||
|
|
||||||
// AuthManager manages password-based authentication and session lifecycle.
|
// AuthManager manages password-based authentication and session lifecycle.
|
||||||
type AuthManager struct {
|
type AuthManager struct {
|
||||||
password string
|
|
||||||
sessionDuration time.Duration
|
sessionDuration time.Duration
|
||||||
db *database.DB
|
db *database.DB
|
||||||
|
|
||||||
@@ -41,39 +40,49 @@ type AuthManager struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthManager creates a new AuthManager instance.
|
// NewAuthManager creates a new AuthManager instance.
|
||||||
func NewAuthManager(password string, sessionDurationHours int) (*AuthManager, error) {
|
func NewAuthManager(sessionDurationHours int) *AuthManager {
|
||||||
if strings.TrimSpace(password) == "" {
|
|
||||||
return nil, errors.New("auth password must be configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
if sessionDurationHours <= 0 {
|
if sessionDurationHours <= 0 {
|
||||||
sessionDurationHours = 12
|
sessionDurationHours = 12
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AuthManager{
|
return &AuthManager{
|
||||||
password: password,
|
|
||||||
sessionDuration: time.Duration(sessionDurationHours) * time.Hour,
|
sessionDuration: time.Duration(sessionDurationHours) * time.Hour,
|
||||||
sessions: make(map[string]Session),
|
sessions: make(map[string]Session),
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AttachRBACStore enables multi-user RBAC authentication and bootstraps the
|
// AttachRBACStore enables multi-user RBAC authentication. When no users exist yet,
|
||||||
// built-in admin account from the legacy auth.password value.
|
// it bootstraps the built-in admin account and returns the generated initial password.
|
||||||
func (a *AuthManager) AttachRBACStore(db *database.DB) error {
|
func (a *AuthManager) AttachRBACStore(db *database.DB) (generatedAdminPassword string, err error) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return nil
|
return "", errors.New("database is required for authentication")
|
||||||
}
|
}
|
||||||
hash, err := HashPassword(a.password)
|
|
||||||
|
needsAdminPassword, err := db.RBACNeedsAdminPassword()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := db.BootstrapRBAC(hash, PermissionCatalog); err != nil {
|
|
||||||
return err
|
adminPasswordHash := ""
|
||||||
|
if needsAdminPassword {
|
||||||
|
generatedAdminPassword, err = GenerateStrongPassword(24)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
adminPasswordHash, err = HashPassword(generatedAdminPassword)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := db.BootstrapRBAC(adminPasswordHash, PermissionCatalog); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.db = db
|
a.db = db
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
return nil
|
return generatedAdminPassword, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate validates the password and creates a new session.
|
// Authenticate validates the password and creates a new session.
|
||||||
@@ -94,23 +103,9 @@ func (a *AuthManager) authenticateSession(username, password string) (Session, e
|
|||||||
|
|
||||||
a.mu.RLock()
|
a.mu.RLock()
|
||||||
db := a.db
|
db := a.db
|
||||||
legacyPassword := a.password
|
|
||||||
a.mu.RUnlock()
|
a.mu.RUnlock()
|
||||||
|
|
||||||
if db == nil {
|
if db == nil {
|
||||||
if password != legacyPassword {
|
return Session{}, errors.New("authentication store is not configured")
|
||||||
return Session{}, ErrInvalidPassword
|
|
||||||
}
|
|
||||||
return Session{
|
|
||||||
Token: token,
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
UserID: "admin",
|
|
||||||
Username: "admin",
|
|
||||||
DisplayName: "管理员",
|
|
||||||
Roles: []string{database.RBACSystemRoleAdmin},
|
|
||||||
Permissions: allPermissions(),
|
|
||||||
Scope: database.RBACScopeAll,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
username = strings.TrimSpace(strings.ToLower(username))
|
username = strings.TrimSpace(strings.ToLower(username))
|
||||||
@@ -187,10 +182,9 @@ func (a *AuthManager) CheckPassword(password string) bool {
|
|||||||
func (a *AuthManager) CheckUserPassword(username, password string) bool {
|
func (a *AuthManager) CheckUserPassword(username, password string) bool {
|
||||||
a.mu.RLock()
|
a.mu.RLock()
|
||||||
db := a.db
|
db := a.db
|
||||||
legacyPassword := a.password
|
|
||||||
a.mu.RUnlock()
|
a.mu.RUnlock()
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return password == legacyPassword
|
return false
|
||||||
}
|
}
|
||||||
user, err := db.GetRBACUserByUsername(username)
|
user, err := db.GetRBACUserByUsername(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -212,7 +206,7 @@ func (a *AuthManager) UpdateUserPassword(userID, password string) error {
|
|||||||
db := a.db
|
db := a.db
|
||||||
a.mu.RUnlock()
|
a.mu.RUnlock()
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return a.UpdateConfig(password, a.SessionDurationHours())
|
return errors.New("authentication store is not configured")
|
||||||
}
|
}
|
||||||
if err := db.UpdateRBACUserPassword(userID, hash); err != nil {
|
if err := db.UpdateRBACUserPassword(userID, hash); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -263,41 +257,6 @@ func (a *AuthManager) SessionDurationHours() int {
|
|||||||
return int(a.sessionDuration / time.Hour)
|
return int(a.sessionDuration / time.Hour)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateConfig updates the password and session duration, revoking existing sessions.
|
|
||||||
func (a *AuthManager) UpdateConfig(password string, sessionDurationHours int) error {
|
|
||||||
password = strings.TrimSpace(password)
|
|
||||||
if password == "" {
|
|
||||||
return errors.New("auth password must be configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
if sessionDurationHours <= 0 {
|
|
||||||
sessionDurationHours = 12
|
|
||||||
}
|
|
||||||
|
|
||||||
hash := ""
|
|
||||||
if a.db != nil {
|
|
||||||
var err error
|
|
||||||
hash, err = HashPassword(password)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
a.password = password
|
|
||||||
a.sessionDuration = time.Duration(sessionDurationHours) * time.Hour
|
|
||||||
a.sessions = make(map[string]Session)
|
|
||||||
db := a.db
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if db != nil {
|
|
||||||
if err := db.UpdateRBACAdminPassword(hash); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func allPermissions() map[string]bool {
|
func allPermissions() map[string]bool {
|
||||||
out := make(map[string]bool, len(PermissionCatalog))
|
out := make(map[string]bool, len(PermissionCatalog))
|
||||||
for key := range PermissionCatalog {
|
for key := range PermissionCatalog {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAttachRBACStoreBootstrapsAdminPassword(t *testing.T) {
|
||||||
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "auth-bootstrap.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
manager := NewAuthManager(12)
|
||||||
|
generated, err := manager.AttachRBACStore(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AttachRBACStore: %v", err)
|
||||||
|
}
|
||||||
|
if generated == "" {
|
||||||
|
t.Fatal("expected generated admin password on first bootstrap")
|
||||||
|
}
|
||||||
|
if !manager.CheckUserPassword("admin", generated) {
|
||||||
|
t.Fatal("generated password should authenticate admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := manager.AttachRBACStore(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AttachRBACStore second call: %v", err)
|
||||||
|
}
|
||||||
|
if second != "" {
|
||||||
|
t.Fatalf("expected no password on second bootstrap, got %q", second)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,11 +20,8 @@ func TestAuthManagerAuthenticatesCreatedRBACUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = db.Close() })
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
manager, err := NewAuthManager("admin-secret", 12)
|
manager := NewAuthManager(12)
|
||||||
if err != nil {
|
if _, err := manager.AttachRBACStore(db); err != nil {
|
||||||
t.Fatalf("NewAuthManager: %v", err)
|
|
||||||
}
|
|
||||||
if err := manager.AttachRBACStore(db); err != nil {
|
|
||||||
t.Fatalf("AttachRBACStore: %v", err)
|
t.Fatalf("AttachRBACStore: %v", err)
|
||||||
}
|
}
|
||||||
hash, err := HashPassword("operator-secret")
|
hash, err := HashPassword("operator-secret")
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func (e *Executor) buildToolIndex() {
|
|||||||
e.toolIndex[e.config.Tools[i].Name] = &e.config.Tools[i]
|
e.toolIndex[e.config.Tools[i].Name] = &e.config.Tools[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
e.logger.Info("工具索引构建完成",
|
e.logger.Debug("工具索引构建完成",
|
||||||
zap.Int("totalTools", len(e.config.Tools)),
|
zap.Int("totalTools", len(e.config.Tools)),
|
||||||
zap.Int("enabledTools", len(e.toolIndex)),
|
zap.Int("enabledTools", len(e.toolIndex)),
|
||||||
)
|
)
|
||||||
@@ -73,14 +73,14 @@ func (e *Executor) buildToolIndex() {
|
|||||||
|
|
||||||
// ExecuteTool 执行安全工具
|
// ExecuteTool 执行安全工具
|
||||||
func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[string]interface{}) (*mcp.ToolResult, error) {
|
func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||||
e.logger.Info("ExecuteTool被调用",
|
e.logger.Debug("ExecuteTool被调用",
|
||||||
zap.String("toolName", toolName),
|
zap.String("toolName", toolName),
|
||||||
zap.Any("args", args),
|
zap.Any("args", args),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 特殊处理:exec工具直接执行系统命令
|
// 特殊处理:exec工具直接执行系统命令
|
||||||
if toolName == "exec" {
|
if toolName == "exec" {
|
||||||
e.logger.Info("执行exec工具")
|
e.logger.Debug("执行exec工具")
|
||||||
return e.executeSystemCommand(ctx, args)
|
return e.executeSystemCommand(ctx, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
|
|||||||
return nil, fmt.Errorf("工具 %s 未找到或未启用", toolName)
|
return nil, fmt.Errorf("工具 %s 未找到或未启用", toolName)
|
||||||
}
|
}
|
||||||
|
|
||||||
e.logger.Info("找到工具配置",
|
e.logger.Debug("找到工具配置",
|
||||||
zap.String("toolName", toolName),
|
zap.String("toolName", toolName),
|
||||||
zap.String("command", toolConfig.Command),
|
zap.String("command", toolConfig.Command),
|
||||||
zap.Strings("args", toolConfig.Args),
|
zap.Strings("args", toolConfig.Args),
|
||||||
@@ -103,7 +103,7 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
|
|||||||
|
|
||||||
// 特殊处理:内部工具(command 以 "internal:" 开头)
|
// 特殊处理:内部工具(command 以 "internal:" 开头)
|
||||||
if strings.HasPrefix(toolConfig.Command, "internal:") {
|
if strings.HasPrefix(toolConfig.Command, "internal:") {
|
||||||
e.logger.Info("执行内部工具",
|
e.logger.Debug("执行内部工具",
|
||||||
zap.String("toolName", toolName),
|
zap.String("toolName", toolName),
|
||||||
zap.String("command", toolConfig.Command),
|
zap.String("command", toolConfig.Command),
|
||||||
)
|
)
|
||||||
@@ -113,7 +113,7 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
|
|||||||
// 构建命令 - 根据工具类型使用不同的参数格式
|
// 构建命令 - 根据工具类型使用不同的参数格式
|
||||||
cmdArgs := e.buildCommandArgs(toolName, toolConfig, args)
|
cmdArgs := e.buildCommandArgs(toolName, toolConfig, args)
|
||||||
|
|
||||||
e.logger.Info("构建命令参数完成",
|
e.logger.Debug("构建命令参数完成",
|
||||||
zap.String("toolName", toolName),
|
zap.String("toolName", toolName),
|
||||||
zap.Strings("cmdArgs", cmdArgs),
|
zap.Strings("cmdArgs", cmdArgs),
|
||||||
zap.Int("argsCount", len(cmdArgs)),
|
zap.Int("argsCount", len(cmdArgs)),
|
||||||
@@ -142,7 +142,7 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
|
|||||||
attachNonInteractiveStdin(cmd)
|
attachNonInteractiveStdin(cmd)
|
||||||
_ = prepareShellCmdSession(cmd)
|
_ = prepareShellCmdSession(cmd)
|
||||||
|
|
||||||
e.logger.Info("执行安全工具",
|
e.logger.Debug("执行安全工具",
|
||||||
zap.String("tool", toolName),
|
zap.String("tool", toolName),
|
||||||
zap.Strings("args", cmdArgs),
|
zap.Strings("args", cmdArgs),
|
||||||
)
|
)
|
||||||
@@ -180,7 +180,7 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
|
|||||||
if exitCode != nil && toolConfig.AllowedExitCodes != nil {
|
if exitCode != nil && toolConfig.AllowedExitCodes != nil {
|
||||||
for _, allowedCode := range toolConfig.AllowedExitCodes {
|
for _, allowedCode := range toolConfig.AllowedExitCodes {
|
||||||
if *exitCode == allowedCode {
|
if *exitCode == allowedCode {
|
||||||
e.logger.Info("工具执行完成(退出码在允许列表中)",
|
e.logger.Debug("工具执行完成(退出码在允许列表中)",
|
||||||
zap.String("tool", toolName),
|
zap.String("tool", toolName),
|
||||||
zap.Int("exitCode", *exitCode),
|
zap.Int("exitCode", *exitCode),
|
||||||
zap.String("output", string(output)),
|
zap.String("output", string(output)),
|
||||||
@@ -215,7 +215,7 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
e.logger.Info("工具执行成功",
|
e.logger.Debug("工具执行成功",
|
||||||
zap.String("tool", toolName),
|
zap.String("tool", toolName),
|
||||||
zap.String("output", string(output)),
|
zap.String("output", string(output)),
|
||||||
)
|
)
|
||||||
@@ -233,7 +233,7 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
|
|||||||
|
|
||||||
// RegisterTools 注册工具到MCP服务器
|
// RegisterTools 注册工具到MCP服务器
|
||||||
func (e *Executor) RegisterTools(mcpServer *mcp.Server) {
|
func (e *Executor) RegisterTools(mcpServer *mcp.Server) {
|
||||||
e.logger.Info("开始注册工具",
|
e.logger.Debug("开始注册工具",
|
||||||
zap.Int("totalTools", len(e.config.Tools)),
|
zap.Int("totalTools", len(e.config.Tools)),
|
||||||
zap.Int("enabledTools", len(e.toolIndex)),
|
zap.Int("enabledTools", len(e.toolIndex)),
|
||||||
)
|
)
|
||||||
@@ -281,7 +281,7 @@ func (e *Executor) RegisterTools(mcpServer *mcp.Server) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
handler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||||
e.logger.Info("工具handler被调用",
|
e.logger.Debug("工具handler被调用",
|
||||||
zap.String("toolName", toolName),
|
zap.String("toolName", toolName),
|
||||||
zap.Any("args", args),
|
zap.Any("args", args),
|
||||||
)
|
)
|
||||||
@@ -289,14 +289,14 @@ func (e *Executor) RegisterTools(mcpServer *mcp.Server) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mcpServer.RegisterTool(tool, handler)
|
mcpServer.RegisterTool(tool, handler)
|
||||||
e.logger.Info("注册安全工具成功",
|
e.logger.Debug("注册安全工具成功",
|
||||||
zap.String("tool", toolConfigCopy.Name),
|
zap.String("tool", toolConfigCopy.Name),
|
||||||
zap.String("command", toolConfigCopy.Command),
|
zap.String("command", toolConfigCopy.Command),
|
||||||
zap.Int("index", i),
|
zap.Int("index", i),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
e.logger.Info("工具注册完成",
|
e.logger.Debug("工具注册完成",
|
||||||
zap.Int("registeredCount", len(e.config.Tools)),
|
zap.Int("registeredCount", len(e.config.Tools)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateStrongPassword returns a URL-safe random password of the given length.
|
||||||
|
func GenerateStrongPassword(length int) (string, error) {
|
||||||
|
if length <= 0 {
|
||||||
|
length = 24
|
||||||
|
}
|
||||||
|
|
||||||
|
randomBytes := make([]byte, length)
|
||||||
|
if _, err := rand.Read(randomBytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
password := base64.RawURLEncoding.EncodeToString(randomBytes)
|
||||||
|
if len(password) > length {
|
||||||
|
password = password[:length]
|
||||||
|
}
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package termout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartupWebUIOptions configures the startup Web UI banner.
|
||||||
|
type StartupWebUIOptions struct {
|
||||||
|
Scheme string
|
||||||
|
Port int
|
||||||
|
SelfSigned bool
|
||||||
|
HTTPRedirect bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintConfigCreated prints a short notice when config.yaml is bootstrapped.
|
||||||
|
func PrintConfigCreated() {
|
||||||
|
s := New(os.Stdout)
|
||||||
|
s.Println("")
|
||||||
|
s.Println(s.Green("✔ ") + s.Bold("已创建 config.yaml") + s.Dim("(来自 config.example.yaml)"))
|
||||||
|
s.BlankLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintStartupWebUI prints a colored startup banner for the Web UI.
|
||||||
|
func PrintStartupWebUI(opts StartupWebUIOptions) {
|
||||||
|
s := New(os.Stdout)
|
||||||
|
scheme := opts.Scheme
|
||||||
|
if scheme == "" {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
port := opts.Port
|
||||||
|
if port <= 0 {
|
||||||
|
port = 8080
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("%s://127.0.0.1:%d/", scheme, port)
|
||||||
|
|
||||||
|
s.BlankLine()
|
||||||
|
s.Println(s.Bold(s.Cyan("CYBERSTRIKE AI")) + s.Dim(" / secure workspace"))
|
||||||
|
s.Println(s.Dim(strings.Repeat("─", 60)))
|
||||||
|
s.Println(s.Green("● ONLINE") + " " + s.Bold(s.White(url)))
|
||||||
|
if opts.SelfSigned {
|
||||||
|
s.Println(s.Dim(" TLS ") + s.Yellow("self-signed") + s.Dim(" · accept the browser warning once"))
|
||||||
|
}
|
||||||
|
if opts.HTTPRedirect {
|
||||||
|
s.Println(s.Dim(" Redirect ") + fmt.Sprintf("http://127.0.0.1:%d/ → HTTPS", port))
|
||||||
|
}
|
||||||
|
s.BlankLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintBootstrapAdminCredentials prints the initial admin password banner.
|
||||||
|
func PrintBootstrapAdminCredentials(password string) {
|
||||||
|
password = strings.TrimSpace(password)
|
||||||
|
if password == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s := New(os.Stdout)
|
||||||
|
s.Println(s.Bold(s.Yellow("ADMIN SETUP REQUIRED")))
|
||||||
|
s.Println(s.Dim(strings.Repeat("─", 60)))
|
||||||
|
s.Println(s.Dim(" Username ") + s.Bold(s.White("admin")))
|
||||||
|
s.Println(s.Dim(" Password ") + s.Bold(s.Yellow(password)))
|
||||||
|
s.BlankLine()
|
||||||
|
s.Println(s.Yellow(" ! ") + s.White("Store this password securely. It is shown only once."))
|
||||||
|
s.Println(s.Dim(" Change it in Settings immediately after signing in."))
|
||||||
|
s.BlankLine()
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package termout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDisplayWidthEmoji(t *testing.T) {
|
||||||
|
if got := displayWidth("🚀"); got != 2 {
|
||||||
|
t.Fatalf("displayWidth(emoji) = %d, want 2", got)
|
||||||
|
}
|
||||||
|
if got := displayWidth("ab"); got != 2 {
|
||||||
|
t.Fatalf("displayWidth(ab) = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayWidthIgnoresANSI(t *testing.T) {
|
||||||
|
s := New(nil)
|
||||||
|
colored := s.Bold("admin")
|
||||||
|
if got := displayWidth(colored); got != 5 {
|
||||||
|
t.Fatalf("displayWidth colored = %d, want 5", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPadRightDisplay(t *testing.T) {
|
||||||
|
got := padRightDisplay("pwd", 10)
|
||||||
|
if displayWidth(got) != 10 {
|
||||||
|
t.Fatalf("padded width = %d, want 10", displayWidth(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestColorDisabledWithoutTTY(t *testing.T) {
|
||||||
|
s := New(nil)
|
||||||
|
if s.enabled {
|
||||||
|
t.Fatal("expected colors disabled for nil writer")
|
||||||
|
}
|
||||||
|
if got := s.Cyan("x"); got != "x" {
|
||||||
|
t.Fatalf("Cyan without TTY = %q, want plain text", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintBootstrapAdminCredentialsEmpty(t *testing.T) {
|
||||||
|
PrintBootstrapAdminCredentials(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintStartupWebUIOptions(t *testing.T) {
|
||||||
|
PrintStartupWebUI(StartupWebUIOptions{
|
||||||
|
Scheme: "https",
|
||||||
|
Port: 8080,
|
||||||
|
SelfSigned: true,
|
||||||
|
HTTPRedirect: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoxRowAlignedWidth(t *testing.T) {
|
||||||
|
s := New(nil)
|
||||||
|
rows := []string{
|
||||||
|
s.Bold("CyberStrikeAI") + s.White(" is ready"),
|
||||||
|
s.Dim("Web UI ") + s.Bold("https://127.0.0.1:8080/"),
|
||||||
|
}
|
||||||
|
inner := maxDisplayWidth(rows...)
|
||||||
|
for _, row := range rows {
|
||||||
|
line := s.boxRow(inner, row)
|
||||||
|
if !strings.Contains(line, "│") {
|
||||||
|
t.Fatalf("box row missing border: %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaxDisplayWidth(t *testing.T) {
|
||||||
|
short := "abc"
|
||||||
|
long := "https://127.0.0.1:8080/"
|
||||||
|
if got := maxDisplayWidth(short, long); got != displayWidth(long) {
|
||||||
|
t.Fatalf("maxDisplayWidth = %d, want %d", got, displayWidth(long))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package termout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
codeReset = "\033[0m"
|
||||||
|
codeBold = "\033[1m"
|
||||||
|
codeDim = "\033[2m"
|
||||||
|
codeRed = "\033[31m"
|
||||||
|
codeGreen = "\033[32m"
|
||||||
|
codeYellow = "\033[33m"
|
||||||
|
codeBlue = "\033[34m"
|
||||||
|
codeCyan = "\033[36m"
|
||||||
|
codeWhite = "\033[97m"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Style wraps ANSI styling with TTY / NO_COLOR awareness.
|
||||||
|
type Style struct {
|
||||||
|
out io.Writer
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Style writing to out (typically os.Stdout).
|
||||||
|
func New(out io.Writer) *Style {
|
||||||
|
return &Style{out: out, enabled: colorEnabled(out)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorEnabled(w io.Writer) bool {
|
||||||
|
if strings.TrimSpace(os.Getenv("NO_COLOR")) != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
force := strings.TrimSpace(os.Getenv("FORCE_COLOR"))
|
||||||
|
if force == "1" || strings.EqualFold(force, "true") || strings.EqualFold(force, "yes") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
f, ok := w.(*os.File)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
stat, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return stat.Mode()&os.ModeCharDevice != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Style) paint(code, text string) string {
|
||||||
|
if !s.enabled || text == "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return code + text + codeReset
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Style) Bold(text string) string { return s.paint(codeBold, text) }
|
||||||
|
func (s *Style) Dim(text string) string { return s.paint(codeDim, text) }
|
||||||
|
func (s *Style) Red(text string) string { return s.paint(codeRed, text) }
|
||||||
|
func (s *Style) Green(text string) string { return s.paint(codeGreen, text) }
|
||||||
|
func (s *Style) Yellow(text string) string { return s.paint(codeYellow, text) }
|
||||||
|
func (s *Style) Blue(text string) string { return s.paint(codeBlue, text) }
|
||||||
|
func (s *Style) Cyan(text string) string { return s.paint(codeCyan, text) }
|
||||||
|
func (s *Style) White(text string) string { return s.paint(codeWhite, text) }
|
||||||
|
|
||||||
|
func (s *Style) Println(text string) {
|
||||||
|
_, _ = fmt.Fprintln(s.out, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Style) Printf(format string, args ...interface{}) {
|
||||||
|
_, _ = fmt.Fprintf(s.out, format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Style) BlankLine() {
|
||||||
|
s.Println("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Style) boxTop(innerWidth int) string {
|
||||||
|
return s.Cyan("╭" + strings.Repeat("─", innerWidth+2) + "╮")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Style) boxBottom(innerWidth int) string {
|
||||||
|
return s.Cyan("╰" + strings.Repeat("─", innerWidth+2) + "╯")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Style) boxRow(innerWidth int, content string) string {
|
||||||
|
return s.Cyan("│ ") + padRightDisplay(content, innerWidth) + s.Cyan(" │")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Style) printBox(rows []string, minInner, maxInner int) {
|
||||||
|
inner := maxDisplayWidth(rows...)
|
||||||
|
if inner < minInner {
|
||||||
|
inner = minInner
|
||||||
|
}
|
||||||
|
if maxInner > 0 && inner > maxInner {
|
||||||
|
inner = maxInner
|
||||||
|
}
|
||||||
|
|
||||||
|
s.BlankLine()
|
||||||
|
s.Println(s.boxTop(inner))
|
||||||
|
for _, row := range rows {
|
||||||
|
s.Println(s.boxRow(inner, row))
|
||||||
|
}
|
||||||
|
s.Println(s.boxBottom(inner))
|
||||||
|
s.BlankLine()
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package termout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"golang.org/x/text/width"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||||
|
|
||||||
|
// displayWidth returns the terminal display width of text, ignoring ANSI codes.
|
||||||
|
func displayWidth(text string) int {
|
||||||
|
plain := ansiEscapeRe.ReplaceAllString(text, "")
|
||||||
|
w := 0
|
||||||
|
for _, r := range plain {
|
||||||
|
w += runeDisplayWidth(r)
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func runeDisplayWidth(r rune) int {
|
||||||
|
if r == utf8.RuneError {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// Most emoji / symbols render as double-width in modern terminals.
|
||||||
|
if isEmojiLikeRune(r) {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
switch width.LookupRune(r).Kind() {
|
||||||
|
case width.EastAsianWide, width.EastAsianFullwidth:
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmojiLikeRune(r rune) bool {
|
||||||
|
switch {
|
||||||
|
case r >= 0x1F300 && r <= 0x1FAFF: // pictographs / emoji
|
||||||
|
return true
|
||||||
|
case r >= 0x2600 && r <= 0x27BF: // misc symbols
|
||||||
|
return true
|
||||||
|
case r >= 0x2300 && r <= 0x23FF: // misc technical (⌚ etc.)
|
||||||
|
return true
|
||||||
|
case r >= 0x2B50 && r <= 0x2B55:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func padRightDisplay(text string, target int) string {
|
||||||
|
if target <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
gap := target - displayWidth(text)
|
||||||
|
if gap <= 0 {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return text + strings.Repeat(" ", gap)
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxDisplayWidth(rows ...string) int {
|
||||||
|
max := 0
|
||||||
|
for _, row := range rows {
|
||||||
|
if w := displayWidth(row); w > max {
|
||||||
|
max = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max
|
||||||
|
}
|
||||||
@@ -89,7 +89,7 @@ func RegisterAnalyzeImageTool(mcpServer *mcp.Server, cfg *config.Config, logger
|
|||||||
|
|
||||||
mcpServer.RegisterTool(tool, handler)
|
mcpServer.RegisterTool(tool, handler)
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
logger.Info("vision: analyze_image 工具已注册", zap.String("model", cfg.Vision.Model))
|
logger.Debug("vision: analyze_image 工具已注册", zap.String("model", cfg.Vision.Model))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package workflow
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/compose"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -235,6 +237,89 @@ func TestExecuteEinoGraph_linearStartOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteEinoGraph_checkpointRestoresStartOutput(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
checkpointStore, err := newFileCheckPointStore(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new checkpoint store: %v", err)
|
||||||
|
}
|
||||||
|
state := newWorkflowLocalState(map[string]interface{}{"message": "ping"}, "run-checkpoint")
|
||||||
|
node := graphNode{ID: "start-1", Type: "start"}
|
||||||
|
wf := compose.NewWorkflow[WorkflowInput, WorkflowOutput](
|
||||||
|
compose.WithGenLocalState(func(context.Context) *WorkflowLocalState { return state }),
|
||||||
|
)
|
||||||
|
start := wf.AddLambdaNode("start-1", compose.InvokableLambda(func(_ context.Context, input WorkflowInput) (WorkflowNodeOutput, error) {
|
||||||
|
result := startOutputMap(node, input.Message, input.ConversationID, input.ProjectID)
|
||||||
|
state.NodeOutputs[node.ID] = result
|
||||||
|
state.NodeOutputs["condition-1"] = conditionOutputMap(graphNode{ID: "condition-1", Type: "condition"}, "{{inputs.message}} == ping", true)
|
||||||
|
state.NodeOutputs["tool-1"] = toolOutputMap(graphNode{ID: "tool-1", Type: "tool"}, "tool result", "lookup", map[string]any{"id": "1"}, "exec-1", false)
|
||||||
|
state.NodeOutputs["agent-1"] = agentOutputMap(graphNode{ID: "agent-1", Type: "agent"}, "agent result", "chat", []string{"exec-1"})
|
||||||
|
state.NodeOutputs["hitl-1"] = hitlOutputMap(graphNode{ID: "hitl-1", Type: "hitl"}, "completed", "approved", "continue?", "reviewer", true)
|
||||||
|
state.NodeOutputs["output-1"] = outputNodeOutputMap(graphNode{ID: "output-1", Type: "output"}, "result", "ping")
|
||||||
|
state.NodeOutputs["end-1"] = endOutputMap(graphNode{ID: "end-1", Type: "end"}, "done")
|
||||||
|
state.LastOutput = result
|
||||||
|
state.Outputs["seed"] = "preserved"
|
||||||
|
return result, nil
|
||||||
|
}))
|
||||||
|
outputNode := wf.AddLambdaNode("out-1", compose.InvokableLambda(func(_ context.Context, input WorkflowNodeOutput) (WorkflowNodeOutput, error) {
|
||||||
|
return input, nil
|
||||||
|
}))
|
||||||
|
start.AddInput(compose.START)
|
||||||
|
outputNode.AddInput("start-1")
|
||||||
|
wf.End().AddInput("out-1", compose.ToField("out-1"))
|
||||||
|
runnable, err := wf.Compile(ctx,
|
||||||
|
compose.WithCheckPointStore(checkpointStore),
|
||||||
|
compose.WithInterruptAfterNodes([]string{"start-1"}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = runnable.Invoke(ctx, workflowInputFromMap(state.Inputs), compose.WithCheckPointID("run-checkpoint"))
|
||||||
|
info, ok := compose.ExtractInterruptInfo(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("invoke error = %v, want checkpoint interrupt", err)
|
||||||
|
}
|
||||||
|
restored, ok := info.State.(*WorkflowLocalState)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("checkpoint state = %T, want *WorkflowLocalState", info.State)
|
||||||
|
}
|
||||||
|
for nodeID, wantType := range map[string]string{
|
||||||
|
"start-1": "StartOutput",
|
||||||
|
"condition-1": "ConditionOutput",
|
||||||
|
"tool-1": "ToolOutput",
|
||||||
|
"agent-1": "AgentOutput",
|
||||||
|
"hitl-1": "HITLOutput",
|
||||||
|
"output-1": "OutputNodeOutput",
|
||||||
|
"end-1": "NodeOutputEnvelope",
|
||||||
|
} {
|
||||||
|
if got := fmt.Sprintf("%T", restored.NodeOutputs[nodeID]["typed"]); got != "workflow."+wantType {
|
||||||
|
t.Fatalf("restored %s typed output = %s, want workflow.%s", nodeID, got, wantType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := valueFromPath("previous.message", restored); got != "ping" {
|
||||||
|
t.Fatalf("restored previous.message = %v, want ping", got)
|
||||||
|
}
|
||||||
|
if got := valueFromPath("inputs.message", restored); got != "ping" {
|
||||||
|
t.Fatalf("restored inputs.message = %v, want ping", got)
|
||||||
|
}
|
||||||
|
if got := valueFromPath("outputs.seed", restored); got != "preserved" {
|
||||||
|
t.Fatalf("restored outputs.seed = %v, want preserved", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := runnable.Invoke(ctx, WorkflowInput{}, compose.WithCheckPointID("run-checkpoint"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resume checkpoint: %v", err)
|
||||||
|
}
|
||||||
|
output, ok := result["out-1"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("resumed output type = %T, want map[string]any", result["out-1"])
|
||||||
|
}
|
||||||
|
if got := output["output"]; got != "ping" {
|
||||||
|
t.Fatalf("resumed output = %v, want ping", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteEinoGraph_conditionBranch(t *testing.T) {
|
func TestExecuteEinoGraph_conditionBranch(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
SetCheckpointDir(t.TempDir())
|
SetCheckpointDir(t.TempDir())
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import (
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
schema.RegisterName[*WorkflowLocalState]("_cyberstrike_workflow_local_state")
|
schema.RegisterName[*WorkflowLocalState]("_cyberstrike_workflow_local_state")
|
||||||
|
schema.RegisterName[NodeOutputEnvelope]("_cyberstrike_workflow_node_output_envelope")
|
||||||
|
schema.RegisterName[StartOutput]("_cyberstrike_workflow_start_output")
|
||||||
|
schema.RegisterName[ConditionOutput]("_cyberstrike_workflow_condition_output")
|
||||||
|
schema.RegisterName[ToolOutput]("_cyberstrike_workflow_tool_output")
|
||||||
|
schema.RegisterName[AgentOutput]("_cyberstrike_workflow_agent_output")
|
||||||
|
schema.RegisterName[HITLOutput]("_cyberstrike_workflow_hitl_output")
|
||||||
|
schema.RegisterName[OutputNodeOutput]("_cyberstrike_workflow_output_node_output")
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkflowLocalState is the Eino WithGenLocalState payload (checkpoint-serializable).
|
// WorkflowLocalState is the Eino WithGenLocalState payload (checkpoint-serializable).
|
||||||
|
|||||||
+56
-17
@@ -5366,12 +5366,17 @@ html[data-theme="dark"] .user-menu-dropdown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.timeline-live-pruned-marker {
|
.timeline-live-pruned-marker {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 3;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border: 1px dashed rgba(148, 163, 184, 0.35);
|
border: 1px dashed rgba(148, 163, 184, 0.35);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: rgba(148, 163, 184, 0.08);
|
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;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11507,27 +11512,35 @@ html[data-theme="dark"] .robot-binding-one-time-badge {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
|
padding: 1px 1px 3px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mcp-stats-timeline-moment {
|
.mcp-stats-timeline-moment {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 156px;
|
||||||
max-width: 144px;
|
max-width: none;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(59, 130, 246, 0.08);
|
background: rgba(59, 130, 246, 0.08);
|
||||||
color: #1d4ed8;
|
color: #1d4ed8;
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
flex: 0 0 auto;
|
flex: 1 0 156px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mcp-stats-timeline-moment__time {
|
.mcp-stats-timeline-moment__time {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -11544,6 +11557,7 @@ html[data-theme="dark"] .robot-binding-one-time-badge {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
background: #2563eb;
|
background: #2563eb;
|
||||||
box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0.04);
|
box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0.04);
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mcp-stats-timeline-moment__fail {
|
.mcp-stats-timeline-moment__fail {
|
||||||
@@ -11557,12 +11571,16 @@ html[data-theme="dark"] .robot-binding-one-time-badge {
|
|||||||
font-size: 0.625rem;
|
font-size: 0.625rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mcp-stats-timeline-moment--more {
|
.mcp-stats-timeline-moment--more {
|
||||||
|
min-width: 42px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: rgba(148, 163, 184, 0.14);
|
background: rgba(148, 163, 184, 0.14);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mcp-stats-timeline__chart-wrap {
|
.mcp-stats-timeline__chart-wrap {
|
||||||
@@ -11632,7 +11650,8 @@ html[data-theme="dark"] .robot-binding-one-time-badge {
|
|||||||
}
|
}
|
||||||
.mcp-stats-timeline-moments__list {
|
.mcp-stats-timeline-moments__list {
|
||||||
flex-wrap: nowrap;
|
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;
|
position: relative;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 10px 0 12px;
|
padding: 6px 0;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
@@ -30351,23 +30370,27 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
|
|||||||
}
|
}
|
||||||
.projects-graph-legend {
|
.projects-graph-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px 14px;
|
gap: 8px 14px;
|
||||||
}
|
}
|
||||||
.projects-graph-legend-group {
|
.projects-graph-legend-group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px 10px;
|
gap: 6px 10px;
|
||||||
}
|
}
|
||||||
.projects-graph-legend-heading {
|
.projects-graph-legend-heading {
|
||||||
|
flex: 0 0 auto;
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.projects-graph-legend-divider {
|
.projects-graph-legend-divider {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -30378,10 +30401,12 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
|
|||||||
}
|
}
|
||||||
.projects-graph-legend-item {
|
.projects-graph-legend-item {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.projects-graph-legend-item--edge i {
|
.projects-graph-legend-item--edge i {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -30619,8 +30644,8 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
.project-fact-graph-edges-wrap {
|
.project-fact-graph-edges-wrap {
|
||||||
flex: 1 1 auto;
|
flex: 0 0 auto;
|
||||||
min-height: 0;
|
min-height: min-content;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -30643,8 +30668,8 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.project-fact-graph-edges-list {
|
.project-fact-graph-edges-list {
|
||||||
flex: 1 1 auto;
|
flex: 0 1 auto;
|
||||||
min-height: 0;
|
max-height: 180px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -30715,6 +30740,11 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.project-fact-graph-edge-delete svg {
|
||||||
|
display: block;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.project-fact-graph-edge-delete:hover {
|
.project-fact-graph-edge-delete:hover {
|
||||||
background: #fef2f2;
|
background: #fef2f2;
|
||||||
border-color: #f87171;
|
border-color: #f87171;
|
||||||
@@ -30764,6 +30794,7 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
.project-fact-graph-sidebar-actions {
|
.project-fact-graph-sidebar-actions {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -30772,24 +30803,31 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
|
|||||||
}
|
}
|
||||||
.project-fact-graph-footer {
|
.project-fact-graph-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px 12px;
|
gap: 12px;
|
||||||
margin: 10px 0 0;
|
margin: 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.project-fact-graph-footer::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
.project-fact-graph-stats {
|
.project-fact-graph-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex: 0 1 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.projects-graph-stat-badge {
|
.projects-graph-stat-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
@@ -30798,6 +30836,7 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error {
|
|||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.projects-graph-stat-badge strong {
|
.projects-graph-stat-badge strong {
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
|
|||||||
@@ -575,6 +575,8 @@
|
|||||||
"viewToolDetail": "View details",
|
"viewToolDetail": "View details",
|
||||||
"collapseToolDetail": "Collapse",
|
"collapseToolDetail": "Collapse",
|
||||||
"liveTimelinePruned": "Collapsed the first {{count}} live process details. View the full record page by page after the task completes.",
|
"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",
|
"toolExecutionsCount": "{{n}} tool runs",
|
||||||
"collapseToolExecutions": "Collapse tool runs",
|
"collapseToolExecutions": "Collapse tool runs",
|
||||||
"noProcessDetail": "No process details (execution may be too fast or no detailed events)",
|
"noProcessDetail": "No process details (execution may be too fast or no detailed events)",
|
||||||
@@ -2658,28 +2660,37 @@
|
|||||||
},
|
},
|
||||||
"settingsRobotsExtra": {
|
"settingsRobotsExtra": {
|
||||||
"botCommandsTitle": "Bot command instructions",
|
"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",
|
"viewAllCommands": "View all commands",
|
||||||
"botCommandsDesc": "You can send the following commands in chat (Chinese and English supported):",
|
"botCommandsDesc": "You can send the following commands in chat (Chinese and English supported):",
|
||||||
"botCmdCategoryGeneral": "General",
|
"botCmdCategoryGeneral": "General",
|
||||||
"botCmdCategoryIdentity": "Identity and authorization",
|
"botCmdCategoryIdentity": "Identity and authorization",
|
||||||
"botCmdCategoryConversation": "Conversation",
|
"botCmdCategoryConversation": "Conversation",
|
||||||
"botCmdCategoryRole": "Role",
|
"botCmdCategoryRole": "Role",
|
||||||
|
"botCmdCategoryMode": "Mode",
|
||||||
|
"botCmdCategoryDiagnostics": "Diagnostics and safety",
|
||||||
"botCmdCategoryProject": "Project",
|
"botCmdCategoryProject": "Project",
|
||||||
"botCmdHelp": "Show this help",
|
"botCmdHelp": "Show this help",
|
||||||
"botCmdIdentity": "Show the platform sender, authorization mode, and effective RBAC identity",
|
"botCmdIdentity": "Show the platform sender, authorization mode, and effective RBAC identity",
|
||||||
"botCmdBindUser": "Bind this platform identity to an RBAC user (user-binding mode only)",
|
"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.",
|
"botAuthHint": "Service-account mode does not accept bind or unbind commands; only configured allowlisted senders are accepted.",
|
||||||
"botCmdList": "List conversations",
|
"botCmdList": "List conversations",
|
||||||
"botCmdSwitch": "Switch to conversation",
|
"botCmdSwitch": "Switch to conversation",
|
||||||
"botCmdNew": "Start new conversation",
|
"botCmdNew": "Start new conversation",
|
||||||
"botCmdClear": "Clear context",
|
"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",
|
"botCmdStop": "Stop running task",
|
||||||
"botCmdRoles": "List roles",
|
"botCmdRoles": "List roles",
|
||||||
"botCmdRole": "Switch role",
|
"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",
|
"botCmdVersion": "Show version",
|
||||||
"botCmdProjects": "List projects",
|
"botCmdProjects": "List projects",
|
||||||
"botCmdNewProject": "Create project and bind current conversation",
|
"botCmdNewProject": "Create project and bind current conversation",
|
||||||
|
|||||||
@@ -563,6 +563,8 @@
|
|||||||
"viewToolDetail": "查看详情",
|
"viewToolDetail": "查看详情",
|
||||||
"collapseToolDetail": "收起",
|
"collapseToolDetail": "收起",
|
||||||
"liveTimelinePruned": "已收起前 {{count}} 条实时过程详情,任务完成后可按页查看完整记录",
|
"liveTimelinePruned": "已收起前 {{count}} 条实时过程详情,任务完成后可按页查看完整记录",
|
||||||
|
"liveTimelinePrunedSingleRound": "主代理第 {{n}} 轮",
|
||||||
|
"liveTimelinePrunedRoundRange": "主代理第 {{from}}–{{to}} 轮",
|
||||||
"toolExecutionsCount": "{{n}}次工具执行",
|
"toolExecutionsCount": "{{n}}次工具执行",
|
||||||
"collapseToolExecutions": "收起工具执行",
|
"collapseToolExecutions": "收起工具执行",
|
||||||
"noProcessDetail": "暂无过程详情(可能执行过快或未触发详细事件)",
|
"noProcessDetail": "暂无过程详情(可能执行过快或未触发详细事件)",
|
||||||
@@ -2646,28 +2648,37 @@
|
|||||||
},
|
},
|
||||||
"settingsRobotsExtra": {
|
"settingsRobotsExtra": {
|
||||||
"botCommandsTitle": "机器人命令说明",
|
"botCommandsTitle": "机器人命令说明",
|
||||||
"botCommandsEntryDesc": "查看机器人对话中可用的中英文命令,包括身份鉴权、对话、角色和项目操作。",
|
"botCommandsEntryDesc": "查看机器人对话中可用的中英文命令,包括身份鉴权、对话、角色、模式、项目和安全诊断。",
|
||||||
"viewAllCommands": "查看全部命令",
|
"viewAllCommands": "查看全部命令",
|
||||||
"botCommandsDesc": "在对话中可发送以下命令(支持中英文):",
|
"botCommandsDesc": "在对话中可发送以下命令(支持中英文):",
|
||||||
"botCmdCategoryGeneral": "通用",
|
"botCmdCategoryGeneral": "通用",
|
||||||
"botCmdCategoryIdentity": "身份与鉴权",
|
"botCmdCategoryIdentity": "身份与鉴权",
|
||||||
"botCmdCategoryConversation": "对话",
|
"botCmdCategoryConversation": "对话",
|
||||||
"botCmdCategoryRole": "角色",
|
"botCmdCategoryRole": "角色",
|
||||||
|
"botCmdCategoryMode": "模式",
|
||||||
|
"botCmdCategoryDiagnostics": "诊断与安全",
|
||||||
"botCmdCategoryProject": "项目",
|
"botCmdCategoryProject": "项目",
|
||||||
"botCmdHelp": "显示本帮助 | Show this help",
|
"botCmdHelp": "显示本帮助 | Show this help",
|
||||||
"botCmdIdentity": "显示平台发送者、鉴权模式及当前实际 RBAC 身份 | Show effective identity",
|
"botCmdIdentity": "显示平台发送者、鉴权模式及当前实际 RBAC 身份 | Show effective identity",
|
||||||
"botCmdBindUser": "绑定当前平台账号到 RBAC 用户(仅逐用户绑定模式) | Bind RBAC user",
|
"botCmdBindUser": "绑定当前平台账号到 RBAC 用户(仅逐用户绑定模式) | Bind RBAC user",
|
||||||
"botCmdUnbindUser": "解除当前平台账号绑定(仅逐用户绑定模式) | Unbind RBAC user",
|
"botCmdUnbindUser": "解除当前平台账号绑定(仅逐用户绑定模式,需确认) | Unbind RBAC user",
|
||||||
"botAuthHint": "专用服务账号模式不接受“绑定/解绑”命令,仅允许机器人配置中白名单内的发送者。",
|
"botAuthHint": "专用服务账号模式不接受“绑定/解绑”命令,仅允许机器人配置中白名单内的发送者。",
|
||||||
"botCmdList": "列出所有对话标题与 ID | List conversations",
|
"botCmdList": "列出所有对话标题与 ID | List conversations",
|
||||||
"botCmdSwitch": "指定对话继续 | Switch to conversation",
|
"botCmdSwitch": "指定对话继续 | Switch to conversation",
|
||||||
"botCmdNew": "开启新对话 | Start new conversation",
|
"botCmdNew": "开启新对话 | Start new conversation",
|
||||||
"botCmdClear": "清空当前上下文 | Clear context",
|
"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",
|
"botCmdStop": "中断当前任务 | Stop running task",
|
||||||
"botCmdRoles": "列出所有可用角色 | List roles",
|
"botCmdRoles": "列出所有可用角色 | List roles",
|
||||||
"botCmdRole": "切换当前角色 | Switch role",
|
"botCmdRole": "切换当前角色 | Switch role",
|
||||||
"botCmdDelete": "删除指定对话 | Delete conversation",
|
"botCmdModes": "列出对话模式与当前选择 | List conversation modes",
|
||||||
|
"botCmdMode": "切换对话模式 | Switch conversation mode",
|
||||||
|
"botCmdDelete": "删除指定对话(需确认) | Delete conversation",
|
||||||
"botCmdVersion": "显示当前版本号 | Show version",
|
"botCmdVersion": "显示当前版本号 | Show version",
|
||||||
"botCmdProjects": "列出所有项目 | List projects",
|
"botCmdProjects": "列出所有项目 | List projects",
|
||||||
"botCmdNewProject": "创建项目并绑定当前对话 | Create & bind project",
|
"botCmdNewProject": "创建项目并绑定当前对话 | Create & bind project",
|
||||||
|
|||||||
+24
-26
@@ -1,7 +1,13 @@
|
|||||||
let currentConversationId = null;
|
let currentConversationId = null;
|
||||||
let loadConversationRequestSeq = 0;
|
let loadConversationRequestSeq = 0;
|
||||||
|
|
||||||
/** 轻量会话 LRU 缓存:来回切换已加载会话时避免重复网络 + 全量 DOM 重建 */
|
/**
|
||||||
|
* 轻量会话 LRU 缓存。
|
||||||
|
*
|
||||||
|
* 缓存只用作请求失败时的降级数据,不能先于服务端响应直接渲染:
|
||||||
|
* 运行中会话的 process details 会持续写入,直接渲染旧快照会让
|
||||||
|
* UI 暂时回退到旧轮次,等 task-events 接管后又突然跳到最新轮次。
|
||||||
|
*/
|
||||||
const CONVERSATION_LITE_CACHE_MAX = 12;
|
const CONVERSATION_LITE_CACHE_MAX = 12;
|
||||||
const conversationLiteCache = new Map();
|
const conversationLiteCache = new Map();
|
||||||
|
|
||||||
@@ -4131,32 +4137,24 @@ async function loadConversation(conversationId) {
|
|||||||
const seq = ++loadConversationRequestSeq;
|
const seq = ++loadConversationRequestSeq;
|
||||||
try {
|
try {
|
||||||
const cachedConversation = getConversationLiteFromCache(conversationId);
|
const cachedConversation = getConversationLiteFromCache(conversationId);
|
||||||
const fetchPromise = apiFetch(`/api/conversations/${conversationId}?include_process_details=0`)
|
let conversation = null;
|
||||||
.then(async (response) => {
|
let response = null;
|
||||||
const data = await response.json();
|
try {
|
||||||
return { response, data };
|
response = await apiFetch(`/api/conversations/${conversationId}?include_process_details=0`);
|
||||||
});
|
conversation = await response.json();
|
||||||
|
} catch (fetchError) {
|
||||||
let conversation;
|
if (!cachedConversation) throw fetchError;
|
||||||
let response;
|
console.warn('加载最新对话失败,使用本地缓存:', fetchError);
|
||||||
if (cachedConversation) {
|
|
||||||
conversation = cachedConversation;
|
conversation = cachedConversation;
|
||||||
fetchPromise.then(({ response: freshResp, data }) => {
|
}
|
||||||
if (freshResp.ok && data && seq === loadConversationRequestSeq && currentConversationId === conversationId) {
|
if (seq !== loadConversationRequestSeq) {
|
||||||
putConversationLiteCache(conversationId, data);
|
return;
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
if (response && !response.ok) {
|
||||||
} else {
|
showChatToast('加载对话失败: ' + (conversation.error || '未知错误'), 'error');
|
||||||
const fetched = await fetchPromise;
|
return;
|
||||||
response = fetched.response;
|
}
|
||||||
conversation = fetched.data;
|
if (response && response.ok) {
|
||||||
if (seq !== loadConversationRequestSeq) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
showChatToast('加载对话失败: ' + (conversation.error || '未知错误'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
putConversationLiteCache(conversationId, conversation);
|
putConversationLiteCache(conversationId, conversation);
|
||||||
}
|
}
|
||||||
if (seq !== loadConversationRequestSeq) {
|
if (seq !== loadConversationRequestSeq) {
|
||||||
|
|||||||
+72
-13
@@ -319,7 +319,10 @@ function finalizeOutstandingToolCallsForProgress(progressId, finalStatus) {
|
|||||||
if (!mapping) continue;
|
if (!mapping) continue;
|
||||||
if (mapping.progressId != null && String(mapping.progressId) !== pid) 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));
|
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);
|
toolCallStatusMap.delete(mapKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1866,15 +1869,30 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
? String(prevMainIter.workflowNodeId).trim()
|
? String(prevMainIter.workflowNodeId).trim()
|
||||||
: '';
|
: '';
|
||||||
const curNode = d.workflowNodeId != null ? String(d.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), {
|
mainIterationStateByProgressId.set(String(progressId), {
|
||||||
iteration: n,
|
iteration: n,
|
||||||
orchestration: d.orchestration != null ? d.orchestration : '',
|
orchestration: d.orchestration != null ? d.orchestration : '',
|
||||||
workflowNodeId: curNode
|
workflowNodeId: curNode,
|
||||||
|
einoAgent: curAgent
|
||||||
});
|
});
|
||||||
// 主通道进入新轮次或图编排切换到新 Agent 节点后,不复用上一段的流式时间线条目
|
// 主通道进入新轮次或图编排切换到新 Agent 节点后,不复用上一段的流式时间线条目
|
||||||
if (prevN != null && (n < prevN || prevN !== n || (curNode && prevNode && curNode !== prevNode))) {
|
if (prevN != null && (n < prevN || prevN !== n || (curNode && prevNode && curNode !== prevNode))) {
|
||||||
clearTimelineStreamStates(progressId);
|
clearTimelineStreamStates(progressId);
|
||||||
}
|
}
|
||||||
|
// 同一主代理可能从“进入模型”和“检测到工具批次”两条路径补发同一轮次。
|
||||||
|
// 状态缓存仍更新,但时间线只保留一个幂等条目。
|
||||||
|
if (duplicateMainIteration) break;
|
||||||
}
|
}
|
||||||
let iterTitle;
|
let iterTitle;
|
||||||
if (d.orchestration === 'plan_execute' && d.einoScope === 'main') {
|
if (d.orchestration === 'plan_execute' && d.einoScope === 'main') {
|
||||||
@@ -2306,7 +2324,9 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
const existingItem = document.getElementById(existing.itemId);
|
const existingItem = document.getElementById(existing.itemId);
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
// 同一 toolCallId 的重复 tool_call(重试/补发)只更新状态,不重复追加条目。
|
// 同一 toolCallId 的重复 tool_call(重试/补发)只更新状态,不重复追加条目。
|
||||||
updateToolCallStatus(progressId, toolCallId, 'running');
|
if (!existing.terminalStatus) {
|
||||||
|
updateToolCallStatus(progressId, toolCallId, 'running');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2359,7 +2379,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
const mapKey = toolCallMapKey(progressId, resultToolCallId);
|
const mapKey = toolCallMapKey(progressId, resultToolCallId);
|
||||||
if (toolCallStatusMap.has(mapKey)) {
|
if (toolCallStatusMap.has(mapKey)) {
|
||||||
updateToolCallStatus(progressId, resultToolCallId, success ? 'completed' : 'failed');
|
updateToolCallStatus(progressId, resultToolCallId, success ? 'completed' : 'failed');
|
||||||
toolCallStatusMap.delete(mapKey);
|
toolCallStatusMap.get(mapKey).terminalStatus = success ? 'completed' : 'failed';
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2367,7 +2387,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
const mapKey = toolCallMapKey(progressId, resultToolCallId);
|
const mapKey = toolCallMapKey(progressId, resultToolCallId);
|
||||||
if (toolCallStatusMap.has(mapKey)) {
|
if (toolCallStatusMap.has(mapKey)) {
|
||||||
updateToolCallStatus(progressId, resultToolCallId, success ? 'completed' : 'failed');
|
updateToolCallStatus(progressId, resultToolCallId, success ? 'completed' : 'failed');
|
||||||
toolCallStatusMap.delete(mapKey);
|
toolCallStatusMap.get(mapKey).terminalStatus = success ? 'completed' : 'failed';
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2375,7 +2395,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
|
|
||||||
if (resultToolCallId && toolCallStatusMap.has(toolCallMapKey(progressId, resultToolCallId))) {
|
if (resultToolCallId && toolCallStatusMap.has(toolCallMapKey(progressId, resultToolCallId))) {
|
||||||
updateToolCallStatus(progressId, resultToolCallId, success ? 'completed' : 'failed');
|
updateToolCallStatus(progressId, resultToolCallId, success ? 'completed' : 'failed');
|
||||||
toolCallStatusMap.delete(toolCallMapKey(progressId, resultToolCallId));
|
toolCallStatusMap.get(toolCallMapKey(progressId, resultToolCallId)).terminalStatus = success ? 'completed' : 'failed';
|
||||||
}
|
}
|
||||||
addTimelineItem(timeline, 'tool_result', {
|
addTimelineItem(timeline, 'tool_result', {
|
||||||
title: timelineAgentBracketPrefix(resultInfo) + statusIcon + ' ' + resultExecText,
|
title: timelineAgentBracketPrefix(resultInfo) + statusIcon + ' ' + resultExecText,
|
||||||
@@ -3948,6 +3968,25 @@ function isLiveProgressTimeline(timeline) {
|
|||||||
return !!(timeline && timeline.id && /^progress-\d+-\d+-timeline$/.test(timeline.id));
|
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) {
|
function pruneLiveTimelineIfNeeded(timeline) {
|
||||||
if (!isLiveProgressTimeline(timeline)) return;
|
if (!isLiveProgressTimeline(timeline)) return;
|
||||||
const items = Array.from(timeline.children).filter(function (el) {
|
const items = Array.from(timeline.children).filter(function (el) {
|
||||||
@@ -3970,16 +4009,37 @@ function pruneLiveTimelineIfNeeded(timeline) {
|
|||||||
if (!marker) {
|
if (!marker) {
|
||||||
marker = document.createElement('div');
|
marker = document.createElement('div');
|
||||||
marker.className = 'timeline-live-pruned-marker';
|
marker.className = 'timeline-live-pruned-marker';
|
||||||
|
marker.setAttribute('role', 'status');
|
||||||
|
marker.setAttribute('aria-live', 'polite');
|
||||||
timeline.insertBefore(marker, timeline.firstChild);
|
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++) {
|
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;
|
pruned += removeCount;
|
||||||
marker.dataset.prunedCount = String(pruned);
|
marker.dataset.prunedCount = String(pruned);
|
||||||
marker.textContent = typeof window.t === 'function'
|
marker.dataset.prunedMainIterationMin = String(prunedMainIterationMin);
|
||||||
? window.t('chat.liveTimelinePruned', { count: pruned })
|
marker.dataset.prunedMainIterationMax = String(prunedMainIterationMax);
|
||||||
: ('已收起前 ' + pruned + ' 条实时过程详情,任务完成后可按页查看完整记录');
|
marker.textContent = formatLiveTimelinePrunedMarker(marker);
|
||||||
|
// 始终放在已保留详情之前;配合 sticky 样式,查看最新内容时也能感知历史已裁剪。
|
||||||
|
if (timeline.firstChild !== marker) {
|
||||||
|
timeline.insertBefore(marker, timeline.firstChild);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTimelineItem(timeline, type, options) {
|
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'];
|
const MCP_TIMELINE_RANGES = ['24h', '7d', '30d'];
|
||||||
|
|
||||||
function getMcpMonitorTimelineRange() {
|
function getMcpMonitorTimelineRange() {
|
||||||
@@ -6565,8 +6625,7 @@ function refreshProgressAndTimelineI18n() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.timeline-live-pruned-marker').forEach(function (marker) {
|
document.querySelectorAll('.timeline-live-pruned-marker').forEach(function (marker) {
|
||||||
const count = parseInt(marker.dataset.prunedCount || '0', 10) || 0;
|
marker.textContent = formatLiveTimelinePrunedMarker(marker);
|
||||||
marker.textContent = _t('chat.liveTimelinePruned', { count: count });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 详情区「展开/收起」按钮
|
// 详情区「展开/收起」按钮
|
||||||
|
|||||||
@@ -1178,7 +1178,7 @@ function renderGraphEdgesListHtml(factKey, graphData, selectedEdgeId) {
|
|||||||
const synthetic = isSyntheticGraphEdge(e);
|
const synthetic = isSyntheticGraphEdge(e);
|
||||||
const deleteBtn = synthetic
|
const deleteBtn = synthetic
|
||||||
? `<span class="project-fact-graph-edge-synthetic" title="${escapeHtml(tp('projects.graphEdgeSynthetic'))}">—</span>`
|
? `<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)})">
|
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-dir">${escapeHtml(dirLabel)}</span>
|
||||||
<span class="project-fact-graph-edge-type">${escapeHtml(e.type || '')}</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') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initChatProjectSelector();
|
initChatProjectSelector();
|
||||||
initProjectListActionMenu();
|
initProjectListActionMenu();
|
||||||
|
initProjectGraphFooterWheelScroll();
|
||||||
refreshProjectsFilterSelects();
|
refreshProjectsFilterSelects();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
initChatProjectSelector();
|
initChatProjectSelector();
|
||||||
initProjectListActionMenu();
|
initProjectListActionMenu();
|
||||||
|
initProjectGraphFooterWheelScroll();
|
||||||
refreshProjectsFilterSelects();
|
refreshProjectsFilterSelects();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3980,7 +3980,7 @@
|
|||||||
<ul class="robot-cmd-list">
|
<ul class="robot-cmd-list">
|
||||||
<li><code>身份</code> <code>whoami</code> — <span data-i18n="settingsRobotsExtra.botCmdIdentity">显示平台发送者、鉴权模式及当前实际 RBAC 身份 | Show effective identity</span></li>
|
<li><code>身份</code> <code>whoami</code> — <span data-i18n="settingsRobotsExtra.botCmdIdentity">显示平台发送者、鉴权模式及当前实际 RBAC 身份 | Show effective identity</span></li>
|
||||||
<li><code>绑定 <绑定码></code> <code>bind <code></code> — <span data-i18n="settingsRobotsExtra.botCmdBindUser">绑定当前平台账号到 RBAC 用户(仅逐用户绑定模式) | Bind RBAC user</span></li>
|
<li><code>绑定 <绑定码></code> <code>bind <code></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>
|
</ul>
|
||||||
<p class="settings-description" data-i18n="settingsRobotsExtra.botAuthHint">专用服务账号模式不接受“绑定/解绑”命令,仅允许机器人配置中白名单内的发送者。</p>
|
<p class="settings-description" data-i18n="settingsRobotsExtra.botAuthHint">专用服务账号模式不接受“绑定/解绑”命令,仅允许机器人配置中白名单内的发送者。</p>
|
||||||
|
|
||||||
@@ -3990,9 +3990,11 @@
|
|||||||
<li><code>切换 <ID></code> <code>switch <ID></code> — <span data-i18n="settingsRobotsExtra.botCmdSwitch">指定对话继续 | Switch to conversation</span></li>
|
<li><code>切换 <ID></code> <code>switch <ID></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>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>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>重命名 <名称></code> <code>rename <name></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>停止</code> <code>stop</code> — <span data-i18n="settingsRobotsExtra.botCmdStop">中断当前任务 | Stop running task</span></li>
|
||||||
<li><code>删除 <ID></code> <code>delete <ID></code> — <span data-i18n="settingsRobotsExtra.botCmdDelete">删除指定对话 | Delete conversation</span></li>
|
<li><code>删除 <ID></code> <code>delete <ID></code> — <span data-i18n="settingsRobotsExtra.botCmdDelete">删除指定对话(需确认) | Delete conversation</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryRole">角色</p>
|
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryRole">角色</p>
|
||||||
@@ -4001,6 +4003,12 @@
|
|||||||
<li><code>角色 <名></code> <code>role <name></code> — <span data-i18n="settingsRobotsExtra.botCmdRole">切换当前角色 | Switch role</span></li>
|
<li><code>角色 <名></code> <code>role <name></code> — <span data-i18n="settingsRobotsExtra.botCmdRole">切换当前角色 | Switch role</span></li>
|
||||||
</ul>
|
</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>模式 <名称></code> <code>mode <name></code> — <span data-i18n="settingsRobotsExtra.botCmdMode">切换对话模式 | Switch conversation mode</span></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryProject">项目</p>
|
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryProject">项目</p>
|
||||||
<ul class="robot-cmd-list">
|
<ul class="robot-cmd-list">
|
||||||
<li><code>项目</code> <code>projects</code> — <span data-i18n="settingsRobotsExtra.botCmdProjects">列出所有项目 | List projects</span></li>
|
<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>
|
<li><code>解除项目</code> <code>unbind project</code> — <span data-i18n="settingsRobotsExtra.botCmdUnbindProject">解除当前对话的项目绑定 | Unbind project</span></li>
|
||||||
</ul>
|
</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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -4017,7 +4032,7 @@
|
|||||||
<div class="settings-subsection robot-command-entry">
|
<div class="settings-subsection robot-command-entry">
|
||||||
<div class="robot-command-entry-copy">
|
<div class="robot-command-entry-copy">
|
||||||
<h4 data-i18n="settingsRobotsExtra.botCommandsTitle">机器人命令说明</h4>
|
<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>
|
</div>
|
||||||
<button type="button" class="btn-secondary" onclick="openRobotCommandsModal()" data-i18n="settingsRobotsExtra.viewAllCommands">查看全部命令</button>
|
<button type="button" class="btn-secondary" onclick="openRobotCommandsModal()" data-i18n="settingsRobotsExtra.viewAllCommands">查看全部命令</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user