mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-01 08:37:41 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d1f5d2886 | ||
|
|
0f92817261 | ||
|
|
2ec5953416 | ||
|
|
b4fe2e795e | ||
|
|
904d860797 | ||
|
|
dc08199af6 | ||
|
|
84e99220ff | ||
|
|
86f1d10a8b | ||
|
|
5c643a1606 | ||
|
|
b9854192c6 | ||
|
|
5a5762e1d1 | ||
|
|
8882d70393 | ||
|
|
5747ebc612 | ||
|
|
a6b3773f00 | ||
|
|
c2b950ad53 | ||
|
|
0283fff743 | ||
|
|
018835d6b8 | ||
|
|
4b7df4e0f3 | ||
|
|
0324b41a01 | ||
|
|
4a19620137 | ||
|
|
f8110413c0 | ||
|
|
59dc7cf858 | ||
|
|
52595e07e5 | ||
|
|
e0965594bb | ||
|
|
9ef8263eaf | ||
|
|
3c54a67416 | ||
|
|
98ca395edd | ||
|
|
c616822cd6 | ||
|
|
ba1796d7ce | ||
|
|
dad14c55c1 | ||
|
|
cc0233d7b4 | ||
|
|
7b6f56e476 | ||
|
|
2522fc6ae2 | ||
|
|
99d7380450 | ||
|
|
837e41459a | ||
|
|
5cbd828cad | ||
|
|
8cb317cbd6 | ||
|
|
94a2ba0406 | ||
|
|
4ee7204509 | ||
|
|
c326adbb66 | ||
|
|
7d1e9bdac4 | ||
|
|
151b445c74 |
+91
-8
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"cyberstrike-ai/internal/app"
|
"cyberstrike-ai/internal/app"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/logger"
|
"cyberstrike-ai/internal/logger"
|
||||||
|
"cyberstrike-ai/internal/security"
|
||||||
"cyberstrike-ai/internal/termout"
|
"cyberstrike-ai/internal/termout"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,17 +14,21 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var configPath = flag.String("config", "config.yaml", "配置文件路径")
|
var configPath = flag.String("config", "config.yaml", "Path to the configuration file")
|
||||||
var httpsBootstrap = flag.Bool("https", false, "启用主站 HTTPS:未配置 tls_cert_path/tls_key_path 时使用内存自签证书(本地测试);与 run.sh 默认行为一致")
|
var httpsBootstrap = flag.Bool("https", false, "Enable HTTPS for the main site; uses an in-memory self-signed certificate when no cert/key is configured")
|
||||||
var httpBootstrap = flag.Bool("http", false, "强制主站使用明文 HTTP:覆盖配置文件中的 tls_enabled/tls_auto_self_sign/tls_cert_path/tls_key_path")
|
var httpBootstrap = flag.Bool("http", false, "Force plain HTTP for the main site, overriding TLS settings in the configuration file")
|
||||||
|
var resetAdminPassword = flag.Bool("reset-admin-password", false, "Interactively reset the built-in admin password and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// 环境变量兼容(便于 systemd/docker 等不传参场景)
|
// 环境变量兼容(便于 systemd/docker 等不传参场景)
|
||||||
if *httpsBootstrap && *httpBootstrap {
|
if *httpsBootstrap && *httpBootstrap {
|
||||||
fmt.Fprintln(os.Stderr, "--http 与 --https 不能同时使用")
|
fmt.Fprintln(os.Stderr, "--http and --https cannot be used together")
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
if !*httpsBootstrap && !*httpBootstrap {
|
if !*httpsBootstrap && !*httpBootstrap {
|
||||||
@@ -38,24 +44,32 @@ func main() {
|
|||||||
cp = "config.yaml"
|
cp = "config.yaml"
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(cp, "-") {
|
if strings.HasPrefix(cp, "-") {
|
||||||
fmt.Fprintf(os.Stderr, "无效的 -config 路径 %q。\n若同时需要 HTTPS,请写成: ./cyberstrike-ai --https -config config.yaml(-config 后必须是 yaml 文件路径)。\n", cp)
|
fmt.Fprintf(os.Stderr, "Invalid -config path %q.\nIf HTTPS is also needed, use: ./cyberstrike-ai --https -config config.yaml (-config must be followed by a yaml file path).\n", cp)
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
localConfig, err := config.EnsureLocalConfig(cp)
|
localConfig, err := config.EnsureLocalConfig(cp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("加载配置失败: %v\n", err)
|
fmt.Printf("Failed to load config: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(cp)
|
cfg, err := config.Load(cp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("加载配置失败: %v\n", err)
|
fmt.Printf("Failed to load config: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if localConfig.Created {
|
if localConfig.Created {
|
||||||
termout.PrintConfigCreated()
|
termout.PrintConfigCreated()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *resetAdminPassword {
|
||||||
|
if err := runResetAdminPassword(cfg); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to reset admin password: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if *httpBootstrap {
|
if *httpBootstrap {
|
||||||
config.ApplyPlainHTTPBootstrap(cfg)
|
config.ApplyPlainHTTPBootstrap(cfg)
|
||||||
} else if *httpsBootstrap {
|
} else if *httpsBootstrap {
|
||||||
@@ -79,7 +93,7 @@ func main() {
|
|||||||
|
|
||||||
// MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
|
// MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
|
||||||
if err := config.EnsureMCPAuth(cp, cfg); err != nil {
|
if err := config.EnsureMCPAuth(cp, cfg); err != nil {
|
||||||
fmt.Printf("MCP 鉴权配置失败: %v\n", err)
|
fmt.Printf("Failed to configure MCP authentication: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cfg.MCP.Enabled {
|
if cfg.MCP.Enabled {
|
||||||
@@ -121,3 +135,72 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runResetAdminPassword(cfg *config.Config) error {
|
||||||
|
dbPath := strings.TrimSpace(cfg.Database.Path)
|
||||||
|
if dbPath == "" {
|
||||||
|
dbPath = "data/conversations.db"
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dbPath); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("database does not exist: %s; start the service once to initialize it first", dbPath)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Reset built-in admin password")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
password, err := readHiddenPassword("New admin password: ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
password = strings.TrimSpace(password)
|
||||||
|
if len(password) < 8 {
|
||||||
|
return fmt.Errorf("new password must be at least 8 characters")
|
||||||
|
}
|
||||||
|
confirm, err := readHiddenPassword("Confirm new password: ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if password != strings.TrimSpace(confirm) {
|
||||||
|
return fmt.Errorf("passwords do not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := security.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.NewDB(dbPath, zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
admin, err := db.GetRBACUserByUsername("admin")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("built-in admin account was not found; start the service once to initialize it first: %w", err)
|
||||||
|
}
|
||||||
|
if !admin.IsBuiltin {
|
||||||
|
return fmt.Errorf("admin account is not built in; refusing to reset it")
|
||||||
|
}
|
||||||
|
if err := db.UpdateRBACAdminPassword(hash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Admin password has been reset.")
|
||||||
|
fmt.Println("If the service is running, existing login sessions remain valid until the service restarts or the sessions expire.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readHiddenPassword(prompt string) (string, error) {
|
||||||
|
fmt.Fprint(os.Stderr, prompt)
|
||||||
|
password, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(password), nil
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -68,7 +68,7 @@ ai:
|
|||||||
api_key: sk-xxxxxxx
|
api_key: sk-xxxxxxx
|
||||||
model: qwen3-max
|
model: qwen3-max
|
||||||
max_total_tokens: 120000
|
max_total_tokens: 120000
|
||||||
max_completion_tokens: 16384
|
max_completion_tokens: 32768
|
||||||
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort;Claude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
|
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort;Claude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
|
||||||
reasoning:
|
reasoning:
|
||||||
mode: on # auto | on | off;off:OpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考)
|
mode: on # auto | on | off;off:OpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考)
|
||||||
|
|||||||
@@ -46,13 +46,21 @@ Login fails:
|
|||||||
|
|
||||||
If another administrator with `rbac:write` is available, reset the password under **Platform permissions → User management**.
|
If another administrator with `rbac:write` is available, reset the password under **Platform permissions → User management**.
|
||||||
|
|
||||||
If no administrator session is available, the built-in `admin` account can be recovered on the server. Stop CyberStrikeAI, back up the database, change to the project root, and run the command below. Enter and confirm the new password when prompted:
|
If no administrator session is available, the built-in `admin` account can be recovered on the server. Change to the project root and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.sh --reset-admin-password
|
||||||
|
```
|
||||||
|
|
||||||
|
Enter and confirm the new password when prompted. The script hides input and stores a bcrypt hash. If the service is running, restart it afterward to invalidate existing login sessions.
|
||||||
|
|
||||||
|
If `run.sh` is not available, run the command below manually. Enter and confirm the new password when prompted:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
HASH=$(htpasswd -nBC 10 '' | cut -d: -f2 | tr -d '\n') && sqlite3 data/conversations.db "UPDATE rbac_users SET password_hash='$HASH', updated_at=CURRENT_TIMESTAMP WHERE id='admin' AND username='admin' AND is_builtin=1; SELECT changes();"
|
HASH=$(htpasswd -nBC 10 '' | cut -d: -f2 | tr -d '\n') && sqlite3 data/conversations.db "UPDATE rbac_users SET password_hash='$HASH', updated_at=CURRENT_TIMESTAMP WHERE id='admin' AND username='admin' AND is_builtin=1; SELECT changes();"
|
||||||
```
|
```
|
||||||
|
|
||||||
Output `1` means that the row was updated. The command requires `sqlite3` and `htpasswd`. If `database.path` in `config.yaml` is not the default, replace `data/conversations.db`. Password input is hidden, is not written to shell history, and is stored as a bcrypt hash. Restart the service afterward to invalidate existing login sessions.
|
Output `1` means that the row was updated. The command requires `sqlite3` and `htpasswd`. If `database.path` in `config.yaml` is not the default, replace `data/conversations.db`. Password input is hidden and is not written to shell history.
|
||||||
|
|
||||||
Model fails:
|
Model fails:
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,39 @@ Saved workflows can be bound to a role under **Role Management**. When `workflow
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Execution model (read this before configuring)
|
## 3. Natural-language Draft Generation
|
||||||
|
|
||||||
|
The Workflows page provides a **Create from natural language** entry point. After a user describes a security operations goal, CyberStrikeAI generates an editable draft and returns a structured audit result:
|
||||||
|
|
||||||
|
- Draft generation does not save, dry-run, or execute tools automatically.
|
||||||
|
- The server endpoint is `POST /api/workflows/generate-draft`, protected by `workflow:write`.
|
||||||
|
- The response includes `graph`, `meta`, `capabilities`, `audit`, and `stats`; after applying the draft to the canvas, normal save validation still runs.
|
||||||
|
- High-risk language such as executing scripts, isolating hosts, blocking, deleting, or exploitation is marked as `high_risk` and defaults to HITL approval or `requires_human_confirmation`.
|
||||||
|
- Tool capabilities are matched against the available tool list; unmatched capabilities fall back to Agent draft nodes and are surfaced in `audit.missing_fields` / `audit.assumptions`.
|
||||||
|
- If server generation is unavailable, the frontend uses a local deterministic fallback and still returns an editable draft with risk notes.
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/workflows/generate-draft
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Scan target assets for open ports, create a vulnerability task when critical ports are found, ask the owner for approval, and output a report",
|
||||||
|
"options": {
|
||||||
|
"include_objective": true,
|
||||||
|
"allow_schedule": false,
|
||||||
|
"allow_high_risk": false
|
||||||
|
},
|
||||||
|
"available_tools": [
|
||||||
|
{ "key": "nmap", "name": "nmap", "enabled": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Execution model (read this before configuring)
|
||||||
|
|
||||||
The engine executes the workflow as a **directed graph**, starting from the **Start** node and following edges to downstream nodes.
|
The engine executes the workflow as a **directed graph**, starting from the **Start** node and following edges to downstream nodes.
|
||||||
|
|
||||||
@@ -116,7 +148,7 @@ Agent B still receives Agent A’s output even when a condition node lies betwee
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Template syntax
|
## 5. Template syntax
|
||||||
|
|
||||||
### 4.1 Basic format
|
### 4.1 Basic format
|
||||||
|
|
||||||
@@ -198,7 +230,7 @@ Field bindings can read ordinary fields such as `output` or `message`, and also
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Node types and configuration
|
## 6. Node types and configuration
|
||||||
|
|
||||||
### 5.1 Start
|
### 5.1 Start
|
||||||
|
|
||||||
@@ -318,7 +350,7 @@ Optional node for an end summary template (less common in role-bound flows).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Edge configuration
|
## 7. Edge configuration
|
||||||
|
|
||||||
Select an **edge** to configure its **condition** in the right panel.
|
Select an **edge** to configure its **condition** in the right panel.
|
||||||
|
|
||||||
@@ -335,7 +367,7 @@ If no edge condition is set:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Full example: passing Agent output across a condition
|
## 8. Full example: passing Agent output across a condition
|
||||||
|
|
||||||
### 7.1 Graph structure
|
### 7.1 Graph structure
|
||||||
|
|
||||||
@@ -384,7 +416,7 @@ Start → Agent (initial value) → Condition → Agent (transform) → Output
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Bind to a role and run
|
## 9. Bind to a role and run
|
||||||
|
|
||||||
### 8.1 Bind in Role Management
|
### 8.1 Bind in Role Management
|
||||||
|
|
||||||
@@ -414,7 +446,7 @@ If no Output node is reached or no branch matches, `outputs` may be empty and th
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Debugging, dry-run, and replay
|
## 10. Debugging, dry-run, and replay
|
||||||
|
|
||||||
### 9.1 Safe dry-run
|
### 9.1 Safe dry-run
|
||||||
|
|
||||||
@@ -487,7 +519,7 @@ Token and cost metrics depend on whether the underlying model/Agent events repor
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Validation before save
|
## 11. Validation before save
|
||||||
|
|
||||||
On save, the system checks:
|
On save, the system checks:
|
||||||
|
|
||||||
@@ -510,7 +542,7 @@ On save, the system checks:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Troubleshooting
|
## 12. Troubleshooting
|
||||||
|
|
||||||
| Symptom | Likely cause | Fix |
|
| Symptom | Likely cause | Fix |
|
||||||
|---------|--------------|-----|
|
|---------|--------------|-----|
|
||||||
@@ -526,7 +558,7 @@ On save, the system checks:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Best practices
|
## 13. Best practices
|
||||||
|
|
||||||
1. **Meaningful names**: Use descriptive output variable names (`scan_result`, `parsed_targets`) instead of reusing `agent_result` everywhere.
|
1. **Meaningful names**: Use descriptive output variable names (`scan_result`, `parsed_targets`) instead of reusing `agent_result` everywhere.
|
||||||
2. **Prefer `outputs` for cross-node data**: If a condition, tool, or HITL node might sit in between, use named variables.
|
2. **Prefer `outputs` for cross-node data**: If a condition, tool, or HITL node might sit in between, use named variables.
|
||||||
@@ -539,7 +571,7 @@ On save, the system checks:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Code references (for developers)
|
## 14. Code references (for developers)
|
||||||
|
|
||||||
| Module | Path |
|
| Module | Path |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
## 核心概念与编排
|
## 核心概念与编排
|
||||||
|
|
||||||
- [架构说明](architecture.md) · [安全模型](security-model.md) · [RBAC](rbac.md)
|
- [架构说明](architecture.md) · [安全模型](security-model.md) · [RBAC](rbac.md)
|
||||||
- [Agent 与角色](agent-and-role-guide.md) · [Skills](skills-guide.md) · [Eino 多代理](MULTI_AGENT_EINO.md)
|
- [Agent 与角色](agent-and-role-guide.md) · [Skills](skills-guide.md) · [Eino 多代理](MULTI_AGENT_EINO.md) · [Agent 最终回复治理](agent-finalization-best-practices.md)
|
||||||
- [工作流](workflow-graph.md) · [工具执行治理](tool-execution-governance.md) · [人机协同最佳实践](hitl-best-practices.md)
|
- [工作流](workflow-graph.md) · [工具执行治理](tool-execution-governance.md) · [人机协同最佳实践](hitl-best-practices.md)
|
||||||
|
|
||||||
## 功能指南
|
## 功能指南
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
# Agent 最终回复治理最佳实践
|
||||||
|
|
||||||
|
[返回中文文档](README.md)
|
||||||
|
|
||||||
|
调研日期:2026-07-28
|
||||||
|
|
||||||
|
本文聚焦一个具体问题:Agent 在工具调用、推理、计划或子代理协作尚未真正完成时,输出了一段“像结论”的自然语言,前端或编排层把它当作最终回复展示。结论先说清楚:成熟 Agent 系统不会用“最近一段 assistant 文本”判断任务完成,而是用运行时状态、工具状态、验证结果和显式终态事件共同决定是否 final。
|
||||||
|
|
||||||
|
## 一、核心结论
|
||||||
|
|
||||||
|
1. **最终回复是运行时事件,不是自然语言内容。**
|
||||||
|
“已拿到”“下一步”“Huge breakthrough”这类文本只能作为候选观察或进展,不能作为完成信号。
|
||||||
|
|
||||||
|
2. **过程面和交付面必须隔离。**
|
||||||
|
`thinking`、`reasoning_chain`、`planning`、`response_delta`、子代理回复、工具输出都属于过程面;只有通过 final gate 的 `response` / `final` 事件才能写入主消息气泡和 `messages.content`。
|
||||||
|
|
||||||
|
3. **复杂任务需要 verifier,而不是更长 prompt。**
|
||||||
|
Prompt 可以提醒模型谨慎,但最终完成必须由代码层判断:是否仍有待执行工具、后台 execution、未完成计划步骤、未验证证据、未记录事实/漏洞、未清理或未说明不可清理。
|
||||||
|
|
||||||
|
4. **不同 agent 模式不同,但 final 治理原则一致。**
|
||||||
|
单代理、Deep、Plan-Execute、Supervisor 都需要 final gate。区别只是 gate 的证据来源不同:单代理看工具轨迹,Deep 还要看子代理结果,Plan-Execute 要看 Replanner 的终止判断,Supervisor 要看 `exit` 与 supervisor 汇总。
|
||||||
|
|
||||||
|
## 二、成熟 Agent 的公开做法
|
||||||
|
|
||||||
|
| 系统 | 公开做法 | 对 final 治理的启发 |
|
||||||
|
|---|---|---|
|
||||||
|
| Codex | OpenAI 的 Codex prompting guide 建议不要在 prompt 中强行要求 upfront plan、preamble 或 status updates,因为这可能导致 rollout 未完成就停止。 | 不要把“模型自己说的阶段性计划/状态”当完成依据;agent harness 应负责执行循环和收尾。 |
|
||||||
|
| Claude Code | Claude Code 提供 `PreToolUse`、`PostToolUse`、`Stop` 等 hooks;`PostToolUse` 明确发生在工具成功执行之后。 | 生命周期事件比自然语言可靠。验证、审计、阻断应挂在确定的阶段边界上。 |
|
||||||
|
| Claude Code Subagents | 子代理有独立上下文、自定义系统提示、特定工具权限和独立权限;子代理适合隔离大量检索/日志/文件读取。 | 子代理输出是证据材料,不是主任务最终结论;主代理必须汇总、验收、再 final。 |
|
||||||
|
| Claude Code Plan Mode | Plan mode 先读文件并产出计划,获得批准前不编辑。 | 计划与执行是不同状态;计划完成不等于任务完成。 |
|
||||||
|
| Cursor Plan Mode | Cursor Plan Mode 会研究代码库、询问澄清问题、生成可审查计划,并等待用户确认后再构建。 | UI 层把 plan/review/build 拆开,用户不会把计划误认为最终交付。 |
|
||||||
|
| OpenCode | OpenCode 把 Build、Plan、Review、Debug、Docs 等 agent 分成不同工具权限与用途,Plan agent 只分析规划不做修改。 | 用 agent 能力边界降低误触发:能规划的 agent 不等于能执行完成。 |
|
||||||
|
| Eino ADK | Eino ADK 提供事件驱动输出、Runner 回调、中断、checkpoint,以及 Supervisor、Plan-Execute 等协作原语。Plan-Execute 由 Planner、Executor、Replanner 协作。 | 当前项目选型方向正确;需要把事件驱动能力进一步固化为 finalization contract。 |
|
||||||
|
|
||||||
|
主要参考:
|
||||||
|
|
||||||
|
- OpenAI Codex Prompting Guide: https://developers.openai.com/cookbook/examples/gpt-5/codex_prompting_guide
|
||||||
|
- Claude Code Hooks: https://docs.anthropic.com/en/docs/claude-code/hooks
|
||||||
|
- Claude Code Subagents: https://docs.anthropic.com/en/docs/claude-code/sub-agents
|
||||||
|
- Claude Code Common Workflows: https://docs.anthropic.com/en/docs/claude-code/common-workflows
|
||||||
|
- Cursor Agent Best Practices: https://cursor.com/blog/agent-best-practices
|
||||||
|
- OpenCode Agents: https://opencode.ai/docs/agents/
|
||||||
|
- CloudWeGo Eino ADK: https://www.cloudwego.io/docs/eino/core_modules/eino_adk/
|
||||||
|
- CloudWeGo Eino ADK Patterns: https://www.cloudwego.io/docs/eino/overview/eino_adk0_1/
|
||||||
|
|
||||||
|
## 三、通用最佳实践
|
||||||
|
|
||||||
|
### 1. 建立 Finalization Contract
|
||||||
|
|
||||||
|
所有执行入口统一产出一个结构化收尾对象,只有它允许触发最终回复。
|
||||||
|
|
||||||
|
```go
|
||||||
|
type FinalizationDecision struct {
|
||||||
|
Status string // in_progress | completed | blocked | failed | cancelled
|
||||||
|
Finalizable bool
|
||||||
|
CompletionReason string // verified | user_cancelled | timeout | blocked | failed
|
||||||
|
FinalText string
|
||||||
|
EvidenceVerified bool
|
||||||
|
EvidenceRefs []string
|
||||||
|
PendingToolRuns []string
|
||||||
|
PendingPlanSteps []string
|
||||||
|
PendingApprovals []string
|
||||||
|
MissingChecks []string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
硬规则:
|
||||||
|
|
||||||
|
- `Finalizable=false` 时禁止发送 `response` 终态事件。
|
||||||
|
- `Status=in_progress` 时只能发 `progress`、`planning`、`tool_*`、`reasoning_chain` 等过程事件。
|
||||||
|
- `FinalText` 不能为空,但非空不代表可以 final。
|
||||||
|
- `PendingToolRuns`、`PendingPlanSteps`、`PendingApprovals` 任一非空时不能 `completed`。
|
||||||
|
- `EvidenceVerified=false` 时不能把候选输出写成已验证结论。
|
||||||
|
|
||||||
|
### 2. 固定 SSE 事件语义
|
||||||
|
|
||||||
|
推荐事件分层:
|
||||||
|
|
||||||
|
| 事件 | 展示位置 | 可否写 `messages.content` | 说明 |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `progress` | 任务状态/时间线 | 否 | 简短进度 |
|
||||||
|
| `planning` | 执行详情 | 否 | 主代理计划、阶段性判断 |
|
||||||
|
| `reasoning_chain` / `thinking` | 执行详情 | 否 | 推理/思考摘要 |
|
||||||
|
| `tool_call` / `tool_result` | 执行详情 | 否 | 工具事件 |
|
||||||
|
| `eino_agent_reply` | 执行详情 | 否 | 子代理返回材料 |
|
||||||
|
| `finalization_check` | 执行详情 | 否 | verifier 结果 |
|
||||||
|
| `finalization_auto_continue` | 执行详情 | 否 | verifier 触发的工程续跑,`contextInjection=false` |
|
||||||
|
| `response` | 主消息气泡 | 是 | 只能在 `data.finalized=true` 时使用 |
|
||||||
|
| `done` | 关闭流 | 否 | 仅表示流结束,不表示任务成功 |
|
||||||
|
| `error` / `cancelled` | 主消息气泡或系统提示 | 是,终态失败类 | 必须带原因 |
|
||||||
|
|
||||||
|
### 3. 把“最终候选”与“最终回复”分开
|
||||||
|
|
||||||
|
模型可以输出候选结论,但候选结论必须先进入 `final_candidate` 或 `planning`,再由 verifier 决定是否提升:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assistant text
|
||||||
|
-> candidate
|
||||||
|
-> finalization gate
|
||||||
|
-> response(finalized=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
不要这样做:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assistant text
|
||||||
|
-> response
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Stop-time Verification
|
||||||
|
|
||||||
|
借鉴 Claude Code hook 思路,在 agent run 停止时做一次确定性检查:
|
||||||
|
|
||||||
|
- 所有工具调用都有对应 tool result。
|
||||||
|
- 后台 execution 都处于 terminal 状态,或被明确登记为仍在运行且任务状态为 `in_progress` / `blocked`。
|
||||||
|
- Plan-Execute 没有未执行的 required step。
|
||||||
|
- Supervisor 没有未汇总的子代理结果。
|
||||||
|
- 在 evidence-required 策略下,至少存在可查询到的 completed 工具执行证据。
|
||||||
|
|
||||||
|
### 5. 子代理输出只作证据
|
||||||
|
|
||||||
|
子代理返回不能直接成为用户最终回复。主代理必须完成:
|
||||||
|
|
||||||
|
- 去重和冲突合并。
|
||||||
|
- 证据强度排序。
|
||||||
|
- 不确定性标注。
|
||||||
|
- 范围边界确认。
|
||||||
|
- 用户可读交付。
|
||||||
|
|
||||||
|
### 6. Prompt 只做软约束,代码做硬约束
|
||||||
|
|
||||||
|
Prompt 中可以写:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Interim observations must be marked as progress, not final.
|
||||||
|
Do not produce a final answer until verification is complete.
|
||||||
|
```
|
||||||
|
|
||||||
|
但真正决定 final 的必须是后端字段和状态机。否则模型只要生成一段像最终结论的自然语言,UI 仍可能误判。
|
||||||
|
|
||||||
|
## 四、CyberStrikeAI 当前落地状态
|
||||||
|
|
||||||
|
当前项目已经具备一套显式 final gate:
|
||||||
|
|
||||||
|
- [internal/agentfinalizer/decision.go](../../internal/agentfinalizer/decision.go) 是唯一的最终回复决策契约。
|
||||||
|
- [internal/handler/finalization_helpers.go](../../internal/handler/finalization_helpers.go) 负责把决策结果写入 `process_details`,并且只有 `Finalizable=true` 时才调用 `UpdateAssistantMessageFinalize`。
|
||||||
|
- [internal/handler/eino_single_agent.go](../../internal/handler/eino_single_agent.go)、[internal/handler/multi_agent.go](../../internal/handler/multi_agent.go)、[internal/handler/workflow_integration.go](../../internal/handler/workflow_integration.go)、[internal/handler/batch_queue_executor.go](../../internal/handler/batch_queue_executor.go) 均已在收尾处接入 finalizer。
|
||||||
|
- [web/static/js/monitor.js](../../web/static/js/monitor.js) 只把 `data.finalized === true` 的 `response` 当最终回复;未最终化文本会显示为最终回复检查未通过。
|
||||||
|
- [web/static/js/webshell.js](../../web/static/js/webshell.js) 将流式正文标记为候选输出,只有 `response(finalized=true)` 才切换为完成态。
|
||||||
|
- [internal/agentfinalizer/decision_test.go](../../internal/agentfinalizer/decision_test.go) 覆盖 pending tool、HITL、空输出、证据策略要求但缺执行证据、失败证据不能支撑最终化、完成态证据可 final 等回归场景。
|
||||||
|
- [internal/handler/finalization_auto_continue.go](../../internal/handler/finalization_auto_continue.go) 在缺 completed 执行证据时最多自动续跑 2 段;续跑只恢复已有模型轨迹,不向 agent 注入新的 user/system 文案。
|
||||||
|
|
||||||
|
当前契约的核心规则:
|
||||||
|
|
||||||
|
1. **模型自然语言只是 candidate。**
|
||||||
|
`RunResult.Response` 不能直接升级为最终回复,必须经过 `agentfinalizer.Decide`。
|
||||||
|
|
||||||
|
2. **所有 `response` 事件必须携带终态字段。**
|
||||||
|
至少包含 `finalized`、`finalizable`、`status`、`completionReason`、`evidenceVerified`、`evidenceRefs`、`pendingExecutionIds`、`missingChecks`。
|
||||||
|
|
||||||
|
3. **未完成工具会阻断 final。**
|
||||||
|
`queued/running` 工具执行仍存在时,决策结果为 `in_progress/pending_tool_executions`。
|
||||||
|
|
||||||
|
4. **执行证据必须由结构化策略声明。**
|
||||||
|
后端不从用户自然语言、助手回复或 agent mode 名称中推断执行意图。聊天请求通过 `finalization.requireExecutionEvidence` 显式声明;WebShell、Workflow、批量、机器人等执行入口由调用点显式传入 policy。policy 要求证据时,至少需要一个可查询到的 `completed` 工具执行记录;只有 failed/cancelled 记录不能支撑最终化。
|
||||||
|
|
||||||
|
5. **缺执行证据先工程续跑,再阻断。**
|
||||||
|
Eino 单代理和 Eino 多代理主链路在 `missing_execution_evidence` 时会先通过已有 trace 自动续跑,不注入额外上下文;达到续跑上限后仍缺证据才写入 blocked。
|
||||||
|
|
||||||
|
6. **HITL 和空输出不会 final。**
|
||||||
|
workflow 等待人工确认、空 assistant 文本、Eino 空输出占位均会写入阻断文案,而不是成功总结。
|
||||||
|
|
||||||
|
## 五、贴合当前项目的推荐架构
|
||||||
|
|
||||||
|
当前采用的链路是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Agent / Eino ADK events
|
||||||
|
-> event normalizer
|
||||||
|
-> process_details
|
||||||
|
-> finalization verifier
|
||||||
|
-> response(finalized=true)
|
||||||
|
-> messages.content
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. 后端统一 Finalizer
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 接收 `RunResult` / 候选文本、`mcpExecutionIds`、会话与助手消息 ID、HITL 状态、编排模式。
|
||||||
|
- 通过数据库查询工具执行状态,识别 pending、completed、failed、cancelled 等证据状态。
|
||||||
|
- 返回 `FinalizationDecision`。
|
||||||
|
- 不调用高风险工具,只做状态和证据检查。
|
||||||
|
|
||||||
|
### 2. RunResult 终态字段
|
||||||
|
|
||||||
|
[internal/multiagent/runner.go](../../internal/multiagent/runner.go) 已扩展终态字段:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type RunResult struct {
|
||||||
|
Response string
|
||||||
|
MCPExecutionIDs []string
|
||||||
|
LastAgentTraceInput string
|
||||||
|
LastAgentTraceOutput string
|
||||||
|
|
||||||
|
Finalized bool
|
||||||
|
Status string
|
||||||
|
CompletionReason string
|
||||||
|
EvidenceVerified bool
|
||||||
|
EvidenceRefs []string
|
||||||
|
PendingExecutionIDs []string
|
||||||
|
MissingChecks []string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 发送 `response` 的条件
|
||||||
|
|
||||||
|
在单代理、多代理、工作流、批处理收尾处统一执行:
|
||||||
|
|
||||||
|
```go
|
||||||
|
decision := h.finalizeAgentRunForDelivery(...)
|
||||||
|
if !decision.Finalizable {
|
||||||
|
sendEvent("finalization_check", "任务尚未达到最终回复条件", decision)
|
||||||
|
sendEvent("response", finalizationBlockedMessage(decision), finalizationResponsePayload(decision, extra))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEvent("response", decision.FinalText, finalizationResponsePayload(decision, extra))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 前端只信 `finalized=true`
|
||||||
|
|
||||||
|
在 [web/static/js/monitor.js](../../web/static/js/monitor.js) 的 `case 'response'` 中执行硬判断:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const responseFinalized = isFinalizedResponseData(responseData);
|
||||||
|
const bubbleText = responseFinalized
|
||||||
|
? resolvedResponseText
|
||||||
|
: (event.message || '任务尚未达到最终回复条件,暂不生成成功结论。');
|
||||||
|
markAssistantFinalizationState(assistantIdFinal, responseData);
|
||||||
|
```
|
||||||
|
|
||||||
|
WebShell 侧同理:`response_delta` 可以用于实时预览,但 UI 文案应标记为“执行中输出”,只有最终 `response(finalized=true)` 才显示为完成态。
|
||||||
|
|
||||||
|
### 5. 各模式 final gate
|
||||||
|
|
||||||
|
| 模式 | 谁可以产出最终候选 | 谁决定 final | 必须检查 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Eino 单代理 | 单代理最后助手文本 | Finalizer | 无 pending tool、证据引用完整、任务状态 terminal |
|
||||||
|
| Deep | 主代理汇总文本 | Finalizer | 子代理结果已汇总;子代理文本不能直接 final;工具状态 terminal |
|
||||||
|
| Plan-Execute | Replanner 结束后的汇总文本 | Replanner + Finalizer | Executor 单步输出不能 final;计划步骤完成或明确 blocked |
|
||||||
|
| Supervisor | Supervisor 的 `exit` / 汇总文本 | Supervisor + Finalizer | transfer 已返回;无未处理专家结果;最终由 supervisor 统一口径 |
|
||||||
|
|
||||||
|
### 6. 安全测试场景的证据 gate
|
||||||
|
|
||||||
|
安全测试、WebShell、批量验证、Workflow 和多代理执行等 evidence-required 场景,最终回复必须至少满足:
|
||||||
|
|
||||||
|
- 有明确目标和授权范围标识。
|
||||||
|
- 有可复核证据引用,例如工具 execution id、请求/响应摘要、截图路径、命令输出摘要、事实/漏洞记录 ID。
|
||||||
|
- 有身份或影响验证结果,而不是只凭 marker 文本判断。
|
||||||
|
- 已记录到项目黑板或漏洞库,或明确说明未绑定项目导致无法记录。
|
||||||
|
- 高风险动作已清理、回滚、取消,或明确说明未执行清理的原因。
|
||||||
|
- 仍在运行的扫描/命令/WebShell/C2 任务不能被隐式当作完成。
|
||||||
|
|
||||||
|
注意:这里的 gate 是治理规则,不要求最终报告暴露敏感利用细节;可以只给证据摘要和内部引用。
|
||||||
|
|
||||||
|
## 六、落地状态与后续增强
|
||||||
|
|
||||||
|
### P0:先修“误 final”(已落地)
|
||||||
|
|
||||||
|
1. 已引入 `FinalizationDecision`。
|
||||||
|
2. 主要 agent SSE `response` 事件已携带 `data.finalized/finalizable/status/completionReason` 等字段。
|
||||||
|
3. 前端 `monitor.js` 和 `webshell.js` 已按 `finalized=true` 区分候选输出和最终回复。
|
||||||
|
4. `RunResult.Response` 仍保留兼容字段名,但语义已由 finalizer 统一提升;后续可再拆成 `CandidateResponse` / `FinalResponse`,减少误用空间。
|
||||||
|
5. Plan-Execute / Deep / Supervisor / Eino Single 等模式均通过统一 handler 收尾 gate。
|
||||||
|
|
||||||
|
### P1:补证据链(部分落地)
|
||||||
|
|
||||||
|
1. 已用 `mcp_execution:<id>` 作为基础 evidence refs。
|
||||||
|
2. `finalization_check` 事件已展示 pending execution 与 missing checks。
|
||||||
|
3. 执行入口已启用显式 execution evidence policy;Eino 主链路在 policy 要求证据且缺少 completed 工具证据时先无注入续跑,达到上限后才阻断 final。
|
||||||
|
4. 后续建议:为 `record_vulnerability`、`upsert_project_fact`、项目黑板记录建立更细粒度 evidence refs。
|
||||||
|
5. 后续建议:最终报告模板固定包含“结论、证据、风险/不确定性、后续动作”。
|
||||||
|
|
||||||
|
### P2:体验和观测(后续增强)
|
||||||
|
|
||||||
|
1. 在任务卡片展示 `in_progress / verifying / finalizing / completed / blocked`。
|
||||||
|
2. 为 finalizer 加日志和指标:误拦截率、缺失证据类型、pending tool 数量。
|
||||||
|
3. 支持“继续验证”按钮,从 `FinalizationDecision.MissingChecks` 自动生成下一轮输入。
|
||||||
|
|
||||||
|
## 七、验收测试建议
|
||||||
|
|
||||||
|
至少加入这些回归测试:
|
||||||
|
|
||||||
|
1. **推理文本不 final**
|
||||||
|
模拟 `reasoning_chain` 里出现看似完成的候选结论,但本轮没有 completed 工具执行证据;预期主消息气泡不显示成功结论,只显示执行中或阻断态。
|
||||||
|
|
||||||
|
2. **主代理阶段性输出不 final**
|
||||||
|
模拟 `response_start/delta` 输出“下一步继续验证”;预期只进入 timeline `planning`。
|
||||||
|
|
||||||
|
3. **未完成后台工具不 final**
|
||||||
|
工具返回 `execution_id` 且状态 `running`;即使模型给出总结,也只能 `in_progress`。
|
||||||
|
|
||||||
|
4. **Plan-Execute Executor 输出不 final**
|
||||||
|
Executor 输出“突破成功”,但 Replanner 未结束;预期不触发 `messages.content` finalize。
|
||||||
|
|
||||||
|
5. **Supervisor 子代理输出不 final**
|
||||||
|
子代理返回确定结论,Supervisor 未 `exit`;预期只进入 `eino_agent_reply`。
|
||||||
|
|
||||||
|
6. **最终事件必须带 finalized**
|
||||||
|
前端收到旧格式 `response` 无 `finalized=true`;预期候选内容只进入详情/警告,主消息显示阻断态,不创建成功最终气泡。
|
||||||
|
|
||||||
|
7. **失败和取消可终态**
|
||||||
|
`error` / `cancelled` 仍可更新助手消息,但 `completionReason` 必须是 `failed` / `user_cancelled`,不能伪装为成功完成。
|
||||||
|
|
||||||
|
## 八、推荐默认策略
|
||||||
|
|
||||||
|
对 CyberStrikeAI,建议默认策略是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
eino_single:轻量任务可用,但 final gate 必须开启
|
||||||
|
deep:复杂安全测试默认推荐
|
||||||
|
plan_execute:目标明确、需要严格“规划-执行-重规划”的任务推荐
|
||||||
|
supervisor:多专家路由任务使用,不作为默认泛化模式
|
||||||
|
```
|
||||||
|
|
||||||
|
最终治理一句话:
|
||||||
|
|
||||||
|
```text
|
||||||
|
messages.content 只能来自 FinalizationDecision.FinalText;
|
||||||
|
process_details 可以展示所有过程;
|
||||||
|
前端只能把 response(finalized=true) 当最终回复。
|
||||||
|
```
|
||||||
@@ -32,13 +32,21 @@ https://127.0.0.1:8080/
|
|||||||
|
|
||||||
如果仍有其他具备 `rbac:write` 权限的管理员账号,优先在 **平台权限 → 用户管理** 中重置密码。
|
如果仍有其他具备 `rbac:write` 权限的管理员账号,优先在 **平台权限 → 用户管理** 中重置密码。
|
||||||
|
|
||||||
如果没有可用的管理员会话,可在服务器上紧急重置内置 `admin` 账号。先停止 CyberStrikeAI 服务并备份数据库,然后在项目根目录执行以下命令,按提示输入并确认新密码:
|
如果没有可用的管理员会话,可在服务器上紧急重置内置 `admin` 账号。在项目根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.sh --reset-admin-password
|
||||||
|
```
|
||||||
|
|
||||||
|
按提示输入并确认新密码。脚本会隐藏输入并写入 bcrypt 哈希。如果服务正在运行,完成后重新启动服务,使原有登录会话失效。
|
||||||
|
|
||||||
|
如果无法使用 `run.sh`,也可以手动执行以下命令,按提示输入并确认新密码:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
HASH=$(htpasswd -nBC 10 '' | cut -d: -f2 | tr -d '\n') && sqlite3 data/conversations.db "UPDATE rbac_users SET password_hash='$HASH', updated_at=CURRENT_TIMESTAMP WHERE id='admin' AND username='admin' AND is_builtin=1; SELECT changes();"
|
HASH=$(htpasswd -nBC 10 '' | cut -d: -f2 | tr -d '\n') && sqlite3 data/conversations.db "UPDATE rbac_users SET password_hash='$HASH', updated_at=CURRENT_TIMESTAMP WHERE id='admin' AND username='admin' AND is_builtin=1; SELECT changes();"
|
||||||
```
|
```
|
||||||
|
|
||||||
输出 `1` 表示修改成功。该命令需要 `sqlite3` 和 `htpasswd`;如果 `config.yaml` 中的 `database.path` 不是默认值,请替换 `data/conversations.db`。密码输入不会显示,也不会写入 Shell 历史,并以 bcrypt 哈希保存。完成后重新启动服务,使原有登录会话失效。
|
输出 `1` 表示修改成功。该命令需要 `sqlite3` 和 `htpasswd`;如果 `config.yaml` 中的 `database.path` 不是默认值,请替换 `data/conversations.db`。密码输入不会显示,也不会写入 Shell 历史。
|
||||||
|
|
||||||
## 模型无响应
|
## 模型无响应
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,39 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三、执行模型(先理解再配置)
|
## 三、自然语言生成草稿
|
||||||
|
|
||||||
|
工作流页面提供 **用自然语言创建** 入口。用户描述一句安全作业目标后,平台会生成一个可编辑草稿,并返回结构化审计结果:
|
||||||
|
|
||||||
|
- 只生成草稿,不会自动保存、试运行或真实执行工具。
|
||||||
|
- 服务端接口为 `POST /api/workflows/generate-draft`,权限为 `workflow:write`。
|
||||||
|
- 生成结果包含 `graph`、`meta`、`capabilities`、`audit` 和 `stats`,前端应用到画布后仍走现有保存校验。
|
||||||
|
- 高风险语义(执行脚本、隔离、封禁、删除、利用等)会标记 `high_risk`,并默认插入 HITL 审批或 `requires_human_confirmation`。
|
||||||
|
- 工具能力按已有工具列表匹配;未匹配到时降级为 Agent 草稿,并在 `audit.missing_fields` / `audit.assumptions` 中提示需要补配置。
|
||||||
|
- 如果服务端生成不可用,前端会使用本地确定性兜底生成器,继续给出可编辑草稿和风险提示。
|
||||||
|
|
||||||
|
示例请求:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/workflows/generate-draft
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "对目标资产做端口扫描,如果发现高危端口就创建漏洞任务并通知负责人审批,最后输出报告",
|
||||||
|
"options": {
|
||||||
|
"include_objective": true,
|
||||||
|
"allow_schedule": false,
|
||||||
|
"allow_high_risk": false
|
||||||
|
},
|
||||||
|
"available_tools": [
|
||||||
|
{ "key": "nmap", "name": "nmap", "enabled": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、执行模型(先理解再配置)
|
||||||
|
|
||||||
工作流按 **有向图** 执行,引擎从 **开始** 节点出发,沿连线依次运行下游节点。
|
工作流按 **有向图** 执行,引擎从 **开始** 节点出发,沿连线依次运行下游节点。
|
||||||
|
|
||||||
@@ -116,7 +148,7 @@ outputs["你填的变量名"] = 节点输出内容
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、模板语法
|
## 五、模板语法
|
||||||
|
|
||||||
### 4.1 基本格式
|
### 4.1 基本格式
|
||||||
|
|
||||||
@@ -198,7 +230,7 @@ jq({{outputs.scan}}, ".severity") == "high"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 五、节点类型与配置
|
## 六、节点类型与配置
|
||||||
|
|
||||||
### 5.1 开始(start)
|
### 5.1 开始(start)
|
||||||
|
|
||||||
@@ -318,7 +350,7 @@ HITL 等待信息会记录:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 六、连线配置
|
## 七、连线配置
|
||||||
|
|
||||||
选中 **连线** 后,右侧可配置 **连线条件**。
|
选中 **连线** 后,右侧可配置 **连线条件**。
|
||||||
|
|
||||||
@@ -335,7 +367,7 @@ HITL 等待信息会记录:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 七、完整示例:跨条件节点传递 Agent 输出
|
## 八、完整示例:跨条件节点传递 Agent 输出
|
||||||
|
|
||||||
### 7.1 流程结构
|
### 7.1 流程结构
|
||||||
|
|
||||||
@@ -384,7 +416,7 @@ HITL 等待信息会记录:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 八、绑定角色并运行
|
## 九、绑定角色并运行
|
||||||
|
|
||||||
### 8.1 在角色管理中绑定
|
### 8.1 在角色管理中绑定
|
||||||
|
|
||||||
@@ -414,7 +446,7 @@ workflow_policy: auto
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 九、调试、试运行与复盘
|
## 十、调试、试运行与复盘
|
||||||
|
|
||||||
### 9.1 安全试运行(dry-run)
|
### 9.1 安全试运行(dry-run)
|
||||||
|
|
||||||
@@ -487,7 +519,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十、保存前校验规则
|
## 十一、保存前校验规则
|
||||||
|
|
||||||
保存时系统会自动检查:
|
保存时系统会自动检查:
|
||||||
|
|
||||||
@@ -510,7 +542,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十一、排错指南
|
## 十二、排错指南
|
||||||
|
|
||||||
| 现象 | 可能原因 | 处理建议 |
|
| 现象 | 可能原因 | 处理建议 |
|
||||||
|------|----------|----------|
|
|------|----------|----------|
|
||||||
@@ -526,7 +558,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十二、最佳实践
|
## 十三、最佳实践
|
||||||
|
|
||||||
1. **命名规范**:为每个需要被引用的节点设置有意义的输出变量名,如 `scan_result`、`parsed_targets`,避免都叫 `agent_result`。
|
1. **命名规范**:为每个需要被引用的节点设置有意义的输出变量名,如 `scan_result`、`parsed_targets`,避免都叫 `agent_result`。
|
||||||
2. **跨节点传参优先用 `outputs`**:只要中间可能插入条件、工具、审批节点,就应用命名变量。
|
2. **跨节点传参优先用 `outputs`**:只要中间可能插入条件、工具、审批节点,就应用命名变量。
|
||||||
@@ -539,7 +571,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十三、相关代码位置(开发者参考)
|
## 十四、相关代码位置(开发者参考)
|
||||||
|
|
||||||
| 模块 | 路径 |
|
| 模块 | 路径 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ require (
|
|||||||
go.opentelemetry.io/otel/trace v1.34.0
|
go.opentelemetry.io/otel/trace v1.34.0
|
||||||
go.uber.org/zap v1.26.0
|
go.uber.org/zap v1.26.0
|
||||||
golang.org/x/net v0.35.0
|
golang.org/x/net v0.35.0
|
||||||
|
golang.org/x/term v0.32.0
|
||||||
golang.org/x/text v0.26.0
|
golang.org/x/text v0.26.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.14.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
+14
-22
@@ -514,6 +514,14 @@ type ToolExecutionResult struct {
|
|||||||
IsError bool
|
IsError bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildToolFailureMessage(toolName, detail string, err error) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "工具调用失败\n\n")
|
||||||
|
fmt.Fprintf(&b, "工具名称: %s\n", toolName)
|
||||||
|
fmt.Fprintf(&b, "错误详情: %s", detail)
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
// executeToolViaMCP 通过MCP执行工具
|
// executeToolViaMCP 通过MCP执行工具
|
||||||
// 即使工具执行失败,也返回结果而不是错误,让AI能够处理错误情况
|
// 即使工具执行失败,也返回结果而不是错误,让AI能够处理错误情况
|
||||||
func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map[string]interface{}) (*ToolExecutionResult, error) {
|
func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map[string]interface{}) (*ToolExecutionResult, error) {
|
||||||
@@ -573,32 +581,16 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
|
|||||||
// 如果调用失败(如工具不存在、超时),返回友好的错误信息而不是抛出异常
|
// 如果调用失败(如工具不存在、超时),返回友好的错误信息而不是抛出异常
|
||||||
if err != nil {
|
if err != nil {
|
||||||
detail := err.Error()
|
detail := err.Error()
|
||||||
|
timeoutMinutes := 10
|
||||||
|
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
|
||||||
|
timeoutMinutes = a.agentConfig.ToolTimeoutMinutes
|
||||||
|
}
|
||||||
if errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.Canceled) {
|
||||||
detail = "工具调用已被手动终止(MCP 监控页)。智能体将携带此结果继续后续步骤,整条任务不会因此被停止。"
|
detail = "工具调用已被手动终止(MCP 监控页)。智能体将携带此结果继续后续步骤,整条任务不会因此被停止。"
|
||||||
} else if errors.Is(err, context.DeadlineExceeded) {
|
} else if errors.Is(err, context.DeadlineExceeded) {
|
||||||
min := 10
|
detail = fmt.Sprintf("工具执行超过 %d 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)", timeoutMinutes)
|
||||||
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
|
|
||||||
min = a.agentConfig.ToolTimeoutMinutes
|
|
||||||
}
|
|
||||||
detail = fmt.Sprintf("工具执行超过 %d 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)", min)
|
|
||||||
}
|
}
|
||||||
errorMsg := fmt.Sprintf(`工具调用失败
|
errorMsg := buildToolFailureMessage(toolName, detail, err)
|
||||||
|
|
||||||
工具名称: %s
|
|
||||||
错误类型: 系统错误
|
|
||||||
错误详情: %s
|
|
||||||
|
|
||||||
可能的原因:
|
|
||||||
- 工具 "%s" 不存在或未启用
|
|
||||||
- 单次执行超时(agent.tool_timeout_minutes)
|
|
||||||
- 系统配置问题
|
|
||||||
- 网络或权限问题
|
|
||||||
|
|
||||||
建议:
|
|
||||||
- 检查工具名称是否正确
|
|
||||||
- 若需更长执行时间,可适当增大 agent.tool_timeout_minutes
|
|
||||||
- 尝试使用其他替代工具
|
|
||||||
- 如果这是必需的工具,请向用户说明情况`, toolName, detail, toolName)
|
|
||||||
|
|
||||||
return &ToolExecutionResult{
|
return &ToolExecutionResult{
|
||||||
Result: errorMsg,
|
Result: errorMsg,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -71,6 +72,80 @@ func TestAgent_NewAgent_CustomConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildToolFailureMessageAuthorizationDenied(t *testing.T) {
|
||||||
|
msg := buildToolFailureMessage(
|
||||||
|
"list_project_facts",
|
||||||
|
"tool authorization denied: no access to project",
|
||||||
|
errors.New("tool authorization denied: no access to project"),
|
||||||
|
)
|
||||||
|
for _, want := range []string{
|
||||||
|
"工具名称: list_project_facts",
|
||||||
|
"错误详情: tool authorization denied: no access to project",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, notWant := range []string{
|
||||||
|
"可能的原因",
|
||||||
|
"建议",
|
||||||
|
"错误类型",
|
||||||
|
"工具 \"list_project_facts\" 不存在或未启用",
|
||||||
|
"单次执行超时",
|
||||||
|
} {
|
||||||
|
if strings.Contains(msg, notWant) {
|
||||||
|
t.Fatalf("message should not include generic hint %q:\n%s", notWant, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildToolFailureMessageCanceled(t *testing.T) {
|
||||||
|
msg := buildToolFailureMessage(
|
||||||
|
"long_running_tool",
|
||||||
|
"工具调用已被手动终止(MCP 监控页)。智能体将携带此结果继续后续步骤,整条任务不会因此被停止。",
|
||||||
|
context.Canceled,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"工具名称: long_running_tool",
|
||||||
|
"错误详情: 工具调用已被手动终止",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildToolFailureMessageDeadlineExceeded(t *testing.T) {
|
||||||
|
msg := buildToolFailureMessage(
|
||||||
|
"nmap",
|
||||||
|
"工具执行超过 15 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)",
|
||||||
|
context.DeadlineExceeded,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"工具名称: nmap",
|
||||||
|
"错误详情: 工具执行超过 15 分钟被自动终止",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildToolFailureMessageUnknownKeepsGenericFallback(t *testing.T) {
|
||||||
|
msg := buildToolFailureMessage("custom_tool", "dial tcp: connection reset by peer", errors.New("dial tcp: connection reset by peer"))
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"工具名称: custom_tool",
|
||||||
|
"错误详情: dial tcp: connection reset by peer",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAgentCancelRunningMCPToolsForConversation(t *testing.T) {
|
func TestAgentCancelRunningMCPToolsForConversation(t *testing.T) {
|
||||||
ag := setupTestAgent(t)
|
ag := setupTestAgent(t)
|
||||||
ag.mcpServer.ConfigureToolWaitTimeoutSeconds(1)
|
ag.mcpServer.ConfigureToolWaitTimeoutSeconds(1)
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package agentfinalizer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusCompleted = "completed"
|
||||||
|
StatusInProgress = "in_progress"
|
||||||
|
StatusBlocked = "blocked"
|
||||||
|
StatusFailed = "failed"
|
||||||
|
StatusCancelled = "cancelled"
|
||||||
|
StatusAwaitingHITL = "awaiting_hitl"
|
||||||
|
|
||||||
|
ReasonVerified = "verified"
|
||||||
|
ReasonPendingTools = "pending_tool_executions"
|
||||||
|
ReasonEmptyResponse = "empty_response"
|
||||||
|
ReasonAwaitingHITL = "awaiting_hitl"
|
||||||
|
ReasonFailed = "failed"
|
||||||
|
ReasonCancelled = "cancelled"
|
||||||
|
ReasonMissingEvidence = "missing_execution_evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decision is the single contract that may promote an agent run to a final
|
||||||
|
// user-facing answer. Natural-language assistant text is only a candidate until
|
||||||
|
// this object says Finalizable.
|
||||||
|
type Decision struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Finalizable bool `json:"finalizable"`
|
||||||
|
Finalized bool `json:"finalized"`
|
||||||
|
CompletionReason string `json:"completionReason"`
|
||||||
|
FinalText string `json:"finalText,omitempty"`
|
||||||
|
EvidenceVerified bool `json:"evidenceVerified"`
|
||||||
|
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
|
||||||
|
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
|
||||||
|
PendingToolRuns []string `json:"pendingToolRuns,omitempty"`
|
||||||
|
MissingChecks []string `json:"missingChecks,omitempty"`
|
||||||
|
AgentMode string `json:"agentMode,omitempty"`
|
||||||
|
ConversationID string `json:"conversationId,omitempty"`
|
||||||
|
AssistantMessageID string `json:"messageId,omitempty"`
|
||||||
|
CandidateResponseLen int `json:"candidateResponseLen,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Input struct {
|
||||||
|
Response string
|
||||||
|
MCPExecutionIDs []string
|
||||||
|
ConversationID string
|
||||||
|
AssistantMessageID string
|
||||||
|
AgentMode string
|
||||||
|
Status string
|
||||||
|
CompletionReason string
|
||||||
|
AwaitingHITL bool
|
||||||
|
RequireExecutionEvidence bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromRunResult(db *database.DB, result *multiagent.RunResult, in Input) Decision {
|
||||||
|
if result != nil {
|
||||||
|
if strings.TrimSpace(in.Response) == "" {
|
||||||
|
in.Response = result.Response
|
||||||
|
}
|
||||||
|
if len(in.MCPExecutionIDs) == 0 {
|
||||||
|
in.MCPExecutionIDs = result.MCPExecutionIDs
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.Status) == "" {
|
||||||
|
in.Status = result.Status
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.CompletionReason) == "" {
|
||||||
|
in.CompletionReason = result.CompletionReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d := Decide(db, in)
|
||||||
|
if result != nil {
|
||||||
|
result.Finalized = d.Finalized
|
||||||
|
result.Status = d.Status
|
||||||
|
result.CompletionReason = d.CompletionReason
|
||||||
|
result.EvidenceVerified = d.EvidenceVerified
|
||||||
|
result.EvidenceRefs = append([]string(nil), d.EvidenceRefs...)
|
||||||
|
result.PendingExecutionIDs = append([]string(nil), d.PendingExecutionIDs...)
|
||||||
|
result.MissingChecks = append([]string(nil), d.MissingChecks...)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func Decide(db *database.DB, in Input) Decision {
|
||||||
|
text := strings.TrimSpace(in.Response)
|
||||||
|
status := strings.TrimSpace(in.Status)
|
||||||
|
if status == "" {
|
||||||
|
status = StatusCompleted
|
||||||
|
}
|
||||||
|
reason := strings.TrimSpace(in.CompletionReason)
|
||||||
|
if reason == "" {
|
||||||
|
reason = ReasonVerified
|
||||||
|
}
|
||||||
|
d := Decision{
|
||||||
|
Status: status,
|
||||||
|
CompletionReason: reason,
|
||||||
|
FinalText: text,
|
||||||
|
EvidenceVerified: true,
|
||||||
|
EvidenceRefs: evidenceRefs(in.MCPExecutionIDs),
|
||||||
|
AgentMode: strings.TrimSpace(in.AgentMode),
|
||||||
|
ConversationID: strings.TrimSpace(in.ConversationID),
|
||||||
|
AssistantMessageID: strings.TrimSpace(in.AssistantMessageID),
|
||||||
|
CandidateResponseLen: len([]rune(text)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.AwaitingHITL {
|
||||||
|
d.Status = StatusAwaitingHITL
|
||||||
|
d.CompletionReason = ReasonAwaitingHITL
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "workflow is awaiting HITL approval")
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
if isEmptyCandidate(text) {
|
||||||
|
d.Status = StatusBlocked
|
||||||
|
d.CompletionReason = ReasonEmptyResponse
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "assistant final text is empty or only an empty-response placeholder")
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case StatusInProgress, StatusBlocked, StatusFailed, StatusCancelled, StatusAwaitingHITL:
|
||||||
|
d.Status = status
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
if d.CompletionReason == ReasonVerified {
|
||||||
|
d.CompletionReason = status
|
||||||
|
}
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "agent run status is "+status)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
pending := pendingExecutions(db, in.MCPExecutionIDs)
|
||||||
|
if len(pending) > 0 {
|
||||||
|
d.Status = StatusInProgress
|
||||||
|
d.CompletionReason = ReasonPendingTools
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
d.PendingExecutionIDs = pending
|
||||||
|
d.PendingToolRuns = append([]string(nil), pending...)
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "tool execution still queued or running")
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.RequireExecutionEvidence && !hasCompletedEvidence(db, in.MCPExecutionIDs) {
|
||||||
|
d.Status = StatusBlocked
|
||||||
|
d.CompletionReason = ReasonMissingEvidence
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "execution evidence is required but no completed tool execution was recorded")
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Finalizable = true
|
||||||
|
d.Finalized = true
|
||||||
|
d.Status = StatusCompleted
|
||||||
|
if d.CompletionReason == "" {
|
||||||
|
d.CompletionReason = ReasonVerified
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponsePayload(d Decision, extra map[string]interface{}) map[string]interface{} {
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"finalized": d.Finalized,
|
||||||
|
"finalizable": d.Finalizable,
|
||||||
|
"status": d.Status,
|
||||||
|
"completionReason": d.CompletionReason,
|
||||||
|
"evidenceVerified": d.EvidenceVerified,
|
||||||
|
"evidenceRefs": d.EvidenceRefs,
|
||||||
|
"pendingExecutionIds": d.PendingExecutionIDs,
|
||||||
|
"pendingToolRuns": d.PendingToolRuns,
|
||||||
|
"missingChecks": d.MissingChecks,
|
||||||
|
}
|
||||||
|
if d.ConversationID != "" {
|
||||||
|
out["conversationId"] = d.ConversationID
|
||||||
|
}
|
||||||
|
if d.AssistantMessageID != "" {
|
||||||
|
out["messageId"] = d.AssistantMessageID
|
||||||
|
}
|
||||||
|
if d.AgentMode != "" {
|
||||||
|
out["agentMode"] = d.AgentMode
|
||||||
|
}
|
||||||
|
for k, v := range extra {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmptyCandidate(s string) bool {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.Contains(s, "no assistant text was captured") ||
|
||||||
|
strings.Contains(s, "未捕获到助手文本输出")
|
||||||
|
}
|
||||||
|
|
||||||
|
func evidenceRefs(ids []string) []string {
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, "mcp_execution:"+id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func pendingExecutions(db *database.DB, ids []string) []string {
|
||||||
|
if db == nil || len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0)
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
exec, err := db.GetToolExecution(id)
|
||||||
|
if err != nil || exec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(exec.Status) {
|
||||||
|
case mcp.ToolExecutionStatusQueued, mcp.ToolExecutionStatusRunning:
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasCompletedEvidence(db *database.DB, ids []string) bool {
|
||||||
|
if db == nil || len(ids) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
exec, err := db.GetToolExecution(id)
|
||||||
|
if err != nil || exec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(exec.Status) == mcp.ToolExecutionStatusCompleted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package agentfinalizer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newDecisionTestDB(t *testing.T) *database.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "finalizer.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveDecisionTestExecution(t *testing.T, db *database.DB, id, status string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := db.SaveToolExecution(&mcp.ToolExecution{
|
||||||
|
ID: id,
|
||||||
|
ToolName: "test::tool",
|
||||||
|
Arguments: map[string]interface{}{"input": id},
|
||||||
|
Status: status,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveToolExecution(%s): %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideBlocksPendingToolExecutions(t *testing.T) {
|
||||||
|
db := newDecisionTestDB(t)
|
||||||
|
saveDecisionTestExecution(t, db, "run-queued", mcp.ToolExecutionStatusQueued)
|
||||||
|
saveDecisionTestExecution(t, db, "run-running", mcp.ToolExecutionStatusRunning)
|
||||||
|
saveDecisionTestExecution(t, db, "run-completed", mcp.ToolExecutionStatusCompleted)
|
||||||
|
|
||||||
|
d := Decide(db, Input{
|
||||||
|
Response: "工具还没全部结束时,这只是一段候选输出。",
|
||||||
|
MCPExecutionIDs: []string{"run-queued", "run-running", "run-completed"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if d.Finalizable || d.Finalized {
|
||||||
|
t.Fatalf("pending tools should not be finalizable: %+v", d)
|
||||||
|
}
|
||||||
|
if d.Status != StatusInProgress || d.CompletionReason != ReasonPendingTools {
|
||||||
|
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusInProgress, ReasonPendingTools)
|
||||||
|
}
|
||||||
|
if got, want := len(d.PendingExecutionIDs), 2; got != want {
|
||||||
|
t.Fatalf("pending execution count = %d, want %d (%v)", got, want, d.PendingExecutionIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideBlocksAwaitingHITLAndEmptyCandidate(t *testing.T) {
|
||||||
|
hitl := Decide(nil, Input{Response: "等待人工审批", AwaitingHITL: true})
|
||||||
|
if hitl.Finalizable || hitl.Status != StatusAwaitingHITL || hitl.CompletionReason != ReasonAwaitingHITL {
|
||||||
|
t.Fatalf("HITL decision mismatch: %+v", hitl)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := Decide(nil, Input{Response: "⚠️ Eino 执行完成,但未捕获到助手文本输出。"})
|
||||||
|
if empty.Finalizable || empty.Status != StatusBlocked || empty.CompletionReason != ReasonEmptyResponse {
|
||||||
|
t.Fatalf("empty candidate decision mismatch: %+v", empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideBlocksWhenExecutionEvidenceIsRequiredButMissing(t *testing.T) {
|
||||||
|
d := Decide(nil, Input{
|
||||||
|
Response: "任务已处理完成。",
|
||||||
|
RequireExecutionEvidence: true,
|
||||||
|
})
|
||||||
|
if d.Finalizable || d.Finalized {
|
||||||
|
t.Fatalf("missing required execution evidence should not finalize: %+v", d)
|
||||||
|
}
|
||||||
|
if d.Status != StatusBlocked || d.CompletionReason != ReasonMissingEvidence {
|
||||||
|
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusBlocked, ReasonMissingEvidence)
|
||||||
|
}
|
||||||
|
if d.EvidenceVerified {
|
||||||
|
t.Fatalf("missing required execution evidence should be marked unverified: %+v", d)
|
||||||
|
}
|
||||||
|
if len(d.MissingChecks) == 0 {
|
||||||
|
t.Fatalf("missing checks should explain the evidence gap: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideBlocksWhenOnlyFailedEvidenceIsRecorded(t *testing.T) {
|
||||||
|
db := newDecisionTestDB(t)
|
||||||
|
saveDecisionTestExecution(t, db, "run-failed", mcp.ToolExecutionStatusFailed)
|
||||||
|
saveDecisionTestExecution(t, db, "run-cancelled", mcp.ToolExecutionStatusCancelled)
|
||||||
|
|
||||||
|
d := Decide(db, Input{
|
||||||
|
Response: "任务已处理完成。",
|
||||||
|
MCPExecutionIDs: []string{"run-failed", "run-cancelled"},
|
||||||
|
RequireExecutionEvidence: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if d.Finalizable || d.Finalized {
|
||||||
|
t.Fatalf("failed evidence should not satisfy required execution evidence: %+v", d)
|
||||||
|
}
|
||||||
|
if d.Status != StatusBlocked || d.CompletionReason != ReasonMissingEvidence {
|
||||||
|
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusBlocked, ReasonMissingEvidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideFinalizesCompletedEvidence(t *testing.T) {
|
||||||
|
db := newDecisionTestDB(t)
|
||||||
|
saveDecisionTestExecution(t, db, "run-ok", mcp.ToolExecutionStatusCompleted)
|
||||||
|
|
||||||
|
d := Decide(db, Input{
|
||||||
|
Response: "任务已处理完成,见工具执行记录。",
|
||||||
|
MCPExecutionIDs: []string{"run-ok"},
|
||||||
|
RequireExecutionEvidence: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if !d.Finalizable || !d.Finalized || d.Status != StatusCompleted {
|
||||||
|
t.Fatalf("completed execution should finalize: %+v", d)
|
||||||
|
}
|
||||||
|
if !d.EvidenceVerified || len(d.EvidenceRefs) != 1 {
|
||||||
|
t.Fatalf("evidence refs mismatch: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideAllowsInformationalAnswerWhenExecutionEvidenceIsNotRequired(t *testing.T) {
|
||||||
|
d := Decide(nil, Input{Response: "这是一个概念解释,不需要执行工具。"})
|
||||||
|
if !d.Finalizable || !d.Finalized || d.Status != StatusCompleted {
|
||||||
|
t.Fatalf("informational response should finalize when execution evidence is not required: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1362,6 +1362,7 @@ func setupRoutes(
|
|||||||
protected.POST("/workflows/runs/:runId/resume", workflowHandler.ResumeRun)
|
protected.POST("/workflows/runs/:runId/resume", workflowHandler.ResumeRun)
|
||||||
protected.POST("/workflows/validate", workflowHandler.Validate)
|
protected.POST("/workflows/validate", workflowHandler.Validate)
|
||||||
protected.POST("/workflows/dry-run", workflowHandler.DryRun)
|
protected.POST("/workflows/dry-run", workflowHandler.DryRun)
|
||||||
|
protected.POST("/workflows/generate-draft", workflowHandler.GenerateDraft)
|
||||||
protected.GET("/workflows/:id/package", workflowHandler.ExportPackage)
|
protected.GET("/workflows/:id/package", workflowHandler.ExportPackage)
|
||||||
protected.POST("/workflow-package-inspections", workflowHandler.CreatePackageInspection)
|
protected.POST("/workflow-package-inspections", workflowHandler.CreatePackageInspection)
|
||||||
protected.GET("/workflow-package-inspections/:inspectionId", workflowHandler.GetPackageInspection)
|
protected.GET("/workflow-package-inspections/:inspectionId", workflowHandler.GetPackageInspection)
|
||||||
|
|||||||
@@ -382,7 +382,13 @@ func authorizeProjectTool(ctx context.Context, principal authctx.Principal, db *
|
|||||||
return fmt.Errorf("no access to conversation %s", conversationID)
|
return fmt.Errorf("no access to conversation %s", conversationID)
|
||||||
}
|
}
|
||||||
projectID, err := db.GetConversationProjectID(conversationID)
|
projectID, err := db.GetConversationProjectID(conversationID)
|
||||||
if err != nil || strings.TrimSpace(projectID) == "" || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID) {
|
if err != nil {
|
||||||
|
return fmt.Errorf("no access to project: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(projectID) == "" {
|
||||||
|
return fmt.Errorf("当前对话未绑定项目,无法使用项目黑板工具,请先在对话中选择项目或创建带项目的对话")
|
||||||
|
}
|
||||||
|
if !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID) {
|
||||||
return fmt.Errorf("no access to project %s", projectID)
|
return fmt.Errorf("no access to project %s", projectID)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+150
-8
@@ -13,6 +13,7 @@ import (
|
|||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
"golang.org/x/net/idna"
|
"golang.org/x/net/idna"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ type Asset struct {
|
|||||||
LastScanTaskID string `json:"last_scan_task_id,omitempty"`
|
LastScanTaskID string `json:"last_scan_task_id,omitempty"`
|
||||||
VulnerabilityCount int `json:"vulnerability_count"`
|
VulnerabilityCount int `json:"vulnerability_count"`
|
||||||
RiskLevel string `json:"risk_level"`
|
RiskLevel string `json:"risk_level"`
|
||||||
|
RiskScore int `json:"-"`
|
||||||
OwnerUserID string `json:"-"`
|
OwnerUserID string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,15 +445,15 @@ func assetWhere(filter AssetListFilter, access RBACListAccess) (string, []interf
|
|||||||
args = append(args, *filter.Port)
|
args = append(args, *filter.Port)
|
||||||
}
|
}
|
||||||
if filter.RiskLevel != "" {
|
if filter.RiskLevel != "" {
|
||||||
query += " AND " + assetRiskLevelExpr + " = ?"
|
query += " AND " + assetRiskLevelCachedExpr + " = ?"
|
||||||
args = append(args, strings.ToLower(strings.TrimSpace(filter.RiskLevel)))
|
args = append(args, strings.ToLower(strings.TrimSpace(filter.RiskLevel)))
|
||||||
}
|
}
|
||||||
if filter.MinVulnerabilities != nil {
|
if filter.MinVulnerabilities != nil {
|
||||||
query += " AND " + assetVulnerabilityCountExpr + " >= ?"
|
query += " AND " + assetVulnerabilityCountCachedExpr + " >= ?"
|
||||||
args = append(args, *filter.MinVulnerabilities)
|
args = append(args, *filter.MinVulnerabilities)
|
||||||
}
|
}
|
||||||
if filter.MaxVulnerabilities != nil {
|
if filter.MaxVulnerabilities != nil {
|
||||||
query += " AND " + assetVulnerabilityCountExpr + " <= ?"
|
query += " AND " + assetVulnerabilityCountCachedExpr + " <= ?"
|
||||||
args = append(args, *filter.MaxVulnerabilities)
|
args = append(args, *filter.MaxVulnerabilities)
|
||||||
}
|
}
|
||||||
for _, item := range []struct {
|
for _, item := range []struct {
|
||||||
@@ -573,20 +575,24 @@ const assetVulnerabilityMatchExpr = `(
|
|||||||
|
|
||||||
const assetVulnerabilityCountExpr = `(SELECT COUNT(DISTINCT v.id) FROM vulnerabilities v WHERE ` + assetVulnerabilityMatchExpr + `)`
|
const assetVulnerabilityCountExpr = `(SELECT COUNT(DISTINCT v.id) FROM vulnerabilities v WHERE ` + assetVulnerabilityMatchExpr + `)`
|
||||||
|
|
||||||
const assetRiskScoreExpr = `COALESCE((
|
const assetRiskScoreQueryExpr = `COALESCE((
|
||||||
SELECT MAX(CASE LOWER(COALESCE(v.severity,'')) WHEN 'critical' THEN 5 WHEN 'high' THEN 4 WHEN 'medium' THEN 3 WHEN 'low' THEN 2 WHEN 'info' THEN 1 ELSE 0 END)
|
SELECT MAX(CASE LOWER(COALESCE(v.severity,'')) WHEN 'critical' THEN 5 WHEN 'high' THEN 4 WHEN 'medium' THEN 3 WHEN 'low' THEN 2 WHEN 'info' THEN 1 ELSE 0 END)
|
||||||
FROM vulnerabilities v
|
FROM vulnerabilities v
|
||||||
WHERE LOWER(COALESCE(v.status,'open')) NOT IN ('fixed','false_positive','ignored') AND ` + assetVulnerabilityMatchExpr + `
|
WHERE LOWER(COALESCE(v.status,'open')) NOT IN ('fixed','false_positive','ignored') AND ` + assetVulnerabilityMatchExpr + `
|
||||||
),0)`
|
),0)`
|
||||||
|
|
||||||
const assetRiskLevelExpr = `(CASE WHEN ` + assetEffectiveLastScanExpr + ` IS NULL THEN 'unassessed' ELSE CASE ` + assetRiskScoreExpr + `
|
const assetRiskLevelQueryExpr = `(CASE WHEN ` + assetEffectiveLastScanExpr + ` IS NULL THEN 'unassessed' ELSE CASE ` + assetRiskScoreQueryExpr + `
|
||||||
WHEN 5 THEN 'critical' WHEN 4 THEN 'high' WHEN 3 THEN 'medium' WHEN 2 THEN 'low' WHEN 1 THEN 'info' ELSE 'normal' END END)`
|
WHEN 5 THEN 'critical' WHEN 4 THEN 'high' WHEN 3 THEN 'medium' WHEN 2 THEN 'low' WHEN 1 THEN 'info' ELSE 'normal' END END)`
|
||||||
|
|
||||||
|
const assetVulnerabilityCountCachedExpr = `COALESCE(assets.vulnerability_count,0)`
|
||||||
|
const assetRiskScoreCachedExpr = `COALESCE(assets.risk_score,0)`
|
||||||
|
const assetRiskLevelCachedExpr = `COALESCE(NULLIF(assets.risk_level,''),'unassessed')`
|
||||||
|
|
||||||
const assetSelectColumns = `assets.id,COALESCE(assets.project_id,''),COALESCE(p.name,''),assets.host,assets.ip,assets.port,assets.domain,assets.protocol,assets.title,assets.server,assets.country,
|
const assetSelectColumns = `assets.id,COALESCE(assets.project_id,''),COALESCE(p.name,''),assets.host,assets.ip,assets.port,assets.domain,assets.protocol,assets.title,assets.server,assets.country,
|
||||||
assets.province,assets.city,assets.responsible_person,assets.department,assets.business_system,assets.environment,assets.criticality,
|
assets.province,assets.city,assets.responsible_person,assets.department,assets.business_system,assets.environment,assets.criticality,
|
||||||
assets.source,assets.source_query,assets.status,assets.tags_json,assets.first_seen_at,assets.last_seen_at,assets.created_at,assets.updated_at,
|
assets.source,assets.source_query,assets.status,assets.tags_json,assets.first_seen_at,assets.last_seen_at,assets.created_at,assets.updated_at,
|
||||||
` + assetEffectiveLastScanExpr + `,COALESCE(assets.last_scan_conversation_id,''),COALESCE(assets.last_scan_queue_id,''),COALESCE(assets.last_scan_task_id,''),
|
` + assetEffectiveLastScanExpr + `,COALESCE(assets.last_scan_conversation_id,''),COALESCE(assets.last_scan_queue_id,''),COALESCE(assets.last_scan_task_id,''),
|
||||||
` + assetVulnerabilityCountExpr + `,` + assetRiskLevelExpr
|
` + assetVulnerabilityCountCachedExpr + `,` + assetRiskLevelCachedExpr
|
||||||
|
|
||||||
// MarkAssetScanned links an asset to the conversation or batch subtask created from it.
|
// MarkAssetScanned links an asset to the conversation or batch subtask created from it.
|
||||||
// The link lets the asset list show the latest scan time and vulnerabilities produced by that scan.
|
// The link lets the asset list show the latest scan time and vulnerabilities produced by that scan.
|
||||||
@@ -601,6 +607,9 @@ func (db *DB) MarkAssetScanned(id, conversationID, queueID, taskID string, acces
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
return sql.ErrNoRows
|
return sql.ErrNoRows
|
||||||
}
|
}
|
||||||
|
if err := db.RefreshAssetRiskCache(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,6 +638,9 @@ func (db *DB) CompleteAssetScan(id, conversationID string, access RBACListAccess
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
return sql.ErrNoRows
|
return sql.ErrNoRows
|
||||||
}
|
}
|
||||||
|
if err := db.RefreshAssetRiskCache(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,6 +650,136 @@ func (db *DB) BatchTaskBelongsToQueue(taskID, queueID string) bool {
|
|||||||
return err == nil && count > 0
|
return err == nil && count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assetRiskLevelFromScore(score int, scanned bool) string {
|
||||||
|
if !scanned {
|
||||||
|
return "unassessed"
|
||||||
|
}
|
||||||
|
switch score {
|
||||||
|
case 5:
|
||||||
|
return "critical"
|
||||||
|
case 4:
|
||||||
|
return "high"
|
||||||
|
case 3:
|
||||||
|
return "medium"
|
||||||
|
case 2:
|
||||||
|
return "low"
|
||||||
|
case 1:
|
||||||
|
return "info"
|
||||||
|
default:
|
||||||
|
return "normal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshAssetRiskCache recalculates the denormalized fields used by the asset
|
||||||
|
// list. Keeping this in the database layer makes Web API and MCP writes share
|
||||||
|
// one consistency path.
|
||||||
|
func (db *DB) RefreshAssetRiskCache(assetID string) error {
|
||||||
|
assetID = strings.TrimSpace(assetID)
|
||||||
|
if assetID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRow("SELECT "+assetVulnerabilityCountExpr+" FROM assets WHERE assets.id=?", assetID).Scan(&count); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("刷新资产漏洞数量失败: %w", err)
|
||||||
|
}
|
||||||
|
var score int
|
||||||
|
if err := db.QueryRow("SELECT "+assetRiskScoreQueryExpr+" FROM assets WHERE assets.id=?", assetID).Scan(&score); err != nil {
|
||||||
|
return fmt.Errorf("刷新资产风险分数失败: %w", err)
|
||||||
|
}
|
||||||
|
var lastScan interface{}
|
||||||
|
if err := db.QueryRow("SELECT "+assetEffectiveLastScanExpr+" FROM assets WHERE assets.id=?", assetID).Scan(&lastScan); err != nil {
|
||||||
|
return fmt.Errorf("刷新资产扫描状态失败: %w", err)
|
||||||
|
}
|
||||||
|
level := assetRiskLevelFromScore(score, lastScan != nil)
|
||||||
|
if _, err := db.Exec(`UPDATE assets SET vulnerability_count=?, risk_score=?, risk_level=? WHERE id=?`, count, score, level, assetID); err != nil {
|
||||||
|
return fmt.Errorf("更新资产风险缓存失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) RefreshAllAssetRiskCache() error {
|
||||||
|
rows, err := db.Query(`SELECT id FROM assets`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("查询资产列表失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.RefreshAssetRiskCache(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) AssetIDsForVulnerabilityConversations(conversationIDs []string) ([]string, error) {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
cleaned := make([]string, 0, len(conversationIDs))
|
||||||
|
for _, id := range conversationIDs {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
cleaned = append(cleaned, id)
|
||||||
|
}
|
||||||
|
if len(cleaned) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
placeholders := strings.TrimRight(strings.Repeat("?,", len(cleaned)), ",")
|
||||||
|
args := make([]interface{}, 0, len(cleaned)*2)
|
||||||
|
for _, id := range cleaned {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
for _, id := range cleaned {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
rows, err := db.Query(`SELECT DISTINCT assets.id FROM assets
|
||||||
|
WHERE assets.last_scan_conversation_id IN (`+placeholders+`)
|
||||||
|
OR assets.last_scan_task_id IN (SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id IN (`+placeholders+`))`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询受影响资产失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
assetIDs := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
assetIDs = append(assetIDs, id)
|
||||||
|
}
|
||||||
|
return assetIDs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) RefreshAssetRiskCacheForConversations(conversationIDs ...string) error {
|
||||||
|
assetIDs, err := db.AssetIDsForVulnerabilityConversations(conversationIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range assetIDs {
|
||||||
|
if err := db.RefreshAssetRiskCache(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) refreshAssetRiskCacheForConversationsBestEffort(conversationIDs ...string) {
|
||||||
|
if err := db.RefreshAssetRiskCacheForConversations(conversationIDs...); err != nil && db.logger != nil {
|
||||||
|
db.logger.Warn("刷新资产风险缓存失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) ListAssets(limit, offset int, filter AssetListFilter, access RBACListAccess) ([]*Asset, int, error) {
|
func (db *DB) ListAssets(limit, offset int, filter AssetListFilter, access RBACListAccess) ([]*Asset, int, error) {
|
||||||
if limit < 1 {
|
if limit < 1 {
|
||||||
limit = 20
|
limit = 20
|
||||||
@@ -726,9 +868,9 @@ func assetOrderBy(sortBy, sortOrder string) string {
|
|||||||
case "port":
|
case "port":
|
||||||
expression = "assets.port"
|
expression = "assets.port"
|
||||||
case "vulnerability_count":
|
case "vulnerability_count":
|
||||||
expression = assetVulnerabilityCountExpr
|
expression = assetVulnerabilityCountCachedExpr
|
||||||
case "risk_level":
|
case "risk_level":
|
||||||
expression = assetRiskScoreExpr
|
expression = assetRiskScoreCachedExpr
|
||||||
default:
|
default:
|
||||||
expression = "assets.last_seen_at"
|
expression = "assets.last_seen_at"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,7 +383,12 @@ func TestAssetScanLinkReturnsTimeAndRelatedVulnerabilities(t *testing.T) {
|
|||||||
if linked.LastScanAt == nil || linked.LastScanConversationID != conv.ID || linked.VulnerabilityCount != 1 || linked.RiskLevel != "high" {
|
if linked.LastScanAt == nil || linked.LastScanConversationID != conv.ID || linked.VulnerabilityCount != 1 || linked.RiskLevel != "high" {
|
||||||
t.Fatalf("unexpected scan metadata: %#v", linked)
|
t.Fatalf("unexpected scan metadata: %#v", linked)
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(`UPDATE vulnerabilities SET status='fixed' WHERE conversation_id=?`, conv.ID); err != nil {
|
vulns, err := db.ListVulnerabilities(10, 0, VulnerabilityListFilter{ConversationID: conv.ID})
|
||||||
|
if err != nil || len(vulns) != 1 {
|
||||||
|
t.Fatalf("list linked vulnerabilities: len=%d err=%v", len(vulns), err)
|
||||||
|
}
|
||||||
|
vulns[0].Status = "fixed"
|
||||||
|
if err := db.UpdateVulnerability(vulns[0].ID, vulns[0]); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
resolved, err := db.GetAsset(assets[0].ID, RBACListAccess{Scope: RBACScopeAll})
|
resolved, err := db.GetAsset(assets[0].ID, RBACListAccess{Scope: RBACScopeAll})
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Conversation struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
ProjectID string `json:"projectId,omitempty"`
|
ProjectID string `json:"projectId,omitempty"`
|
||||||
RoleName string `json:"roleName,omitempty"`
|
RoleName string `json:"roleName,omitempty"`
|
||||||
|
AgentMode string `json:"agentMode,omitempty"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
@@ -59,29 +60,30 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
roleName := normalizeConversationRoleName(meta.RoleName)
|
roleName := normalizeConversationRoleName(meta.RoleName)
|
||||||
|
agentMode := normalizeConversationAgentMode(meta.AgentMode)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
wsID := strings.TrimSpace(webshellConnectionID)
|
wsID := strings.TrimSpace(webshellConnectionID)
|
||||||
switch {
|
switch {
|
||||||
case wsID != "" && projectID != "":
|
case wsID != "" && projectID != "":
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id, role_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
id, title, now, now, wsID, projectID, roleName,
|
id, title, now, now, wsID, projectID, roleName, agentMode,
|
||||||
)
|
)
|
||||||
case wsID != "":
|
case wsID != "":
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, role_name) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
id, title, now, now, wsID, roleName,
|
id, title, now, now, wsID, roleName, agentMode,
|
||||||
)
|
)
|
||||||
case projectID != "":
|
case projectID != "":
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at, project_id, role_name) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, project_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
id, title, now, now, projectID, roleName,
|
id, title, now, now, projectID, roleName, agentMode,
|
||||||
)
|
)
|
||||||
default:
|
default:
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at, role_name) VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
id, title, now, now, roleName,
|
id, title, now, now, roleName, agentMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,6 +95,7 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
|
|||||||
Title: title,
|
Title: title,
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
RoleName: roleName,
|
RoleName: roleName,
|
||||||
|
AgentMode: agentMode,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
@@ -240,10 +243,11 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
|||||||
|
|
||||||
var projectID sql.NullString
|
var projectID sql.NullString
|
||||||
var roleName sql.NullString
|
var roleName sql.NullString
|
||||||
|
var agentMode sql.NullString
|
||||||
err := db.QueryRow(
|
err := db.QueryRow(
|
||||||
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name FROM conversations WHERE id = ?",
|
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name, agent_mode FROM conversations WHERE id = ?",
|
||||||
id,
|
id,
|
||||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName)
|
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("对话不存在")
|
return nil, fmt.Errorf("对话不存在")
|
||||||
@@ -256,6 +260,9 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
|||||||
if roleName.Valid {
|
if roleName.Valid {
|
||||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||||
}
|
}
|
||||||
|
if agentMode.Valid {
|
||||||
|
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试多种时间格式解析
|
// 尝试多种时间格式解析
|
||||||
var err1, err2 error
|
var err1, err2 error
|
||||||
@@ -330,10 +337,11 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
|||||||
|
|
||||||
var projectID sql.NullString
|
var projectID sql.NullString
|
||||||
var roleName sql.NullString
|
var roleName sql.NullString
|
||||||
|
var agentMode sql.NullString
|
||||||
err := db.QueryRow(
|
err := db.QueryRow(
|
||||||
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name FROM conversations WHERE id = ?",
|
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name, agent_mode FROM conversations WHERE id = ?",
|
||||||
id,
|
id,
|
||||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName)
|
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("对话不存在")
|
return nil, fmt.Errorf("对话不存在")
|
||||||
@@ -346,6 +354,9 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
|||||||
if roleName.Valid {
|
if roleName.Valid {
|
||||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||||
}
|
}
|
||||||
|
if agentMode.Valid {
|
||||||
|
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试多种时间格式解析
|
// 尝试多种时间格式解析
|
||||||
var err1, err2 error
|
var err1, err2 error
|
||||||
@@ -384,6 +395,17 @@ func normalizeConversationRoleName(roleName string) string {
|
|||||||
return roleName
|
return roleName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeConversationAgentMode(agentMode string) string {
|
||||||
|
agentMode = strings.ToLower(strings.TrimSpace(agentMode))
|
||||||
|
agentMode = strings.ReplaceAll(agentMode, "-", "_")
|
||||||
|
switch agentMode {
|
||||||
|
case "deep", "plan_execute", "supervisor":
|
||||||
|
return agentMode
|
||||||
|
default:
|
||||||
|
return "eino_single"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) SetConversationRoleName(id, roleName string) error {
|
func (db *DB) SetConversationRoleName(id, roleName string) error {
|
||||||
roleName = normalizeConversationRoleName(roleName)
|
roleName = normalizeConversationRoleName(roleName)
|
||||||
_, err := db.Exec(
|
_, err := db.Exec(
|
||||||
@@ -396,6 +418,18 @@ func (db *DB) SetConversationRoleName(id, roleName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) SetConversationAgentMode(id, agentMode string) error {
|
||||||
|
agentMode = normalizeConversationAgentMode(agentMode)
|
||||||
|
_, err := db.Exec(
|
||||||
|
"UPDATE conversations SET agent_mode = ? WHERE id = ?",
|
||||||
|
agentMode, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("更新对话模式失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func conversationProjectIDColumn(alias string) string {
|
func conversationProjectIDColumn(alias string) string {
|
||||||
if alias != "" {
|
if alias != "" {
|
||||||
return alias + ".project_id"
|
return alias + ".project_id"
|
||||||
@@ -520,7 +554,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
|
|||||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode
|
||||||
FROM conversations c`+where+`
|
FROM conversations c`+where+`
|
||||||
`+orderClause+`
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
@@ -536,7 +570,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
|
|||||||
}
|
}
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name, agent_mode FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
||||||
args...,
|
args...,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -564,7 +598,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
|
|||||||
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode
|
||||||
FROM conversations c`+where+`
|
FROM conversations c`+where+`
|
||||||
`+orderClause+`
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`, args...)
|
LIMIT ? OFFSET ?`, args...)
|
||||||
@@ -579,7 +613,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
|
|||||||
}
|
}
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name, agent_mode FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
||||||
args...)
|
args...)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -597,7 +631,8 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
|
|||||||
var pinned int
|
var pinned int
|
||||||
var projectID sql.NullString
|
var projectID sql.NullString
|
||||||
var roleName sql.NullString
|
var roleName sql.NullString
|
||||||
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName); err != nil {
|
var agentMode sql.NullString
|
||||||
|
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode); err != nil {
|
||||||
return nil, fmt.Errorf("扫描对话失败: %w", err)
|
return nil, fmt.Errorf("扫描对话失败: %w", err)
|
||||||
}
|
}
|
||||||
if projectID.Valid {
|
if projectID.Valid {
|
||||||
@@ -606,6 +641,9 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
|
|||||||
if roleName.Valid {
|
if roleName.Valid {
|
||||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||||
}
|
}
|
||||||
|
if agentMode.Valid {
|
||||||
|
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||||
|
}
|
||||||
var err1, err2 error
|
var err1, err2 error
|
||||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
||||||
if err1 != nil {
|
if err1 != nil {
|
||||||
@@ -665,7 +703,7 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID st
|
|||||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name `+
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+
|
||||||
where+`
|
where+`
|
||||||
`+orderClause+`
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
@@ -689,7 +727,7 @@ func (db *DB) ListUngroupedConversationsForAccess(limit, offset int, sortBy, pro
|
|||||||
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name `+
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+
|
||||||
where+`
|
where+`
|
||||||
`+orderClause+`
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
@@ -1386,6 +1424,7 @@ type ProcessDetailsSummary struct {
|
|||||||
|
|
||||||
type ProcessDetailsToolExecution struct {
|
type ProcessDetailsToolExecution struct {
|
||||||
ProcessDetailID string `json:"processDetailId,omitempty"`
|
ProcessDetailID string `json:"processDetailId,omitempty"`
|
||||||
|
ResultDetailID string `json:"resultDetailId,omitempty"`
|
||||||
ToolName string `json:"toolName,omitempty"`
|
ToolName string `json:"toolName,omitempty"`
|
||||||
ToolCallID string `json:"toolCallId,omitempty"`
|
ToolCallID string `json:"toolCallId,omitempty"`
|
||||||
ExecutionID string `json:"executionId,omitempty"`
|
ExecutionID string `json:"executionId,omitempty"`
|
||||||
@@ -1424,7 +1463,8 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
|||||||
seenExecIDs := make(map[string]bool)
|
seenExecIDs := make(map[string]bool)
|
||||||
// A provider may reuse a fallback toolCallId across streaming rounds. Keep a
|
// A provider may reuse a fallback toolCallId across streaming rounds. Keep a
|
||||||
// FIFO per ID instead of a single index so every persisted call gets at most
|
// FIFO per ID instead of a single index so every persisted call gets at most
|
||||||
// one result. Results without an ID fall back to the oldest unmatched call.
|
// one result. Results without a stable ID are kept separate instead of being
|
||||||
|
// guessed by order; showing no link is safer than linking to the wrong tool.
|
||||||
toolIndexesByCallID := make(map[string][]int)
|
toolIndexesByCallID := make(map[string][]int)
|
||||||
lastMatchedToolIndexByCallID := make(map[string]int)
|
lastMatchedToolIndexByCallID := make(map[string]int)
|
||||||
matchedToolIndexes := make([]bool, 0)
|
matchedToolIndexes := make([]bool, 0)
|
||||||
@@ -1499,7 +1539,7 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if idx < 0 {
|
if idx < 0 && toolCallID != "" {
|
||||||
for nextUnmatchedToolIdx < len(matchedToolIndexes) && matchedToolIndexes[nextUnmatchedToolIdx] {
|
for nextUnmatchedToolIdx < len(matchedToolIndexes) && matchedToolIndexes[nextUnmatchedToolIdx] {
|
||||||
nextUnmatchedToolIdx++
|
nextUnmatchedToolIdx++
|
||||||
}
|
}
|
||||||
@@ -1513,6 +1553,7 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
|||||||
if toolCallID != "" {
|
if toolCallID != "" {
|
||||||
lastMatchedToolIndexByCallID[toolCallID] = idx
|
lastMatchedToolIndexByCallID[toolCallID] = idx
|
||||||
}
|
}
|
||||||
|
summary.ToolExecutions[idx].ResultDetailID = strings.TrimSpace(detailID)
|
||||||
if summary.ToolExecutions[idx].ToolName == "" {
|
if summary.ToolExecutions[idx].ToolName == "" {
|
||||||
summary.ToolExecutions[idx].ToolName = toolName
|
summary.ToolExecutions[idx].ToolName = toolName
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type ConversationCreateMeta struct {
|
|||||||
WebShellConnectionID string
|
WebShellConnectionID string
|
||||||
ProjectID string
|
ProjectID string
|
||||||
RoleName string
|
RoleName string
|
||||||
|
AgentMode string
|
||||||
ClientIP string
|
ClientIP string
|
||||||
SessionHint string
|
SessionHint string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ func (db *DB) initTables() error {
|
|||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
updated_at DATETIME NOT NULL,
|
updated_at DATETIME NOT NULL,
|
||||||
role_name TEXT NOT NULL DEFAULT '默认',
|
role_name TEXT NOT NULL DEFAULT '默认',
|
||||||
|
agent_mode TEXT NOT NULL DEFAULT 'eino_single',
|
||||||
last_react_input TEXT,
|
last_react_input TEXT,
|
||||||
last_react_output TEXT
|
last_react_output TEXT
|
||||||
);`
|
);`
|
||||||
@@ -420,6 +421,7 @@ func (db *DB) initTables() error {
|
|||||||
responsible_person TEXT NOT NULL DEFAULT '', department TEXT NOT NULL DEFAULT '', business_system TEXT NOT NULL DEFAULT '',
|
responsible_person TEXT NOT NULL DEFAULT '', department TEXT NOT NULL DEFAULT '', business_system TEXT NOT NULL DEFAULT '',
|
||||||
environment TEXT NOT NULL DEFAULT '', criticality TEXT NOT NULL DEFAULT '',
|
environment TEXT NOT NULL DEFAULT '', criticality TEXT NOT NULL DEFAULT '',
|
||||||
source TEXT NOT NULL DEFAULT 'manual', source_query TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'active',
|
source TEXT NOT NULL DEFAULT 'manual', source_query TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
vulnerability_count INTEGER NOT NULL DEFAULT 0, risk_score INTEGER NOT NULL DEFAULT 0, risk_level TEXT NOT NULL DEFAULT 'unassessed',
|
||||||
tags_json TEXT NOT NULL DEFAULT '[]', first_seen_at DATETIME NOT NULL, last_seen_at DATETIME NOT NULL,
|
tags_json TEXT NOT NULL DEFAULT '[]', first_seen_at DATETIME NOT NULL, last_seen_at DATETIME NOT NULL,
|
||||||
created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, owner_user_id TEXT,
|
created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, owner_user_id TEXT,
|
||||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
|
||||||
@@ -744,6 +746,9 @@ func (db *DB) initTables() error {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
|
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_assets_owner ON assets(owner_user_id);
|
CREATE INDEX IF NOT EXISTS idx_assets_owner ON assets(owner_user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_assets_project ON assets(project_id);
|
CREATE INDEX IF NOT EXISTS idx_assets_project ON assets(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_vulnerability_count ON assets(vulnerability_count);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_risk_score ON assets(risk_score);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_risk_level ON assets(risk_level);
|
||||||
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
|
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_projects_updated_at ON projects(updated_at);
|
CREATE INDEX IF NOT EXISTS idx_projects_updated_at ON projects(updated_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
|
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
|
||||||
@@ -976,7 +981,6 @@ func (db *DB) initTables() error {
|
|||||||
if _, err := db.Exec(createIndexes); err != nil {
|
if _, err := db.Exec(createIndexes); err != nil {
|
||||||
return fmt.Errorf("创建索引失败: %w", err)
|
return fmt.Errorf("创建索引失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
db.logger.Debug("数据库表初始化完成")
|
db.logger.Debug("数据库表初始化完成")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1026,6 +1030,9 @@ func (db *DB) migrateAssetsTable() error {
|
|||||||
{"business_system", "ALTER TABLE assets ADD COLUMN business_system TEXT NOT NULL DEFAULT ''"},
|
{"business_system", "ALTER TABLE assets ADD COLUMN business_system TEXT NOT NULL DEFAULT ''"},
|
||||||
{"environment", "ALTER TABLE assets ADD COLUMN environment TEXT NOT NULL DEFAULT ''"},
|
{"environment", "ALTER TABLE assets ADD COLUMN environment TEXT NOT NULL DEFAULT ''"},
|
||||||
{"criticality", "ALTER TABLE assets ADD COLUMN criticality TEXT NOT NULL DEFAULT ''"},
|
{"criticality", "ALTER TABLE assets ADD COLUMN criticality TEXT NOT NULL DEFAULT ''"},
|
||||||
|
{"vulnerability_count", "ALTER TABLE assets ADD COLUMN vulnerability_count INTEGER NOT NULL DEFAULT 0"},
|
||||||
|
{"risk_score", "ALTER TABLE assets ADD COLUMN risk_score INTEGER NOT NULL DEFAULT 0"},
|
||||||
|
{"risk_level", "ALTER TABLE assets ADD COLUMN risk_level TEXT NOT NULL DEFAULT 'unassessed'"},
|
||||||
}
|
}
|
||||||
for _, column := range columns {
|
for _, column := range columns {
|
||||||
var count int
|
var count int
|
||||||
@@ -1174,6 +1181,21 @@ func (db *DB) migrateConversationsTable() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查 agent_mode 字段是否存在(对话绑定的执行模式,用于历史任务切换时恢复对话模式)
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversations') WHERE name='agent_mode'").Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
if _, addErr := db.Exec("ALTER TABLE conversations ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'"); addErr != nil {
|
||||||
|
errMsg := strings.ToLower(addErr.Error())
|
||||||
|
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||||
|
db.logger.Warn("添加agent_mode字段失败", zap.Error(addErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if count == 0 {
|
||||||
|
if _, err := db.Exec("ALTER TABLE conversations ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'"); err != nil {
|
||||||
|
db.logger.Warn("添加agent_mode字段失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProcessDetailsSummaryPairsMixedIdentifiedAndIDLessResults(t *testing.T) {
|
func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
|
||||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||||
for _, id := range []string{"call-1", "call-2", "call-3", "call-4"} {
|
for _, id := range []string{"call-1", "call-2", "call-3", "call-4"} {
|
||||||
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
|
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
|
||||||
@@ -22,23 +22,39 @@ func TestProcessDetailsSummaryPairsMixedIdentifiedAndIDLessResults(t *testing.T)
|
|||||||
{"toolName": "http-framework-test", "success": true},
|
{"toolName": "http-framework-test", "success": true},
|
||||||
{"toolName": "http-framework-test", "success": true},
|
{"toolName": "http-framework-test", "success": true},
|
||||||
}
|
}
|
||||||
|
var resultIDs []string
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
if err := db.AddProcessDetail(messageID, conversationID, "tool_result", "result", result); err != nil {
|
resultID, err := db.AddProcessDetailWithID(messageID, conversationID, "tool_result", "result", result)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("AddProcessDetail(tool_result): %v", err)
|
t.Fatalf("AddProcessDetail(tool_result): %v", err)
|
||||||
}
|
}
|
||||||
|
resultIDs = append(resultIDs, resultID)
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||||
}
|
}
|
||||||
if len(summary.ToolExecutions) != 4 {
|
if len(summary.ToolExecutions) != 6 {
|
||||||
t.Fatalf("tool executions = %d, want 4", len(summary.ToolExecutions))
|
t.Fatalf("tool executions = %d, want 6", len(summary.ToolExecutions))
|
||||||
}
|
}
|
||||||
for i, execution := range summary.ToolExecutions {
|
for i, execution := range summary.ToolExecutions[:2] {
|
||||||
if execution.Status != "completed" {
|
if execution.Status != "completed" {
|
||||||
t.Fatalf("execution %d status = %q, want completed", i, execution.Status)
|
t.Fatalf("execution %d status = %q, want completed", i, execution.Status)
|
||||||
}
|
}
|
||||||
|
if execution.ResultDetailID != resultIDs[i] {
|
||||||
|
t.Fatalf("execution %d result detail id = %q, want %q", i, execution.ResultDetailID, resultIDs[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, execution := range summary.ToolExecutions[2:4] {
|
||||||
|
if execution.Status != "result_missing" {
|
||||||
|
t.Fatalf("unmatched call %d status = %q, want result_missing", i, execution.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, execution := range summary.ToolExecutions[4:] {
|
||||||
|
if execution.Status != "completed" || execution.ToolCallID != "" {
|
||||||
|
t.Fatalf("idless result %d = %#v, want separate completed result without toolCallId", i, execution)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
var factKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*$`)
|
var factKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`)
|
||||||
|
|
||||||
// ValidateFactKey 校验事实 key(项目内唯一标识)。
|
// ValidateFactKey 校验事实 key(项目内唯一标识)。
|
||||||
func ValidateFactKey(key string) error {
|
func ValidateFactKey(key string) error {
|
||||||
@@ -22,7 +22,7 @@ func ValidateFactKey(key string) error {
|
|||||||
return fmt.Errorf("fact_key 过长(最多 128 字符)")
|
return fmt.Errorf("fact_key 过长(最多 128 字符)")
|
||||||
}
|
}
|
||||||
if !factKeyPattern.MatchString(key) {
|
if !factKeyPattern.MatchString(key) {
|
||||||
return fmt.Errorf("fact_key 格式无效,仅允许小写字母、数字及 . _ / -,且须以小写字母或数字开头")
|
return fmt.Errorf("fact_key 格式无效,仅允许字母、数字及 . _ / -,且须以字母或数字开头(支持驼峰命名)")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("创建漏洞失败: %w", err)
|
return nil, fmt.Errorf("创建漏洞失败: %w", err)
|
||||||
}
|
}
|
||||||
|
db.refreshAssetRiskCacheForConversationsBestEffort(vuln.ConversationID)
|
||||||
return vuln, nil
|
return vuln, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +300,8 @@ func (db *DB) CountVulnerabilitiesForAccess(filter VulnerabilityListFilter, acce
|
|||||||
// UpdateVulnerability 更新漏洞
|
// UpdateVulnerability 更新漏洞
|
||||||
func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
|
func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
|
||||||
vuln.UpdatedAt = time.Now()
|
vuln.UpdatedAt = time.Now()
|
||||||
|
var oldConversationID string
|
||||||
|
_ = db.QueryRow(`SELECT COALESCE(conversation_id,'') FROM vulnerabilities WHERE id = ?`, id).Scan(&oldConversationID)
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
UPDATE vulnerabilities
|
UPDATE vulnerabilities
|
||||||
@@ -318,6 +321,7 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
|
|||||||
return fmt.Errorf("更新漏洞失败: %w", err)
|
return fmt.Errorf("更新漏洞失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
db.refreshAssetRiskCacheForConversationsBestEffort(oldConversationID, vuln.ConversationID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +341,10 @@ func (db *DB) DeleteVulnerabilitiesByFilterForAccess(filter VulnerabilityListFil
|
|||||||
args := []interface{}{}
|
args := []interface{}{}
|
||||||
where, args = filter.appendWhere(where, args)
|
where, args = filter.appendWhere(where, args)
|
||||||
where, args = appendVulnerabilityAccessFilter(where, args, access)
|
where, args = appendVulnerabilityAccessFilter(where, args, access)
|
||||||
|
affectedConversations, err := collectVulnerabilityConversationIDs(tx, where, args)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
clearQuery := `UPDATE project_facts SET related_vulnerability_id = NULL
|
clearQuery := `UPDATE project_facts SET related_vulnerability_id = NULL
|
||||||
WHERE related_vulnerability_id IN (SELECT id FROM vulnerabilities ` + where + `)`
|
WHERE related_vulnerability_id IN (SELECT id FROM vulnerabilities ` + where + `)`
|
||||||
@@ -356,6 +364,7 @@ func (db *DB) DeleteVulnerabilitiesByFilterForAccess(filter VulnerabilityListFil
|
|||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return 0, fmt.Errorf("提交事务失败: %w", err)
|
return 0, fmt.Errorf("提交事务失败: %w", err)
|
||||||
}
|
}
|
||||||
|
db.refreshAssetRiskCacheForConversationsBestEffort(affectedConversations...)
|
||||||
return deleted, nil
|
return deleted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +375,8 @@ func (db *DB) DeleteVulnerability(id string) error {
|
|||||||
return fmt.Errorf("开启事务失败: %w", err)
|
return fmt.Errorf("开启事务失败: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
var conversationID string
|
||||||
|
_ = tx.QueryRow(`SELECT COALESCE(conversation_id,'') FROM vulnerabilities WHERE id = ?`, id).Scan(&conversationID)
|
||||||
|
|
||||||
// 删除漏洞前先解除项目事实中的关联,避免前端继续显示已删除漏洞的短 ID。
|
// 删除漏洞前先解除项目事实中的关联,避免前端继续显示已删除漏洞的短 ID。
|
||||||
if _, err := tx.Exec("UPDATE project_facts SET related_vulnerability_id = NULL WHERE related_vulnerability_id = ?", id); err != nil {
|
if _, err := tx.Exec("UPDATE project_facts SET related_vulnerability_id = NULL WHERE related_vulnerability_id = ?", id); err != nil {
|
||||||
@@ -377,9 +388,29 @@ func (db *DB) DeleteVulnerability(id string) error {
|
|||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return fmt.Errorf("提交事务失败: %w", err)
|
return fmt.Errorf("提交事务失败: %w", err)
|
||||||
}
|
}
|
||||||
|
db.refreshAssetRiskCacheForConversationsBestEffort(conversationID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func collectVulnerabilityConversationIDs(tx *sql.Tx, where string, args []interface{}) ([]string, error) {
|
||||||
|
rows, err := tx.Query(`SELECT DISTINCT COALESCE(conversation_id,'') FROM vulnerabilities `+where, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询受影响漏洞会话失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
ids := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(id) != "" {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// GetVulnerabilityStats 获取漏洞统计(筛选条件与 ListVulnerabilities / CountVulnerabilities 一致)
|
// GetVulnerabilityStats 获取漏洞统计(筛选条件与 ListVulnerabilities / CountVulnerabilities 一致)
|
||||||
func (db *DB) GetVulnerabilityStats(filter VulnerabilityListFilter) (map[string]interface{}, error) {
|
func (db *DB) GetVulnerabilityStats(filter VulnerabilityListFilter) (map[string]interface{}, error) {
|
||||||
return db.GetVulnerabilityStatsForAccess(filter, RBACListAccess{})
|
return db.GetVulnerabilityStatsForAccess(filter, RBACListAccess{})
|
||||||
|
|||||||
+39
-20
@@ -333,17 +333,24 @@ type ChatReasoningRequest struct {
|
|||||||
Effort string `json:"effort,omitempty"`
|
Effort string `json:"effort,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChatFinalizationRequest is a caller-provided delivery policy. The server does
|
||||||
|
// not infer execution intent from natural-language user text.
|
||||||
|
type ChatFinalizationRequest struct {
|
||||||
|
RequireExecutionEvidence *bool `json:"requireExecutionEvidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ChatRequest 聊天请求
|
// ChatRequest 聊天请求
|
||||||
type ChatRequest struct {
|
type ChatRequest struct {
|
||||||
Message string `json:"message" binding:"required"`
|
Message string `json:"message" binding:"required"`
|
||||||
ConversationID string `json:"conversationId,omitempty"`
|
ConversationID string `json:"conversationId,omitempty"`
|
||||||
ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id)
|
ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id)
|
||||||
Role string `json:"role,omitempty"` // 角色名称
|
Role string `json:"role,omitempty"` // 角色名称
|
||||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||||
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
||||||
AIChannelID string `json:"aiChannelId,omitempty"` // 会话级 AI 通道;空则使用 ai.default_channel
|
AIChannelID string `json:"aiChannelId,omitempty"` // 会话级 AI 通道;空则使用 ai.default_channel
|
||||||
Hitl *HITLRequest `json:"hitl,omitempty"`
|
Hitl *HITLRequest `json:"hitl,omitempty"`
|
||||||
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
|
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
|
||||||
|
Finalization ChatFinalizationRequest `json:"finalization,omitempty"`
|
||||||
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
|
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
|
||||||
Orchestration string `json:"orchestration,omitempty"`
|
Orchestration string `json:"orchestration,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -668,10 +675,18 @@ func (h *AgentHandler) mergeAssistantMessagePartialOnCancel(messageID, partial s
|
|||||||
|
|
||||||
// ChatResponse 聊天响应
|
// ChatResponse 聊天响应
|
||||||
type ChatResponse struct {
|
type ChatResponse struct {
|
||||||
Response string `json:"response"`
|
Response string `json:"response"`
|
||||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
||||||
ConversationID string `json:"conversationId"` // 对话ID
|
ConversationID string `json:"conversationId"` // 对话ID
|
||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
|
Finalizable bool `json:"finalizable"`
|
||||||
|
Finalized bool `json:"finalized"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
CompletionReason string `json:"completionReason,omitempty"`
|
||||||
|
EvidenceVerified bool `json:"evidenceVerified"`
|
||||||
|
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
|
||||||
|
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
|
||||||
|
MissingChecks []string `json:"missingChecks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
|
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
|
||||||
@@ -687,19 +702,20 @@ func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMes
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
||||||
if assistantMessageID != "" {
|
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput), true)
|
||||||
if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resultMA.Response, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)); errU != nil {
|
responseText := decision.FinalText
|
||||||
h.logger.Warn("机器人:更新助手消息失败", zap.Error(errU))
|
if !decision.Finalizable {
|
||||||
}
|
responseText = finalizationBlockedMessage(decision)
|
||||||
} else {
|
}
|
||||||
if _, err := h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil {
|
if assistantMessageID == "" {
|
||||||
|
if _, err := h.db.AddMessage(conversationID, "assistant", responseText, resultMA.MCPExecutionIDs); err != nil {
|
||||||
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
|
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "" {
|
if resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "" {
|
||||||
_ = h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput)
|
_ = h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput)
|
||||||
}
|
}
|
||||||
return resultMA.Response, conversationID, nil
|
return responseText, conversationID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) runRobotEinoSingleWithRetry(
|
func (h *AgentHandler) runRobotEinoSingleWithRetry(
|
||||||
@@ -830,6 +846,9 @@ 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 := config.NormalizeAgentMode(agentMode)
|
robotMode := config.NormalizeAgentMode(agentMode)
|
||||||
|
if err := h.db.SetConversationAgentMode(conversationID, robotMode); err != nil {
|
||||||
|
h.logger.Warn("机器人:更新对话模式失败", zap.String("conversationId", conversationID), zap.String("agentMode", robotMode), zap.Error(err))
|
||||||
|
}
|
||||||
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)
|
||||||
|
|||||||
@@ -238,6 +238,11 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
|||||||
useBatchMulti = true
|
useBatchMulti = true
|
||||||
batchOrch = "deep"
|
batchOrch = "deep"
|
||||||
}
|
}
|
||||||
|
if useBatchMulti {
|
||||||
|
_ = h.db.SetConversationAgentMode(conversationID, batchOrch)
|
||||||
|
} else {
|
||||||
|
_ = h.db.SetConversationAgentMode(conversationID, "eino_single")
|
||||||
|
}
|
||||||
|
|
||||||
var resultMA *multiagent.RunResult
|
var resultMA *multiagent.RunResult
|
||||||
var runErr error
|
var runErr error
|
||||||
@@ -268,19 +273,38 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
|||||||
|
|
||||||
h.logger.Info("批量任务执行成功", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
|
h.logger.Info("批量任务执行成功", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
|
||||||
|
|
||||||
resText := resultMA.Response
|
|
||||||
mcpIDs := resultMA.MCPExecutionIDs
|
mcpIDs := resultMA.MCPExecutionIDs
|
||||||
lastIn := resultMA.LastAgentTraceInput
|
lastIn := resultMA.LastAgentTraceInput
|
||||||
lastOut := resultMA.LastAgentTraceOutput
|
lastOut := resultMA.LastAgentTraceOutput
|
||||||
|
reasoningContent := multiagent.AggregatedReasoningFromTraceJSON(lastIn)
|
||||||
|
agentMode := "batch_eino_single"
|
||||||
|
if useBatchMulti {
|
||||||
|
agentMode = "batch_eino_" + batchOrch
|
||||||
|
}
|
||||||
|
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, reasoningContent, true)
|
||||||
|
resText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
resText = finalizationBlockedMessage(decision)
|
||||||
|
finishStatus = decision.Status
|
||||||
|
sendEvent("finalization_check", resText, decision)
|
||||||
|
}
|
||||||
|
sendEvent("response", resText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"messageId": assistantMessageID,
|
||||||
|
"agentMode": agentMode,
|
||||||
|
"mcpExecutionIds": mcpIDs,
|
||||||
|
"batchQueueId": queueID,
|
||||||
|
"batchTaskId": task.ID,
|
||||||
|
"batchTaskStatus": map[bool]string{true: string(BatchTaskStatusCompleted), false: string(BatchTaskStatusFailed)}[decision.Finalizable],
|
||||||
|
"candidatePreview": safeTruncateString(resultMA.Response, 500),
|
||||||
|
}))
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID == "" {
|
||||||
if updateErr := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resText, mcpIDs, multiagent.AggregatedReasoningFromTraceJSON(lastIn)); updateErr != nil {
|
_, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs)
|
||||||
h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
|
} else if !decision.Finalizable {
|
||||||
if _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs); err != nil {
|
err = nil
|
||||||
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
}
|
||||||
}
|
if err != nil {
|
||||||
}
|
|
||||||
} else if _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs); err != nil {
|
|
||||||
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +314,10 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !decision.Finalizable {
|
||||||
|
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusFailed, resText, finalizationCheckMessage(decision), conversationID)
|
||||||
|
return
|
||||||
|
}
|
||||||
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusCompleted, resText, "", conversationID)
|
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusCompleted, resText, "", conversationID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,15 +68,15 @@ func (h *AgentHandler) tryContinueOnEinoEmptyResponse(
|
|||||||
case <-time.After(backoff):
|
case <-time.After(backoff):
|
||||||
}
|
}
|
||||||
|
|
||||||
inject := multiagent.FormatEmptyResponseContinueUserMessage()
|
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, "")
|
||||||
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, inject)
|
|
||||||
if progressCallback != nil {
|
if progressCallback != nil {
|
||||||
progressCallback("eino_empty_response_continue", "已恢复上下文,正在续跑…", map[string]interface{}{
|
progressCallback("eino_empty_response_continue", "已恢复上下文,正在续跑…", map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"source": "eino",
|
"source": "eino",
|
||||||
"attempt": *attempt,
|
"attempt": *attempt,
|
||||||
"maxAttempts": maxAttempts,
|
"maxAttempts": maxAttempts,
|
||||||
"contextSource": "empty_response_continue",
|
"contextSource": "empty_response_continue",
|
||||||
|
"contextInjection": false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
@@ -189,6 +190,8 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||||
var mainIterationOffset int
|
var mainIterationOffset int
|
||||||
var emptyResponseContinueAttempt int
|
var emptyResponseContinueAttempt int
|
||||||
|
var finalizationAutoContinueAttempt int
|
||||||
|
var decision agentfinalizer.Decision
|
||||||
|
|
||||||
for {
|
for {
|
||||||
segmentMainIterationMax := 0
|
segmentMainIterationMax := 0
|
||||||
@@ -258,6 +261,13 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||||
|
mainIterationOffset += segmentMainIterationMax
|
||||||
|
timeoutCancel()
|
||||||
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -358,9 +368,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if decision.CompletionReason == "" {
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
}
|
}
|
||||||
|
h.persistFinalizationDecision(conversationID, assistantMessageID, "eino_single", cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||||
@@ -368,12 +379,19 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendEvent("response", result.Response, map[string]interface{}{
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
sendEvent("finalization_check", responseText, decision)
|
||||||
|
taskStatus = decision.Status
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
|
}
|
||||||
|
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
"agentMode": "eino_single",
|
"agentMode": "eino_single",
|
||||||
})
|
}))
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +447,9 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
curMsg := prep.FinalMessage
|
curMsg := prep.FinalMessage
|
||||||
var result *multiagent.RunResult
|
var result *multiagent.RunResult
|
||||||
var runErr error
|
var runErr error
|
||||||
|
var emptyResponseContinueAttempt int
|
||||||
|
var finalizationAutoContinueAttempt int
|
||||||
|
var decision agentfinalizer.Decision
|
||||||
for {
|
for {
|
||||||
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
||||||
taskCtx,
|
taskCtx,
|
||||||
@@ -446,28 +467,46 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
chatReasoningToClientIntent(req.Reasoning),
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
h.agentSessionContextBlock(prep.ConversationID),
|
h.agentSessionContextBlock(prep.ConversationID),
|
||||||
)
|
)
|
||||||
if runErr == nil {
|
if runErr != nil {
|
||||||
break
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
mw := &h.config.MultiAgent.EinoMiddleware
|
||||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
|
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, "eino_single", result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
return
|
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if prep.AssistantMessageID != "" {
|
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, "eino_single", result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
|
||||||
}
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
_ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput)
|
_ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"response": result.Response,
|
"response": responseText,
|
||||||
"conversationId": prep.ConversationID,
|
"conversationId": prep.ConversationID,
|
||||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||||
"assistantMessageId": prep.AssistantMessageID,
|
"assistantMessageId": prep.AssistantMessageID,
|
||||||
"agentMode": "eino_single",
|
"agentMode": "eino_single",
|
||||||
|
"finalized": decision.Finalized,
|
||||||
|
"finalizable": decision.Finalizable,
|
||||||
|
"status": decision.Status,
|
||||||
|
"completionReason": decision.CompletionReason,
|
||||||
|
"evidenceVerified": decision.EvidenceVerified,
|
||||||
|
"evidenceRefs": decision.EvidenceRefs,
|
||||||
|
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||||
|
"missingChecks": decision.MissingChecks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agent"
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const finalizationAutoContinueMaxAttempts = 2
|
||||||
|
|
||||||
|
func shouldAutoContinueAfterFinalization(d agentfinalizer.Decision, attempt int) bool {
|
||||||
|
if d.Finalizable || d.Finalized {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if attempt >= finalizationAutoContinueMaxAttempts {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return d.CompletionReason == agentfinalizer.ReasonMissingEvidence
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) tryAutoContinueAfterFinalization(
|
||||||
|
taskCtx context.Context,
|
||||||
|
conversationID string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
decision agentfinalizer.Decision,
|
||||||
|
attempt *int,
|
||||||
|
curHistory *[]agent.ChatMessage,
|
||||||
|
curFinalMessage *string,
|
||||||
|
progressCallback func(eventType, message string, data interface{}),
|
||||||
|
) bool {
|
||||||
|
if !shouldAutoContinueAfterFinalization(decision, *attempt) || result == nil || !multiagent.HasEinoResumeTrace(result) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
*attempt++
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
|
if hist, err := h.loadHistoryFromAgentTrace(conversationID); err == nil && len(hist) > 0 {
|
||||||
|
*curHistory = hist
|
||||||
|
} else if h.logger != nil {
|
||||||
|
h.logger.Warn("finalization auto-continue could not restore trace",
|
||||||
|
zap.String("conversationId", conversationID),
|
||||||
|
zap.Error(err))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Agent 无感续跑:不追加新的 user/system 文案,只使用上一段模型可见轨迹继续 Runner。
|
||||||
|
*curFinalMessage = ""
|
||||||
|
if progressCallback != nil {
|
||||||
|
progressCallback("finalization_auto_continue", "最终回复检查尚未收敛,正在基于已有轨迹继续执行…", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "finalizer",
|
||||||
|
"attempt": *attempt,
|
||||||
|
"maxAttempts": finalizationAutoContinueMaxAttempts,
|
||||||
|
"status": decision.Status,
|
||||||
|
"completionReason": decision.CompletionReason,
|
||||||
|
"missingChecks": decision.MissingChecks,
|
||||||
|
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||||
|
"contextInjection": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-taskCtx.Done():
|
||||||
|
return false
|
||||||
|
case <-time.After(finalizationAutoContinueBackoff(*attempt)):
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizationAutoContinueBackoff(attempt int) time.Duration {
|
||||||
|
if attempt <= 1 {
|
||||||
|
return 500 * time.Millisecond
|
||||||
|
}
|
||||||
|
return time.Duration(attempt) * time.Second
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShouldAutoContinueAfterFinalization(t *testing.T) {
|
||||||
|
missingEvidence := agentfinalizer.Decision{
|
||||||
|
Status: agentfinalizer.StatusBlocked,
|
||||||
|
CompletionReason: agentfinalizer.ReasonMissingEvidence,
|
||||||
|
}
|
||||||
|
if !shouldAutoContinueAfterFinalization(missingEvidence, 0) {
|
||||||
|
t.Fatal("missing execution evidence should trigger auto-continue")
|
||||||
|
}
|
||||||
|
if shouldAutoContinueAfterFinalization(missingEvidence, finalizationAutoContinueMaxAttempts) {
|
||||||
|
t.Fatal("auto-continue should stop at max attempts")
|
||||||
|
}
|
||||||
|
|
||||||
|
finalized := agentfinalizer.Decision{
|
||||||
|
Status: agentfinalizer.StatusCompleted,
|
||||||
|
CompletionReason: agentfinalizer.ReasonVerified,
|
||||||
|
Finalizable: true,
|
||||||
|
Finalized: true,
|
||||||
|
}
|
||||||
|
if shouldAutoContinueAfterFinalization(finalized, 0) {
|
||||||
|
t.Fatal("finalized decision should not auto-continue")
|
||||||
|
}
|
||||||
|
|
||||||
|
awaitingHITL := agentfinalizer.Decision{
|
||||||
|
Status: agentfinalizer.StatusAwaitingHITL,
|
||||||
|
CompletionReason: agentfinalizer.ReasonAwaitingHITL,
|
||||||
|
}
|
||||||
|
if shouldAutoContinueAfterFinalization(awaitingHITL, 0) {
|
||||||
|
t.Fatal("awaiting HITL should not auto-continue without approval")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestRequiresExecutionEvidenceUsesExplicitPolicyOnly(t *testing.T) {
|
||||||
|
if requestRequiresExecutionEvidence(nil) {
|
||||||
|
t.Fatal("nil request should not require execution evidence")
|
||||||
|
}
|
||||||
|
if requestRequiresExecutionEvidence(&ChatRequest{}) {
|
||||||
|
t.Fatal("missing finalization policy should not require execution evidence")
|
||||||
|
}
|
||||||
|
require := true
|
||||||
|
if !requestRequiresExecutionEvidence(&ChatRequest{
|
||||||
|
Finalization: ChatFinalizationRequest{RequireExecutionEvidence: &require},
|
||||||
|
}) {
|
||||||
|
t.Fatal("explicit true policy should require execution evidence")
|
||||||
|
}
|
||||||
|
require = false
|
||||||
|
if requestRequiresExecutionEvidence(&ChatRequest{
|
||||||
|
Finalization: ChatFinalizationRequest{RequireExecutionEvidence: &require},
|
||||||
|
}) {
|
||||||
|
t.Fatal("explicit false policy should not require execution evidence")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeAgentRunForDelivery(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
reasoningContent string,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
return h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, mcpExecutionIDs, reasoningContent, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeAgentRunForDeliveryWithPolicy(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
reasoningContent string,
|
||||||
|
requireExecutionEvidence bool,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
decision := agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AssistantMessageID: assistantMessageID,
|
||||||
|
AgentMode: agentMode,
|
||||||
|
MCPExecutionIDs: mcpExecutionIDs,
|
||||||
|
RequireExecutionEvidence: requireExecutionEvidence,
|
||||||
|
})
|
||||||
|
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, mcpExecutionIDs, reasoningContent, decision)
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) decideAgentRunForDeliveryWithPolicy(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
requireExecutionEvidence bool,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
return agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AssistantMessageID: assistantMessageID,
|
||||||
|
AgentMode: agentMode,
|
||||||
|
MCPExecutionIDs: mcpExecutionIDs,
|
||||||
|
RequireExecutionEvidence: requireExecutionEvidence,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) decideAgentRunForDelivery(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
return agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AssistantMessageID: assistantMessageID,
|
||||||
|
AgentMode: agentMode,
|
||||||
|
MCPExecutionIDs: mcpExecutionIDs,
|
||||||
|
RequireExecutionEvidence: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) persistFinalizationDecision(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
reasoningContent string,
|
||||||
|
decision agentfinalizer.Decision,
|
||||||
|
) {
|
||||||
|
if assistantMessageID == "" || h.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "finalization_check", finalizationCheckMessage(decision), decision)
|
||||||
|
if decision.Finalizable {
|
||||||
|
if err := h.db.UpdateAssistantMessageFinalize(assistantMessageID, decision.FinalText, mcpExecutionIDs, reasoningContent); err != nil && h.logger != nil {
|
||||||
|
h.logger.Warn("更新最终助手消息失败", zap.Error(err), zap.String("conversationId", conversationID), zap.String("agentMode", agentMode))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", finalizationBlockedMessage(decision), time.Now(), assistantMessageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeCandidateForDelivery(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
response string,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
awaitingHITL bool,
|
||||||
|
reasoningContent string,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
return h.finalizeCandidateForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, response, mcpExecutionIDs, awaitingHITL, reasoningContent, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeCandidateForDeliveryWithPolicy(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
response string,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
awaitingHITL bool,
|
||||||
|
reasoningContent string,
|
||||||
|
requireExecutionEvidence bool,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
decision := agentfinalizer.Decide(h.db, agentfinalizer.Input{
|
||||||
|
Response: response,
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AssistantMessageID: assistantMessageID,
|
||||||
|
AgentMode: agentMode,
|
||||||
|
MCPExecutionIDs: mcpExecutionIDs,
|
||||||
|
AwaitingHITL: awaitingHITL,
|
||||||
|
RequireExecutionEvidence: requireExecutionEvidence,
|
||||||
|
})
|
||||||
|
if assistantMessageID == "" || h.db == nil {
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "finalization_check", finalizationCheckMessage(decision), decision)
|
||||||
|
if decision.Finalizable {
|
||||||
|
if err := h.db.UpdateAssistantMessageFinalize(assistantMessageID, decision.FinalText, mcpExecutionIDs, reasoningContent); err != nil && h.logger != nil {
|
||||||
|
h.logger.Warn("更新最终助手消息失败", zap.Error(err), zap.String("conversationId", conversationID), zap.String("agentMode", agentMode))
|
||||||
|
}
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", finalizationBlockedMessage(decision), time.Now(), assistantMessageID)
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizationCheckMessage(d agentfinalizer.Decision) string {
|
||||||
|
if d.Finalizable {
|
||||||
|
return "最终回复检查通过。"
|
||||||
|
}
|
||||||
|
return finalizationBlockedMessage(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizationBlockedMessage(d agentfinalizer.Decision) string {
|
||||||
|
parts := []string{"任务尚未达到最终回复条件,暂不生成成功结论。"}
|
||||||
|
if d.CompletionReason != "" {
|
||||||
|
parts = append(parts, "原因: "+d.CompletionReason)
|
||||||
|
}
|
||||||
|
if len(d.PendingExecutionIDs) > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("仍有 %d 个工具执行未结束: %s", len(d.PendingExecutionIDs), strings.Join(d.PendingExecutionIDs, ", ")))
|
||||||
|
}
|
||||||
|
if len(d.MissingChecks) > 0 {
|
||||||
|
parts = append(parts, "缺失检查: "+strings.Join(d.MissingChecks, "; "))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizationResponsePayload(d agentfinalizer.Decision, extra map[string]interface{}) map[string]interface{} {
|
||||||
|
return agentfinalizer.ResponsePayload(d, extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestRequiresExecutionEvidence(req *ChatRequest) bool {
|
||||||
|
return req != nil && req.Finalization.RequireExecutionEvidence != nil && *req.Finalization.RequireExecutionEvidence
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ func (h *MonitorHandler) SetAgentHandler(ah *AgentHandler) {
|
|||||||
h.agentHandler = ah
|
h.agentHandler = ah
|
||||||
}
|
}
|
||||||
|
|
||||||
const monitorPageTopTools = 3
|
const monitorPageTopTools = 6
|
||||||
|
|
||||||
// MonitorStatsSummary 工具调用汇总
|
// MonitorStatsSummary 工具调用汇总
|
||||||
type MonitorStatsSummary struct {
|
type MonitorStatsSummary struct {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
@@ -197,6 +198,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||||
var mainIterationOffset int
|
var mainIterationOffset int
|
||||||
var emptyResponseContinueAttempt int
|
var emptyResponseContinueAttempt int
|
||||||
|
var finalizationAutoContinueAttempt int
|
||||||
|
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
|
||||||
|
if o := strings.TrimSpace(req.Orchestration); o != "" {
|
||||||
|
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
||||||
|
}
|
||||||
|
agentMode := "eino_" + effectiveOrch
|
||||||
|
var decision agentfinalizer.Decision
|
||||||
|
|
||||||
for {
|
for {
|
||||||
segmentMainIterationMax := 0
|
segmentMainIterationMax := 0
|
||||||
@@ -267,6 +275,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||||
|
mainIterationOffset += segmentMainIterationMax
|
||||||
|
timeoutCancel()
|
||||||
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -367,9 +382,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if decision.CompletionReason == "" {
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
}
|
}
|
||||||
|
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||||
@@ -377,16 +393,19 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
|
responseText := decision.FinalText
|
||||||
if o := strings.TrimSpace(req.Orchestration); o != "" {
|
if !decision.Finalizable {
|
||||||
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
sendEvent("finalization_check", responseText, decision)
|
||||||
|
taskStatus = decision.Status
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
}
|
}
|
||||||
sendEvent("response", result.Response, map[string]interface{}{
|
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
"agentMode": "eino_" + effectiveOrch,
|
"agentMode": agentMode,
|
||||||
})
|
}))
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +456,14 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
curMsg := prep.FinalMessage
|
curMsg := prep.FinalMessage
|
||||||
var result *multiagent.RunResult
|
var result *multiagent.RunResult
|
||||||
var runErr error
|
var runErr error
|
||||||
|
var emptyResponseContinueAttempt int
|
||||||
|
var finalizationAutoContinueAttempt int
|
||||||
|
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
|
||||||
|
if o := strings.TrimSpace(req.Orchestration); o != "" {
|
||||||
|
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
||||||
|
}
|
||||||
|
agentMode := "eino_" + effectiveOrch
|
||||||
|
var decision agentfinalizer.Decision
|
||||||
for {
|
for {
|
||||||
result, runErr = multiagent.RunDeepAgent(
|
result, runErr = multiagent.RunDeepAgent(
|
||||||
taskCtx,
|
taskCtx,
|
||||||
@@ -456,24 +483,30 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
chatReasoningToClientIntent(req.Reasoning),
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
h.agentSessionContextBlock(prep.ConversationID),
|
h.agentSessionContextBlock(prep.ConversationID),
|
||||||
)
|
)
|
||||||
if runErr == nil {
|
if runErr != nil {
|
||||||
break
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||||
|
}
|
||||||
|
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
|
||||||
|
errMsg := "执行失败: " + runErr.Error()
|
||||||
|
if prep.AssistantMessageID != "" {
|
||||||
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
mw := &h.config.MultiAgent.EinoMiddleware
|
||||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
|
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, agentMode, result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
errMsg := "执行失败: " + runErr.Error()
|
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
if prep.AssistantMessageID != "" {
|
continue
|
||||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
|
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
break
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if prep.AssistantMessageID != "" {
|
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, agentMode, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
if err := h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
if err := h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||||
@@ -481,11 +514,23 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, ChatResponse{
|
c.JSON(http.StatusOK, ChatResponse{
|
||||||
Response: result.Response,
|
Response: responseText,
|
||||||
MCPExecutionIDs: result.MCPExecutionIDs,
|
MCPExecutionIDs: result.MCPExecutionIDs,
|
||||||
ConversationID: prep.ConversationID,
|
ConversationID: prep.ConversationID,
|
||||||
Time: time.Now(),
|
Time: time.Now(),
|
||||||
|
Finalizable: decision.Finalizable,
|
||||||
|
Finalized: decision.Finalized,
|
||||||
|
Status: decision.Status,
|
||||||
|
CompletionReason: decision.CompletionReason,
|
||||||
|
EvidenceVerified: decision.EvidenceVerified,
|
||||||
|
EvidenceRefs: decision.EvidenceRefs,
|
||||||
|
PendingExecutionIDs: decision.PendingExecutionIDs,
|
||||||
|
MissingChecks: decision.MissingChecks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"cyberstrike-ai/internal/agent"
|
"cyberstrike-ai/internal/agent"
|
||||||
"cyberstrike-ai/internal/audit"
|
"cyberstrike-ai/internal/audit"
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/mcp/builtin"
|
"cyberstrike-ai/internal/mcp/builtin"
|
||||||
"cyberstrike-ai/internal/security"
|
"cyberstrike-ai/internal/security"
|
||||||
@@ -25,6 +26,13 @@ type multiAgentPrepared struct {
|
|||||||
UserMessageID string
|
UserMessageID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func chatRequestAgentMode(req *ChatRequest, source string) string {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(source), "multi_agent") {
|
||||||
|
return config.NormalizeMultiAgentOrchestration(req.Orchestration)
|
||||||
|
}
|
||||||
|
return "eino_single"
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context, source string) (*multiAgentPrepared, error) {
|
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context, source string) (*multiAgentPrepared, error) {
|
||||||
if len(req.Attachments) > maxAttachments {
|
if len(req.Attachments) > maxAttachments {
|
||||||
return nil, fmt.Errorf("附件最多 %d 个", maxAttachments)
|
return nil, fmt.Errorf("附件最多 %d 个", maxAttachments)
|
||||||
@@ -57,6 +65,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
|
|||||||
meta := audit.ConversationCreateMetaFromGin(c, source)
|
meta := audit.ConversationCreateMetaFromGin(c, source)
|
||||||
meta.ProjectID = projectID
|
meta.ProjectID = projectID
|
||||||
meta.RoleName = req.Role
|
meta.RoleName = req.Role
|
||||||
|
meta.AgentMode = chatRequestAgentMode(req, source)
|
||||||
if webshellID != "" {
|
if webshellID != "" {
|
||||||
meta.Source = source + "_webshell"
|
meta.Source = source + "_webshell"
|
||||||
meta.WebShellConnectionID = webshellID
|
meta.WebShellConnectionID = webshellID
|
||||||
@@ -84,6 +93,9 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
|
|||||||
if err := h.db.SetConversationRoleName(conversationID, req.Role); err != nil {
|
if err := h.db.SetConversationRoleName(conversationID, req.Role); err != nil {
|
||||||
h.logger.Warn("更新对话角色失败", zap.String("conversationId", conversationID), zap.String("role", req.Role), zap.Error(err))
|
h.logger.Warn("更新对话角色失败", zap.String("conversationId", conversationID), zap.String("role", req.Role), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
if err := h.db.SetConversationAgentMode(conversationID, chatRequestAgentMode(req, source)); err != nil {
|
||||||
|
h.logger.Warn("更新对话模式失败", zap.String("conversationId", conversationID), zap.String("source", source), zap.String("orchestration", req.Orchestration), zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
|
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finalizationRequestSchema := map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "最终回复交付策略。后端不会从自然语言内容推断执行意图;执行入口应显式声明是否要求 completed 工具证据。",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"requireExecutionEvidence": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "为 true 时,缺少 completed 工具执行记录会触发无注入续跑或最终阻断;普通聊天可省略或设为 false。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
spec := map[string]interface{}{
|
spec := map[string]interface{}{
|
||||||
"openapi": "3.0.0",
|
"openapi": "3.0.0",
|
||||||
"info": map[string]interface{}{
|
"info": map[string]interface{}{
|
||||||
@@ -85,6 +96,70 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
"required": []string{"projectId"},
|
"required": []string{"projectId"},
|
||||||
},
|
},
|
||||||
|
"AgentChatResponse": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Agent 非流式响应。response 只是交付文本;是否为成功最终回复必须以 finalized/finalizable/status 为准。",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"response": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "交付给用户的文本。finalized=false 时为阻断/未完成说明,不是成功结论。",
|
||||||
|
},
|
||||||
|
"conversationId": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "对话 ID",
|
||||||
|
},
|
||||||
|
"assistantMessageId": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "助手消息 ID(部分接口返回)",
|
||||||
|
},
|
||||||
|
"mcpExecutionIds": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "本轮关联的 MCP 工具执行 ID",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
"agentMode": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "agent 模式,例如 eino_single、eino_deep、workflow",
|
||||||
|
},
|
||||||
|
"finalized": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "是否已经通过最终回复检查。只有 true 才能当成功最终回复。",
|
||||||
|
},
|
||||||
|
"finalizable": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "候选输出是否可提升为最终回复。",
|
||||||
|
},
|
||||||
|
"status": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "最终化状态",
|
||||||
|
"enum": []string{"completed", "in_progress", "blocked", "failed", "cancelled", "awaiting_hitl"},
|
||||||
|
},
|
||||||
|
"completionReason": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "最终化或阻断原因,例如 verified、pending_tool_executions、missing_execution_evidence",
|
||||||
|
},
|
||||||
|
"evidenceVerified": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "证据是否满足最终化要求",
|
||||||
|
},
|
||||||
|
"evidenceRefs": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "证据引用,例如 mcp_execution:<id>",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
"pendingExecutionIds": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "仍处于 queued/running 的工具执行 ID",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
"missingChecks": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "未通过最终化检查的原因列表",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"response", "conversationId", "finalized", "finalizable", "status", "evidenceVerified"},
|
||||||
|
},
|
||||||
"Conversation": map[string]interface{}{
|
"Conversation": map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
@@ -1581,6 +1656,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"conversationId": map[string]interface{}{"type": "string"},
|
"conversationId": map[string]interface{}{"type": "string"},
|
||||||
"role": map[string]interface{}{"type": "string"},
|
"role": map[string]interface{}{"type": "string"},
|
||||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||||
|
"finalization": finalizationRequestSchema,
|
||||||
},
|
},
|
||||||
"required": []string{"message"},
|
"required": []string{"message"},
|
||||||
},
|
},
|
||||||
@@ -1588,7 +1664,14 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"responses": map[string]interface{}{
|
"responses": map[string]interface{}{
|
||||||
"200": map[string]interface{}{"description": "成功,响应格式同 /api/eino-agent"},
|
"200": map[string]interface{}{
|
||||||
|
"description": "成功。只有 finalized=true 表示成功最终回复;finalized=false 时 response 为未完成/阻断说明。",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/AgentChatResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"400": map[string]interface{}{"description": "参数错误"},
|
"400": map[string]interface{}{"description": "参数错误"},
|
||||||
"401": map[string]interface{}{"description": "未授权"},
|
"401": map[string]interface{}{"description": "未授权"},
|
||||||
"500": map[string]interface{}{"description": "执行失败"},
|
"500": map[string]interface{}{"description": "执行失败"},
|
||||||
@@ -1599,7 +1682,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"post": map[string]interface{}{
|
"post": map[string]interface{}{
|
||||||
"tags": []string{"对话交互"},
|
"tags": []string{"对话交互"},
|
||||||
"summary": "发送消息并获取 AI 回复(Eino ADK 单代理,SSE)",
|
"summary": "发送消息并获取 AI 回复(Eino ADK 单代理,SSE)",
|
||||||
"description": "向 AI 发送消息并获取流式回复(SSE)。由 Eino **单代理** ADK 执行;事件类型与多代理流式一致(含 `tool_call` / `response_delta` / `thinking` 等)。**不依赖** `multi_agent.enabled`。",
|
"description": "向 AI 发送消息并获取流式回复(SSE)。由 Eino **单代理** ADK 执行;事件类型与多代理流式一致(含 `tool_call` / `response_delta` / `thinking` 等)。`response_start` / `response_delta` 仅为候选/过程输出;只有 `type: response` 且 `data.finalized=true` 才表示成功最终回复。缺 completed 执行证据时可能先发送 `finalization_auto_continue`,表示服务端基于已有 trace 无注入续跑。`data.finalized=false` 时 message 为未完成/阻断说明。**不依赖** `multi_agent.enabled`。",
|
||||||
"operationId": "sendMessageEinoSingleAgentStream",
|
"operationId": "sendMessageEinoSingleAgentStream",
|
||||||
"requestBody": map[string]interface{}{
|
"requestBody": map[string]interface{}{
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -1612,6 +1695,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"conversationId": map[string]interface{}{"type": "string"},
|
"conversationId": map[string]interface{}{"type": "string"},
|
||||||
"role": map[string]interface{}{"type": "string"},
|
"role": map[string]interface{}{"type": "string"},
|
||||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||||
|
"finalization": finalizationRequestSchema,
|
||||||
},
|
},
|
||||||
"required": []string{"message"},
|
"required": []string{"message"},
|
||||||
},
|
},
|
||||||
@@ -1625,7 +1709,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"text/event-stream": map[string]interface{}{
|
"text/event-stream": map[string]interface{}{
|
||||||
"schema": map[string]interface{}{
|
"schema": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "SSE 流",
|
"description": "SSE 流。终态 response 事件 data 包含 finalized、finalizable、status、completionReason、evidenceVerified、evidenceRefs、pendingExecutionIds、missingChecks;过程事件可能包含 finalization_auto_continue。",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1663,6 +1747,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "WebShell 连接 ID(可选,与 Eino 单/多代理流式行为一致)",
|
"description": "WebShell 连接 ID(可选,与 Eino 单/多代理流式行为一致)",
|
||||||
},
|
},
|
||||||
|
"finalization": finalizationRequestSchema,
|
||||||
"orchestration": map[string]interface{}{
|
"orchestration": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Eino 预置编排:deep | plan_execute | supervisor;缺省 deep",
|
"description": "Eino 预置编排:deep | plan_execute | supervisor;缺省 deep",
|
||||||
@@ -1676,7 +1761,12 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
"responses": map[string]interface{}{
|
"responses": map[string]interface{}{
|
||||||
"200": map[string]interface{}{
|
"200": map[string]interface{}{
|
||||||
"description": "成功,响应格式同 /api/eino-agent",
|
"description": "成功。只有 finalized=true 表示成功最终回复;finalized=false 时 response 为未完成/阻断说明。",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/AgentChatResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"400": map[string]interface{}{"description": "参数错误"},
|
"400": map[string]interface{}{"description": "参数错误"},
|
||||||
"401": map[string]interface{}{"description": "未授权"},
|
"401": map[string]interface{}{"description": "未授权"},
|
||||||
@@ -1689,7 +1779,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"post": map[string]interface{}{
|
"post": map[string]interface{}{
|
||||||
"tags": []string{"对话交互"},
|
"tags": []string{"对话交互"},
|
||||||
"summary": "发送消息并获取 AI 回复(Eino 多代理,SSE)",
|
"summary": "发送消息并获取 AI 回复(Eino 多代理,SSE)",
|
||||||
"description": "与 `POST /api/eino-agent/stream` 类似;由 Eino 多代理执行。`orchestration` 指定 deep / plan_execute / supervisor,缺省 deep。**前提**:`multi_agent.enabled: true`;未启用时 SSE 内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
|
"description": "与 `POST /api/eino-agent/stream` 类似;由 Eino 多代理执行。`orchestration` 指定 deep / plan_execute / supervisor,缺省 deep。`response_start` / `response_delta` 仅为候选/过程输出;只有 `type: response` 且 `data.finalized=true` 才表示成功最终回复。缺 completed 执行证据时可能先发送 `finalization_auto_continue`,表示服务端基于已有 trace 无注入续跑。**前提**:`multi_agent.enabled: true`;未启用时 SSE 内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
|
||||||
"operationId": "sendMessageMultiAgentStream",
|
"operationId": "sendMessageMultiAgentStream",
|
||||||
"requestBody": map[string]interface{}{
|
"requestBody": map[string]interface{}{
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -1702,6 +1792,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"conversationId": map[string]interface{}{"type": "string"},
|
"conversationId": map[string]interface{}{"type": "string"},
|
||||||
"role": map[string]interface{}{"type": "string"},
|
"role": map[string]interface{}{"type": "string"},
|
||||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||||
|
"finalization": finalizationRequestSchema,
|
||||||
"orchestration": map[string]interface{}{
|
"orchestration": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "deep | plan_execute | supervisor;缺省 deep",
|
"description": "deep | plan_execute | supervisor;缺省 deep",
|
||||||
@@ -1720,7 +1811,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"text/event-stream": map[string]interface{}{
|
"text/event-stream": map[string]interface{}{
|
||||||
"schema": map[string]interface{}{
|
"schema": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "SSE 流",
|
"description": "SSE 流。终态 response 事件 data 包含 finalized、finalizable、status、completionReason、evidenceVerified、evidenceRefs、pendingExecutionIds、missingChecks;过程事件可能包含 finalization_auto_continue。",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -47,6 +48,12 @@ type workflowDryRunRequest struct {
|
|||||||
Inputs map[string]interface{} `json:"inputs,omitempty"`
|
Inputs map[string]interface{} `json:"inputs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type workflowGenerateDraftRequest struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Options workflowrunner.DraftOptions `json:"options"`
|
||||||
|
AvailableTools []workflowrunner.DraftTool `json:"available_tools,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func (h *WorkflowHandler) List(c *gin.Context) {
|
func (h *WorkflowHandler) List(c *gin.Context) {
|
||||||
includeDisabled := strings.EqualFold(c.Query("includeDisabled"), "true") || c.Query("include_disabled") == "1"
|
includeDisabled := strings.EqualFold(c.Query("includeDisabled"), "true") || c.Query("include_disabled") == "1"
|
||||||
items, err := h.db.ListWorkflowDefinitions(includeDisabled)
|
items, err := h.db.ListWorkflowDefinitions(includeDisabled)
|
||||||
@@ -126,6 +133,44 @@ func (h *WorkflowHandler) DryRun(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"result": result})
|
c.JSON(http.StatusOK, gin.H{"result": result})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WorkflowHandler) GenerateDraft(c *gin.Context) {
|
||||||
|
var req workflowGenerateDraftRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draftReq := workflowrunner.DraftRequest{
|
||||||
|
Prompt: req.Prompt,
|
||||||
|
Options: req.Options,
|
||||||
|
AvailableTools: req.AvailableTools,
|
||||||
|
}
|
||||||
|
var result *workflowrunner.DraftResult
|
||||||
|
var llmErr error
|
||||||
|
if h.cfg != nil {
|
||||||
|
if llmCfg, _, ok := h.cfg.ResolveAIChannel(""); ok && strings.TrimSpace(llmCfg.APIKey) != "" && strings.TrimSpace(llmCfg.Model) != "" {
|
||||||
|
result, llmErr = workflowrunner.GenerateDraftFromLLM(c.Request.Context(), draftReq, llmCfg, h.logger)
|
||||||
|
} else {
|
||||||
|
llmErr = errors.New("AI 通道未配置 api_key 或 model")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
llmErr = errors.New("工作流生成器未加载平台 AI 配置")
|
||||||
|
}
|
||||||
|
if llmErr != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "大模型生成失败: " + llmErr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "workflow", "generate_draft", "自然语言生成工作流草稿", "", "", map[string]interface{}{
|
||||||
|
"generator": result.Generator,
|
||||||
|
"nodes": result.Stats["nodes"],
|
||||||
|
"edges": result.Stats["edges"],
|
||||||
|
"high_risk": result.Audit.HighRisk,
|
||||||
|
"savable": result.Audit.Savable,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"result": result})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *WorkflowHandler) Update(c *gin.Context) {
|
func (h *WorkflowHandler) Update(c *gin.Context) {
|
||||||
h.save(c, c.Param("id"))
|
h.save(c, c.Param("id"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,20 +152,37 @@ func (h *AgentHandler) runRoleWorkflowStreamIfBound(
|
|||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if prep.AssistantMessageID != "" {
|
decision := h.finalizeCandidateForDeliveryWithPolicy(
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
|
prep.ConversationID,
|
||||||
|
prep.AssistantMessageID,
|
||||||
|
"workflow",
|
||||||
|
result.Response,
|
||||||
|
nil,
|
||||||
|
result.AwaitingHITL,
|
||||||
|
"",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
taskStatus = decision.Status
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
|
sendEvent("finalization_check", responseText, decision)
|
||||||
}
|
}
|
||||||
payload := map[string]interface{}{
|
payload := finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
"conversationId": prep.ConversationID,
|
"conversationId": prep.ConversationID,
|
||||||
"messageId": prep.AssistantMessageID,
|
"messageId": prep.AssistantMessageID,
|
||||||
"agentMode": "workflow",
|
"agentMode": "workflow",
|
||||||
"workflowRunId": result.RunID,
|
"workflowRunId": result.RunID,
|
||||||
}
|
})
|
||||||
if result.AwaitingHITL {
|
if result.AwaitingHITL {
|
||||||
payload["workflowStatus"] = "awaiting_hitl"
|
payload["workflowStatus"] = "awaiting_hitl"
|
||||||
payload["awaitingHitl"] = true
|
payload["awaitingHitl"] = true
|
||||||
|
} else {
|
||||||
|
payload["workflowStatus"] = result.Status
|
||||||
|
payload["awaitingHitl"] = false
|
||||||
}
|
}
|
||||||
sendEvent("response", result.Response, payload)
|
sendEvent("response", responseText, payload)
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": prep.ConversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": prep.ConversationID})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -251,17 +268,37 @@ func (h *AgentHandler) runRoleWorkflowJSONIfBound(c *gin.Context, req *ChatReque
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg, "conversationId": conversationID})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg, "conversationId": conversationID})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if prep.AssistantMessageID != "" {
|
decision := h.finalizeCandidateForDeliveryWithPolicy(
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
|
prep.ConversationID,
|
||||||
|
prep.AssistantMessageID,
|
||||||
|
"workflow",
|
||||||
|
result.Response,
|
||||||
|
nil,
|
||||||
|
result.AwaitingHITL,
|
||||||
|
"",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
taskStatus = decision.Status
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"response": result.Response,
|
"response": responseText,
|
||||||
"conversationId": prep.ConversationID,
|
"conversationId": prep.ConversationID,
|
||||||
"assistantMessageId": prep.AssistantMessageID,
|
"assistantMessageId": prep.AssistantMessageID,
|
||||||
"agentMode": "workflow",
|
"agentMode": "workflow",
|
||||||
"workflowRunId": result.RunID,
|
"workflowRunId": result.RunID,
|
||||||
"workflowStatus": result.Status,
|
"workflowStatus": result.Status,
|
||||||
"awaitingHitl": result.AwaitingHITL,
|
"awaitingHitl": result.AwaitingHITL,
|
||||||
|
"finalized": decision.Finalized,
|
||||||
|
"finalizable": decision.Finalizable,
|
||||||
|
"status": decision.Status,
|
||||||
|
"completionReason": decision.CompletionReason,
|
||||||
|
"evidenceVerified": decision.EvidenceVerified,
|
||||||
|
"evidenceRefs": decision.EvidenceRefs,
|
||||||
|
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||||
|
"missingChecks": decision.MissingChecks,
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -76,3 +77,26 @@ func TestWorkflowPackageHandlerInspectionAndCreateImport(t *testing.T) {
|
|||||||
t.Fatalf("saved=%#v", saved)
|
t.Fatalf("saved=%#v", saved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkflowHandlerGenerateDraft(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
h := NewWorkflowHandler(nil, zap.NewNop())
|
||||||
|
body := bytes.NewBufferString(`{"prompt":"对目标资产做端口扫描,如果发现高危端口就执行加固脚本,最后输出报告","options":{"include_objective":true},"available_tools":[{"key":"nmap","name":"nmap","enabled":true}]}`)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/api/workflows/generate-draft", body)
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
h.GenerateDraft(c)
|
||||||
|
if w.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Error, "大模型生成失败") {
|
||||||
|
t.Fatalf("unexpected error: %#v", resp.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -674,48 +674,6 @@ func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) {
|
|||||||
|
|
||||||
// CallTool 调用外部MCP工具(返回执行ID)
|
// CallTool 调用外部MCP工具(返回执行ID)
|
||||||
func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args map[string]interface{}) (*ToolResult, string, error) {
|
func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args map[string]interface{}) (*ToolResult, string, error) {
|
||||||
_, authenticated := authctx.PrincipalFromContext(ctx)
|
|
||||||
m.mu.RLock()
|
|
||||||
authorizer := m.toolAuthorizer
|
|
||||||
m.mu.RUnlock()
|
|
||||||
if authorizer != nil {
|
|
||||||
if err := authorizer(ctx, toolName, args); err != nil {
|
|
||||||
return nil, "", fmt.Errorf("external tool authorization denied: %w", err)
|
|
||||||
}
|
|
||||||
} else if authenticated {
|
|
||||||
return nil, "", fmt.Errorf("external tool authorization policy is not configured")
|
|
||||||
}
|
|
||||||
// 解析工具名称:name::toolName
|
|
||||||
var mcpName, actualToolName string
|
|
||||||
if idx := findSubstring(toolName, "::"); idx > 0 {
|
|
||||||
mcpName = toolName[:idx]
|
|
||||||
actualToolName = toolName[idx+2:]
|
|
||||||
} else {
|
|
||||||
return nil, "", fmt.Errorf("无效的工具名称格式: %s", toolName)
|
|
||||||
}
|
|
||||||
|
|
||||||
client, exists := m.GetClient(mcpName)
|
|
||||||
if !exists {
|
|
||||||
return nil, "", fmt.Errorf("外部MCP客户端不存在: %s", mcpName)
|
|
||||||
}
|
|
||||||
if err := m.checkExternalMCPCircuit(mcpName); err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查连接状态,如果未连接或状态为error,不允许调用
|
|
||||||
if !client.IsConnected() {
|
|
||||||
status := client.GetStatus()
|
|
||||||
if status == "error" {
|
|
||||||
// 获取错误信息(如果有)
|
|
||||||
errorMsg := m.GetError(mcpName)
|
|
||||||
if errorMsg != "" {
|
|
||||||
return nil, "", fmt.Errorf("外部MCP连接失败: %s (错误: %s)", mcpName, errorMsg)
|
|
||||||
}
|
|
||||||
return nil, "", fmt.Errorf("外部MCP连接失败: %s", mcpName)
|
|
||||||
}
|
|
||||||
return nil, "", fmt.Errorf("外部MCP客户端未连接: %s (状态: %s)", mcpName, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.executionService == nil {
|
if m.executionService == nil {
|
||||||
m.executionService = NewExecutionService(m.storage, m.logger)
|
m.executionService = NewExecutionService(m.storage, m.logger)
|
||||||
m.executionService.ConfigureToolResultMaxBytes(m.toolResultMaxBytes)
|
m.executionService.ConfigureToolResultMaxBytes(m.toolResultMaxBytes)
|
||||||
@@ -725,12 +683,57 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
|
|||||||
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
|
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
|
||||||
ownerUserID = principal.UserID
|
ownerUserID = principal.UserID
|
||||||
}
|
}
|
||||||
|
var mcpName, actualToolName string
|
||||||
|
var client ExternalMCPClient
|
||||||
handle, err := m.executionService.Submit(ctx, ExecutionRequest{
|
handle, err := m.executionService.Submit(ctx, ExecutionRequest{
|
||||||
ToolName: toolName,
|
ToolName: toolName,
|
||||||
Arguments: args,
|
Arguments: args,
|
||||||
ConversationID: MCPConversationIDFromContext(ctx),
|
ConversationID: MCPConversationIDFromContext(ctx),
|
||||||
OwnerUserID: ownerUserID,
|
OwnerUserID: ownerUserID,
|
||||||
PreRun: func(runCtx context.Context, exec *ToolExecution) (func(), error) {
|
PreRun: func(runCtx context.Context, exec *ToolExecution) (func(), error) {
|
||||||
|
_, authenticated := authctx.PrincipalFromContext(runCtx)
|
||||||
|
m.mu.RLock()
|
||||||
|
authorizer := m.toolAuthorizer
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if authorizer != nil {
|
||||||
|
if err := authorizer(runCtx, toolName, args); err != nil {
|
||||||
|
return nil, fmt.Errorf("external tool authorization denied: %w", err)
|
||||||
|
}
|
||||||
|
} else if authenticated {
|
||||||
|
return nil, fmt.Errorf("external tool authorization policy is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析工具名称:name::toolName
|
||||||
|
if idx := findSubstring(toolName, "::"); idx > 0 {
|
||||||
|
mcpName = toolName[:idx]
|
||||||
|
actualToolName = toolName[idx+2:]
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("无效的工具名称格式: %s", toolName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
client, exists = m.GetClient(mcpName)
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("外部MCP客户端不存在: %s", mcpName)
|
||||||
|
}
|
||||||
|
if err := m.checkExternalMCPCircuit(mcpName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查连接状态,如果未连接或状态为error,不允许调用
|
||||||
|
if !client.IsConnected() {
|
||||||
|
status := client.GetStatus()
|
||||||
|
if status == "error" {
|
||||||
|
// 获取错误信息(如果有)
|
||||||
|
errorMsg := m.GetError(mcpName)
|
||||||
|
if errorMsg != "" {
|
||||||
|
return nil, fmt.Errorf("外部MCP连接失败: %s (错误: %s)", mcpName, errorMsg)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("外部MCP连接失败: %s", mcpName)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("外部MCP客户端未连接: %s (状态: %s)", mcpName, status)
|
||||||
|
}
|
||||||
|
|
||||||
release, acquireErr := m.acquireExternalMCPCallSlot(runCtx, mcpName)
|
release, acquireErr := m.acquireExternalMCPCallSlot(runCtx, mcpName)
|
||||||
if acquireErr != nil {
|
if acquireErr != nil {
|
||||||
return nil, acquireErr
|
return nil, acquireErr
|
||||||
@@ -746,7 +749,9 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
|
|||||||
},
|
},
|
||||||
OnDone: func(exec *ToolExecution) {
|
OnDone: func(exec *ToolExecution) {
|
||||||
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled
|
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled
|
||||||
m.recordExternalMCPResult(mcpName, failed)
|
if mcpName != "" {
|
||||||
|
m.recordExternalMCPResult(mcpName, failed)
|
||||||
|
}
|
||||||
m.updateStats(toolName, failed)
|
m.updateStats(toolName, failed)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,10 +20,20 @@ func TestExternalManagerEnforcesConfiguredAuthorizer(t *testing.T) {
|
|||||||
return errors.New("denied by policy")
|
return errors.New("denied by policy")
|
||||||
})
|
})
|
||||||
ctx := authctx.WithPrincipal(context.Background(), authctx.NewPrincipal("u1", "user", "assigned", map[string]bool{"agent:execute": true}))
|
ctx := authctx.WithPrincipal(context.Background(), authctx.NewPrincipal("u1", "user", "assigned", map[string]bool{"agent:execute": true}))
|
||||||
_, _, err := manager.CallTool(ctx, "server::tool", map[string]interface{}{})
|
_, executionID, err := manager.CallTool(ctx, "server::tool", map[string]interface{}{})
|
||||||
if err == nil || !strings.Contains(err.Error(), "authorization denied") {
|
if err == nil || !strings.Contains(err.Error(), "authorization denied") {
|
||||||
t.Fatalf("external call bypassed authorizer: %v", err)
|
t.Fatalf("external call bypassed authorizer: %v", err)
|
||||||
}
|
}
|
||||||
|
if executionID == "" {
|
||||||
|
t.Fatal("denied external call should still return an execution id")
|
||||||
|
}
|
||||||
|
execution, ok := manager.GetExecution(executionID)
|
||||||
|
if !ok || execution == nil {
|
||||||
|
t.Fatalf("missing denied external execution %q", executionID)
|
||||||
|
}
|
||||||
|
if execution.Status != ToolExecutionStatusFailed || !strings.Contains(execution.Error, "denied by policy") {
|
||||||
|
t.Fatalf("denied external execution = %#v, want failed with policy error", execution)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExternalMCPManager_AddOrUpdateConfig(t *testing.T) {
|
func TestExternalMCPManager_AddOrUpdateConfig(t *testing.T) {
|
||||||
|
|||||||
+15
-19
@@ -895,25 +895,6 @@ func (s *Server) GetAllTools() []Tool {
|
|||||||
|
|
||||||
// CallTool 直接调用工具(用于内部调用)
|
// CallTool 直接调用工具(用于内部调用)
|
||||||
func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]interface{}) (*ToolResult, string, error) {
|
func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]interface{}) (*ToolResult, string, error) {
|
||||||
_, authenticated := authctx.PrincipalFromContext(ctx)
|
|
||||||
s.mu.RLock()
|
|
||||||
authorizer := s.toolAuthorizer
|
|
||||||
s.mu.RUnlock()
|
|
||||||
if authorizer != nil {
|
|
||||||
if err := authorizer(ctx, toolName, args); err != nil {
|
|
||||||
return nil, "", fmt.Errorf("tool authorization denied: %w", err)
|
|
||||||
}
|
|
||||||
} else if authenticated {
|
|
||||||
return nil, "", errors.New("tool authorization policy is not configured")
|
|
||||||
}
|
|
||||||
s.mu.RLock()
|
|
||||||
handler, exists := s.tools[toolName]
|
|
||||||
s.mu.RUnlock()
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
return nil, "", fmt.Errorf("工具 %s 未找到", toolName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.executionService == nil {
|
if s.executionService == nil {
|
||||||
s.executionService = NewExecutionService(s.storage, s.logger)
|
s.executionService = NewExecutionService(s.storage, s.logger)
|
||||||
s.executionService.ConfigureToolResultMaxBytes(s.toolResultMaxBytes)
|
s.executionService.ConfigureToolResultMaxBytes(s.toolResultMaxBytes)
|
||||||
@@ -929,6 +910,21 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
|
|||||||
ConversationID: MCPConversationIDFromContext(ctx),
|
ConversationID: MCPConversationIDFromContext(ctx),
|
||||||
OwnerUserID: ownerUserID,
|
OwnerUserID: ownerUserID,
|
||||||
Run: func(runCtx context.Context) (*ToolResult, error) {
|
Run: func(runCtx context.Context) (*ToolResult, error) {
|
||||||
|
_, authenticated := authctx.PrincipalFromContext(runCtx)
|
||||||
|
s.mu.RLock()
|
||||||
|
authorizer := s.toolAuthorizer
|
||||||
|
handler, exists := s.tools[toolName]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if authorizer != nil {
|
||||||
|
if err := authorizer(runCtx, toolName, args); err != nil {
|
||||||
|
return nil, fmt.Errorf("tool authorization denied: %w", err)
|
||||||
|
}
|
||||||
|
} else if authenticated {
|
||||||
|
return nil, errors.New("tool authorization policy is not configured")
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("工具 %s 未找到", toolName)
|
||||||
|
}
|
||||||
return handler(runCtx, args)
|
return handler(runCtx, args)
|
||||||
},
|
},
|
||||||
OnDone: func(exec *ToolExecution) {
|
OnDone: func(exec *ToolExecution) {
|
||||||
|
|||||||
@@ -23,9 +23,20 @@ func TestToolAuthorizerIsUniversalAndExecutionKeepsOwner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if _, _, err := server.CallTool(context.Background(), "echo", nil); err == nil {
|
_, deniedExecutionID, err := server.CallTool(context.Background(), "echo", nil)
|
||||||
|
if err == nil {
|
||||||
t.Fatal("tool call without principal was allowed")
|
t.Fatal("tool call without principal was allowed")
|
||||||
}
|
}
|
||||||
|
if deniedExecutionID == "" {
|
||||||
|
t.Fatal("denied tool call should still return an execution id")
|
||||||
|
}
|
||||||
|
deniedExecution, ok := server.GetExecution(deniedExecutionID)
|
||||||
|
if !ok || deniedExecution == nil {
|
||||||
|
t.Fatalf("missing denied execution %q", deniedExecutionID)
|
||||||
|
}
|
||||||
|
if deniedExecution.Status != ToolExecutionStatusFailed || !strings.Contains(deniedExecution.Error, "principal required") {
|
||||||
|
t.Fatalf("denied execution = %#v, want failed with authorization error", deniedExecution)
|
||||||
|
}
|
||||||
ctx := authctx.WithPrincipal(context.Background(), authctx.NewPrincipal("u1", "user", "assigned", map[string]bool{"mcp:execute": true}))
|
ctx := authctx.WithPrincipal(context.Background(), authctx.NewPrincipal("u1", "user", "assigned", map[string]bool{"mcp:execute": true}))
|
||||||
_, executionID, err := server.CallTool(ctx, "echo", nil)
|
_, executionID, err := server.CallTool(ctx, "echo", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ func isEinoTransientRunError(err error) bool {
|
|||||||
"bad gateway",
|
"bad gateway",
|
||||||
"gateway timeout",
|
"gateway timeout",
|
||||||
"internal server error",
|
"internal server error",
|
||||||
|
"unexpected internal error",
|
||||||
"connection reset",
|
"connection reset",
|
||||||
"connection refused",
|
"connection refused",
|
||||||
"connection closed",
|
"connection closed",
|
||||||
@@ -72,6 +73,7 @@ func isEinoTransientRunError(err error) bool {
|
|||||||
"dial tcp",
|
"dial tcp",
|
||||||
"tls handshake timeout",
|
"tls handshake timeout",
|
||||||
"stream error",
|
"stream error",
|
||||||
|
"failed to receive stream chunk",
|
||||||
"goaway", // http2: server sent GOAWAY and closed the connection
|
"goaway", // http2: server sent GOAWAY and closed the connection
|
||||||
"unexpected eof",
|
"unexpected eof",
|
||||||
`": eof`, // net/http: Post "url": EOF (often wraps io.EOF)
|
`": eof`, // net/http: Post "url": EOF (often wraps io.EOF)
|
||||||
@@ -136,7 +138,8 @@ func einoTransientRunErrorUserDetail(err error) (kind, summary string) {
|
|||||||
case strings.Contains(lower, "overloaded") ||
|
case strings.Contains(lower, "overloaded") ||
|
||||||
strings.Contains(lower, "capacity") ||
|
strings.Contains(lower, "capacity") ||
|
||||||
strings.Contains(lower, "temporarily unavailable") ||
|
strings.Contains(lower, "temporarily unavailable") ||
|
||||||
strings.Contains(lower, "service unavailable"):
|
strings.Contains(lower, "service unavailable") ||
|
||||||
|
strings.Contains(lower, "unexpected internal error"):
|
||||||
kind = "upstream_busy"
|
kind = "upstream_busy"
|
||||||
case strings.Contains(lower, "connection reset") ||
|
case strings.Contains(lower, "connection reset") ||
|
||||||
strings.Contains(lower, "connection refused") ||
|
strings.Contains(lower, "connection refused") ||
|
||||||
@@ -153,6 +156,7 @@ func einoTransientRunErrorUserDetail(err error) (kind, summary string) {
|
|||||||
strings.Contains(lower, "unexpected eof"):
|
strings.Contains(lower, "unexpected eof"):
|
||||||
kind = "network"
|
kind = "network"
|
||||||
case strings.Contains(lower, "stream error") ||
|
case strings.Contains(lower, "stream error") ||
|
||||||
|
strings.Contains(lower, "failed to receive stream chunk") ||
|
||||||
strings.Contains(lower, "unexpected end of json"):
|
strings.Contains(lower, "unexpected end of json"):
|
||||||
kind = "stream"
|
kind = "stream"
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ func TestIsEinoTransientRunError(t *testing.T) {
|
|||||||
{"rate limit", errors.New(`{"error":"rate limit exceeded"}`), true},
|
{"rate limit", errors.New(`{"error":"rate limit exceeded"}`), true},
|
||||||
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
|
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
|
||||||
{"http2 goaway", errors.New("failed to receive stream chunk: error, http2: server sent GOAWAY and closed the connection; LastStreamID=791, ErrCode=NO_ERROR"), true},
|
{"http2 goaway", errors.New("failed to receive stream chunk: error, http2: server sent GOAWAY and closed the connection; LastStreamID=791, ErrCode=NO_ERROR"), true},
|
||||||
|
{"unexpected internal stream chunk", errors.New("failed to receive stream chunk: error, The service encountered an unexpected internal error. Request id: 0217851391106464f01ec66621d0980a42fd45436ed75957a6a0a"), true},
|
||||||
{"unexpected eof", errors.New("unexpected EOF"), true},
|
{"unexpected eof", errors.New("unexpected EOF"), true},
|
||||||
{"503", errors.New("upstream returned 503"), true},
|
{"503", errors.New("upstream returned 503"), true},
|
||||||
{"iteration limit", errors.New("max iteration reached"), false},
|
{"iteration limit", errors.New("max iteration reached"), false},
|
||||||
@@ -74,6 +75,7 @@ func TestEinoTransientRunErrorUserDetail(t *testing.T) {
|
|||||||
{"upstream", errors.New("upstream returned 503"), "upstream_server"},
|
{"upstream", errors.New("upstream returned 503"), "upstream_server"},
|
||||||
{"network", errors.New("read tcp: connection reset by peer"), "network"},
|
{"network", errors.New("read tcp: connection reset by peer"), "network"},
|
||||||
{"stream", errors.New("unexpected end of JSON"), "stream"},
|
{"stream", errors.New("unexpected end of JSON"), "stream"},
|
||||||
|
{"stream chunk", errors.New("failed to receive stream chunk: error, The service encountered an unexpected internal error. Request id: abc"), "upstream_busy"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
tc := tc
|
tc := tc
|
||||||
|
|||||||
@@ -96,7 +96,17 @@ func (m *modelOutputGuardMiddleware) AfterModelRewriteState(
|
|||||||
badIndex := -1
|
badIndex := -1
|
||||||
argumentBytes := 0
|
argumentBytes := 0
|
||||||
if strings.EqualFold(strings.TrimSpace(finishReason), "length") {
|
if strings.EqualFold(strings.TrimSpace(finishReason), "length") {
|
||||||
reason = "output_limit"
|
if len(last.ToolCalls) == 0 {
|
||||||
|
reason = "output_limit"
|
||||||
|
} else {
|
||||||
|
for i, tc := range last.ToolCalls {
|
||||||
|
r, n := validateGeneratedToolCall(tc, m.cfg)
|
||||||
|
if r != "" {
|
||||||
|
reason, badIndex, argumentBytes = "output_limit", i, n
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
for i, tc := range last.ToolCalls {
|
for i, tc := range last.ToolCalls {
|
||||||
r, n := validateGeneratedToolCall(tc, m.cfg)
|
r, n := validateGeneratedToolCall(tc, m.cfg)
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ func TestModelOutputGuardRejectsTruncatedToolCallBeforeExecution(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModelOutputGuardAllowsValidToolCallDespiteLengthFinish(t *testing.T) {
|
||||||
|
original := `{"command":"echo ok"}`
|
||||||
|
state, err := runModelOutputGuard(t, []adk.Message{schema.UserMessage("run"), guardedAssistant(original, "length")}, config.MultiAgentEinoMiddlewareConfig{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := state.Messages[len(state.Messages)-1].ToolCalls[0].Function.Arguments
|
||||||
|
if got != original {
|
||||||
|
t.Fatalf("valid arguments should pass unchanged: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestModelOutputGuardRejectsInvalidJSONShapes(t *testing.T) {
|
func TestModelOutputGuardRejectsInvalidJSONShapes(t *testing.T) {
|
||||||
for _, arguments := range []string{"", `[]`, `{"command":`} {
|
for _, arguments := range []string{"", `[]`, `{"command":`} {
|
||||||
t.Run(arguments, func(t *testing.T) {
|
t.Run(arguments, func(t *testing.T) {
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ type RunResult struct {
|
|||||||
MCPExecutionIDs []string
|
MCPExecutionIDs []string
|
||||||
LastAgentTraceInput string // 已序列化的消息带(JSON):原生循环或 Eino 均写入,供续跑/攻击链等恢复上下文
|
LastAgentTraceInput string // 已序列化的消息带(JSON):原生循环或 Eino 均写入,供续跑/攻击链等恢复上下文
|
||||||
LastAgentTraceOutput string // 本轮助手侧对外展示文本(摘要或最终回复)
|
LastAgentTraceOutput string // 本轮助手侧对外展示文本(摘要或最终回复)
|
||||||
|
Finalized bool
|
||||||
|
Status string
|
||||||
|
CompletionReason string
|
||||||
|
EvidenceVerified bool
|
||||||
|
EvidenceRefs []string
|
||||||
|
PendingExecutionIDs []string
|
||||||
|
MissingChecks []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolCallPendingInfo tracks a tool_call emitted to the UI so we can later
|
// toolCallPendingInfo tracks a tool_call emitted to the UI so we can later
|
||||||
|
|||||||
@@ -893,7 +893,7 @@ func NewEinoHTTPClient(cfg *config.OpenAIConfig, base *http.Client) *http.Client
|
|||||||
if transport == nil {
|
if transport == nil {
|
||||||
transport = http.DefaultTransport
|
transport = http.DefaultTransport
|
||||||
}
|
}
|
||||||
transport = &reasoningToolChoiceCompatRoundTripper{base: transport}
|
transport = &reasoningToolChoiceCompatRoundTripper{base: transport, cfg: cfg}
|
||||||
if isClaudeProvider(cfg) {
|
if isClaudeProvider(cfg) {
|
||||||
transport = &claudeRoundTripper{
|
transport = &claudeRoundTripper{
|
||||||
base: transport,
|
base: transport,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package openai
|
package openai
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/bytedance/sonic"
|
"github.com/bytedance/sonic"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,6 +54,28 @@ func StripReasoningIfForcedToolChoice(rawBody []byte) ([]byte, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StripToolChoiceForThinkingMode removes tool_choice while preserving tools and
|
||||||
|
// thinking fields. DeepSeek thinking mode can use tools, but rejects the
|
||||||
|
// tool_choice parameter itself on some agent requests.
|
||||||
|
func StripToolChoiceForThinkingMode(rawBody []byte) ([]byte, error) {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
|
||||||
|
return rawBody, nil
|
||||||
|
}
|
||||||
|
if !thinkingModeEnabledByPayload(payload) {
|
||||||
|
return rawBody, nil
|
||||||
|
}
|
||||||
|
if _, ok := payload["tool_choice"]; !ok {
|
||||||
|
return rawBody, nil
|
||||||
|
}
|
||||||
|
delete(payload, "tool_choice")
|
||||||
|
out, err := sonic.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return rawBody, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func stripReasoningFields(payload map[string]any) bool {
|
func stripReasoningFields(payload map[string]any) bool {
|
||||||
changed := false
|
changed := false
|
||||||
for _, key := range reasoningPayloadKeys {
|
for _, key := range reasoningPayloadKeys {
|
||||||
@@ -77,3 +101,17 @@ func forcedToolChoiceIncompatibleWithThinking(payload map[string]any) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func thinkingModeEnabledByPayload(payload map[string]any) bool {
|
||||||
|
thinking, ok := payload["thinking"]
|
||||||
|
if !ok || thinking == nil {
|
||||||
|
// DeepSeek enables thinking by default unless explicitly disabled.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if m, ok := thinking.(map[string]any); ok {
|
||||||
|
if typ, ok := m["type"].(string); ok && strings.EqualFold(strings.TrimSpace(typ), "disabled") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestStripReasoningFromChatCompletionBody(t *testing.T) {
|
func TestStripReasoningFromChatCompletionBody(t *testing.T) {
|
||||||
@@ -82,6 +84,58 @@ func TestStripReasoningIfForcedToolChoice(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStripToolChoiceForThinkingMode(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
wantToolChoice bool
|
||||||
|
wantThinking bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "enabled thinking removes tool_choice",
|
||||||
|
in: `{"model":"deepseek-v4","messages":[],"thinking":{"type":"enabled"},"tool_choice":"required","tools":[{"type":"function","function":{"name":"scan"}}]}`,
|
||||||
|
wantToolChoice: false,
|
||||||
|
wantThinking: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default thinking removes tool_choice",
|
||||||
|
in: `{"model":"deepseek-v4","messages":[],"tool_choice":"auto","tools":[]}`,
|
||||||
|
wantToolChoice: false,
|
||||||
|
wantThinking: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disabled thinking keeps tool_choice",
|
||||||
|
in: `{"model":"deepseek-v4","messages":[],"thinking":{"type":"disabled"},"tool_choice":"required","tools":[]}`,
|
||||||
|
wantToolChoice: true,
|
||||||
|
wantThinking: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no tool_choice unchanged",
|
||||||
|
in: `{"model":"deepseek-v4","messages":[],"thinking":{"type":"enabled"},"tools":[]}`,
|
||||||
|
wantToolChoice: false,
|
||||||
|
wantThinking: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
out, err := StripToolChoiceForThinkingMode([]byte(tc.in))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(out)
|
||||||
|
if strings.Contains(s, "tool_choice") != tc.wantToolChoice {
|
||||||
|
t.Fatalf("tool_choice presence mismatch, got %s", s)
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "thinking") != tc.wantThinking {
|
||||||
|
t.Fatalf("thinking presence mismatch, got %s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "tools") {
|
||||||
|
t.Fatalf("expected tools preserved, got %s", s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReasoningToolChoiceCompatRoundTripper(t *testing.T) {
|
func TestReasoningToolChoiceCompatRoundTripper(t *testing.T) {
|
||||||
var gotBody string
|
var gotBody string
|
||||||
rt := &reasoningToolChoiceCompatRoundTripper{
|
rt := &reasoningToolChoiceCompatRoundTripper{
|
||||||
@@ -113,6 +167,44 @@ func TestReasoningToolChoiceCompatRoundTripper(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReasoningToolChoiceCompatRoundTripperDeepSeek(t *testing.T) {
|
||||||
|
var gotBody string
|
||||||
|
rt := &reasoningToolChoiceCompatRoundTripper{
|
||||||
|
cfg: &config.OpenAIConfig{
|
||||||
|
BaseURL: "https://api.deepseek.com/v1",
|
||||||
|
Model: "deepseek-v4",
|
||||||
|
},
|
||||||
|
base: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
b, _ := io.ReadAll(req.Body)
|
||||||
|
gotBody = string(b)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`)),
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
}, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://api.deepseek.com/v1/chat/completions", strings.NewReader(
|
||||||
|
`{"model":"deepseek-v4","thinking":{"type":"enabled"},"tool_choice":"required","tools":[],"messages":[]}`,
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = rt.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(gotBody, "tool_choice") {
|
||||||
|
t.Fatalf("expected DeepSeek tool_choice stripped in transit, got %s", gotBody)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotBody, "thinking") {
|
||||||
|
t.Fatalf("expected thinking preserved for DeepSeek, got %s", gotBody)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotBody, "tools") {
|
||||||
|
t.Fatalf("expected tools preserved for DeepSeek, got %s", gotBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// reasoningToolChoiceCompatRoundTripper strips thinking/reasoning fields from
|
// reasoningToolChoiceCompatRoundTripper strips thinking/reasoning fields from
|
||||||
@@ -13,6 +15,7 @@ import (
|
|||||||
// when thinking mode is enabled on the same request.
|
// when thinking mode is enabled on the same request.
|
||||||
type reasoningToolChoiceCompatRoundTripper struct {
|
type reasoningToolChoiceCompatRoundTripper struct {
|
||||||
base http.RoundTripper
|
base http.RoundTripper
|
||||||
|
cfg *config.OpenAIConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
@@ -32,7 +35,13 @@ func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (*
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
patched, perr := StripReasoningIfForcedToolChoice(body)
|
patched := body
|
||||||
|
var perr error
|
||||||
|
if isDeepSeekToolChoiceCompatProfile(rt.cfg) {
|
||||||
|
patched, perr = StripToolChoiceForThinkingMode(body)
|
||||||
|
} else {
|
||||||
|
patched, perr = StripReasoningIfForcedToolChoice(body)
|
||||||
|
}
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
patched = body
|
patched = body
|
||||||
}
|
}
|
||||||
@@ -41,3 +50,19 @@ func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (*
|
|||||||
req.Header.Set("Content-Length", strconv.Itoa(len(patched)))
|
req.Header.Set("Content-Length", strconv.Itoa(len(patched)))
|
||||||
return rt.base.RoundTrip(req)
|
return rt.base.RoundTrip(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isDeepSeekToolChoiceCompatProfile(cfg *config.OpenAIConfig) bool {
|
||||||
|
if cfg == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
profile := strings.ToLower(strings.TrimSpace(cfg.Reasoning.ProfileEffective()))
|
||||||
|
if profile == "deepseek" || profile == "deepseek_compat" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if profile != "" && profile != "auto" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
baseURL := strings.ToLower(cfg.BaseURL)
|
||||||
|
model := strings.ToLower(cfg.Model)
|
||||||
|
return strings.Contains(baseURL, "deepseek") || strings.Contains(model, "deepseek")
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
bodyDepFactLine = regexp.MustCompile(`(?im)^[\s\-*]*依赖事实\s*[::]\s*([a-z0-9][a-z0-9._/-]*)`)
|
bodyDepFactLine = regexp.MustCompile(`(?im)^[\s\-*]*依赖事实\s*[::]\s*([a-zA-Z0-9][a-zA-Z0-9._/-]*)`)
|
||||||
bodyRelFactLine = regexp.MustCompile(`(?im)^[\s\-*]*相关\s*fact_key\s*[::]\s*([a-z0-9][a-z0-9._/-]*)`)
|
bodyRelFactLine = regexp.MustCompile(`(?im)^[\s\-*]*相关\s*fact_key\s*[::]\s*([a-zA-Z0-9][a-zA-Z0-9._/-]*)`)
|
||||||
bodyAssocSection = regexp.MustCompile(`(?im)^##\s*关联\s*$`)
|
bodyAssocSection = regexp.MustCompile(`(?im)^##\s*关联\s*$`)
|
||||||
bodySyncLinksHead = "结构化关系边(自动同步)"
|
bodySyncLinksHead = "结构化关系边(自动同步)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -172,6 +172,8 @@ func permissionForRequest(method, fullPath string) string {
|
|||||||
return "workflow:read"
|
return "workflow:read"
|
||||||
case strings.HasPrefix(path, "/workflow-package-inspections"), strings.HasPrefix(path, "/workflow-package-imports"):
|
case strings.HasPrefix(path, "/workflow-package-inspections"), strings.HasPrefix(path, "/workflow-package-imports"):
|
||||||
return "workflow:write"
|
return "workflow:write"
|
||||||
|
case path == "/workflows/generate-draft":
|
||||||
|
return "workflow:write"
|
||||||
case strings.HasPrefix(path, "/workflows"):
|
case strings.HasPrefix(path, "/workflows"):
|
||||||
if path == "/workflows/validate" || path == "/workflows/dry-run" || strings.HasSuffix(path, "/resume") {
|
if path == "/workflows/validate" || path == "/workflows/dry-run" || strings.HasSuffix(path, "/resume") {
|
||||||
return "workflow:execute"
|
return "workflow:execute"
|
||||||
@@ -265,7 +267,7 @@ func isProcessGlobalMutationPath(path string) bool {
|
|||||||
}
|
}
|
||||||
if strings.HasPrefix(path, "/workflows") {
|
if strings.HasPrefix(path, "/workflows") {
|
||||||
// Workflow runs inherit conversation access; definitions are global.
|
// Workflow runs inherit conversation access; definitions are global.
|
||||||
return !strings.HasPrefix(path, "/workflows/runs/") && path != "/workflows/validate" && path != "/workflows/dry-run"
|
return !strings.HasPrefix(path, "/workflows/runs/") && path != "/workflows/validate" && path != "/workflows/dry-run" && path != "/workflows/generate-draft"
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(path, "/workflow-package-inspections") || strings.HasPrefix(path, "/workflow-package-imports") {
|
if strings.HasPrefix(path, "/workflow-package-inspections") || strings.HasPrefix(path, "/workflow-package-imports") {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -157,9 +157,15 @@ func TestWorkflowRunPermissionIsSeparateFromDefinitionManagement(t *testing.T) {
|
|||||||
if got := permissionForRequest(http.MethodPost, "/api/workflows/runs/run-1/resume"); got != "workflow:execute" {
|
if got := permissionForRequest(http.MethodPost, "/api/workflows/runs/run-1/resume"); got != "workflow:execute" {
|
||||||
t.Fatalf("resume permission = %q, want workflow:execute", got)
|
t.Fatalf("resume permission = %q, want workflow:execute", got)
|
||||||
}
|
}
|
||||||
|
if got := permissionForRequest(http.MethodPost, "/api/workflows/generate-draft"); got != "workflow:write" {
|
||||||
|
t.Fatalf("generate draft permission = %q, want workflow:write", got)
|
||||||
|
}
|
||||||
if got := permissionForRequest(http.MethodPut, "/api/workflows/workflow-1"); got != "workflow:write" {
|
if got := permissionForRequest(http.MethodPut, "/api/workflows/workflow-1"); got != "workflow:write" {
|
||||||
t.Fatalf("definition permission = %q, want workflow:write", got)
|
t.Fatalf("definition permission = %q, want workflow:write", got)
|
||||||
}
|
}
|
||||||
|
if isProcessGlobalMutationPath("/workflows/generate-draft") {
|
||||||
|
t.Fatalf("generate draft should not be treated as a process-global mutation")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRBACDenyHookReceivesDeniedDecision(t *testing.T) {
|
func TestRBACDenyHookReceivesDeniedDecision(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,782 @@
|
|||||||
|
package workflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/openai"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DraftTool struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftOptions struct {
|
||||||
|
IncludeObjective bool `json:"include_objective"`
|
||||||
|
AllowSchedule bool `json:"allow_schedule"`
|
||||||
|
AllowHighRisk bool `json:"allow_high_risk"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftRequest struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Options DraftOptions `json:"options"`
|
||||||
|
AvailableTools []DraftTool `json:"available_tools,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftMeta struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftCapability struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
ToolName string `json:"tool_name,omitempty"`
|
||||||
|
ToolCandidates []string `json:"tool_candidates,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftAudit struct {
|
||||||
|
Savable bool `json:"savable"`
|
||||||
|
Validation []string `json:"validation,omitempty"`
|
||||||
|
MissingFields []string `json:"missing_fields,omitempty"`
|
||||||
|
RiskWarnings []string `json:"risk_warnings,omitempty"`
|
||||||
|
Assumptions []string `json:"assumptions,omitempty"`
|
||||||
|
HighRisk bool `json:"high_risk"`
|
||||||
|
NeedsHITL bool `json:"needs_hitl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftResult struct {
|
||||||
|
Graph *graphDef `json:"graph"`
|
||||||
|
Meta DraftMeta `json:"meta"`
|
||||||
|
Generator string `json:"generator"`
|
||||||
|
Audit DraftAudit `json:"audit"`
|
||||||
|
Capabilities []DraftCapability `json:"capabilities,omitempty"`
|
||||||
|
Stats map[string]int `json:"stats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type llmDraftEnvelope struct {
|
||||||
|
Graph graphDef `json:"graph"`
|
||||||
|
Meta DraftMeta `json:"meta"`
|
||||||
|
Capabilities []DraftCapability `json:"capabilities,omitempty"`
|
||||||
|
Audit DraftAudit `json:"audit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type draftToolHint struct {
|
||||||
|
Label string
|
||||||
|
Keywords []string
|
||||||
|
Tools []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var draftToolHints = []draftToolHint{
|
||||||
|
{Label: "子域名发现", Keywords: []string{"子域名", "subdomain", "subfinder", "amass"}, Tools: []string{"subfinder", "amass"}},
|
||||||
|
{Label: "端口扫描", Keywords: []string{"端口", "port", "nmap", "rustscan", "masscan"}, Tools: []string{"nmap", "rustscan", "masscan"}},
|
||||||
|
{Label: "漏洞扫描", Keywords: []string{"漏洞", "vuln", "漏洞扫描", "nuclei", "nikto", "zap"}, Tools: []string{"nuclei", "nikto", "zap"}},
|
||||||
|
{Label: "暴露面探测", Keywords: []string{"目录", "路径", "暴露页面", "dir", "ffuf", "gobuster", "feroxbuster"}, Tools: []string{"ffuf", "gobuster", "feroxbuster", "dirsearch"}},
|
||||||
|
{Label: "证书与域名线索收集", Keywords: []string{"证书", "certificate", "crt"}, Tools: []string{"subfinder"}},
|
||||||
|
{Label: "云配置审计", Keywords: []string{"云", "cloud", "配置审计", "prowler", "scout"}, Tools: []string{"prowler", "scout-suite"}},
|
||||||
|
{Label: "容器安全检查", Keywords: []string{"容器", "镜像", "k8s", "kubernetes", "trivy", "kube"}, Tools: []string{"trivy", "kube-bench", "kube-hunter"}},
|
||||||
|
{Label: "威胁情报收集", Keywords: []string{"情报", "威胁情报", "threat", "ioc", "virustotal", "shodan", "fofa"}, Tools: []string{"virustotal_search", "shodan_search", "fofa_search"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
var highRiskDraftRE = regexp.MustCompile(`(?i)(隔离|封禁|加固|修复|执行|命令|脚本|删除|清理|阻断|封锁|攻击|利用|getshell|shell|payload|exploit|isolate|block|execute|script|delete|exploit|payload)`)
|
||||||
|
|
||||||
|
func GenerateDraftFromNaturalLanguage(ctx context.Context, req DraftRequest) (*DraftResult, error) {
|
||||||
|
prompt := strings.TrimSpace(req.Prompt)
|
||||||
|
if prompt == "" {
|
||||||
|
return nil, fmt.Errorf("工作流需求不能为空")
|
||||||
|
}
|
||||||
|
capabilities := detectDraftCapabilities(prompt, req.AvailableTools)
|
||||||
|
wantsApproval := containsAnyFold(prompt, "审批", "确认", "审核", "负责人", "人工", "review", "approve", "approval", "human")
|
||||||
|
wantsReport := containsAnyFold(prompt, "报告", "汇总", "输出", "通知", "任务", "工单", "report", "summary", "notify", "ticket")
|
||||||
|
wantsCondition := containsAnyFold(prompt, "如果", "发现", "存在", "高危", "新增", "失败", "通过", "否则", "if", "when", "high", "critical", "new", "fail")
|
||||||
|
highRisk := highRiskDraftRE.MatchString(prompt)
|
||||||
|
|
||||||
|
builder := &draftGraphBuilder{x: 120, y: 150}
|
||||||
|
assumptions := make([]string, 0)
|
||||||
|
riskWarnings := make([]string, 0)
|
||||||
|
missingFields := make([]string, 0)
|
||||||
|
|
||||||
|
start := builder.add("start", "开始", map[string]any{"input_keys": "message, conversationId, projectId, target"}, 0)
|
||||||
|
previous := start
|
||||||
|
for _, capability := range capabilities {
|
||||||
|
hasTool := strings.TrimSpace(capability.ToolName) != ""
|
||||||
|
var id string
|
||||||
|
if hasTool {
|
||||||
|
id = builder.add("tool", capability.Label, map[string]any{
|
||||||
|
"tool_name": capability.ToolName,
|
||||||
|
"arguments": `{"target":"{{inputs.target}}","message":"{{inputs.message}}"}`,
|
||||||
|
"timeout_seconds": "120",
|
||||||
|
"join_strategy": "all_merge",
|
||||||
|
}, 0)
|
||||||
|
} else {
|
||||||
|
id = builder.add("agent", capability.Label, map[string]any{
|
||||||
|
"agent_mode": "eino_single",
|
||||||
|
"input_binding": map[string]any{"from": "previous", "field": "output"},
|
||||||
|
"instruction": capability.Label + "。根据用户需求执行安全流程步骤,并输出结构化结果:" + prompt,
|
||||||
|
"output_key": "agent_result",
|
||||||
|
"join_strategy": "all_merge",
|
||||||
|
"missing_tool_candidates": strings.Join(capability.ToolCandidates, ", "),
|
||||||
|
}, 0)
|
||||||
|
if len(capability.ToolCandidates) > 0 {
|
||||||
|
assumptions = append(assumptions, capability.Label+" 未匹配到已启用工具,已生成 Agent 草稿节点。")
|
||||||
|
missingFields = append(missingFields, capability.Label+": 选择或启用对应 MCP 工具")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.connect(previous, id, "", nil)
|
||||||
|
previous = id
|
||||||
|
}
|
||||||
|
|
||||||
|
openConditionID := ""
|
||||||
|
if wantsCondition {
|
||||||
|
expr := `{{previous.output}} != ""`
|
||||||
|
label := "是否满足触发条件"
|
||||||
|
if highRisk {
|
||||||
|
expr = `{{previous.output}} contains "高危"`
|
||||||
|
label = "是否需要高风险处置"
|
||||||
|
}
|
||||||
|
condition := builder.add("condition", label, map[string]any{"expression": expr, "join_strategy": "all_merge"}, 0)
|
||||||
|
builder.connect(previous, condition, "", nil)
|
||||||
|
openConditionID = condition
|
||||||
|
report := builder.add("output", draftOutputLabel(wantsReport), map[string]any{
|
||||||
|
"output_key": "result",
|
||||||
|
"source_binding": map[string]any{"from": "previous", "field": "output"},
|
||||||
|
"static_value": "",
|
||||||
|
"join_strategy": "all_merge",
|
||||||
|
}, 130)
|
||||||
|
builder.connect(condition, report, "否", map[string]any{"condition": `{{previous.matched}} == "false"`, "branch": "false"})
|
||||||
|
previous = condition
|
||||||
|
}
|
||||||
|
|
||||||
|
insertedHITL := false
|
||||||
|
if highRisk {
|
||||||
|
if !req.Options.AllowHighRisk || wantsApproval {
|
||||||
|
approval := builder.add("hitl", "人工审批", map[string]any{
|
||||||
|
"prompt": "请确认是否允许继续执行高风险处置:" + prompt,
|
||||||
|
"prompt_binding": map[string]any{"from": "previous", "field": "output"},
|
||||||
|
"reviewer": "human",
|
||||||
|
"join_strategy": "all_merge",
|
||||||
|
"risk_level": "high",
|
||||||
|
}, 0)
|
||||||
|
builder.connect(previous, approval, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
|
||||||
|
if previous == openConditionID {
|
||||||
|
openConditionID = ""
|
||||||
|
}
|
||||||
|
previous = approval
|
||||||
|
insertedHITL = true
|
||||||
|
}
|
||||||
|
action := builder.add("agent", "执行受控处置", map[string]any{
|
||||||
|
"agent_mode": "eino_single",
|
||||||
|
"input_binding": map[string]any{"from": "previous", "field": "output"},
|
||||||
|
"instruction": "仅在授权范围内生成处置步骤草稿;实际执行前必须由人工确认。用户需求:" + prompt,
|
||||||
|
"output_key": "remediation_plan",
|
||||||
|
"join_strategy": "all_merge",
|
||||||
|
"risk_level": "high",
|
||||||
|
"requires_human_confirmation": "true",
|
||||||
|
}, 0)
|
||||||
|
builder.connect(previous, action, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
|
||||||
|
if previous == openConditionID {
|
||||||
|
openConditionID = ""
|
||||||
|
}
|
||||||
|
previous = action
|
||||||
|
if insertedHITL {
|
||||||
|
riskWarnings = append(riskWarnings, "检测到高风险动作,已加入人工审批与 requires_human_confirmation 标记。")
|
||||||
|
} else {
|
||||||
|
riskWarnings = append(riskWarnings, "检测到高风险动作,已保留为草稿并添加 requires_human_confirmation 标记。")
|
||||||
|
}
|
||||||
|
} else if wantsApproval {
|
||||||
|
approval := builder.add("hitl", "人工审批", map[string]any{
|
||||||
|
"prompt": "请审核工作流阶段结果:" + prompt,
|
||||||
|
"prompt_binding": map[string]any{"from": "previous", "field": "output"},
|
||||||
|
"reviewer": "human",
|
||||||
|
"join_strategy": "all_merge",
|
||||||
|
}, 0)
|
||||||
|
builder.connect(previous, approval, "", nil)
|
||||||
|
previous = approval
|
||||||
|
insertedHITL = true
|
||||||
|
}
|
||||||
|
|
||||||
|
output := builder.add("output", draftOutputLabel(wantsReport), map[string]any{
|
||||||
|
"output_key": "result",
|
||||||
|
"source_binding": map[string]any{"from": "previous", "field": "output"},
|
||||||
|
"static_value": "",
|
||||||
|
"join_strategy": "all_merge",
|
||||||
|
}, 0)
|
||||||
|
builder.connect(previous, output, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
|
||||||
|
|
||||||
|
graph := &graphDef{Nodes: builder.nodes, Edges: builder.edges, Config: map[string]any{
|
||||||
|
"schema_version": 1,
|
||||||
|
"generated_by": "natural_language",
|
||||||
|
"source_prompt": prompt,
|
||||||
|
}}
|
||||||
|
if req.Options.IncludeObjective {
|
||||||
|
graph.Config["objective"] = prompt
|
||||||
|
}
|
||||||
|
if req.Options.AllowSchedule && containsAnyFold(prompt, "每天", "每周", "定时", "周期", "持续", "daily", "weekly", "schedule", "monitor") {
|
||||||
|
if containsAnyFold(prompt, "每天", "daily") {
|
||||||
|
graph.Config["trigger_suggestion"] = "daily"
|
||||||
|
} else {
|
||||||
|
graph.Config["trigger_suggestion"] = "scheduled"
|
||||||
|
}
|
||||||
|
assumptions = append(assumptions, "已记录定时触发建议;保存后仍需在触发器或角色绑定处配置。")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _ := json.Marshal(graph)
|
||||||
|
validation := make([]string, 0)
|
||||||
|
if err := ValidateGraphJSON(ctx, string(raw)); err != nil {
|
||||||
|
validation = append(validation, err.Error())
|
||||||
|
}
|
||||||
|
return &DraftResult{
|
||||||
|
Graph: graph,
|
||||||
|
Meta: DraftMeta{ID: draftSlug(prompt), Name: draftName(prompt), Description: prompt, Enabled: true},
|
||||||
|
Generator: "deterministic",
|
||||||
|
Audit: DraftAudit{
|
||||||
|
Savable: len(validation) == 0,
|
||||||
|
Validation: validation,
|
||||||
|
MissingFields: missingFields,
|
||||||
|
RiskWarnings: riskWarnings,
|
||||||
|
Assumptions: assumptions,
|
||||||
|
HighRisk: highRisk,
|
||||||
|
NeedsHITL: insertedHITL,
|
||||||
|
},
|
||||||
|
Capabilities: capabilities,
|
||||||
|
Stats: map[string]int{"nodes": len(graph.Nodes), "edges": len(graph.Edges)},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateDraftFromLLM(ctx context.Context, req DraftRequest, oa config.OpenAIConfig, logger *zap.Logger) (*DraftResult, error) {
|
||||||
|
prompt := strings.TrimSpace(req.Prompt)
|
||||||
|
if prompt == "" {
|
||||||
|
return nil, fmt.Errorf("工作流需求不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(oa.APIKey) == "" || strings.TrimSpace(oa.Model) == "" {
|
||||||
|
return nil, fmt.Errorf("AI 通道未配置 api_key 或 model")
|
||||||
|
}
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
callCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
toolJSON, _ := json.Marshal(req.AvailableTools)
|
||||||
|
systemPrompt := `你是 CyberStrikeAI 的工作流编排助手。你必须把用户的一句话需求转换为可保存的工作流草稿 JSON。
|
||||||
|
只返回 JSON 对象,不要 Markdown,不要解释。JSON 必须符合:
|
||||||
|
{
|
||||||
|
"meta": {"id":"kebab-case-id","name":"短名称","description":"用户需求","enabled":true},
|
||||||
|
"graph": {
|
||||||
|
"nodes": [{"id":"start-1","type":"start","label":"显示名","position":{"x":120,"y":150},"config":{}}],
|
||||||
|
"edges": [{"id":"edge-1","source":"start-1","target":"node-2","label":"","config":{}}],
|
||||||
|
"config": {"schema_version":1,"generated_by":"llm","source_prompt":"用户原文"}
|
||||||
|
},
|
||||||
|
"capabilities": [{"label":"能力名","tool_name":"已匹配工具名","tool_candidates":["候选工具"]}],
|
||||||
|
"audit": {"assumptions":[],"missing_fields":[],"risk_warnings":[]}
|
||||||
|
}
|
||||||
|
硬性规则:
|
||||||
|
- 只能输出一个合法 JSON object;不要输出 JSON Schema、注释、解释文字、Markdown 代码块或多余前后缀。
|
||||||
|
- 不要在 JSON 字符串值中使用竖线枚举写法;type 字段一次只能填写一个节点类型字符串。
|
||||||
|
- 至少 1 个 start 和 1 个 output;output/end 不能有出边。
|
||||||
|
- 节点 type 只能从这些字符串中选择:start、tool、agent、condition、hitl、output、end。
|
||||||
|
- 每个 agent、tool、output 节点都必须配置唯一的 output_key;output 节点默认使用 result。
|
||||||
|
- agent 节点必须配置 instruction 或 input_binding;默认 input_binding 为 {"from":"previous","field":"output"}。
|
||||||
|
- output 节点必须配置 source_binding 或 static_value;默认 source_binding 为 {"from":"previous","field":"output"}。
|
||||||
|
- tool 节点必须配置 tool_name、arguments、timeout_seconds;arguments 必须是合法 JSON 字符串。
|
||||||
|
- 所有非 start 且可能有多个上游的节点必须配置 join_strategy:"all_merge"。
|
||||||
|
- condition 最多 2 条出边,必须用 branch true/false,并用 label 是/否。
|
||||||
|
- tool 节点只有在 available_tools 中存在启用工具时才使用,否则用 agent 节点并在 audit.missing_fields 写明缺失工具。
|
||||||
|
- 高风险动作(执行脚本、隔离、封禁、删除、利用、payload、命令执行等)必须加入 hitl 审批,或在高风险节点 config 中标记 requires_human_confirmation:"true"、risk_level:"high"。
|
||||||
|
- 不要生成会真实执行攻击的参数;工具参数使用 {{inputs.target}}、{{inputs.message}} 占位。
|
||||||
|
- 所有节点 config 加 generated_by:"llm" 和 needs_review:"true"。`
|
||||||
|
userPrompt := fmt.Sprintf("用户需求:%s\n\n选项:%+v\n\n可用工具 JSON:%s", prompt, req.Options, string(toolJSON))
|
||||||
|
requestBody := map[string]interface{}{
|
||||||
|
"model": strings.TrimSpace(oa.Model),
|
||||||
|
"messages": []map[string]interface{}{
|
||||||
|
{"role": "system", "content": systemPrompt},
|
||||||
|
{"role": "user", "content": userPrompt},
|
||||||
|
},
|
||||||
|
"temperature": 0,
|
||||||
|
"max_completion_tokens": 4096,
|
||||||
|
"response_format": map[string]interface{}{"type": "json_object"},
|
||||||
|
"thinking": map[string]interface{}{"type": "disabled"},
|
||||||
|
}
|
||||||
|
var apiResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ReasoningContent string `json:"reasoning_content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
client := openai.NewClient(&oa, nil, logger)
|
||||||
|
if err := client.ChatCompletion(callCtx, requestBody, &apiResponse); err != nil {
|
||||||
|
return nil, fmt.Errorf("调用大模型失败: %w", err)
|
||||||
|
}
|
||||||
|
if len(apiResponse.Choices) == 0 {
|
||||||
|
return nil, fmt.Errorf("大模型未返回候选结果")
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(apiResponse.Choices[0].Message.Content)
|
||||||
|
if raw == "" {
|
||||||
|
raw = strings.TrimSpace(apiResponse.Choices[0].Message.ReasoningContent)
|
||||||
|
}
|
||||||
|
env, err := parseLLMDraftEnvelope(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := normalizeLLMDraft(prompt, req, env)
|
||||||
|
graphRaw, _ := json.Marshal(result.Graph)
|
||||||
|
validation := make([]string, 0)
|
||||||
|
if err := ValidateGraphJSON(ctx, string(graphRaw)); err != nil {
|
||||||
|
validation = append(validation, err.Error())
|
||||||
|
}
|
||||||
|
result.Audit.Validation = validation
|
||||||
|
result.Audit.Savable = len(validation) == 0
|
||||||
|
if !result.Audit.Savable {
|
||||||
|
return nil, fmt.Errorf("大模型生成的工作流未通过校验: %s", strings.Join(validation, ";"))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type draftGraphBuilder struct {
|
||||||
|
nodes []graphNode
|
||||||
|
edges []graphEdge
|
||||||
|
x float64
|
||||||
|
y float64
|
||||||
|
nodeSeq int
|
||||||
|
edgeSeq int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *draftGraphBuilder) add(nodeType, label string, config map[string]any, yOffset float64) string {
|
||||||
|
b.nodeSeq++
|
||||||
|
id := fmt.Sprintf("%s-%d", nodeType, b.nodeSeq)
|
||||||
|
if config == nil {
|
||||||
|
config = make(map[string]any)
|
||||||
|
}
|
||||||
|
config["generated_by"] = "natural_language"
|
||||||
|
config["needs_review"] = "true"
|
||||||
|
b.nodes = append(b.nodes, graphNode{
|
||||||
|
ID: id,
|
||||||
|
Type: nodeType,
|
||||||
|
Label: label,
|
||||||
|
Position: graphPosition{X: b.x, Y: b.y + yOffset},
|
||||||
|
Config: config,
|
||||||
|
})
|
||||||
|
b.x += 210
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *draftGraphBuilder) connect(source, target, label string, config map[string]any) {
|
||||||
|
b.edgeSeq++
|
||||||
|
if config == nil {
|
||||||
|
config = make(map[string]any)
|
||||||
|
}
|
||||||
|
b.edges = append(b.edges, graphEdge{ID: fmt.Sprintf("edge-ai-%d", b.edgeSeq), Source: source, Target: target, Label: label, Config: config})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLLMDraftEnvelope(raw string) (llmDraftEnvelope, error) {
|
||||||
|
var lastErr error
|
||||||
|
for _, candidate := range jsonObjectCandidates(raw) {
|
||||||
|
var env llmDraftEnvelope
|
||||||
|
if err := json.Unmarshal([]byte(candidate), &env); err == nil {
|
||||||
|
if len(env.Graph.Nodes) == 0 {
|
||||||
|
lastErr = fmt.Errorf("大模型 JSON 缺少 graph.nodes")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return env, nil
|
||||||
|
} else {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("大模型响应为空")
|
||||||
|
}
|
||||||
|
return llmDraftEnvelope{}, fmt.Errorf("解析大模型工作流 JSON 失败: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonObjectCandidates(raw string) []string {
|
||||||
|
s := strings.TrimSpace(raw)
|
||||||
|
s = strings.TrimPrefix(s, "```json")
|
||||||
|
s = strings.TrimPrefix(s, "```")
|
||||||
|
s = strings.TrimSuffix(s, "```")
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
candidates := []string{s}
|
||||||
|
if start := strings.Index(s, "{"); start >= 0 {
|
||||||
|
if end := strings.LastIndex(s, "}"); end > start {
|
||||||
|
candidates = append(candidates, s[start:end+1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLLMDraft(prompt string, req DraftRequest, env llmDraftEnvelope) *DraftResult {
|
||||||
|
g := env.Graph
|
||||||
|
if g.Config == nil {
|
||||||
|
g.Config = make(map[string]any)
|
||||||
|
}
|
||||||
|
g.Config["schema_version"] = 1
|
||||||
|
g.Config["generated_by"] = "llm"
|
||||||
|
g.Config["source_prompt"] = prompt
|
||||||
|
if req.Options.IncludeObjective {
|
||||||
|
g.Config["objective"] = prompt
|
||||||
|
}
|
||||||
|
enabledTools := enabledDraftToolNames(req.AvailableTools)
|
||||||
|
usedOutputKeys := make(map[string]bool)
|
||||||
|
nodeTypes := make(map[string]string, len(g.Nodes))
|
||||||
|
for i := range g.Nodes {
|
||||||
|
if strings.TrimSpace(g.Nodes[i].ID) == "" {
|
||||||
|
g.Nodes[i].ID = fmt.Sprintf("%s-%d", firstNonEmpty(g.Nodes[i].Type, "node"), i+1)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(g.Nodes[i].Type) == "" {
|
||||||
|
g.Nodes[i].Type = "agent"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(g.Nodes[i].Label) == "" {
|
||||||
|
g.Nodes[i].Label = displayNodeType(g.Nodes[i].Type)
|
||||||
|
}
|
||||||
|
if g.Nodes[i].Position.X == 0 && g.Nodes[i].Position.Y == 0 {
|
||||||
|
g.Nodes[i].Position = graphPosition{X: 120 + float64(i)*210, Y: 150}
|
||||||
|
}
|
||||||
|
if g.Nodes[i].Config == nil {
|
||||||
|
g.Nodes[i].Config = make(map[string]any)
|
||||||
|
}
|
||||||
|
g.Nodes[i].Config["generated_by"] = "llm"
|
||||||
|
g.Nodes[i].Config["needs_review"] = "true"
|
||||||
|
normalizeLLMNodeConfig(prompt, &g.Nodes[i], enabledTools, usedOutputKeys)
|
||||||
|
nodeTypes[g.Nodes[i].ID] = strings.ToLower(strings.TrimSpace(g.Nodes[i].Type))
|
||||||
|
}
|
||||||
|
conditionBranchCounts := make(map[string]int)
|
||||||
|
for i := range g.Edges {
|
||||||
|
if strings.TrimSpace(g.Edges[i].ID) == "" {
|
||||||
|
g.Edges[i].ID = fmt.Sprintf("edge-llm-%d", i+1)
|
||||||
|
}
|
||||||
|
if g.Edges[i].Config == nil {
|
||||||
|
g.Edges[i].Config = make(map[string]any)
|
||||||
|
}
|
||||||
|
normalizeLLMEdgeConfig(&g.Edges[i], nodeTypes, conditionBranchCounts)
|
||||||
|
}
|
||||||
|
audit := env.Audit
|
||||||
|
highRisk := highRiskDraftRE.MatchString(prompt) || graphHasHighRisk(g)
|
||||||
|
audit.HighRisk = highRisk
|
||||||
|
audit.NeedsHITL = graphHasNodeType(g, "hitl")
|
||||||
|
if highRisk && !audit.NeedsHITL && !graphHasConfirmation(g) {
|
||||||
|
audit.RiskWarnings = append(audit.RiskWarnings, "大模型生成包含高风险语义,请补充人工审批或确认标记后再运行。")
|
||||||
|
}
|
||||||
|
if len(audit.RiskWarnings) == 0 && highRisk {
|
||||||
|
audit.RiskWarnings = append(audit.RiskWarnings, "检测到高风险动作,已标记为需要重点审计。")
|
||||||
|
}
|
||||||
|
meta := env.Meta
|
||||||
|
if strings.TrimSpace(meta.Description) == "" {
|
||||||
|
meta.Description = prompt
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(meta.Name) == "" {
|
||||||
|
meta.Name = draftName(prompt)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(meta.ID) == "" {
|
||||||
|
meta.ID = draftSlug(prompt)
|
||||||
|
}
|
||||||
|
meta.Enabled = true
|
||||||
|
return &DraftResult{
|
||||||
|
Graph: &g,
|
||||||
|
Meta: meta,
|
||||||
|
Generator: "llm",
|
||||||
|
Audit: audit,
|
||||||
|
Capabilities: env.Capabilities,
|
||||||
|
Stats: map[string]int{"nodes": len(g.Nodes), "edges": len(g.Edges)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLLMEdgeConfig(edge *graphEdge, nodeTypes map[string]string, conditionBranchCounts map[string]int) {
|
||||||
|
if nodeTypes[strings.TrimSpace(edge.Source)] != "condition" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if conditionBranchHint(*edge) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conditionBranchCounts[edge.Source]++
|
||||||
|
branch := "true"
|
||||||
|
label := "是"
|
||||||
|
if conditionBranchCounts[edge.Source] > 1 {
|
||||||
|
branch = "false"
|
||||||
|
label = "否"
|
||||||
|
}
|
||||||
|
edge.Label = label
|
||||||
|
edge.Config["branch"] = branch
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLLMNodeConfig(prompt string, node *graphNode, enabledTools map[string]bool, usedOutputKeys map[string]bool) {
|
||||||
|
nodeType := strings.ToLower(strings.TrimSpace(node.Type))
|
||||||
|
switch nodeType {
|
||||||
|
case "start":
|
||||||
|
if cfgString(node.Config, "input_keys") == "" {
|
||||||
|
node.Config["input_keys"] = "message, conversationId, projectId, target"
|
||||||
|
}
|
||||||
|
case "tool":
|
||||||
|
toolName := cfgString(node.Config, "tool_name")
|
||||||
|
if toolName == "" || !enabledTools[strings.ToLower(toolName)] {
|
||||||
|
node.Type = "agent"
|
||||||
|
node.Config["missing_tool_name"] = toolName
|
||||||
|
normalizeAgentDraftConfig(prompt, node, usedOutputKeys)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfgString(node.Config, "arguments") == "" {
|
||||||
|
node.Config["arguments"] = `{"target":"{{inputs.target}}","message":"{{inputs.message}}"}`
|
||||||
|
}
|
||||||
|
if cfgString(node.Config, "timeout_seconds") == "" {
|
||||||
|
node.Config["timeout_seconds"] = "120"
|
||||||
|
}
|
||||||
|
ensureNodeOutputKey(node, usedOutputKeys, draftOutputKeyBase(node, "tool_result"))
|
||||||
|
ensureJoinStrategy(node)
|
||||||
|
case "agent":
|
||||||
|
normalizeAgentDraftConfig(prompt, node, usedOutputKeys)
|
||||||
|
case "condition":
|
||||||
|
if cfgString(node.Config, "expression") == "" {
|
||||||
|
node.Config["expression"] = `{{previous.output}} != ""`
|
||||||
|
}
|
||||||
|
ensureJoinStrategy(node)
|
||||||
|
case "hitl":
|
||||||
|
if cfgString(node.Config, "prompt") == "" {
|
||||||
|
node.Config["prompt"] = "请审核工作流阶段结果:" + prompt
|
||||||
|
}
|
||||||
|
if cfgString(node.Config, "reviewer") == "" {
|
||||||
|
node.Config["reviewer"] = "human"
|
||||||
|
}
|
||||||
|
ensureJoinStrategy(node)
|
||||||
|
case "output":
|
||||||
|
ensureNodeOutputKey(node, usedOutputKeys, "result")
|
||||||
|
if cfgString(node.Config, "static_value") == "" {
|
||||||
|
if _, ok := parseFieldBinding(node.Config, "source_binding"); !ok {
|
||||||
|
node.Config["source_binding"] = map[string]any{"from": "previous", "field": "output"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ensureJoinStrategy(node)
|
||||||
|
case "end":
|
||||||
|
ensureJoinStrategy(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAgentDraftConfig(prompt string, node *graphNode, usedOutputKeys map[string]bool) {
|
||||||
|
if cfgString(node.Config, "agent_mode") == "" {
|
||||||
|
node.Config["agent_mode"] = "eino_single"
|
||||||
|
}
|
||||||
|
if cfgString(node.Config, "instruction") == "" {
|
||||||
|
node.Config["instruction"] = node.Label + "。根据用户需求执行安全流程步骤,并输出结构化结果:" + prompt
|
||||||
|
}
|
||||||
|
if _, ok := parseFieldBinding(node.Config, "input_binding"); !ok {
|
||||||
|
node.Config["input_binding"] = map[string]any{"from": "previous", "field": "output"}
|
||||||
|
}
|
||||||
|
ensureNodeOutputKey(node, usedOutputKeys, draftOutputKeyBase(node, "agent_result"))
|
||||||
|
ensureJoinStrategy(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureJoinStrategy(node *graphNode) {
|
||||||
|
if cfgString(node.Config, "join_strategy") == "" {
|
||||||
|
node.Config["join_strategy"] = "all_merge"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureNodeOutputKey(node *graphNode, used map[string]bool, fallback string) {
|
||||||
|
current := sanitizeOutputKey(cfgString(node.Config, "output_key"))
|
||||||
|
if current == "" {
|
||||||
|
current = sanitizeOutputKey(fallback)
|
||||||
|
}
|
||||||
|
if current == "" {
|
||||||
|
current = "result"
|
||||||
|
}
|
||||||
|
base := current
|
||||||
|
for i := 2; used[current]; i++ {
|
||||||
|
current = fmt.Sprintf("%s_%d", base, i)
|
||||||
|
}
|
||||||
|
node.Config["output_key"] = current
|
||||||
|
used[current] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func draftOutputKeyBase(node *graphNode, fallback string) string {
|
||||||
|
if name := cfgString(node.Config, "tool_name"); name != "" {
|
||||||
|
return name + "_result"
|
||||||
|
}
|
||||||
|
if node.ID != "" {
|
||||||
|
return node.ID + "_result"
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeOutputKey(value string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
var b strings.Builder
|
||||||
|
lastUnderscore := false
|
||||||
|
for _, r := range value {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||||
|
b.WriteRune(r)
|
||||||
|
lastUnderscore = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Len() > 0 && !lastUnderscore {
|
||||||
|
b.WriteByte('_')
|
||||||
|
lastUnderscore = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(b.String(), "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func enabledDraftToolNames(tools []DraftTool) map[string]bool {
|
||||||
|
names := make(map[string]bool, len(tools)*2)
|
||||||
|
for _, tool := range tools {
|
||||||
|
if !tool.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if key := strings.ToLower(strings.TrimSpace(tool.Key)); key != "" {
|
||||||
|
names[key] = true
|
||||||
|
}
|
||||||
|
if name := strings.ToLower(strings.TrimSpace(tool.Name)); name != "" {
|
||||||
|
names[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func graphHasNodeType(g graphDef, nodeType string) bool {
|
||||||
|
for _, node := range g.Nodes {
|
||||||
|
if strings.EqualFold(node.Type, nodeType) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func graphHasConfirmation(g graphDef) bool {
|
||||||
|
for _, node := range g.Nodes {
|
||||||
|
if cfgString(node.Config, "requires_human_confirmation") == "true" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func graphHasHighRisk(g graphDef) bool {
|
||||||
|
for _, node := range g.Nodes {
|
||||||
|
if cfgString(node.Config, "risk_level") == "high" || cfgString(node.Config, "requires_human_confirmation") == "true" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if highRiskDraftRE.MatchString(node.Label) || highRiskDraftRE.MatchString(cfgString(node.Config, "instruction")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectDraftCapabilities(prompt string, tools []DraftTool) []DraftCapability {
|
||||||
|
capabilities := make([]DraftCapability, 0)
|
||||||
|
for _, hint := range draftToolHints {
|
||||||
|
if containsAnyFold(prompt, hint.Keywords...) {
|
||||||
|
capabilities = append(capabilities, DraftCapability{
|
||||||
|
Label: hint.Label,
|
||||||
|
ToolName: matchDraftTool(hint.Tools, tools),
|
||||||
|
ToolCandidates: append([]string(nil), hint.Tools...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(capabilities) == 0 {
|
||||||
|
capabilities = append(capabilities, DraftCapability{Label: "节点能力", ToolCandidates: nil})
|
||||||
|
}
|
||||||
|
return capabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchDraftTool(candidates []string, tools []DraftTool) string {
|
||||||
|
if len(candidates) == 0 || len(tools) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, enabledOnly := range []bool{true, false} {
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
candidate = strings.ToLower(strings.TrimSpace(candidate))
|
||||||
|
for _, tool := range tools {
|
||||||
|
if enabledOnly && !tool.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(firstNonEmpty(tool.Key, tool.Name)))
|
||||||
|
if key != "" && strings.Contains(key, candidate) {
|
||||||
|
return firstNonEmpty(tool.Key, tool.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAnyFold(text string, needles ...string) bool {
|
||||||
|
lower := strings.ToLower(text)
|
||||||
|
for _, needle := range needles {
|
||||||
|
if strings.Contains(lower, strings.ToLower(needle)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func draftOutputLabel(wantsReport bool) string {
|
||||||
|
if wantsReport {
|
||||||
|
return "输出报告"
|
||||||
|
}
|
||||||
|
return "输出"
|
||||||
|
}
|
||||||
|
|
||||||
|
func branchLabel(source, conditionID string) string {
|
||||||
|
if source == conditionID && conditionID != "" {
|
||||||
|
return "是"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func branchConfig(source, conditionID string, yes bool) map[string]any {
|
||||||
|
if source != conditionID || conditionID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if yes {
|
||||||
|
return map[string]any{"condition": `{{previous.matched}} == "true"`, "branch": "true"}
|
||||||
|
}
|
||||||
|
return map[string]any{"condition": `{{previous.matched}} == "false"`, "branch": "false"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func draftName(prompt string) string {
|
||||||
|
runes := []rune(strings.TrimSpace(prompt))
|
||||||
|
if len(runes) > 22 {
|
||||||
|
return string(runes[:22]) + "..."
|
||||||
|
}
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func draftSlug(prompt string) string {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(prompt))
|
||||||
|
var b strings.Builder
|
||||||
|
lastDash := false
|
||||||
|
for _, r := range lower {
|
||||||
|
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
lastDash = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !lastDash && b.Len() > 0 {
|
||||||
|
b.WriteByte('-')
|
||||||
|
lastDash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slug := strings.Trim(b.String(), "-")
|
||||||
|
if slug != "" {
|
||||||
|
if len(slug) > 48 {
|
||||||
|
return strings.Trim(slug[:48], "-")
|
||||||
|
}
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
h := fnv.New32a()
|
||||||
|
_, _ = h.Write([]byte(lower))
|
||||||
|
if !utf8.ValidString(lower) || lower == "" {
|
||||||
|
lower = "workflow"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("ai-workflow-%x", h.Sum32())
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package workflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateDraftFromNaturalLanguageHighRiskAddsHITLAndValidGraph(t *testing.T) {
|
||||||
|
result, err := GenerateDraftFromNaturalLanguage(context.Background(), DraftRequest{
|
||||||
|
Prompt: "对目标资产做端口扫描,如果发现高危端口就执行加固脚本,最后输出报告",
|
||||||
|
Options: DraftOptions{
|
||||||
|
IncludeObjective: true,
|
||||||
|
AllowSchedule: false,
|
||||||
|
AllowHighRisk: false,
|
||||||
|
},
|
||||||
|
AvailableTools: []DraftTool{{Key: "nmap", Name: "nmap", Enabled: true}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateDraftFromNaturalLanguage: %v", err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(result.Graph)
|
||||||
|
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
|
||||||
|
t.Fatalf("generated graph should validate: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
if !result.Audit.HighRisk || !result.Audit.NeedsHITL || len(result.Audit.RiskWarnings) == 0 {
|
||||||
|
t.Fatalf("audit did not flag high-risk HITL path: %#v", result.Audit)
|
||||||
|
}
|
||||||
|
var hasTool, hasHITL, hasConfirmation bool
|
||||||
|
for _, node := range result.Graph.Nodes {
|
||||||
|
if node.Type == "tool" && cfgString(node.Config, "tool_name") == "nmap" {
|
||||||
|
hasTool = true
|
||||||
|
}
|
||||||
|
if node.Type == "hitl" {
|
||||||
|
hasHITL = true
|
||||||
|
}
|
||||||
|
if cfgString(node.Config, "requires_human_confirmation") == "true" {
|
||||||
|
hasConfirmation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasTool || !hasHITL || !hasConfirmation {
|
||||||
|
t.Fatalf("expected nmap tool, HITL, and confirmation marker; tool=%v hitl=%v confirmation=%v", hasTool, hasHITL, hasConfirmation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDraftAllowHighRiskStillLabelsConditionBranch(t *testing.T) {
|
||||||
|
result, err := GenerateDraftFromNaturalLanguage(context.Background(), DraftRequest{
|
||||||
|
Prompt: "如果漏洞扫描发现高危漏洞,允许生成执行修复脚本的草稿并输出报告",
|
||||||
|
Options: DraftOptions{
|
||||||
|
AllowHighRisk: true,
|
||||||
|
},
|
||||||
|
AvailableTools: []DraftTool{{Key: "nuclei", Name: "nuclei", Enabled: true}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateDraftFromNaturalLanguage: %v", err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(result.Graph)
|
||||||
|
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
|
||||||
|
t.Fatalf("generated graph should validate: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
branches := map[string]bool{}
|
||||||
|
for _, edge := range result.Graph.Edges {
|
||||||
|
if branch := cfgString(edge.Config, "branch"); branch != "" {
|
||||||
|
branches[branch] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !branches["true"] || !branches["false"] {
|
||||||
|
t.Fatalf("condition branches = %#v, want true and false", branches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDraftFromLLMUsesOpenAICompatibleEndpoint(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
if r.URL.Path != "/chat/completions" {
|
||||||
|
t.Fatalf("path = %s, want /chat/completions", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
|
||||||
|
t.Fatalf("authorization = %q", got)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
ResponseFormat struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"response_format"`
|
||||||
|
Messages []struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"messages"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode request: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Temperature != 0 || payload.ResponseFormat.Type != "json_object" {
|
||||||
|
t.Fatalf("unexpected structured output controls: temperature=%v response_format=%#v", payload.Temperature, payload.ResponseFormat)
|
||||||
|
}
|
||||||
|
if len(payload.Messages) == 0 || strings.Contains(payload.Messages[0].Content, "start|tool|agent") {
|
||||||
|
t.Fatalf("system prompt still contains pipe enum: %q", payload.Messages[0].Content)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"meta\":{\"id\":\"llm-port-scan\",\"name\":\"端口扫描\",\"description\":\"端口扫描\",\"enabled\":true},\"graph\":{\"nodes\":[{\"id\":\"start-1\",\"type\":\"start\",\"label\":\"开始\",\"position\":{\"x\":120,\"y\":150},\"config\":{\"input_keys\":\"message, target\"}},{\"id\":\"tool-2\",\"type\":\"tool\",\"label\":\"端口扫描\",\"position\":{\"x\":330,\"y\":150},\"config\":{\"tool_name\":\"nmap\",\"arguments\":\"{\\\"target\\\":\\\"{{inputs.target}}\\\"}\",\"timeout_seconds\":\"120\",\"join_strategy\":\"all_merge\"}},{\"id\":\"output-3\",\"type\":\"output\",\"label\":\"输出报告\",\"position\":{\"x\":540,\"y\":150},\"config\":{\"source_binding\":{\"from\":\"previous\",\"field\":\"output\"},\"join_strategy\":\"all_merge\"}}],\"edges\":[{\"id\":\"edge-1\",\"source\":\"start-1\",\"target\":\"tool-2\"},{\"id\":\"edge-2\",\"source\":\"tool-2\",\"target\":\"output-3\"}],\"config\":{\"schema_version\":1}},\"capabilities\":[{\"label\":\"端口扫描\",\"tool_name\":\"nmap\",\"tool_candidates\":[\"nmap\"]}],\"audit\":{\"assumptions\":[]}}"}}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
result, err := GenerateDraftFromLLM(context.Background(), DraftRequest{
|
||||||
|
Prompt: "对目标做端口扫描并输出报告",
|
||||||
|
AvailableTools: []DraftTool{{Key: "nmap", Name: "nmap", Enabled: true}},
|
||||||
|
}, config.OpenAIConfig{APIKey: "test-key", BaseURL: srv.URL, Model: "test-model"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateDraftFromLLM: %v", err)
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatal("expected LLM endpoint to be called")
|
||||||
|
}
|
||||||
|
if result.Generator != "llm" || !result.Audit.Savable || result.Meta.ID != "llm-port-scan" {
|
||||||
|
t.Fatalf("unexpected result: %#v", result)
|
||||||
|
}
|
||||||
|
for _, node := range result.Graph.Nodes {
|
||||||
|
if node.Type == "output" && cfgString(node.Config, "output_key") != "result" {
|
||||||
|
t.Fatalf("output_key = %q, want result", cfgString(node.Config, "output_key"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDraftFromLLMReturnsErrorOnMalformedJSON(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"meta\":{\"id\":\"bad\"},|\"graph\":{\"nodes\":[]}}"}}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := GenerateDraftFromLLM(context.Background(), DraftRequest{
|
||||||
|
Prompt: "随便生成一个工作流,要求所有节点都用到输出变量",
|
||||||
|
Options: DraftOptions{
|
||||||
|
IncludeObjective: true,
|
||||||
|
},
|
||||||
|
}, config.OpenAIConfig{APIKey: "test-key", BaseURL: srv.URL, Model: "test-model"}, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected malformed JSON error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "解析大模型工作流 JSON 失败") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeLLMDraftRepairsMissingRequiredConfig(t *testing.T) {
|
||||||
|
result := normalizeLLMDraft("随便生成一个工作流", DraftRequest{}, llmDraftEnvelope{
|
||||||
|
Graph: graphDef{
|
||||||
|
Nodes: []graphNode{
|
||||||
|
{ID: "start-1", Type: "start", Label: "开始", Config: map[string]any{}},
|
||||||
|
{ID: "agent-1", Type: "agent", Label: "分析", Config: map[string]any{}},
|
||||||
|
{ID: "out-1", Type: "output", Label: "输出结果", Config: map[string]any{}},
|
||||||
|
},
|
||||||
|
Edges: []graphEdge{
|
||||||
|
{ID: "e1", Source: "start-1", Target: "agent-1"},
|
||||||
|
{ID: "e2", Source: "agent-1", Target: "out-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
raw, _ := json.Marshal(result.Graph)
|
||||||
|
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
|
||||||
|
t.Fatalf("normalized graph should validate: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
var agentKey, outputKey string
|
||||||
|
for _, node := range result.Graph.Nodes {
|
||||||
|
switch node.Type {
|
||||||
|
case "agent":
|
||||||
|
agentKey = cfgString(node.Config, "output_key")
|
||||||
|
case "output":
|
||||||
|
outputKey = cfgString(node.Config, "output_key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if agentKey == "" || outputKey != "result" {
|
||||||
|
t.Fatalf("agentKey=%q outputKey=%q", agentKey, outputKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeLLMDraftRepairsConditionBranches(t *testing.T) {
|
||||||
|
result := normalizeLLMDraft("如果发现异常则输出详情,否则输出正常", DraftRequest{}, llmDraftEnvelope{
|
||||||
|
Graph: graphDef{
|
||||||
|
Nodes: []graphNode{
|
||||||
|
{ID: "start-1", Type: "start", Label: "开始", Config: map[string]any{}},
|
||||||
|
{ID: "cond-1", Type: "condition", Label: "判断", Config: map[string]any{"expression": `{{inputs.message}} != ""`}},
|
||||||
|
{ID: "out-yes", Type: "output", Label: "异常", Config: map[string]any{}},
|
||||||
|
{ID: "out-no", Type: "output", Label: "正常", Config: map[string]any{}},
|
||||||
|
},
|
||||||
|
Edges: []graphEdge{
|
||||||
|
{ID: "e1", Source: "start-1", Target: "cond-1"},
|
||||||
|
{ID: "e2", Source: "cond-1", Target: "out-yes"},
|
||||||
|
{ID: "e3", Source: "cond-1", Target: "out-no"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
raw, _ := json.Marshal(result.Graph)
|
||||||
|
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
|
||||||
|
t.Fatalf("normalized graph should validate: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
branches := map[string]bool{}
|
||||||
|
for _, edge := range result.Graph.Edges {
|
||||||
|
if edge.Source == "cond-1" {
|
||||||
|
branches[cfgString(edge.Config, "branch")] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !branches["true"] || !branches["false"] {
|
||||||
|
t.Fatalf("branches = %#v, want true and false", branches)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,25 +61,30 @@ show_progress() {
|
|||||||
printf "\r"
|
printf "\r"
|
||||||
}
|
}
|
||||||
|
|
||||||
echo ""
|
print_banner() {
|
||||||
echo "=========================================="
|
local show_mirrors="${1:-1}"
|
||||||
echo " CyberStrikeAI Deploy & Start Script"
|
echo ""
|
||||||
echo " (HTTPS with self-signed cert by default; plain HTTP: $0 --http)"
|
echo "=========================================="
|
||||||
echo "=========================================="
|
echo " CyberStrikeAI Deploy & Start Script"
|
||||||
echo ""
|
echo " (HTTPS with self-signed cert by default; plain HTTP: $0 --http)"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
# Show temporary mirror/proxy info
|
if [ "$show_mirrors" -eq 1 ]; then
|
||||||
echo ""
|
# Show temporary mirror/proxy info
|
||||||
warning "Note: this script uses temporary mirrors to speed up downloads"
|
echo ""
|
||||||
echo ""
|
warning "Note: this script uses temporary mirrors to speed up downloads"
|
||||||
info "Python pip temporary mirror:"
|
echo ""
|
||||||
echo " ${PIP_INDEX_URL}"
|
info "Python pip temporary mirror:"
|
||||||
info "Go temporary proxy:"
|
echo " ${PIP_INDEX_URL}"
|
||||||
echo " ${GOPROXY}"
|
info "Go temporary proxy:"
|
||||||
echo ""
|
echo " ${GOPROXY}"
|
||||||
note "These settings apply only while this script runs and do not change system config"
|
echo ""
|
||||||
echo ""
|
note "These settings apply only while this script runs and do not change system config"
|
||||||
sleep 1
|
echo ""
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
CONFIG_FILE="$ROOT_DIR/config.yaml"
|
CONFIG_FILE="$ROOT_DIR/config.yaml"
|
||||||
EXAMPLE_CONFIG_FILE="$ROOT_DIR/config.example.yaml"
|
EXAMPLE_CONFIG_FILE="$ROOT_DIR/config.example.yaml"
|
||||||
@@ -136,6 +141,28 @@ check_go() {
|
|||||||
success "Go check passed: $(go version)"
|
success "Go check passed: $(go version)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check_go_quiet() {
|
||||||
|
if ! command -v go >/dev/null 2>&1; then
|
||||||
|
error "Go not found"
|
||||||
|
echo ""
|
||||||
|
info "Install Go 1.21 or later first:"
|
||||||
|
echo " macOS: brew install go"
|
||||||
|
echo " Ubuntu: sudo apt-get install golang-go"
|
||||||
|
echo " CentOS: sudo yum install golang"
|
||||||
|
echo " Or visit: https://go.dev/dl/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//')
|
||||||
|
GO_MAJOR=$(echo "$GO_VERSION" | cut -d. -f1)
|
||||||
|
GO_MINOR=$(echo "$GO_VERSION" | cut -d. -f2)
|
||||||
|
|
||||||
|
if [ "$GO_MAJOR" -lt 1 ] || ([ "$GO_MAJOR" -eq 1 ] && [ "$GO_MINOR" -lt 21 ]); then
|
||||||
|
error "Go version too old: $GO_VERSION (requires 1.21+)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Set up Python virtual environment
|
# Set up Python virtual environment
|
||||||
setup_python_env() {
|
setup_python_env() {
|
||||||
if [ ! -d "$VENV_DIR" ]; then
|
if [ ! -d "$VENV_DIR" ]; then
|
||||||
@@ -331,6 +358,34 @@ build_go_project() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
build_go_project_quiet() {
|
||||||
|
info "Building $BINARY_NAME..."
|
||||||
|
|
||||||
|
GO_DOWNLOAD_LOG=$(mktemp)
|
||||||
|
if ! GOPROXY="$GOPROXY" go mod download >"$GO_DOWNLOAD_LOG" 2>&1; then
|
||||||
|
error "Go dependency download failed"
|
||||||
|
echo ""
|
||||||
|
info "Download error details:"
|
||||||
|
cat "$GO_DOWNLOAD_LOG" | sed 's/^/ /'
|
||||||
|
echo ""
|
||||||
|
rm -f "$GO_DOWNLOAD_LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -f "$GO_DOWNLOAD_LOG"
|
||||||
|
|
||||||
|
GO_BUILD_LOG=$(mktemp)
|
||||||
|
if ! GOPROXY="$GOPROXY" go build -o "$BINARY_NAME" cmd/server/main.go >"$GO_BUILD_LOG" 2>&1; then
|
||||||
|
error "Build failed"
|
||||||
|
echo ""
|
||||||
|
info "Build error details:"
|
||||||
|
cat "$GO_BUILD_LOG" | sed 's/^/ /'
|
||||||
|
echo ""
|
||||||
|
rm -f "$GO_BUILD_LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -f "$GO_BUILD_LOG"
|
||||||
|
}
|
||||||
|
|
||||||
# Check whether a rebuild is needed
|
# Check whether a rebuild is needed
|
||||||
need_rebuild() {
|
need_rebuild() {
|
||||||
if [ ! -f "$BINARY_NAME" ]; then
|
if [ ! -f "$BINARY_NAME" ]; then
|
||||||
@@ -351,6 +406,7 @@ need_rebuild() {
|
|||||||
# Default: HTTPS (--https passed to binary); --http forces plain HTTP even if config.yaml enables TLS.
|
# Default: HTTPS (--https passed to binary); --http forces plain HTTP even if config.yaml enables TLS.
|
||||||
main() {
|
main() {
|
||||||
USE_HTTPS=1
|
USE_HTTPS=1
|
||||||
|
RESET_ADMIN_PASSWORD=0
|
||||||
FORWARD_ARGS=()
|
FORWARD_ARGS=()
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
if [ "$arg" = "--http" ]; then
|
if [ "$arg" = "--http" ]; then
|
||||||
@@ -361,9 +417,33 @@ main() {
|
|||||||
USE_HTTPS=1
|
USE_HTTPS=1
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
if [ "$arg" = "--reset-admin-password" ]; then
|
||||||
|
RESET_ADMIN_PASSWORD=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
FORWARD_ARGS+=("$arg")
|
FORWARD_ARGS+=("$arg")
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [ "$RESET_ADMIN_PASSWORD" -eq 1 ]; then
|
||||||
|
if [ ! -f "$CONFIG_FILE" ] && [ ! -f "$EXAMPLE_CONFIG_FILE" ]; then
|
||||||
|
error "config.yaml not found, and config.example.yaml is missing"
|
||||||
|
info "The server binary creates config.yaml from config.example.yaml on first start"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
check_go_quiet
|
||||||
|
if need_rebuild; then
|
||||||
|
build_go_project_quiet
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
if [ "${#FORWARD_ARGS[@]}" -gt 0 ]; then
|
||||||
|
exec "./$BINARY_NAME" -config "$CONFIG_FILE" --reset-admin-password "${FORWARD_ARGS[@]}"
|
||||||
|
else
|
||||||
|
exec "./$BINARY_NAME" -config "$CONFIG_FILE" --reset-admin-password
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_banner 1
|
||||||
|
|
||||||
# Environment checks
|
# Environment checks
|
||||||
info "Checking runtime environment..."
|
info "Checking runtime environment..."
|
||||||
check_python
|
check_python
|
||||||
@@ -417,5 +497,5 @@ main() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run main (supports args, e.g. ./run.sh --http)
|
# Run main (supports args, e.g. ./run.sh --http, ./run.sh --reset-admin-password)
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: active-directory-attack
|
name: active-directory-attack
|
||||||
description: >-
|
description: >-
|
||||||
内网域攻击:BloodHound,Kerberoast,ADCS ESC1/ESC8,NTLM Relay,Coerce,DACL,DCSync,Zerologon/NoPac/PrintNightmare,mitm6,LLMNR,Linux内网。Use when attacking Active Directory, ADCS, NTLM relay, or internal domain.
|
内网域攻击:BloodHound,Kerberoast,ADCS ESC1/ESC8,NTLM Relay,Coerce,DACL,DCSync,Zerologon/NoPac/PrintNightmare,mitm6,LLMNR,Linux内网。Use when attacking Active Directory, ADCS, NTLM relay, or internal domain.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 内网域攻击
|
## 内网域攻击
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: ai-llm-app-attack
|
name: ai-llm-app-attack
|
||||||
description: >-
|
description: >-
|
||||||
AI/LLM应用攻击:提示注入,Agent工具滥用RCE,RAG投毒,MCP供应链,torch.load pickle RCE。Use when testing LLM apps, agents, RAG, MCP plugins, or AI model file risks.
|
AI/LLM应用攻击:提示注入,Agent工具滥用RCE,RAG投毒,MCP供应链,torch.load pickle RCE。Use when testing LLM apps, agents, RAG, MCP plugins, or AI model file risks.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## AI / LLM 应用攻击
|
## AI / LLM 应用攻击
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: attack-surface-recon
|
name: attack-surface-recon
|
||||||
description: >-
|
description: >-
|
||||||
侦察/攻击面测绘:被动whois/amass/crt.sh/FOFA/Shodan,主动subfinder/httpx/naabu/katana/nuclei,DNS地域/CDN/Nginx catch-all/宝塔/UniApp指纹。开局第一动作,认知写入项目黑板。Use when starting recon, asset mapping, fingerprinting, or CDN/DNS bypass discovery.
|
侦察/攻击面测绘:被动whois/amass/crt.sh/FOFA/Shodan,主动subfinder/httpx/naabu/katana/nuclei,DNS地域/CDN/Nginx catch-all/宝塔/UniApp指纹。开局第一动作,认知写入项目黑板。Use when starting recon, asset mapping, fingerprinting, or CDN/DNS bypass discovery.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 侦察 / 攻击面测绘
|
## 侦察 / 攻击面测绘
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: binary-mobile-reversing
|
name: binary-mobile-reversing
|
||||||
description: >-
|
description: >-
|
||||||
APK/EXE/二进制:UniApp/DCloud/Flutter逆向,证书固定绕过,导出组件,内存破坏exploit链,IoT固件。Use when reversing APK/EXE, UniApp/Flutter, native .so, or memory-corruption exploits.
|
APK/EXE/二进制:UniApp/DCloud/Flutter逆向,证书固定绕过,导出组件,内存破坏exploit链,IoT固件。Use when reversing APK/EXE, UniApp/Flutter, native .so, or memory-corruption exploits.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## APK / EXE / 二进制逆向
|
## APK / EXE / 二进制逆向
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: blockchain-contract-attack
|
name: blockchain-contract-attack
|
||||||
description: >-
|
description: >-
|
||||||
区块链/智能合约:Etherscan,slither/mythril,重入/访问控制/预言机/闪电贷,跨链桥,RPC暴露。Use when auditing smart contracts, DeFi, or blockchain attack surfaces.
|
区块链/智能合约:Etherscan,slither/mythril,重入/访问控制/预言机/闪电贷,跨链桥,RPC暴露。Use when auditing smart contracts, DeFi, or blockchain attack surfaces.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 区块链 / 智能合约
|
## 区块链 / 智能合约
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: capability-primitive-search
|
name: capability-primitive-search
|
||||||
description: >-
|
description: >-
|
||||||
能力原语+状态空间搜索:read/write/exec/ssrf等原语凑RCE等式A-F,低危映射,正反向搜索,跨域兑现。Use when no single RCE, chaining low-severity vulns, or deriving novel attack chains.
|
能力原语+状态空间搜索:read/write/exec/ssrf等原语凑RCE等式A-F,低危映射,正反向搜索,跨域兑现。Use when no single RCE, chaining low-severity vulns, or deriving novel attack chains.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 能力原语 + 状态空间搜索
|
## 能力原语 + 状态空间搜索
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: cloud-attack-methods
|
name: cloud-attack-methods
|
||||||
description: >-
|
description: >-
|
||||||
云攻击:元数据API,S3/K8s,AWS/Azure/GCP身份提权,MinIO矩阵,阿里云FC,ChengZi SDK解密。Use when attacking cloud metadata, IAM, K8s, MinIO, Aliyun FC, or cloud post-ex.
|
云攻击:元数据API,S3/K8s,AWS/Azure/GCP身份提权,MinIO矩阵,阿里云FC,ChengZi SDK解密。Use when attacking cloud metadata, IAM, K8s, MinIO, Aliyun FC, or cloud post-ex.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 云攻击手法
|
## 云攻击手法
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: component-vuln-intel
|
name: component-vuln-intel
|
||||||
description: >-
|
description: >-
|
||||||
联网情报收集:识别组件后必做CVE/搜索引擎/中文社区/GitHub PoC/资产引擎/即时情报/依赖扩展+受阻换路。Use when a framework/component/version is identified and must search before exploit.
|
联网情报收集:识别组件后必做CVE/搜索引擎/中文社区/GitHub PoC/资产引擎/即时情报/依赖扩展+受阻换路。Use when a framework/component/version is identified and must search before exploit.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 联网情报收集(识别组件→立即全网搜;结果=线索/tentative,验证前不是 confirmed Fact)
|
## 联网情报收集(识别组件→立即全网搜;结果=线索/tentative,验证前不是 confirmed Fact)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: initial-access-phishing
|
name: initial-access-phishing
|
||||||
description: >-
|
description: >-
|
||||||
初始访问/钓鱼/社工:凭据喷洒,AiTM,设备码,OAuth同意钓鱼,载荷,vishing。Use when needing initial access, phishing, AiTM, device code, or social engineering.
|
初始访问/钓鱼/社工:凭据喷洒,AiTM,设备码,OAuth同意钓鱼,载荷,vishing。Use when needing initial access, phishing, AiTM, device code, or social engineering.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 初始访问 / 钓鱼 / 社工
|
## 初始访问 / 钓鱼 / 社工
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ description: >-
|
|||||||
(联网情报/Web/认证/服务端/源码/社工/后渗透/二进制/内网域/云/区块链/AI/无线/硬件)+0day+
|
(联网情报/Web/认证/服务端/源码/社工/后渗透/二进制/内网域/云/区块链/AI/无线/硬件)+0day+
|
||||||
组合拳+代理自举。核心:全网搜不到洞时现场推导独属于目标的攻击链。本文件为套件索引。
|
组合拳+代理自举。核心:全网搜不到洞时现场推导独属于目标的攻击链。本文件为套件索引。
|
||||||
Use when starting a full-chain pentest engagement or needing the skill map for this suite.
|
Use when starting a full-chain pentest engagement or needing the skill map for this suite.
|
||||||
tags: [渗透测试, penetration-testing, 红队, autonomous]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队, autonomous]
|
||||||
---
|
---
|
||||||
|
|
||||||
# 渗透测试Agent操作系统
|
# 渗透测试Agent操作系统
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ description: >-
|
|||||||
CyberStrikeAI 项目黑板:跨会话 Fact 图(SQLite)+ upsert_project_fact/record_vulnerability
|
CyberStrikeAI 项目黑板:跨会话 Fact 图(SQLite)+ upsert_project_fact/record_vulnerability
|
||||||
边渗透边记录节奏、关系边 links、confidence、与多代理协调落库。Use when managing project
|
边渗透边记录节奏、关系边 links、confidence、与多代理协调落库。Use when managing project
|
||||||
facts, blackboard index, writing evidence, or avoiding context-loss after compression.
|
facts, blackboard index, writing evidence, or avoiding context-loss after compression.
|
||||||
tags: [渗透测试, penetration-testing, 红队, 项目黑板]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队, 项目黑板]
|
||||||
---
|
---
|
||||||
|
|
||||||
# 项目黑板(与本产品对齐)
|
# 项目黑板(与本产品对齐)
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ name: pentest-output-standards
|
|||||||
description: >-
|
description: >-
|
||||||
输出规范:中文分析,思维链,漏洞报告模板,负结果,黑板状态总览,改动台账,死锁突破。
|
输出规范:中文分析,思维链,漏洞报告模板,负结果,黑板状态总览,改动台账,死锁突破。
|
||||||
Use when reporting findings, maintaining change ledger, or formatting pentest output.
|
Use when reporting findings, maintaining change ledger, or formatting pentest output.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 输出规范
|
## 输出规范
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ name: pentest-verification
|
|||||||
description: >-
|
description: >-
|
||||||
验证铁律:搜索≠漏洞,confirmed Fact须证据,tentative表线索,禁止空泛推测,负结果也落库,
|
验证铁律:搜索≠漏洞,confirmed Fact须证据,tentative表线索,禁止空泛推测,负结果也落库,
|
||||||
想象力拉满+单步验证零容忍。Use when writing project facts, validating findings, or avoiding hallucination.
|
想象力拉满+单步验证零容忍。Use when writing project facts, validating findings, or avoiding hallucination.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 验证铁律(全系统最高规则,违反即幻觉)
|
## 验证铁律(全系统最高规则,违反即幻觉)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: post-exploitation
|
name: post-exploitation
|
||||||
description: >-
|
description: >-
|
||||||
后渗透/提权+凭据破解+密码学:反弹shell,Linux/Windows提权,横向,隧道,免杀,C2持久化,hashcat/Padding Oracle/hash长度扩展。Use when post-exploitation, privilege escalation, lateral movement, or cracking crypto.
|
后渗透/提权+凭据破解+密码学:反弹shell,Linux/Windows提权,横向,隧道,免杀,C2持久化,hashcat/Padding Oracle/hash长度扩展。Use when post-exploitation, privilege escalation, lateral movement, or cracking crypto.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 后渗透 / 提权 / 凭据破解 / 密码学
|
## 后渗透 / 提权 / 凭据破解 / 密码学
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: proxy-tool-bootstrap
|
name: proxy-tool-bootstrap
|
||||||
description: >-
|
description: >-
|
||||||
自找代理+工具自举:SOCKS5/HTTP/Tor换路序列,工具Python自举,字典自生成,OOB基础设施。Use when blocked by 403/429/WAF/timeout, missing tools, or needing OOB confirmation.
|
自找代理+工具自举:SOCKS5/HTTP/Tor换路序列,工具Python自举,字典自生成,OOB基础设施。Use when blocked by 403/429/WAF/timeout, missing tools, or needing OOB confirmation.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 自找代理 + 工具自举(被拦换路,没工具自己写)
|
## 自找代理 + 工具自举(被拦换路,没工具自己写)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: redteam-opsec
|
name: redteam-opsec
|
||||||
description: >-
|
description: >-
|
||||||
OPSEC隐蔽作战纪律:IP黑名单绕过,速率时序,流量混淆,最小足迹,反取证,渐进暴露。Use when maintaining stealth, bypassing IP bans, or planning covert red-team ops.
|
OPSEC隐蔽作战纪律:IP黑名单绕过,速率时序,流量混淆,最小足迹,反取证,渐进暴露。Use when maintaining stealth, bypassing IP bans, or planning covert red-team ops.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## OPSEC / 隐蔽作战纪律(免杀 / 稳定 / 隐蔽)
|
## OPSEC / 隐蔽作战纪律(免杀 / 稳定 / 隐蔽)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: source-code-hunting
|
name: source-code-hunting
|
||||||
description: >-
|
description: >-
|
||||||
源码狩猎:.git泄露,危险函数grep,JS RC4解混淆,semgrep/CodeQL,trufflehog,patch diff,供应链/CI。Use when hunting source leaks, secrets, JS deobfuscation, or supply-chain issues.
|
源码狩猎:.git泄露,危险函数grep,JS RC4解混淆,semgrep/CodeQL,trufflehog,patch diff,供应链/CI。Use when hunting source leaks, secrets, JS deobfuscation, or supply-chain issues.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 源码狩猎
|
## 源码狩猎
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: specialized-attack-playbooks
|
name: specialized-attack-playbooks
|
||||||
description: >-
|
description: >-
|
||||||
专题实战利用:GoEdge私钥导出,灰产CDN取证,ARP MITM,CDN→S3 STS链,宝塔+UniApp,AI IDE API反代,OCS+MinIO;含references/scripts支持文件索引。Use when applying specialized playbooks for GoEdge, CDN, ARP MITM, BT Panel, OCS, MinIO.
|
专题实战利用:GoEdge私钥导出,灰产CDN取证,ARP MITM,CDN→S3 STS链,宝塔+UniApp,AI IDE API反代,OCS+MinIO;含references/scripts支持文件索引。Use when applying specialized playbooks for GoEdge, CDN, ARP MITM, BT Panel, OCS, MinIO.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 专题实战利用(全内联)
|
## 专题实战利用(全内联)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: unlimited-attack-scope
|
name: unlimited-attack-scope
|
||||||
description: >-
|
description: >-
|
||||||
不设限原则:搜索/验证/串联/记录一切,全领域适用,被拦换路,不达不休。Use when reminding scope is unlimited across Web/APK/cloud/AI/wireless/social eng.
|
不设限原则:搜索/验证/串联/记录一切,全领域适用,被拦换路,不达不休。Use when reminding scope is unlimited across Web/APK/cloud/AI/wireless/social eng.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 不设限原则
|
## 不设限原则
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: web-attack-methods
|
name: web-attack-methods
|
||||||
description: >-
|
description: >-
|
||||||
Web全栈攻击:SQLi/命令注入/SSTI/XSS/SSRF/NoSQL,认证JWT/OAuth/SAML,LFI/上传,Tomcat/WS/STOMP/XFF/PATH_INFO/CDN502/网宿JS挑战绕过。Use when testing Web injection, auth bypass, server-side, WAF/CDN bypass.
|
Web全栈攻击:SQLi/命令注入/SSTI/XSS/SSRF/NoSQL,认证JWT/OAuth/SAML,LFI/上传,Tomcat/WS/STOMP/XFF/PATH_INFO/CDN502/网宿JS挑战绕过。Use when testing Web injection, auth bypass, server-side, WAF/CDN bypass.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## Web 攻击手法(注入 / 认证 / 服务端 / 杂项 / CDN)
|
## Web 攻击手法(注入 / 认证 / 服务端 / 杂项 / CDN)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: wireless-hardware-attack
|
name: wireless-hardware-attack
|
||||||
description: >-
|
description: >-
|
||||||
无线/硬件:WiFi PMKID/WPS/Evil Twin,BLE,Zigbee,NFC,SDR,UART/JTAG/SPI,侧信道,故障注入。Use when attacking WiFi, BLE, RFID, SDR, or hardware interfaces.
|
无线/硬件:WiFi PMKID/WPS/Evil Twin,BLE,Zigbee,NFC,SDR,UART/JTAG/SPI,侧信道,故障注入。Use when attacking WiFi, BLE, RFID, SDR, or hardware interfaces.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 无线 / 硬件攻击
|
## 无线 / 硬件攻击
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: zero-day-discovery
|
name: zero-day-discovery
|
||||||
description: >-
|
description: >-
|
||||||
0day自主发现引擎:变体分析/补丁间隙/差分/Fuzzing/污点推理/N-day武器化/猎人思维。Use when public vulns not found and need to discover 0day or weaponize N-day.
|
0day自主发现引擎:变体分析/补丁间隙/差分/Fuzzing/污点推理/N-day武器化/猎人思维。Use when public vulns not found and need to discover 0day or weaponize N-day.
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
metadata:
|
||||||
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0day 自主发现引擎(全网搜不到漏洞时自己挖)
|
## 0day 自主发现引擎(全网搜不到漏洞时自己挖)
|
||||||
|
|||||||
+829
-88
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 6.6 KiB |
+107
-6
@@ -1106,6 +1106,7 @@
|
|||||||
"importValidRows": "Import valid rows",
|
"importValidRows": "Import valid rows",
|
||||||
"importValidRowsCount": "Import {{count}} valid rows",
|
"importValidRowsCount": "Import {{count}} valid rows",
|
||||||
"importPreviewSummary": "{{total}} rows: {{valid}} valid, {{invalid}} need attention",
|
"importPreviewSummary": "{{total}} rows: {{valid}} valid, {{invalid}} need attention",
|
||||||
|
"importBackendRowError": "Excel row {{row}} (submitted valid asset #{{index}}): {{message}}",
|
||||||
"previewLimited": "Showing the first 100 rows; all {{count}} rows will be processed",
|
"previewLimited": "Showing the first 100 rows; all {{count}} rows will be processed",
|
||||||
"fileTypeInvalid": "Only .xlsx and .csv files are supported",
|
"fileTypeInvalid": "Only .xlsx and .csv files are supported",
|
||||||
"fileTooLarge": "The file must not exceed 100 MB",
|
"fileTooLarge": "The file must not exceed 100 MB",
|
||||||
@@ -2603,23 +2604,43 @@
|
|||||||
"aiChannelNew": "New",
|
"aiChannelNew": "New",
|
||||||
"aiChannelCopy": "Copy",
|
"aiChannelCopy": "Copy",
|
||||||
"aiChannelDelete": "Delete",
|
"aiChannelDelete": "Delete",
|
||||||
"aiChannelHint": "Saved channels appear on the left. Saving writes to ai.channels; the default channel is used by new chats and tasks without an explicit channel.",
|
"aiChannelHint": "Use the dropdown to switch saved channels. Saving writes to ai.channels; the default channel is used by new chats and tasks without an explicit channel.",
|
||||||
"aiChannelSavedList": "Saved channels",
|
"aiChannelSavedList": "Saved channels",
|
||||||
"aiChannelListAria": "AI channel list",
|
"aiChannelListAria": "AI channel list",
|
||||||
"aiChannelEditing": "Editing",
|
"aiChannelEditing": "Editing",
|
||||||
|
"aiChannelFormContext": "Editing the selected channel",
|
||||||
|
"aiChannelFormContextHint": "Saving the form updates this channel configuration.",
|
||||||
|
"aiChannelBulkProbe": "Probe all",
|
||||||
|
"aiChannelBulkProbeTitle": "Check whether all complete channels are available",
|
||||||
|
"aiChannelSelectAria": "Select {name}",
|
||||||
"aiChannelDefaultBadge": "Default",
|
"aiChannelDefaultBadge": "Default",
|
||||||
"aiChannelReady": "Ready",
|
"aiChannelReady": "Ready",
|
||||||
|
"aiChannelReadyWithLatency": "Ready{latency}",
|
||||||
|
"aiChannelComplete": "Complete",
|
||||||
"aiChannelDraft": "Incomplete",
|
"aiChannelDraft": "Incomplete",
|
||||||
"aiChannelDefaultMeta": "Default channel",
|
"aiChannelDefaultMeta": "Default channel",
|
||||||
"aiChannelCustomMeta": "Custom channel",
|
"aiChannelCustomMeta": "Custom channel",
|
||||||
"aiChannelOpenAICompat": "OpenAI compatible",
|
"aiChannelOpenAICompat": "OpenAI compatible",
|
||||||
"aiChannelModelMissing": "Model missing",
|
"aiChannelModelMissing": "Model missing",
|
||||||
"aiChannelName": "Channel name",
|
"aiChannelName": "Channel name",
|
||||||
|
"aiChannelConnectionSection": "Connection",
|
||||||
|
"aiChannelConnectionHint": "Confirm provider, endpoint, key, and model first. Test connection uses these values.",
|
||||||
|
"aiChannelModelSection": "Model and limits",
|
||||||
|
"aiChannelModelHint": "The model name controls routing. Token limits control context and single-response output.",
|
||||||
|
"aiChannelLimitsSection": "Limits",
|
||||||
|
"aiChannelLimitsHint": "Token limits control the context window and single-response output.",
|
||||||
"aiChannelUntitled": "New Channel",
|
"aiChannelUntitled": "New Channel",
|
||||||
"aiChannelDeleteConfirm": "Delete AI channel \"{name}\"?",
|
"aiChannelDeleteConfirm": "Delete AI channel \"{name}\"?",
|
||||||
"aiChannelSaved": "Channel saved",
|
"aiChannelSaved": "Channel saved",
|
||||||
"aiChannelDefaultSaved": "Default channel saved",
|
"aiChannelDefaultSaved": "Default channel saved",
|
||||||
"aiChannelCount": "{count} channel(s) saved",
|
"aiChannelCount": "{count} channel(s) saved",
|
||||||
|
"aiChannelSaving": "Saving channel...",
|
||||||
|
"aiChannelNewUnsaved": "New channel is not saved yet. Fill it in, then click Save changes.",
|
||||||
|
"aiChannelCopyUnsaved": "Copied channel is not saved yet. Review it, then click Save changes.",
|
||||||
|
"aiChannelDeleted": "Channel deleted",
|
||||||
|
"aiChannelProbeNoComplete": "No complete channel to probe. Fill in Base URL, API Key, and Model first.",
|
||||||
|
"aiChannelProbing": "Probing {count} channel(s)...",
|
||||||
|
"aiChannelProbeDone": "Probe complete: {ok}/{total} ready",
|
||||||
"apiProvider": "API Provider",
|
"apiProvider": "API Provider",
|
||||||
"providerOpenAI": "OpenAI / OpenAI-compatible API",
|
"providerOpenAI": "OpenAI / OpenAI-compatible API",
|
||||||
"providerClaude": "Claude (Anthropic Messages API)",
|
"providerClaude": "Claude (Anthropic Messages API)",
|
||||||
@@ -2645,8 +2666,8 @@
|
|||||||
"maxTotalTokensPlaceholder": "120000",
|
"maxTotalTokensPlaceholder": "120000",
|
||||||
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
|
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
|
||||||
"maxCompletionTokens": "Max Output Tokens",
|
"maxCompletionTokens": "Max Output Tokens",
|
||||||
"maxCompletionTokensPlaceholder": "16384",
|
"maxCompletionTokensPlaceholder": "32768",
|
||||||
"maxCompletionTokensHint": "Maximum tokens for a single model response. Default: 16384",
|
"maxCompletionTokensHint": "Maximum tokens for a single model response. Default: 32768",
|
||||||
"openaiReasoningTitle": "Reasoning settings",
|
"openaiReasoningTitle": "Reasoning settings",
|
||||||
"openaiReasoningHint": "Default reasoning settings for this AI channel; chat Session settings can override them.",
|
"openaiReasoningHint": "Default reasoning settings for this AI channel; chat Session settings can override them.",
|
||||||
"openaiReasoningProfile": "Wire profile",
|
"openaiReasoningProfile": "Wire profile",
|
||||||
@@ -2795,11 +2816,16 @@
|
|||||||
"visionTimeout": "Timeout (seconds)",
|
"visionTimeout": "Timeout (seconds)",
|
||||||
"visionTestFillRequired": "Enter vision model and ensure API Key is available (or reuse OpenAI)",
|
"visionTestFillRequired": "Enter vision model and ensure API Key is available (or reuse OpenAI)",
|
||||||
"testConnection": "Test Connection",
|
"testConnection": "Test Connection",
|
||||||
"testFillRequired": "Please fill in API Key and Model first",
|
"testFillRequired": "Please fill in Base URL, API Key, and Model first",
|
||||||
"testing": "Testing connection...",
|
"testing": "Testing connection...",
|
||||||
"testSuccess": "Connection successful",
|
"testSuccess": "Connection successful",
|
||||||
"testFailed": "Connection failed",
|
"testFailed": "Connection failed",
|
||||||
"testError": "Test error"
|
"testError": "Test error",
|
||||||
|
"testErrorInvalidApiKey": "API Key is invalid or unauthorized. Check that the key is correct.",
|
||||||
|
"testErrorUnauthorized": "Authentication failed. Check the API Key.",
|
||||||
|
"testErrorForbidden": "Request denied. Check account permissions or model access.",
|
||||||
|
"testErrorModelUnavailable": "Model is unavailable or the model name is incorrect.",
|
||||||
|
"testErrorBaseUrl": "Base URL is unavailable. Check that the endpoint is correct."
|
||||||
},
|
},
|
||||||
"settingsTerminal": {
|
"settingsTerminal": {
|
||||||
"title": "Terminal",
|
"title": "Terminal",
|
||||||
@@ -3506,6 +3532,81 @@
|
|||||||
"canvasTools": "Canvas tools",
|
"canvasTools": "Canvas tools",
|
||||||
"moreActions": "More",
|
"moreActions": "More",
|
||||||
"deleteWorkflow": "Delete workflow",
|
"deleteWorkflow": "Delete workflow",
|
||||||
|
"ai": {
|
||||||
|
"open": "Create from natural language",
|
||||||
|
"title": "Create Workflow from Natural Language",
|
||||||
|
"subtitle": "AI generates an editable draft and will not save or run it automatically.",
|
||||||
|
"promptLabel": "Workflow request",
|
||||||
|
"promptPlaceholder": "Example: continuously monitor a domain's subdomains, certificates, and exposed pages, then generate a report when new assets appear",
|
||||||
|
"examplesLabel": "Example requests",
|
||||||
|
"exampleDomain": "Domain monitor",
|
||||||
|
"exampleReport": "Report approval",
|
||||||
|
"exampleTriage": "Asset triage",
|
||||||
|
"includeObjective": "Generate objective configuration too",
|
||||||
|
"allowSchedule": "Allow scheduled trigger suggestions",
|
||||||
|
"allowHighRisk": "Allow high-risk node drafts",
|
||||||
|
"allowFallback": "Allow deterministic fallback if AI fails",
|
||||||
|
"promptRequired": "Describe the workflow request first.",
|
||||||
|
"generateFailed": "Generation failed",
|
||||||
|
"generatedWithIssues": "The draft still has validation issues. Adjust the request and try again.",
|
||||||
|
"generate": "Generate draft",
|
||||||
|
"generating": "Generating…",
|
||||||
|
"apply": "Render to canvas",
|
||||||
|
"rendering": "Rendering…",
|
||||||
|
"applied": "Workflow draft generated. Review it before saving.",
|
||||||
|
"canvasRendering": "AI is composing the canvas…",
|
||||||
|
"generatorAI": "AI channel",
|
||||||
|
"generatorLLM": "Model generated",
|
||||||
|
"generatorServer": "Server draft generator",
|
||||||
|
"generatorFallback": "Deterministic fallback",
|
||||||
|
"generatorUnknown": "Unknown generator",
|
||||||
|
"llmTitle": "Generated with the platform default AI channel and structurally validated",
|
||||||
|
"serverTitle": "Generated on the server and structurally audited",
|
||||||
|
"fallbackTitle": "Deterministic fallback was used",
|
||||||
|
"serverFallbackNotice": "Model generation is unavailable: {{reason}}",
|
||||||
|
"preview": "Flow preview",
|
||||||
|
"resultNodes": "Nodes",
|
||||||
|
"resultEdges": "Edges",
|
||||||
|
"resultRepairs": "Repair rounds",
|
||||||
|
"resultCapabilities": "Node capabilities",
|
||||||
|
"resultTools": "Tool capabilities",
|
||||||
|
"resultRisk": "Risk",
|
||||||
|
"resultSaveState": "Savable",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"missingFields": "Missing configuration",
|
||||||
|
"riskWarnings": "Risk warnings",
|
||||||
|
"assumptions": "Assumptions",
|
||||||
|
"capabilityTrace": "Capability toolchain",
|
||||||
|
"nodeGenerated": "AI generated",
|
||||||
|
"nodeNeedsInput": "Needs input",
|
||||||
|
"riskLow": "Low",
|
||||||
|
"riskMedium": "Medium",
|
||||||
|
"riskHigh": "High",
|
||||||
|
"steps": {
|
||||||
|
"understand": "Understand request",
|
||||||
|
"match": "Match tools",
|
||||||
|
"draft": "Generate nodes",
|
||||||
|
"audit": "Safety audit"
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"domainMonitor": "Continuously monitor a domain's subdomains, certificates, and exposed pages, then generate a report when new assets appear",
|
||||||
|
"reportReview": "Collect threat intelligence every day, generate a report, and send it to the security owner for approval",
|
||||||
|
"assetTriage": "Extract high-risk assets from scan results, deduplicate them, and output remediation suggestions"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"audit": {
|
||||||
|
"title": "Draft Audit",
|
||||||
|
"ready": "No blocking items found in this draft",
|
||||||
|
"review": "Review before saving or running",
|
||||||
|
"aiNodes": "AI nodes",
|
||||||
|
"needsInput": "Needs input",
|
||||||
|
"highRisk": "High risk",
|
||||||
|
"validation": "Structure issues",
|
||||||
|
"nodeIssues": "Node notes",
|
||||||
|
"validationIssues": "Structure checks",
|
||||||
|
"saveConfirm": "This draft contains missing configuration or high-risk nodes. Save as draft anyway?"
|
||||||
|
},
|
||||||
"package": {
|
"package": {
|
||||||
"importLocal": "Import local package",
|
"importLocal": "Import local package",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
@@ -3615,7 +3716,7 @@
|
|||||||
"deleteSelected": "Delete selected",
|
"deleteSelected": "Delete selected",
|
||||||
"autoLayout": "Auto layout",
|
"autoLayout": "Auto layout",
|
||||||
"dryRun": "Dry run",
|
"dryRun": "Dry run",
|
||||||
"canvasEmpty": "Drag nodes from the left onto the canvas, or click node buttons to add quickly",
|
"canvasEmpty": "Drag nodes from the left onto the canvas, or generate a workflow from natural language",
|
||||||
"properties": "Properties",
|
"properties": "Properties",
|
||||||
"nodeProperties": "Node properties",
|
"nodeProperties": "Node properties",
|
||||||
"edgeProperties": "Edge properties",
|
"edgeProperties": "Edge properties",
|
||||||
|
|||||||
+107
-6
@@ -1094,6 +1094,7 @@
|
|||||||
"importValidRows": "导入有效数据",
|
"importValidRows": "导入有效数据",
|
||||||
"importValidRowsCount": "导入 {{count}} 条有效数据",
|
"importValidRowsCount": "导入 {{count}} 条有效数据",
|
||||||
"importPreviewSummary": "共 {{total}} 行,{{valid}} 行有效,{{invalid}} 行需修正",
|
"importPreviewSummary": "共 {{total}} 行,{{valid}} 行有效,{{invalid}} 行需修正",
|
||||||
|
"importBackendRowError": "Excel 第 {{row}} 行(提交有效数据第 {{index}} 条):{{message}}",
|
||||||
"previewLimited": "仅展示前 100 行;提交时将处理全部 {{count}} 行",
|
"previewLimited": "仅展示前 100 行;提交时将处理全部 {{count}} 行",
|
||||||
"fileTypeInvalid": "仅支持 .xlsx 和 .csv 文件",
|
"fileTypeInvalid": "仅支持 .xlsx 和 .csv 文件",
|
||||||
"fileTooLarge": "文件不能超过 100 MB",
|
"fileTooLarge": "文件不能超过 100 MB",
|
||||||
@@ -2591,23 +2592,43 @@
|
|||||||
"aiChannelNew": "新增",
|
"aiChannelNew": "新增",
|
||||||
"aiChannelCopy": "复制",
|
"aiChannelCopy": "复制",
|
||||||
"aiChannelDelete": "删除",
|
"aiChannelDelete": "删除",
|
||||||
"aiChannelHint": "已保存的通道会显示在左侧;保存后写入 ai.channels,默认通道用于新对话和未指定通道的任务。",
|
"aiChannelHint": "通过下拉切换已保存通道;保存后写入 ai.channels,默认通道用于新对话和未指定通道的任务。",
|
||||||
"aiChannelSavedList": "已保存通道",
|
"aiChannelSavedList": "已保存通道",
|
||||||
"aiChannelListAria": "AI 通道列表",
|
"aiChannelListAria": "AI 通道列表",
|
||||||
"aiChannelEditing": "正在编辑",
|
"aiChannelEditing": "正在编辑",
|
||||||
|
"aiChannelFormContext": "编辑当前选择的通道",
|
||||||
|
"aiChannelFormContextHint": "表单保存后会更新该通道配置。",
|
||||||
|
"aiChannelBulkProbe": "批量探活",
|
||||||
|
"aiChannelBulkProbeTitle": "检测全部完整通道是否可用",
|
||||||
|
"aiChannelSelectAria": "选择 {name}",
|
||||||
"aiChannelDefaultBadge": "默认",
|
"aiChannelDefaultBadge": "默认",
|
||||||
"aiChannelReady": "可用",
|
"aiChannelReady": "可用",
|
||||||
|
"aiChannelReadyWithLatency": "可用{latency}",
|
||||||
|
"aiChannelComplete": "配置完整",
|
||||||
"aiChannelDraft": "待完善",
|
"aiChannelDraft": "待完善",
|
||||||
"aiChannelDefaultMeta": "默认通道",
|
"aiChannelDefaultMeta": "默认通道",
|
||||||
"aiChannelCustomMeta": "自定义通道",
|
"aiChannelCustomMeta": "自定义通道",
|
||||||
"aiChannelOpenAICompat": "OpenAI 兼容",
|
"aiChannelOpenAICompat": "OpenAI 兼容",
|
||||||
"aiChannelModelMissing": "未填写模型",
|
"aiChannelModelMissing": "未填写模型",
|
||||||
"aiChannelName": "通道名称",
|
"aiChannelName": "通道名称",
|
||||||
|
"aiChannelConnectionSection": "连接信息",
|
||||||
|
"aiChannelConnectionHint": "先确认服务商、地址、密钥和模型,测试连接会使用这些信息。",
|
||||||
|
"aiChannelModelSection": "模型与额度",
|
||||||
|
"aiChannelModelHint": "模型名决定请求路由,Token 上限控制上下文和单次输出。",
|
||||||
|
"aiChannelLimitsSection": "额度设置",
|
||||||
|
"aiChannelLimitsHint": "Token 上限控制上下文窗口和单次输出。",
|
||||||
"aiChannelUntitled": "新通道",
|
"aiChannelUntitled": "新通道",
|
||||||
"aiChannelDeleteConfirm": "确定删除 AI 通道「{name}」吗?",
|
"aiChannelDeleteConfirm": "确定删除 AI 通道「{name}」吗?",
|
||||||
"aiChannelSaved": "通道已保存",
|
"aiChannelSaved": "通道已保存",
|
||||||
"aiChannelDefaultSaved": "已设为默认通道",
|
"aiChannelDefaultSaved": "已设为默认通道",
|
||||||
"aiChannelCount": "已保存 {count} 个通道",
|
"aiChannelCount": "已保存 {count} 个通道",
|
||||||
|
"aiChannelSaving": "正在保存通道...",
|
||||||
|
"aiChannelNewUnsaved": "新通道尚未保存,填写后点击「保存更改」。",
|
||||||
|
"aiChannelCopyUnsaved": "复制的通道尚未保存,确认后点击「保存更改」。",
|
||||||
|
"aiChannelDeleted": "通道已删除",
|
||||||
|
"aiChannelProbeNoComplete": "没有可探活的完整通道,请先填写 Base URL、API Key 和模型",
|
||||||
|
"aiChannelProbing": "正在探活 {count} 个通道...",
|
||||||
|
"aiChannelProbeDone": "探活完成:{ok}/{total} 可用",
|
||||||
"apiProvider": "API 提供商",
|
"apiProvider": "API 提供商",
|
||||||
"providerOpenAI": "OpenAI / 兼容 OpenAI 协议",
|
"providerOpenAI": "OpenAI / 兼容 OpenAI 协议",
|
||||||
"providerClaude": "Claude (Anthropic Messages API)",
|
"providerClaude": "Claude (Anthropic Messages API)",
|
||||||
@@ -2633,8 +2654,8 @@
|
|||||||
"maxTotalTokensPlaceholder": "120000",
|
"maxTotalTokensPlaceholder": "120000",
|
||||||
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
|
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
|
||||||
"maxCompletionTokens": "最大输出 Token 数",
|
"maxCompletionTokens": "最大输出 Token 数",
|
||||||
"maxCompletionTokensPlaceholder": "16384",
|
"maxCompletionTokensPlaceholder": "32768",
|
||||||
"maxCompletionTokensHint": "单次模型回复的输出上限,默认 16384",
|
"maxCompletionTokensHint": "单次模型回复的输出上限,默认 32768",
|
||||||
"openaiReasoningTitle": "推理设置",
|
"openaiReasoningTitle": "推理设置",
|
||||||
"openaiReasoningHint": "作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。",
|
"openaiReasoningHint": "作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。",
|
||||||
"openaiReasoningProfile": "线路 profile",
|
"openaiReasoningProfile": "线路 profile",
|
||||||
@@ -2783,11 +2804,16 @@
|
|||||||
"visionTimeout": "超时(秒)",
|
"visionTimeout": "超时(秒)",
|
||||||
"visionTestFillRequired": "请填写视觉模型,并确保 API Key 可用(可复用 OpenAI)",
|
"visionTestFillRequired": "请填写视觉模型,并确保 API Key 可用(可复用 OpenAI)",
|
||||||
"testConnection": "测试连接",
|
"testConnection": "测试连接",
|
||||||
"testFillRequired": "请先填写 API Key 和模型",
|
"testFillRequired": "请先填写 Base URL、API Key 和模型",
|
||||||
"testing": "测试中...",
|
"testing": "测试中...",
|
||||||
"testSuccess": "连接成功",
|
"testSuccess": "连接成功",
|
||||||
"testFailed": "连接失败",
|
"testFailed": "连接失败",
|
||||||
"testError": "测试出错"
|
"testError": "测试出错",
|
||||||
|
"testErrorInvalidApiKey": "API Key 无效或无权限,请检查密钥是否填写正确",
|
||||||
|
"testErrorUnauthorized": "认证失败,请检查 API Key",
|
||||||
|
"testErrorForbidden": "请求被拒绝,请检查账号权限或模型访问权限",
|
||||||
|
"testErrorModelUnavailable": "模型不可用或模型名不正确,请检查模型名称",
|
||||||
|
"testErrorBaseUrl": "Base URL 不可用,请检查地址是否正确"
|
||||||
},
|
},
|
||||||
"settingsTerminal": {
|
"settingsTerminal": {
|
||||||
"title": "终端",
|
"title": "终端",
|
||||||
@@ -3494,6 +3520,81 @@
|
|||||||
"canvasTools": "画布工具",
|
"canvasTools": "画布工具",
|
||||||
"moreActions": "更多",
|
"moreActions": "更多",
|
||||||
"deleteWorkflow": "删除工作流",
|
"deleteWorkflow": "删除工作流",
|
||||||
|
"ai": {
|
||||||
|
"open": "用自然语言创建",
|
||||||
|
"title": "用自然语言创建工作流",
|
||||||
|
"subtitle": "AI 会生成可编辑草稿,不会自动保存或运行。",
|
||||||
|
"promptLabel": "工作流需求",
|
||||||
|
"promptPlaceholder": "例如:持续监控一个域名的子域名、证书和暴露页面,发现新增资产后生成报告",
|
||||||
|
"examplesLabel": "示例需求",
|
||||||
|
"exampleDomain": "域名监控",
|
||||||
|
"exampleReport": "报告审批",
|
||||||
|
"exampleTriage": "资产研判",
|
||||||
|
"includeObjective": "同时生成 objective 配置",
|
||||||
|
"allowSchedule": "允许生成定时触发建议",
|
||||||
|
"allowHighRisk": "允许包含高风险节点草稿",
|
||||||
|
"allowFallback": "AI 失败时允许确定性兜底",
|
||||||
|
"promptRequired": "请先描述工作流需求。",
|
||||||
|
"generateFailed": "生成失败",
|
||||||
|
"generatedWithIssues": "草稿仍有校验问题,请调整需求后重试。",
|
||||||
|
"generate": "生成草稿",
|
||||||
|
"generating": "正在生成…",
|
||||||
|
"apply": "渲染到画布",
|
||||||
|
"rendering": "正在渲染…",
|
||||||
|
"applied": "已生成工作流草稿,请检查后保存。",
|
||||||
|
"canvasRendering": "AI 正在编排画布…",
|
||||||
|
"generatorAI": "AI 通道",
|
||||||
|
"generatorLLM": "大模型生成",
|
||||||
|
"generatorServer": "服务端草稿生成器",
|
||||||
|
"generatorFallback": "确定性兜底",
|
||||||
|
"generatorUnknown": "未知生成器",
|
||||||
|
"llmTitle": "已调用平台默认 AI 通道生成,并完成结构校验",
|
||||||
|
"serverTitle": "已通过服务端生成并完成结构审计",
|
||||||
|
"fallbackTitle": "已使用确定性兜底",
|
||||||
|
"serverFallbackNotice": "大模型生成不可用:{{reason}}",
|
||||||
|
"preview": "流程预览",
|
||||||
|
"resultNodes": "节点",
|
||||||
|
"resultEdges": "连线",
|
||||||
|
"resultRepairs": "修复轮次",
|
||||||
|
"resultCapabilities": "节点能力",
|
||||||
|
"resultTools": "工具能力",
|
||||||
|
"resultRisk": "风险",
|
||||||
|
"resultSaveState": "可保存",
|
||||||
|
"yes": "是",
|
||||||
|
"no": "否",
|
||||||
|
"missingFields": "缺失配置",
|
||||||
|
"riskWarnings": "风险提示",
|
||||||
|
"assumptions": "生成假设",
|
||||||
|
"capabilityTrace": "能力工具链",
|
||||||
|
"nodeGenerated": "AI 生成",
|
||||||
|
"nodeNeedsInput": "需补配置",
|
||||||
|
"riskLow": "低",
|
||||||
|
"riskMedium": "中",
|
||||||
|
"riskHigh": "高",
|
||||||
|
"steps": {
|
||||||
|
"understand": "理解需求",
|
||||||
|
"match": "匹配工具",
|
||||||
|
"draft": "生成节点",
|
||||||
|
"audit": "安全审计"
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"domainMonitor": "持续监控一个域名的子域名、证书和暴露页面,发现新增资产后生成报告",
|
||||||
|
"reportReview": "每天收集威胁情报,生成报告后交给安全负责人审批",
|
||||||
|
"assetTriage": "从扫描结果中提取高风险资产,去重后输出处置建议"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"audit": {
|
||||||
|
"title": "草稿审计",
|
||||||
|
"ready": "当前草稿未发现阻断项",
|
||||||
|
"review": "保存或运行前建议检查",
|
||||||
|
"aiNodes": "AI 节点",
|
||||||
|
"needsInput": "需补配置",
|
||||||
|
"highRisk": "高风险",
|
||||||
|
"validation": "结构问题",
|
||||||
|
"nodeIssues": "节点提示",
|
||||||
|
"validationIssues": "结构校验",
|
||||||
|
"saveConfirm": "当前草稿包含需补配置或高风险节点,确认仅保存为草稿?"
|
||||||
|
},
|
||||||
"package": {
|
"package": {
|
||||||
"importLocal": "导入本地包",
|
"importLocal": "导入本地包",
|
||||||
"export": "导出",
|
"export": "导出",
|
||||||
@@ -3603,7 +3704,7 @@
|
|||||||
"deleteSelected": "删除选中",
|
"deleteSelected": "删除选中",
|
||||||
"autoLayout": "自动布局",
|
"autoLayout": "自动布局",
|
||||||
"dryRun": "试运行",
|
"dryRun": "试运行",
|
||||||
"canvasEmpty": "从左侧拖拽节点到画布,或点击节点按钮快速添加",
|
"canvasEmpty": "从左侧拖拽节点到画布,或用自然语言生成工作流",
|
||||||
"properties": "属性",
|
"properties": "属性",
|
||||||
"nodeProperties": "节点属性",
|
"nodeProperties": "节点属性",
|
||||||
"edgeProperties": "连线属性",
|
"edgeProperties": "连线属性",
|
||||||
|
|||||||
+27
-2
@@ -57,6 +57,12 @@ function syncAssetSelect(selectOrId) {
|
|||||||
if (typeof syncSettingsCustomSelect === 'function') syncSettingsCustomSelect(select);
|
if (typeof syncSettingsCustomSelect === 'function') syncSettingsCustomSelect(select);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeAssetCustomSelects() {
|
||||||
|
if (typeof closeAllSettingsCustomSelects === 'function') {
|
||||||
|
closeAllSettingsCustomSelects();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assetT(key, fallback, options) {
|
function assetT(key, fallback, options) {
|
||||||
if (window.i18next && typeof window.i18next.t === 'function') {
|
if (window.i18next && typeof window.i18next.t === 'function') {
|
||||||
const value = window.i18next.t(key, options || {});
|
const value = window.i18next.t(key, options || {});
|
||||||
@@ -352,7 +358,8 @@ function renderAssetImportPreview() {
|
|||||||
|
|
||||||
async function submitAssetImport() {
|
async function submitAssetImport() {
|
||||||
if (assetPageState.importBusy) return;
|
if (assetPageState.importBusy) return;
|
||||||
const assets = assetPageState.importRows.filter(row => !row.error).map(row => row.asset);
|
const validRows = assetPageState.importRows.filter(row => !row.error);
|
||||||
|
const assets = validRows.map(row => row.asset);
|
||||||
if (!assets.length) return;
|
if (!assets.length) return;
|
||||||
setAssetImportError('');
|
setAssetImportError('');
|
||||||
setAssetImportBusy(true);
|
setAssetImportBusy(true);
|
||||||
@@ -361,7 +368,7 @@ async function submitAssetImport() {
|
|||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ assets, source: 'manual-import', source_query: assetPageState.importFileName })
|
body: JSON.stringify({ assets, source: 'manual-import', source_query: assetPageState.importFileName })
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(await assetEditorResponseError(response));
|
if (!response.ok) throw new Error(formatAssetImportSubmitError(await assetEditorResponseError(response), validRows));
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
const invalid = assetPageState.importRows.length - assets.length;
|
const invalid = assetPageState.importRows.length - assets.length;
|
||||||
closeAssetImport(true);
|
closeAssetImport(true);
|
||||||
@@ -378,6 +385,20 @@ async function submitAssetImport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAssetImportSubmitError(message, validRows) {
|
||||||
|
const text = String(message || '').trim();
|
||||||
|
const match = text.match(/^第\s*(\d+)\s*个资产无效[::]\s*(.+)$/);
|
||||||
|
if (!match) return text;
|
||||||
|
const assetNumber = Number(match[1]);
|
||||||
|
const row = Array.isArray(validRows) ? validRows[assetNumber - 1] : null;
|
||||||
|
if (!row || !row.rowNumber) return text;
|
||||||
|
return assetT('assets.importBackendRowError', `Excel 第 ${row.rowNumber} 行(提交有效数据第 ${assetNumber} 条): ${match[2]}`, {
|
||||||
|
row: row.rowNumber,
|
||||||
|
index: assetNumber,
|
||||||
|
message: match[2]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAssetOverview() {
|
async function loadAssetOverview() {
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch('/api/assets/stats?days=' + assetOverviewDays);
|
const response = await apiFetch('/api/assets/stats?days=' + assetOverviewDays);
|
||||||
@@ -874,6 +895,7 @@ async function openAssetProjectModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeAssetProjectModal() {
|
function closeAssetProjectModal() {
|
||||||
|
closeAssetCustomSelects();
|
||||||
if (typeof closeAppModal === 'function') closeAppModal('asset-project-modal');
|
if (typeof closeAppModal === 'function') closeAppModal('asset-project-modal');
|
||||||
else document.getElementById('asset-project-modal').style.display = 'none';
|
else document.getElementById('asset-project-modal').style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -922,6 +944,7 @@ function openAssetBulkEdit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeAssetBulkEdit() {
|
function closeAssetBulkEdit() {
|
||||||
|
closeAssetCustomSelects();
|
||||||
if (typeof closeAppModal === 'function') closeAppModal('asset-bulk-edit-modal');
|
if (typeof closeAppModal === 'function') closeAppModal('asset-bulk-edit-modal');
|
||||||
else document.getElementById('asset-bulk-edit-modal').style.display = 'none';
|
else document.getElementById('asset-bulk-edit-modal').style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -1124,6 +1147,7 @@ async function sendAssetsToChat(assets, template) {
|
|||||||
input.value = message;
|
input.value = message;
|
||||||
if (typeof adjustTextareaHeight === 'function') adjustTextareaHeight(input);
|
if (typeof adjustTextareaHeight === 'function') adjustTextareaHeight(input);
|
||||||
// 消息流可能持续很久;启动发送即可返回,让提交弹窗立即关闭。
|
// 消息流可能持续很久;启动发送即可返回,让提交弹窗立即关闭。
|
||||||
|
window.__csNextChatFinalizationPolicy = { requireExecutionEvidence: true };
|
||||||
void sendMessage();
|
void sendMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1279,6 +1303,7 @@ async function openAssetEditor(indexOrAsset) {
|
|||||||
|
|
||||||
function closeAssetEditor(force) {
|
function closeAssetEditor(force) {
|
||||||
if (!force && assetPageState.editorDirty && !confirm(assetT('assets.discardChanges', '放弃尚未保存的更改吗?'))) return;
|
if (!force && assetPageState.editorDirty && !confirm(assetT('assets.discardChanges', '放弃尚未保存的更改吗?'))) return;
|
||||||
|
closeAssetCustomSelects();
|
||||||
if (typeof closeAppModal === 'function') closeAppModal('asset-editor-modal');
|
if (typeof closeAppModal === 'function') closeAppModal('asset-editor-modal');
|
||||||
else document.getElementById('asset-editor-modal').style.display = 'none';
|
else document.getElementById('asset-editor-modal').style.display = 'none';
|
||||||
const returnFocus = assetPageState.editorReturnFocus;
|
const returnFocus = assetPageState.editorReturnFocus;
|
||||||
|
|||||||
+116
-43
@@ -80,6 +80,7 @@ let chatAttachmentSeq = 0;
|
|||||||
|
|
||||||
// 对话模式:eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration)
|
// 对话模式:eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration)
|
||||||
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
|
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
|
||||||
|
const AGENT_MODE_CONVERSATION_STORAGE_PREFIX = 'cyberstrike-chat-agent-mode:conversation';
|
||||||
const AI_CHANNEL_STORAGE_KEY = 'cyberstrike-chat-ai-channel';
|
const AI_CHANNEL_STORAGE_KEY = 'cyberstrike-chat-ai-channel';
|
||||||
const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
|
const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
|
||||||
const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort';
|
const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort';
|
||||||
@@ -733,6 +734,44 @@ function chatAgentModeNormalizeStored(stored, cfg) {
|
|||||||
return CHAT_AGENT_MODE_EINO_SINGLE;
|
return CHAT_AGENT_MODE_EINO_SINGLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeConversationAgentModeForUI(mode) {
|
||||||
|
const v = String(mode || '').trim().toLowerCase().replace(/-/g, '_');
|
||||||
|
if (chatAgentModeIsEinoSingle(v)) return v;
|
||||||
|
if (chatAgentModeIsEino(v)) {
|
||||||
|
return multiAgentAPIEnabled ? v : CHAT_AGENT_MODE_EINO_SINGLE;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function conversationAgentModeStorageKey(conversationId) {
|
||||||
|
return `${AGENT_MODE_CONVERSATION_STORAGE_PREFIX}:${String(conversationId || '').trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConversationAgentModePreference(conversationId) {
|
||||||
|
if (!conversationId) return '';
|
||||||
|
try {
|
||||||
|
return normalizeConversationAgentModeForUI(localStorage.getItem(conversationAgentModeStorageKey(conversationId)) || '');
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConversationAgentModePreference(conversationId, mode) {
|
||||||
|
const normalized = normalizeConversationAgentModeForUI(mode);
|
||||||
|
if (!conversationId || !normalized) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(conversationAgentModeStorageKey(conversationId), normalized);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyConversationAgentMode(conversationId, conversation) {
|
||||||
|
const saved = readConversationAgentModePreference(conversationId);
|
||||||
|
const fromServer = normalizeConversationAgentModeForUI(conversation && (conversation.agentMode || conversation.agent_mode));
|
||||||
|
const mode = saved || fromServer;
|
||||||
|
if (!mode) return;
|
||||||
|
syncAgentModeFromValue(mode);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || [];
|
window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || [];
|
||||||
window.csaiHitlDefaultReviewer = window.csaiHitlDefaultReviewer || 'human';
|
window.csaiHitlDefaultReviewer = window.csaiHitlDefaultReviewer || 'human';
|
||||||
@@ -1098,6 +1137,7 @@ function toggleAgentModePanel() {
|
|||||||
function selectAgentMode(mode) {
|
function selectAgentMode(mode) {
|
||||||
const ok = chatAgentModeIsEinoSingle(mode) || chatAgentModeIsEino(mode);
|
const ok = chatAgentModeIsEinoSingle(mode) || chatAgentModeIsEino(mode);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
saveConversationAgentModePreference(currentConversationId, mode);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode);
|
localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode);
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
@@ -1351,6 +1391,10 @@ async function sendMessage() {
|
|||||||
conversationId: currentConversationId,
|
conversationId: currentConversationId,
|
||||||
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
|
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
|
||||||
};
|
};
|
||||||
|
if (window.__csNextChatFinalizationPolicy && typeof window.__csNextChatFinalizationPolicy === 'object') {
|
||||||
|
body.finalization = window.__csNextChatFinalizationPolicy;
|
||||||
|
window.__csNextChatFinalizationPolicy = null;
|
||||||
|
}
|
||||||
let streamConversationId = body.conversationId ? String(body.conversationId) : null;
|
let streamConversationId = body.conversationId ? String(body.conversationId) : null;
|
||||||
const isStreamStillVisibleForRequest = function () {
|
const isStreamStillVisibleForRequest = function () {
|
||||||
if (!document.getElementById(progressId)) return false;
|
if (!document.getElementById(progressId)) return false;
|
||||||
@@ -1405,6 +1449,7 @@ async function sendMessage() {
|
|||||||
try {
|
try {
|
||||||
const modeSel = document.getElementById('agent-mode-select');
|
const modeSel = document.getElementById('agent-mode-select');
|
||||||
let modeVal = modeSel ? modeSel.value : CHAT_AGENT_MODE_EINO_SINGLE;
|
let modeVal = modeSel ? modeSel.value : CHAT_AGENT_MODE_EINO_SINGLE;
|
||||||
|
saveConversationAgentModePreference(streamConversationId || currentConversationId, modeVal);
|
||||||
const useMulti = multiAgentAPIEnabled && chatAgentModeIsEino(modeVal);
|
const useMulti = multiAgentAPIEnabled && chatAgentModeIsEino(modeVal);
|
||||||
const streamPath = useMulti ? '/api/multi-agent/stream' : '/api/eino-agent/stream';
|
const streamPath = useMulti ? '/api/multi-agent/stream' : '/api/eino-agent/stream';
|
||||||
if (useMulti && modeVal) {
|
if (useMulti && modeVal) {
|
||||||
@@ -3539,13 +3584,14 @@ function setPendingMcpExecutionIds(messageElement, executionIds) {
|
|||||||
|
|
||||||
function normalizeToolExecutionSummaryForButton(raw) {
|
function normalizeToolExecutionSummaryForButton(raw) {
|
||||||
const data = raw && typeof raw === 'object' ? raw : {};
|
const data = raw && typeof raw === 'object' ? raw : {};
|
||||||
return {
|
return {
|
||||||
toolName: data.toolName || data.name || '',
|
toolName: data.toolName || data.name || '',
|
||||||
status: data.status || '',
|
status: data.status || '',
|
||||||
executionId: data.executionId || '',
|
executionId: data.executionId || '',
|
||||||
toolCallId: data.toolCallId || '',
|
toolCallId: data.toolCallId || '',
|
||||||
processDetailId: data.processDetailId || ''
|
processDetailId: data.processDetailId || '',
|
||||||
};
|
resultDetailId: data.resultDetailId || ''
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function cacheToolExecutionSummaries(messageElement, summaries) {
|
function cacheToolExecutionSummaries(messageElement, summaries) {
|
||||||
@@ -3572,39 +3618,14 @@ function getCachedToolExecutionSummaries(messageElement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 过程摘要中的早期/快速工具结果可能没有 executionId,但消息本身会按调用顺序保存 ID。
|
|
||||||
* 合并两份数据,避免渲染摘要时丢失可用的弹窗详情入口。
|
|
||||||
*/
|
|
||||||
function mergeToolExecutionSummariesWithIds(summaries, executionIds) {
|
|
||||||
const normalizedSummaries = Array.isArray(summaries)
|
|
||||||
? summaries.map(normalizeToolExecutionSummaryForButton)
|
|
||||||
: [];
|
|
||||||
const normalizedIds = normalizeMcpExecutionIds(executionIds);
|
|
||||||
const claimedIds = new Set(
|
|
||||||
normalizedSummaries.map((item) => item.executionId).filter(Boolean)
|
|
||||||
);
|
|
||||||
const fallbackIds = normalizedIds.filter((id) => !claimedIds.has(id));
|
|
||||||
let fallbackIndex = 0;
|
|
||||||
return normalizedSummaries.map((item) => {
|
|
||||||
if (item.executionId || fallbackIndex >= fallbackIds.length) return item;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
executionId: fallbackIds[fallbackIndex++]
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectToolExecutionSummariesForButtons(summaries, executionIds) {
|
function selectToolExecutionSummariesForButtons(summaries, executionIds) {
|
||||||
const normalizedSummaries = Array.isArray(summaries)
|
const normalizedSummaries = Array.isArray(summaries)
|
||||||
? summaries.map(normalizeToolExecutionSummaryForButton)
|
? summaries.map(normalizeToolExecutionSummaryForButton)
|
||||||
: [];
|
: [];
|
||||||
const normalizedIds = normalizeMcpExecutionIds(executionIds);
|
const normalizedIds = normalizeMcpExecutionIds(executionIds);
|
||||||
|
if (normalizedSummaries.length > 0) return normalizedSummaries;
|
||||||
if (normalizedIds.length === 0) return normalizedSummaries;
|
if (normalizedIds.length === 0) return normalizedSummaries;
|
||||||
if (normalizedSummaries.length === 0) {
|
return normalizedIds.map((executionId) => normalizeToolExecutionSummaryForButton({ executionId }));
|
||||||
return normalizedIds.map((executionId) => normalizeToolExecutionSummaryForButton({ executionId }));
|
|
||||||
}
|
|
||||||
return mergeToolExecutionSummariesWithIds(normalizedSummaries, normalizedIds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPendingToolExecutionSummaries(messageElement, summaries) {
|
function setPendingToolExecutionSummaries(messageElement, summaries) {
|
||||||
@@ -3819,10 +3840,6 @@ async function findToolExecutionTimelineItem(messageElement, summary, index) {
|
|||||||
if (!target && item.toolCallId) {
|
if (!target && item.toolCallId) {
|
||||||
target = timeline.querySelector('[data-tool-call-id="' + cssEscapeValue(item.toolCallId) + '"]');
|
target = timeline.querySelector('[data-tool-call-id="' + cssEscapeValue(item.toolCallId) + '"]');
|
||||||
}
|
}
|
||||||
if (!target) {
|
|
||||||
const toolItems = timeline.querySelectorAll('.timeline-item-tool_call');
|
|
||||||
target = toolItems[index] || null;
|
|
||||||
}
|
|
||||||
if (!target && item.processDetailId && messageElement.dataset && messageElement.dataset.backendMessageId && typeof window.loadProcessDetailsPaginated === 'function') {
|
if (!target && item.processDetailId && messageElement.dataset && messageElement.dataset.backendMessageId && typeof window.loadProcessDetailsPaginated === 'function') {
|
||||||
await window.loadProcessDetailsPaginated(messageElement.id, messageElement.dataset.backendMessageId, {
|
await window.loadProcessDetailsPaginated(messageElement.id, messageElement.dataset.backendMessageId, {
|
||||||
autoLoadAll: false,
|
autoLoadAll: false,
|
||||||
@@ -3908,6 +3925,15 @@ async function fetchProcessDetailDataForModal(detailId) {
|
|||||||
return detail && detail.data ? detail.data : null;
|
return detail && detail.data ? detail.data : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchProcessDetailForModal(detailId) {
|
||||||
|
const id = detailId != null ? String(detailId).trim() : '';
|
||||||
|
if (!id || typeof apiFetch !== 'function') return null;
|
||||||
|
const res = await apiFetch('/api/process-details/' + encodeURIComponent(id));
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return j && j.processDetail ? j.processDetail : null;
|
||||||
|
}
|
||||||
|
|
||||||
function processToolResultTextFromData(resultData) {
|
function processToolResultTextFromData(resultData) {
|
||||||
if (!resultData) return '';
|
if (!resultData) return '';
|
||||||
const noResultText = typeof window.t === 'function' ? window.t('timeline.noResult') : '无结果';
|
const noResultText = typeof window.t === 'function' ? window.t('timeline.noResult') : '无结果';
|
||||||
@@ -3934,6 +3960,56 @@ function processToolResultToMCPResult(resultData, rawText) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function showMCPDetailFromProcessToolItem(messageElement, summary, index) {
|
async function showMCPDetailFromProcessToolItem(messageElement, summary, index) {
|
||||||
|
const item = normalizeToolExecutionSummaryForButton(summary);
|
||||||
|
if (item.processDetailId) {
|
||||||
|
const callDetail = await fetchProcessDetailForModal(item.processDetailId);
|
||||||
|
const callData = callDetail && callDetail.data ? callDetail.data : null;
|
||||||
|
if (callData) {
|
||||||
|
const args = typeof window.parseToolCallArgsFromData === 'function'
|
||||||
|
? window.parseToolCallArgsFromData(callData)
|
||||||
|
: (callData.argumentsObj || {});
|
||||||
|
let resultData = callData._mergedResult || null;
|
||||||
|
let resultDetailId = item.resultDetailId || callData._mergedResultDetailId || '';
|
||||||
|
let rawText = resultData ? processToolResultTextFromData(resultData) : '';
|
||||||
|
const resultPayloadDeferred = resultData && resultData._payloadDeferred === true;
|
||||||
|
const resultOnlyHasPreview = resultData && resultData.result == null && resultData.error == null && resultData.resultPreview != null;
|
||||||
|
if (resultDetailId && (!resultData || resultPayloadDeferred || resultOnlyHasPreview)) {
|
||||||
|
const resultDetail = await fetchProcessDetailForModal(resultDetailId);
|
||||||
|
const fullResult = resultDetail && resultDetail.data ? resultDetail.data : null;
|
||||||
|
if (fullResult) {
|
||||||
|
resultData = fullResult;
|
||||||
|
rawText = processToolResultTextFromData(fullResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const displayState = resultData && typeof window.getToolResultDisplayState === 'function'
|
||||||
|
? window.getToolResultDisplayState(resultData, { rawText: rawText })
|
||||||
|
: null;
|
||||||
|
const backgroundRunning = (displayState && displayState.kind === 'background_running') || String(item.status || '').toLowerCase() === 'background_running';
|
||||||
|
const success = resultData
|
||||||
|
? !(displayState ? displayState.isError : (resultData.isError || resultData.success === false))
|
||||||
|
: String(item.status || '').toLowerCase() !== 'failed';
|
||||||
|
const status = backgroundRunning
|
||||||
|
? 'background_running'
|
||||||
|
: (resultData || item.status
|
||||||
|
? (success ? 'completed' : 'failed')
|
||||||
|
: 'running');
|
||||||
|
const exec = {
|
||||||
|
id: (resultData && resultData.executionId) || item.executionId || callData.executionId || '',
|
||||||
|
toolName: item.toolName || callData.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具'),
|
||||||
|
status: status,
|
||||||
|
startTime: callDetail.createdAt || '',
|
||||||
|
arguments: args || {},
|
||||||
|
result: processToolResultToMCPResult(resultData, rawText),
|
||||||
|
error: resultData && resultData.error ? String(resultData.error) : ''
|
||||||
|
};
|
||||||
|
openAppModal('mcp-detail-modal', { focus: false });
|
||||||
|
deferModalContent(function () {
|
||||||
|
renderMCPDetailModal(exec);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const target = await findToolExecutionTimelineItem(messageElement, summary, index);
|
const target = await findToolExecutionTimelineItem(messageElement, summary, index);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
alert(typeof window.t === 'function'
|
alert(typeof window.t === 'function'
|
||||||
@@ -3995,11 +4071,7 @@ async function openTaskToolExecutionDetail(messageElement, item, index) {
|
|||||||
let detailItem = item;
|
let detailItem = item;
|
||||||
if (!detailItem.executionId) {
|
if (!detailItem.executionId) {
|
||||||
const refreshedItem = await resolveToolExecutionSummaryForFocus(messageElement, '', index);
|
const refreshedItem = await resolveToolExecutionSummaryForFocus(messageElement, '', index);
|
||||||
const mergedItems = mergeToolExecutionSummariesWithIds(
|
detailItem = refreshedItem || detailItem;
|
||||||
getCachedToolExecutionSummaries(messageElement),
|
|
||||||
getCachedMcpExecutionIds(messageElement)
|
|
||||||
);
|
|
||||||
detailItem = mergedItems[index] || refreshedItem || detailItem;
|
|
||||||
}
|
}
|
||||||
if (detailItem.executionId) {
|
if (detailItem.executionId) {
|
||||||
await showMCPDetail(detailItem.executionId);
|
await showMCPDetail(detailItem.executionId);
|
||||||
@@ -4878,6 +4950,7 @@ async function loadConversation(conversationId) {
|
|||||||
if (typeof window.setCurrentRole === 'function') {
|
if (typeof window.setCurrentRole === 'function') {
|
||||||
window.setCurrentRole(conversationRoleName || '默认');
|
window.setCurrentRole(conversationRoleName || '默认');
|
||||||
}
|
}
|
||||||
|
applyConversationAgentMode(conversationId, conversation);
|
||||||
try {
|
try {
|
||||||
window.currentConversationId = conversationId;
|
window.currentConversationId = conversationId;
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
|
|||||||
@@ -1334,6 +1334,7 @@ function scanFofaRow(encodedRowJson, clickEvent) {
|
|||||||
}
|
}
|
||||||
if (autoSend) {
|
if (autoSend) {
|
||||||
if (typeof sendMessage === 'function') {
|
if (typeof sendMessage === 'function') {
|
||||||
|
window.__csNextChatFinalizationPolicy = { requireExecutionEvidence: true };
|
||||||
sendMessage();
|
sendMessage();
|
||||||
} else {
|
} else {
|
||||||
alert(_t('infoCollect.noSendMessage'));
|
alert(_t('infoCollect.noSendMessage'));
|
||||||
|
|||||||
+123
-4
@@ -198,6 +198,92 @@ function resolveFinalAssistantResponseText(finalMessage, streamState) {
|
|||||||
return finalMessage;
|
return finalMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFinalizedResponseData(data) {
|
||||||
|
return !!(data && data.finalized === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFinalizationContract(data) {
|
||||||
|
if (!data || typeof data !== 'object') return false;
|
||||||
|
return Object.prototype.hasOwnProperty.call(data, 'finalized')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(data, 'finalizable')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(data, 'completionReason')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(data, 'evidenceVerified')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(data, 'missingChecks');
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizationCheckTitle(data) {
|
||||||
|
return isFinalizedResponseData(data) ? '最终回复检查通过' : '最终回复检查未通过';
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizationReasonLabel(reason, status) {
|
||||||
|
const key = String(reason || status || '').trim();
|
||||||
|
const labels = {
|
||||||
|
pending_tool_executions: '等待工具执行完成',
|
||||||
|
missing_execution_evidence: '缺少完成态证据',
|
||||||
|
awaiting_hitl: '等待人工确认',
|
||||||
|
empty_response: '未捕获到有效回复',
|
||||||
|
missing_finalization_contract: '缺少最终化证明',
|
||||||
|
in_progress: '仍在验证',
|
||||||
|
blocked: '检查未通过',
|
||||||
|
failed: '任务失败',
|
||||||
|
cancelled: '任务已取消',
|
||||||
|
verified: '已验证'
|
||||||
|
};
|
||||||
|
return labels[key] || key || '检查未通过';
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizationMissingCheckLabel(check) {
|
||||||
|
const s = String(check || '').trim();
|
||||||
|
if (!s) return '';
|
||||||
|
if (s.indexOf('tool execution still queued or running') !== -1) return '仍有工具执行未结束';
|
||||||
|
if (s.indexOf('execution evidence is required but no completed tool execution was recorded') !== -1) return '本轮要求执行证据,但没有 completed 工具记录';
|
||||||
|
if (s.indexOf('workflow is awaiting HITL approval') !== -1) return '工作流正在等待人工确认';
|
||||||
|
if (s.indexOf('assistant final text is empty') !== -1) return '未捕获到有效最终文本';
|
||||||
|
if (s.indexOf('agent run status is ') === 0) return '任务状态仍为 ' + s.replace('agent run status is ', '');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactStringList(values, limit) {
|
||||||
|
const arr = Array.isArray(values) ? values.filter(Boolean).map(String) : [];
|
||||||
|
const max = limit || 3;
|
||||||
|
if (arr.length <= max) return arr;
|
||||||
|
return arr.slice(0, max).concat('另 ' + (arr.length - max) + ' 项');
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizationNoticeMarkdown(responseData, eventMessage) {
|
||||||
|
const hasContract = hasFinalizationContract(responseData);
|
||||||
|
const reason = hasContract
|
||||||
|
? finalizationReasonLabel(responseData && responseData.completionReason, responseData && responseData.status)
|
||||||
|
: finalizationReasonLabel('missing_finalization_contract');
|
||||||
|
const lines = ['**仍在验证,暂不生成最终结论**', '', '状态:' + reason];
|
||||||
|
const pending = compactStringList(responseData && responseData.pendingExecutionIds, 3);
|
||||||
|
if (pending.length) {
|
||||||
|
lines.push('待完成工具:`' + pending.join('`, `') + '`');
|
||||||
|
}
|
||||||
|
const rawMissingChecks = responseData && responseData.missingChecks;
|
||||||
|
const missingChecks = Array.isArray(rawMissingChecks)
|
||||||
|
? rawMissingChecks
|
||||||
|
: (rawMissingChecks ? [rawMissingChecks] : []);
|
||||||
|
const missing = compactStringList(missingChecks.map(finalizationMissingCheckLabel).filter(Boolean), 3);
|
||||||
|
if (missing.length) {
|
||||||
|
lines.push('待完成检查:' + missing.join(';'));
|
||||||
|
}
|
||||||
|
if (!hasContract && eventMessage != null && String(eventMessage).trim() !== '') {
|
||||||
|
lines.push('', '候选输出已移入过程详情,避免误判为最终结论。');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAssistantFinalizationState(assistantMessageId, responseData) {
|
||||||
|
const assistantElement = document.getElementById(assistantMessageId);
|
||||||
|
if (!assistantElement) return;
|
||||||
|
const finalized = isFinalizedResponseData(responseData);
|
||||||
|
assistantElement.dataset.finalized = finalized ? 'true' : 'false';
|
||||||
|
assistantElement.dataset.finalizationStatus = responseData && responseData.status ? String(responseData.status) : '';
|
||||||
|
assistantElement.classList.toggle('assistant-finalized', finalized);
|
||||||
|
assistantElement.classList.toggle('assistant-not-finalized', !finalized);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主通道 response 结束时:将流式占位条目固化为 planning(与后端 flushResponsePlan 落库类型一致),
|
* 主通道 response 结束时:将流式占位条目固化为 planning(与后端 flushResponsePlan 落库类型一致),
|
||||||
* 避免 integrateProgressToMCPSection 快照前删除占位导致「助手输出」仅刷新后才出现。
|
* 避免 integrateProgressToMCPSection 快照前删除占位导致「助手输出」仅刷新后才出现。
|
||||||
@@ -2440,6 +2526,26 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'finalization_check':
|
||||||
|
const finalizationCheckData = event.data || {};
|
||||||
|
const finalizationCheckPassed = isFinalizedResponseData(finalizationCheckData);
|
||||||
|
addTimelineItem(timeline, 'finalization_check', {
|
||||||
|
title: finalizationCheckTitle(finalizationCheckData),
|
||||||
|
message: finalizationCheckPassed ? (event.message || '最终回复检查通过。') : finalizationNoticeMarkdown(finalizationCheckData, event.message),
|
||||||
|
data: event.data,
|
||||||
|
expanded: !finalizationCheckPassed
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'finalization_auto_continue':
|
||||||
|
addTimelineItem(timeline, 'progress', {
|
||||||
|
title: '继续验证',
|
||||||
|
message: event.message,
|
||||||
|
data: event.data,
|
||||||
|
expanded: false
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
case 'hitl_interrupt':
|
case 'hitl_interrupt':
|
||||||
const hitlTargetItem = findToolCallItemForHitl(timeline, event.data || {});
|
const hitlTargetItem = findToolCallItemForHitl(timeline, event.data || {});
|
||||||
if (hitlTargetItem && hitlTargetItem.id) {
|
if (hitlTargetItem && hitlTargetItem.id) {
|
||||||
@@ -2958,7 +3064,12 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
const streamState = responseStreamStateByProgressId.get(progressId);
|
const streamState = responseStreamStateByProgressId.get(progressId);
|
||||||
const existingAssistantId = streamState?.assistantId || getAssistantId();
|
const existingAssistantId = streamState?.assistantId || getAssistantId();
|
||||||
let assistantIdFinal = existingAssistantId;
|
let assistantIdFinal = existingAssistantId;
|
||||||
const bubbleText = resolveFinalAssistantResponseText(event.message, streamState);
|
const responseFinalized = isFinalizedResponseData(responseData);
|
||||||
|
const responseHasFinalizationContract = hasFinalizationContract(responseData);
|
||||||
|
const resolvedResponseText = resolveFinalAssistantResponseText(event.message, streamState);
|
||||||
|
const bubbleText = responseFinalized
|
||||||
|
? resolvedResponseText
|
||||||
|
: finalizationNoticeMarkdown(responseData, event.message);
|
||||||
|
|
||||||
if (!assistantIdFinal) {
|
if (!assistantIdFinal) {
|
||||||
assistantIdFinal = addMessage('assistant', bubbleText, mcpIds, progressId);
|
assistantIdFinal = addMessage('assistant', bubbleText, mcpIds, progressId);
|
||||||
@@ -2967,11 +3078,12 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
setAssistantId(assistantIdFinal);
|
setAssistantId(assistantIdFinal);
|
||||||
updateAssistantBubbleContent(assistantIdFinal, bubbleText, true);
|
updateAssistantBubbleContent(assistantIdFinal, bubbleText, true);
|
||||||
}
|
}
|
||||||
|
markAssistantFinalizationState(assistantIdFinal, responseData);
|
||||||
|
|
||||||
// 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
|
// 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
|
||||||
if (streamState && streamState.itemId) {
|
if (streamState && streamState.itemId) {
|
||||||
finalizeMainResponseStreamItem(streamState, event.message, responseData);
|
finalizeMainResponseStreamItem(streamState, responseFinalized ? event.message : '', responseData);
|
||||||
} else if (timeline && bubbleText && String(bubbleText).trim() && !isEinoEmptyResponsePlaceholder(event.message)) {
|
} else if (timeline && responseFinalized && bubbleText && String(bubbleText).trim() && !isEinoEmptyResponsePlaceholder(event.message)) {
|
||||||
addTimelineItem(timeline, 'planning', {
|
addTimelineItem(timeline, 'planning', {
|
||||||
title: typeof einoMainStreamPlanningTitle === 'function'
|
title: typeof einoMainStreamPlanningTitle === 'function'
|
||||||
? einoMainStreamPlanningTitle(responseData)
|
? einoMainStreamPlanningTitle(responseData)
|
||||||
@@ -2980,6 +3092,13 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
data: responseData,
|
data: responseData,
|
||||||
expanded: false
|
expanded: false
|
||||||
});
|
});
|
||||||
|
} else if (timeline && !responseFinalized && !responseHasFinalizationContract && resolvedResponseText && String(resolvedResponseText).trim()) {
|
||||||
|
addTimelineItem(timeline, 'finalization_check', {
|
||||||
|
title: '候选输出缺少最终化证明',
|
||||||
|
message: resolvedResponseText,
|
||||||
|
data: Object.assign({}, responseData, { missingFinalizationContract: true }),
|
||||||
|
expanded: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
|
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
|
||||||
@@ -5562,7 +5681,7 @@ function updateMonitorTimelineSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const MCP_STATS_TOP_N = 3;
|
const MCP_STATS_TOP_N = 6;
|
||||||
const MCP_TIMELINE_RANGES = ['24h', '7d', '30d'];
|
const MCP_TIMELINE_RANGES = ['24h', '7d', '30d'];
|
||||||
|
|
||||||
function getMcpMonitorTimelineRange() {
|
function getMcpMonitorTimelineRange() {
|
||||||
|
|||||||
+327
-66
@@ -142,6 +142,19 @@ function syncSettingsCustomSelect(select) {
|
|||||||
|
|
||||||
item.appendChild(check);
|
item.appendChild(check);
|
||||||
item.appendChild(label);
|
item.appendChild(label);
|
||||||
|
|
||||||
|
if (select.id === 'ai-channel-select') {
|
||||||
|
const probeStatus = option.dataset.probeStatus || '';
|
||||||
|
const probeMessage = option.dataset.probeMessage || '';
|
||||||
|
if (probeStatus) {
|
||||||
|
item.classList.add('settings-custom-select-option--probe', `probe-${probeStatus}`);
|
||||||
|
const status = document.createElement('span');
|
||||||
|
status.className = `settings-custom-select-status ${probeStatus}`;
|
||||||
|
status.innerHTML = `<span class="settings-custom-select-status-dot" aria-hidden="true"></span><span class="settings-custom-select-status-text"></span>`;
|
||||||
|
status.querySelector('.settings-custom-select-status-text').textContent = probeMessage || probeStatus;
|
||||||
|
item.appendChild(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
reg.menu.appendChild(item);
|
reg.menu.appendChild(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2506,7 +2519,7 @@ function ensureAIConfigShape(cfg) {
|
|||||||
|
|
||||||
function readAIChannelFromMainForm(id) {
|
function readAIChannelFromMainForm(id) {
|
||||||
const prev = currentConfig?.ai?.channels?.[id] || {};
|
const prev = currentConfig?.ai?.channels?.[id] || {};
|
||||||
const maxCompletionTokens = parseInt(document.getElementById('openai-max-completion-tokens')?.value, 10) || 16384;
|
const maxCompletionTokens = parseInt(document.getElementById('openai-max-completion-tokens')?.value, 10) || 32768;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
name: (document.getElementById('ai-channel-name')?.value || '').trim() || prev.name || id,
|
name: (document.getElementById('ai-channel-name')?.value || '').trim() || prev.name || id,
|
||||||
@@ -2546,7 +2559,7 @@ function writeAIChannelToMainForm(id) {
|
|||||||
const maxTokensEl = document.getElementById('openai-max-total-tokens');
|
const maxTokensEl = document.getElementById('openai-max-total-tokens');
|
||||||
if (maxTokensEl) maxTokensEl.value = ch.max_total_tokens || 120000;
|
if (maxTokensEl) maxTokensEl.value = ch.max_total_tokens || 120000;
|
||||||
const maxCompletionTokensEl = document.getElementById('openai-max-completion-tokens');
|
const maxCompletionTokensEl = document.getElementById('openai-max-completion-tokens');
|
||||||
if (maxCompletionTokensEl) maxCompletionTokensEl.value = ch.max_completion_tokens || 16384;
|
if (maxCompletionTokensEl) maxCompletionTokensEl.value = ch.max_completion_tokens || 32768;
|
||||||
const r = ch.reasoning || {};
|
const r = ch.reasoning || {};
|
||||||
const modeEl = document.getElementById('openai-reasoning-mode');
|
const modeEl = document.getElementById('openai-reasoning-mode');
|
||||||
if (modeEl) modeEl.value = ['auto', 'on', 'off'].includes(String(r.mode || '').toLowerCase()) ? String(r.mode).toLowerCase() : 'auto';
|
if (modeEl) modeEl.value = ['auto', 'on', 'off'].includes(String(r.mode || '').toLowerCase()) ? String(r.mode).toLowerCase() : 'auto';
|
||||||
@@ -2557,6 +2570,100 @@ function writeAIChannelToMainForm(id) {
|
|||||||
const allowEl = document.getElementById('openai-reasoning-allow-client');
|
const allowEl = document.getElementById('openai-reasoning-allow-client');
|
||||||
if (allowEl) allowEl.checked = r.allow_client_reasoning !== false;
|
if (allowEl) allowEl.checked = r.allow_client_reasoning !== false;
|
||||||
syncModelListFetchButtons();
|
syncModelListFetchButtons();
|
||||||
|
syncAIChannelEditorPreview();
|
||||||
|
syncConnectionTestResultForSelectedAIChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayAIChannelName(id, ch) {
|
||||||
|
const name = String(ch?.name || '').trim();
|
||||||
|
if ((name === '新通道' || name === 'New Channel') && !String(ch?.model || '').trim()) {
|
||||||
|
return settingsT('settingsBasic.aiChannelUntitled', name || id);
|
||||||
|
}
|
||||||
|
return name || id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiChannelSelectLabel(id, ch) {
|
||||||
|
const marker = id === currentConfig?.ai?.default_channel ? ' *' : '';
|
||||||
|
return `${displayAIChannelName(id, ch)}${marker} · ${ch?.model || '-'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiChannelOptionProbeMeta(id) {
|
||||||
|
const probe = aiChannelProbeResults[id];
|
||||||
|
if (!probe) return null;
|
||||||
|
const status = probe.status || '';
|
||||||
|
if (!['testing', 'ready', 'failed'].includes(status)) return null;
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
message: probe.message || (status === 'ready'
|
||||||
|
? settingsT('settingsBasic.aiChannelReady', '可用')
|
||||||
|
: status === 'testing'
|
||||||
|
? settingsT('settingsBasic.testing', '测试中...')
|
||||||
|
: settingsT('settingsBasic.testFailed', '连接失败'))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAIChannelSelectOption(id) {
|
||||||
|
const select = document.getElementById('ai-channel-select');
|
||||||
|
if (!select || !currentConfig?.ai?.channels) return;
|
||||||
|
const channelId = normalizeAIChannelId(id || selectedAIChannelId || currentConfig.ai.default_channel || 'default');
|
||||||
|
const ch = currentConfig.ai.channels[channelId];
|
||||||
|
if (!ch) return;
|
||||||
|
const opt = Array.from(select.options).find((option) => option.value === channelId);
|
||||||
|
if (opt) {
|
||||||
|
opt.textContent = aiChannelSelectLabel(channelId, ch);
|
||||||
|
const probeMeta = aiChannelOptionProbeMeta(channelId);
|
||||||
|
if (probeMeta) {
|
||||||
|
opt.dataset.probeStatus = probeMeta.status;
|
||||||
|
opt.dataset.probeMessage = probeMeta.message;
|
||||||
|
} else {
|
||||||
|
delete opt.dataset.probeStatus;
|
||||||
|
delete opt.dataset.probeMessage;
|
||||||
|
}
|
||||||
|
if (channelId === selectedAIChannelId) {
|
||||||
|
select.value = channelId;
|
||||||
|
select.selectedIndex = opt.index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof syncSettingsCustomSelect === 'function') {
|
||||||
|
syncSettingsCustomSelect(select);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSelectedAIChannelUI() {
|
||||||
|
updateAIChannelSelectOption(selectedAIChannelId);
|
||||||
|
updateAIChannelEditorChrome(selectedAIChannelId);
|
||||||
|
renderAIChannelList();
|
||||||
|
syncConnectionTestResultForSelectedAIChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAIChannelEditorPreview() {
|
||||||
|
if (!currentConfig?.ai?.channels || !selectedAIChannelId || !currentConfig.ai.channels[selectedAIChannelId]) return;
|
||||||
|
const id = normalizeAIChannelId(selectedAIChannelId);
|
||||||
|
const prev = currentConfig.ai.channels[id] || {};
|
||||||
|
const next = readAIChannelFromMainForm(id);
|
||||||
|
const connectionChanged = ['provider', 'base_url', 'api_key', 'model'].some((key) => String(prev[key] || '') !== String(next[key] || ''));
|
||||||
|
if (connectionChanged) {
|
||||||
|
delete aiChannelProbeResults[id];
|
||||||
|
}
|
||||||
|
currentConfig.ai.channels[id] = next;
|
||||||
|
syncSelectedAIChannelUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindAIChannelEditorPreviewSync() {
|
||||||
|
const ids = [
|
||||||
|
'ai-channel-name',
|
||||||
|
'openai-provider',
|
||||||
|
'openai-api-key',
|
||||||
|
'openai-base-url',
|
||||||
|
'openai-model'
|
||||||
|
];
|
||||||
|
ids.forEach((fieldId) => {
|
||||||
|
const el = document.getElementById(fieldId);
|
||||||
|
if (!el || el.dataset.aiChannelPreviewBound === '1') return;
|
||||||
|
el.dataset.aiChannelPreviewBound = '1';
|
||||||
|
const eventName = el.tagName === 'SELECT' ? 'change' : 'input';
|
||||||
|
el.addEventListener(eventName, syncAIChannelEditorPreview);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAIChannelSelect() {
|
function renderAIChannelSelect() {
|
||||||
@@ -2570,16 +2677,24 @@ function renderAIChannelSelect() {
|
|||||||
const ch = currentConfig.ai.channels[id] || {};
|
const ch = currentConfig.ai.channels[id] || {};
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = id;
|
opt.value = id;
|
||||||
const marker = id === currentConfig.ai.default_channel ? ' *' : '';
|
opt.textContent = aiChannelSelectLabel(id, ch);
|
||||||
opt.textContent = `${ch.name || id}${marker} · ${ch.model || '-'}`;
|
const probeMeta = aiChannelOptionProbeMeta(id);
|
||||||
|
if (probeMeta) {
|
||||||
|
opt.dataset.probeStatus = probeMeta.status;
|
||||||
|
opt.dataset.probeMessage = probeMeta.message;
|
||||||
|
}
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
selectedAIChannelId = selectedAIChannelId && currentConfig.ai.channels[selectedAIChannelId]
|
selectedAIChannelId = selectedAIChannelId && currentConfig.ai.channels[selectedAIChannelId]
|
||||||
? selectedAIChannelId
|
? selectedAIChannelId
|
||||||
: currentConfig.ai.default_channel;
|
: currentConfig.ai.default_channel;
|
||||||
select.value = selectedAIChannelId;
|
select.value = selectedAIChannelId;
|
||||||
renderAIChannelList(ids);
|
updateAIChannelSelectOption(selectedAIChannelId);
|
||||||
|
if (typeof syncSettingsCustomSelect === 'function') {
|
||||||
|
syncSettingsCustomSelect(select);
|
||||||
|
}
|
||||||
updateAIChannelEditorChrome(selectedAIChannelId);
|
updateAIChannelEditorChrome(selectedAIChannelId);
|
||||||
|
renderAIChannelList(ids);
|
||||||
const countLabel = typeof window.t === 'function'
|
const countLabel = typeof window.t === 'function'
|
||||||
? window.t('settingsBasic.aiChannelCount').replace('{count}', String(ids.length))
|
? window.t('settingsBasic.aiChannelCount').replace('{count}', String(ids.length))
|
||||||
: `已保存 ${ids.length} 个通道`;
|
: `已保存 ${ids.length} 个通道`;
|
||||||
@@ -2621,7 +2736,7 @@ function renderAIChannelList(ids) {
|
|||||||
checkbox.type = 'checkbox';
|
checkbox.type = 'checkbox';
|
||||||
checkbox.className = 'ai-channel-bulk-check';
|
checkbox.className = 'ai-channel-bulk-check';
|
||||||
checkbox.checked = selectedAIChannelBulkIds.has(id);
|
checkbox.checked = selectedAIChannelBulkIds.has(id);
|
||||||
checkbox.setAttribute('aria-label', `选择 ${ch.name || id}`);
|
checkbox.setAttribute('aria-label', settingsT('settingsBasic.aiChannelSelectAria', '选择 {name}').replace('{name}', displayAIChannelName(id, ch)));
|
||||||
checkbox.onclick = (event) => {
|
checkbox.onclick = (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (checkbox.checked) {
|
if (checkbox.checked) {
|
||||||
@@ -2631,11 +2746,12 @@ function renderAIChannelList(ids) {
|
|||||||
}
|
}
|
||||||
item.classList.toggle('checked', checkbox.checked);
|
item.classList.toggle('checked', checkbox.checked);
|
||||||
};
|
};
|
||||||
|
const displayName = displayAIChannelName(id, ch);
|
||||||
const defaultBadge = isDefault ? `<span class="ai-channel-badge">${escapeAIChannelHtml(settingsT('settingsBasic.aiChannelDefaultBadge', '默认'))}</span>` : '';
|
const defaultBadge = isDefault ? `<span class="ai-channel-badge">${escapeAIChannelHtml(settingsT('settingsBasic.aiChannelDefaultBadge', '默认'))}</span>` : '';
|
||||||
let statusText = isComplete
|
let statusText = isComplete
|
||||||
? settingsT('settingsBasic.aiChannelReady', '可用')
|
? settingsT('settingsBasic.aiChannelComplete', '配置完整')
|
||||||
: settingsT('settingsBasic.aiChannelDraft', '待完善');
|
: settingsT('settingsBasic.aiChannelDraft', '待完善');
|
||||||
let statusClass = isComplete ? 'ready' : 'draft';
|
let statusClass = isComplete ? 'complete' : 'draft';
|
||||||
if (probe) {
|
if (probe) {
|
||||||
statusText = probe.message || statusText;
|
statusText = probe.message || statusText;
|
||||||
statusClass = probe.status || statusClass;
|
statusClass = probe.status || statusClass;
|
||||||
@@ -2645,7 +2761,7 @@ function renderAIChannelList(ids) {
|
|||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="ai-channel-list-main">
|
<div class="ai-channel-list-main">
|
||||||
<span class="ai-channel-status-dot ${statusClass}" aria-hidden="true"></span>
|
<span class="ai-channel-status-dot ${statusClass}" aria-hidden="true"></span>
|
||||||
<strong title="${escapeAIChannelHtml(ch.name || id)}">${escapeAIChannelHtml(ch.name || id)}</strong>
|
<strong title="${escapeAIChannelHtml(displayName)}">${escapeAIChannelHtml(displayName)}</strong>
|
||||||
${defaultBadge}
|
${defaultBadge}
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-channel-list-meta" title="${escapeAIChannelHtml(ch.model || '-')} · ${escapeAIChannelHtml(channelHostLabel(ch.base_url))}">${escapeAIChannelHtml(ch.model || '-')} · ${escapeAIChannelHtml(channelHostLabel(ch.base_url))}</div>
|
<div class="ai-channel-list-meta" title="${escapeAIChannelHtml(ch.model || '-')} · ${escapeAIChannelHtml(channelHostLabel(ch.base_url))}">${escapeAIChannelHtml(ch.model || '-')} · ${escapeAIChannelHtml(channelHostLabel(ch.base_url))}</div>
|
||||||
@@ -2672,19 +2788,36 @@ function updateAIChannelEditorChrome(id) {
|
|||||||
const ai = ensureAIConfigShape(currentConfig || {});
|
const ai = ensureAIConfigShape(currentConfig || {});
|
||||||
const channelId = normalizeAIChannelId(id || ai.default_channel || 'default');
|
const channelId = normalizeAIChannelId(id || ai.default_channel || 'default');
|
||||||
const ch = ai.channels[channelId] || {};
|
const ch = ai.channels[channelId] || {};
|
||||||
|
const isDefault = channelId === ai.default_channel;
|
||||||
|
const isComplete = !validateSelectedAIChannelPayload(ch);
|
||||||
|
const probe = aiChannelProbeResults[channelId] || null;
|
||||||
const title = document.getElementById('ai-channel-editor-title');
|
const title = document.getElementById('ai-channel-editor-title');
|
||||||
const meta = document.getElementById('ai-channel-editor-meta');
|
const meta = document.getElementById('ai-channel-editor-meta');
|
||||||
if (title) title.textContent = ch.name || channelId;
|
if (title) {
|
||||||
|
title.textContent = settingsT('settingsBasic.aiChannelFormContextHint', '表单保存后会更新该通道配置。');
|
||||||
|
}
|
||||||
if (meta) {
|
if (meta) {
|
||||||
const parts = [
|
const provider = ch.provider === 'claude' ? 'Claude' : settingsT('settingsBasic.aiChannelOpenAICompat', 'OpenAI 兼容');
|
||||||
channelId === ai.default_channel
|
const statusText = probe?.message || (isComplete
|
||||||
? settingsT('settingsBasic.aiChannelDefaultMeta', '默认通道')
|
? settingsT('settingsBasic.aiChannelComplete', '配置完整')
|
||||||
: settingsT('settingsBasic.aiChannelCustomMeta', '自定义通道'),
|
: settingsT('settingsBasic.aiChannelDraft', '待完善'));
|
||||||
ch.provider === 'claude' ? 'Claude' : settingsT('settingsBasic.aiChannelOpenAICompat', 'OpenAI 兼容'),
|
const statusClass = probe?.status || (isComplete ? 'complete' : 'draft');
|
||||||
ch.model || settingsT('settingsBasic.aiChannelModelMissing', '未填写模型'),
|
const chips = [
|
||||||
channelHostLabel(ch.base_url)
|
{
|
||||||
].filter(Boolean);
|
label: isDefault
|
||||||
meta.textContent = parts.join(' / ');
|
? settingsT('settingsBasic.aiChannelDefaultMeta', '默认通道')
|
||||||
|
: settingsT('settingsBasic.aiChannelCustomMeta', '自定义通道'),
|
||||||
|
className: isDefault ? 'default' : ''
|
||||||
|
},
|
||||||
|
{ label: provider },
|
||||||
|
{ label: ch.model || settingsT('settingsBasic.aiChannelModelMissing', '未填写模型') },
|
||||||
|
{ label: channelHostLabel(ch.base_url) },
|
||||||
|
{ label: statusText, className: statusClass }
|
||||||
|
].filter((chip) => chip.label);
|
||||||
|
meta.innerHTML = chips.map((chip) => {
|
||||||
|
const className = chip.className ? ` ${escapeAIChannelHtml(chip.className)}` : '';
|
||||||
|
return `<span class="ai-channel-editor-chip${className}" title="${escapeAIChannelHtml(chip.label)}">${escapeAIChannelHtml(chip.label)}</span>`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2699,6 +2832,41 @@ function validateSelectedAIChannelPayload(ch) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveSavedAIChannelId(ai, preferredId, preferredPayload) {
|
||||||
|
const channels = ai?.channels || {};
|
||||||
|
const normalizedPreferred = normalizeAIChannelId(preferredId || '');
|
||||||
|
if (normalizedPreferred && channels[normalizedPreferred]) return normalizedPreferred;
|
||||||
|
|
||||||
|
const payload = preferredPayload || {};
|
||||||
|
const targetName = String(payload.name || '').trim();
|
||||||
|
const targetModel = String(payload.model || '').trim();
|
||||||
|
const targetBaseUrl = String(payload.base_url || '').trim();
|
||||||
|
const targetProvider = String(payload.provider || '').trim();
|
||||||
|
const ids = Object.keys(channels).sort();
|
||||||
|
const matched = ids.find((id) => {
|
||||||
|
const ch = channels[id] || {};
|
||||||
|
return String(ch.name || '').trim() === targetName
|
||||||
|
&& String(ch.model || '').trim() === targetModel
|
||||||
|
&& String(ch.base_url || '').trim() === targetBaseUrl
|
||||||
|
&& String(ch.provider || '').trim() === targetProvider;
|
||||||
|
});
|
||||||
|
return matched || ai?.default_channel || ids[0] || normalizedPreferred || 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAIChannelsFromServer(preferredId, preferredPayload) {
|
||||||
|
const response = await apiFetch('/api/config');
|
||||||
|
if (!response.ok) return false;
|
||||||
|
currentConfig = await response.json();
|
||||||
|
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||||
|
selectedAIChannelId = resolveSavedAIChannelId(currentConfig.ai, preferredId, preferredPayload);
|
||||||
|
renderAIChannelSelect();
|
||||||
|
writeAIChannelToMainForm(selectedAIChannelId);
|
||||||
|
if (typeof populateChatAIChannelSelect === 'function') {
|
||||||
|
populateChatAIChannelSelect(currentConfig.ai);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function persistAIChannelsToServer(successMessage, options = {}) {
|
async function persistAIChannelsToServer(successMessage, options = {}) {
|
||||||
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return false;
|
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return false;
|
||||||
if (!currentConfig) currentConfig = {};
|
if (!currentConfig) currentConfig = {};
|
||||||
@@ -2714,7 +2882,7 @@ async function persistAIChannelsToServer(successMessage, options = {}) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
renderAIChannelSelect();
|
renderAIChannelSelect();
|
||||||
showAIChannelSaveHint('正在保存通道...', true);
|
showAIChannelSaveHint(settingsT('settingsBasic.aiChannelSaving', '正在保存通道...'), true);
|
||||||
try {
|
try {
|
||||||
const shouldMergeLatest = options.mergeLatest !== false;
|
const shouldMergeLatest = options.mergeLatest !== false;
|
||||||
const latestResponse = shouldMergeLatest ? await apiFetch('/api/config') : null;
|
const latestResponse = shouldMergeLatest ? await apiFetch('/api/config') : null;
|
||||||
@@ -2744,17 +2912,7 @@ async function persistAIChannelsToServer(successMessage, options = {}) {
|
|||||||
const error = await applyResponse.json().catch(() => ({}));
|
const error = await applyResponse.json().catch(() => ({}));
|
||||||
throw new Error(error.error || '应用通道失败');
|
throw new Error(error.error || '应用通道失败');
|
||||||
}
|
}
|
||||||
const response = await apiFetch('/api/config');
|
await refreshAIChannelsFromServer(id, channelPayload);
|
||||||
if (response.ok) {
|
|
||||||
currentConfig = await response.json();
|
|
||||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
|
||||||
selectedAIChannelId = currentConfig.ai.channels[id] ? id : currentConfig.ai.default_channel;
|
|
||||||
renderAIChannelSelect();
|
|
||||||
writeAIChannelToMainForm(selectedAIChannelId);
|
|
||||||
if (typeof populateChatAIChannelSelect === 'function') {
|
|
||||||
populateChatAIChannelSelect(currentConfig.ai);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showAIChannelSaveHint(successMessage || '通道已保存', true);
|
showAIChannelSaveHint(successMessage || '通道已保存', true);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2768,7 +2926,7 @@ async function persistAIConfigOnlyToServer(successMessage) {
|
|||||||
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return false;
|
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return false;
|
||||||
if (!currentConfig) return false;
|
if (!currentConfig) return false;
|
||||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||||
showAIChannelSaveHint('正在保存通道...', true);
|
showAIChannelSaveHint(settingsT('settingsBasic.aiChannelSaving', '正在保存通道...'), true);
|
||||||
try {
|
try {
|
||||||
const updateResponse = await apiFetch('/api/config', {
|
const updateResponse = await apiFetch('/api/config', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -2784,9 +2942,7 @@ async function persistAIConfigOnlyToServer(successMessage) {
|
|||||||
const error = await applyResponse.json().catch(() => ({}));
|
const error = await applyResponse.json().catch(() => ({}));
|
||||||
throw new Error(error.error || '应用通道失败');
|
throw new Error(error.error || '应用通道失败');
|
||||||
}
|
}
|
||||||
if (typeof populateChatAIChannelSelect === 'function') {
|
await refreshAIChannelsFromServer(selectedAIChannelId);
|
||||||
populateChatAIChannelSelect(currentConfig.ai);
|
|
||||||
}
|
|
||||||
showAIChannelSaveHint(successMessage || '通道已保存', true);
|
showAIChannelSaveHint(successMessage || '通道已保存', true);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2844,13 +3000,13 @@ function createAIChannelFromForm() {
|
|||||||
base_url: '',
|
base_url: '',
|
||||||
model: '',
|
model: '',
|
||||||
max_total_tokens: 120000,
|
max_total_tokens: 120000,
|
||||||
max_completion_tokens: 16384,
|
max_completion_tokens: 32768,
|
||||||
reasoning: { mode: 'auto', effort: '', profile: 'auto', allow_client_reasoning: true }
|
reasoning: { mode: 'auto', effort: '', profile: 'auto', allow_client_reasoning: true }
|
||||||
};
|
};
|
||||||
selectedAIChannelId = id;
|
selectedAIChannelId = id;
|
||||||
renderAIChannelSelect();
|
renderAIChannelSelect();
|
||||||
writeAIChannelToMainForm(id);
|
writeAIChannelToMainForm(id);
|
||||||
showAIChannelSaveHint('新通道尚未保存,填写后点击「保存更改」。', true);
|
showAIChannelSaveHint(settingsT('settingsBasic.aiChannelNewUnsaved', '新通道尚未保存,填写后点击「保存更改」。'), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyAIChannelFromForm() {
|
function copyAIChannelFromForm() {
|
||||||
@@ -2862,10 +3018,10 @@ function copyAIChannelFromForm() {
|
|||||||
selectedAIChannelId = id;
|
selectedAIChannelId = id;
|
||||||
renderAIChannelSelect();
|
renderAIChannelSelect();
|
||||||
writeAIChannelToMainForm(id);
|
writeAIChannelToMainForm(id);
|
||||||
showAIChannelSaveHint('复制的通道尚未保存,确认后点击「保存更改」。', true);
|
showAIChannelSaveHint(settingsT('settingsBasic.aiChannelCopyUnsaved', '复制的通道尚未保存,确认后点击「保存更改」。'), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSelectedAIChannel() {
|
async function deleteSelectedAIChannel() {
|
||||||
if (!currentConfig) return;
|
if (!currentConfig) return;
|
||||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||||
const ids = Object.keys(currentConfig.ai.channels || {});
|
const ids = Object.keys(currentConfig.ai.channels || {});
|
||||||
@@ -2883,10 +3039,21 @@ function deleteSelectedAIChannel() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
delete currentConfig.ai.channels[id];
|
delete currentConfig.ai.channels[id];
|
||||||
currentConfig.ai.default_channel = Object.keys(currentConfig.ai.channels).sort()[0];
|
delete aiChannelProbeResults[id];
|
||||||
|
selectedAIChannelBulkIds.delete(id);
|
||||||
|
|
||||||
|
const remainingIds = Object.keys(currentConfig.ai.channels || {}).sort();
|
||||||
|
if (!currentConfig.ai.channels[currentConfig.ai.default_channel]) {
|
||||||
|
currentConfig.ai.default_channel = remainingIds[0];
|
||||||
|
}
|
||||||
|
selectedAIChannelId = currentConfig.ai.default_channel || remainingIds[0];
|
||||||
renderAIChannelSelect();
|
renderAIChannelSelect();
|
||||||
writeAIChannelToMainForm(currentConfig.ai.default_channel);
|
writeAIChannelToMainForm(selectedAIChannelId);
|
||||||
persistAIChannelsToServer('通道已删除', { mergeLatest: false });
|
const saved = await persistAIConfigOnlyToServer(settingsT('settingsBasic.aiChannelDeleted', '通道已删除'));
|
||||||
|
if (saved) {
|
||||||
|
renderAIChannelSelect();
|
||||||
|
writeAIChannelToMainForm(selectedAIChannelId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedOrAllAIChannelIdsForProbe() {
|
function selectedOrAllAIChannelIdsForProbe() {
|
||||||
@@ -2904,12 +3071,13 @@ async function probeSelectedAIChannels() {
|
|||||||
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return;
|
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return;
|
||||||
const ids = selectedOrAllAIChannelIdsForProbe();
|
const ids = selectedOrAllAIChannelIdsForProbe();
|
||||||
if (!ids.length) {
|
if (!ids.length) {
|
||||||
alert('没有可探活的完整通道,请先填写 Base URL、API Key 和模型');
|
alert(settingsT('settingsBasic.aiChannelProbeNoComplete', '没有可探活的完整通道,请先填写 Base URL、API Key 和模型'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showAIChannelSaveHint(`正在探活 ${ids.length} 个通道...`, true);
|
showAIChannelSaveHint(settingsT('settingsBasic.aiChannelProbing', '正在探活 {count} 个通道...').replace('{count}', String(ids.length)), true);
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
aiChannelProbeResults[id] = { status: 'testing', message: '测试中...' };
|
aiChannelProbeResults[id] = { status: 'testing', message: settingsT('settingsBasic.testing', '测试中...') };
|
||||||
|
updateAIChannelSelectOption(id);
|
||||||
});
|
});
|
||||||
renderAIChannelList();
|
renderAIChannelList();
|
||||||
let okCount = 0;
|
let okCount = 0;
|
||||||
@@ -2933,13 +3101,14 @@ async function probeSelectedAIChannels() {
|
|||||||
if (response.ok && result.success) {
|
if (response.ok && result.success) {
|
||||||
okCount += 1;
|
okCount += 1;
|
||||||
const latency = result.latency_ms ? ` ${result.latency_ms}ms` : '';
|
const latency = result.latency_ms ? ` ${result.latency_ms}ms` : '';
|
||||||
aiChannelProbeResults[id] = { status: 'ready', message: `可用${latency}` };
|
aiChannelProbeResults[id] = { status: 'ready', message: settingsT('settingsBasic.aiChannelReadyWithLatency', '可用{latency}').replace('{latency}', latency) };
|
||||||
} else {
|
} else {
|
||||||
aiChannelProbeResults[id] = { status: 'failed', message: (result.error || '连接失败') };
|
aiChannelProbeResults[id] = { status: 'failed', message: formatConnectionTestError(result.error || settingsT('settingsBasic.testFailed', '连接失败')).message };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
aiChannelProbeResults[id] = { status: 'failed', message: error.message || '测试出错' };
|
aiChannelProbeResults[id] = { status: 'failed', message: formatConnectionTestError(error.message || settingsT('settingsBasic.testError', '测试出错')).message };
|
||||||
}
|
}
|
||||||
|
updateAIChannelSelectOption(id);
|
||||||
renderAIChannelList();
|
renderAIChannelList();
|
||||||
}
|
}
|
||||||
const workers = Array.from({ length: Math.min(AI_CHANNEL_PROBE_CONCURRENCY, ids.length) }, async function () {
|
const workers = Array.from({ length: Math.min(AI_CHANNEL_PROBE_CONCURRENCY, ids.length) }, async function () {
|
||||||
@@ -2948,7 +3117,7 @@ async function probeSelectedAIChannels() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
await Promise.all(workers);
|
await Promise.all(workers);
|
||||||
showAIChannelSaveHint(`探活完成:${okCount}/${ids.length} 可用`, okCount === ids.length);
|
showAIChannelSaveHint(settingsT('settingsBasic.aiChannelProbeDone', '探活完成:{ok}/{total} 可用').replace('{ok}', String(okCount)).replace('{total}', String(ids.length)), okCount === ids.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCheckedAIChannels() {
|
async function deleteCheckedAIChannels() {
|
||||||
@@ -2997,7 +3166,17 @@ if (typeof window !== 'undefined') {
|
|||||||
window.deleteCheckedAIChannels = deleteCheckedAIChannels;
|
window.deleteCheckedAIChannels = deleteCheckedAIChannels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined' && !document.__aiChannelI18nBound) {
|
||||||
|
document.__aiChannelI18nBound = true;
|
||||||
|
document.addEventListener('languagechange', function () {
|
||||||
|
if (!currentConfig?.ai) return;
|
||||||
|
renderAIChannelSelect();
|
||||||
|
updateAIChannelEditorChrome(selectedAIChannelId || currentConfig.ai.default_channel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function initModelListControls() {
|
function initModelListControls() {
|
||||||
|
bindAIChannelEditorPreviewSync();
|
||||||
const providerEl = document.getElementById('openai-provider');
|
const providerEl = document.getElementById('openai-provider');
|
||||||
if (providerEl && !providerEl.dataset.modelListBound) {
|
if (providerEl && !providerEl.dataset.modelListBound) {
|
||||||
providerEl.dataset.modelListBound = '1';
|
providerEl.dataset.modelListBound = '1';
|
||||||
@@ -3048,6 +3227,9 @@ function bindModelSelect(scope) {
|
|||||||
if (!select.value) return;
|
if (!select.value) return;
|
||||||
const input = document.getElementById(inputId);
|
const input = document.getElementById(inputId);
|
||||||
if (input) input.value = select.value;
|
if (input) input.value = select.value;
|
||||||
|
if (scope === 'openai') {
|
||||||
|
syncAIChannelEditorPreview();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3381,10 +3563,10 @@ async function testHitlAuditModelConnection() {
|
|||||||
const resultEl = document.getElementById('test-hitl-audit-model-result');
|
const resultEl = document.getElementById('test-hitl-audit-model-result');
|
||||||
const cfg = collectHitlAuditModelEffectiveConfig();
|
const cfg = collectHitlAuditModelEffectiveConfig();
|
||||||
|
|
||||||
if (!cfg.api_key || !cfg.model) {
|
if (!cfg.base_url || !cfg.api_key || !cfg.model) {
|
||||||
if (resultEl) {
|
if (resultEl) {
|
||||||
resultEl.style.color = 'var(--danger-color, #e53e3e)';
|
resultEl.style.color = 'var(--danger-color, #e53e3e)';
|
||||||
resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testFillRequired') : '请先填写 API Key 和模型';
|
resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testFillRequired') : '请先填写 Base URL、API Key 和模型';
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3430,6 +3612,59 @@ async function testHitlAuditModelConnection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatConnectionTestError(errorText) {
|
||||||
|
const raw = String(errorText || '').trim() || settingsT('settingsBasic.testError', '测试出错');
|
||||||
|
return {
|
||||||
|
message: raw,
|
||||||
|
detail: raw
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConnectionTestResult(resultEl, state, message, title) {
|
||||||
|
if (!resultEl) return;
|
||||||
|
resultEl.classList.remove('is-error', 'is-success', 'is-muted', 'is-visible');
|
||||||
|
resultEl.style.color = '';
|
||||||
|
resultEl.textContent = message || '';
|
||||||
|
resultEl.title = title || '';
|
||||||
|
if (message) {
|
||||||
|
resultEl.classList.add('is-visible', state || 'is-muted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncConnectionTestResultForSelectedAIChannel() {
|
||||||
|
const resultEl = document.getElementById('test-openai-result');
|
||||||
|
if (!resultEl) return;
|
||||||
|
const channelId = normalizeAIChannelId(selectedAIChannelId || currentConfig?.ai?.default_channel || 'default');
|
||||||
|
const probe = aiChannelProbeResults[channelId];
|
||||||
|
if (!probe) {
|
||||||
|
setConnectionTestResult(resultEl, '', '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = probe.status === 'ready'
|
||||||
|
? 'is-success'
|
||||||
|
: probe.status === 'failed'
|
||||||
|
? 'is-error'
|
||||||
|
: 'is-muted';
|
||||||
|
let message = probe.message || '';
|
||||||
|
const fillRequiredMessage = settingsT('settingsBasic.testFillRequired', '请先填写 Base URL、API Key 和模型');
|
||||||
|
const failedPrefix = (typeof window.t === 'function' ? window.t('settingsBasic.testFailed') : '连接失败') + ': ';
|
||||||
|
if (probe.status === 'failed' && message && message !== fillRequiredMessage && !message.startsWith(failedPrefix)) {
|
||||||
|
message = failedPrefix + message;
|
||||||
|
}
|
||||||
|
setConnectionTestResult(resultEl, state, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConnectionTestFailure(resultEl, errorText) {
|
||||||
|
if (!resultEl) return;
|
||||||
|
const formatted = formatConnectionTestError(errorText);
|
||||||
|
setConnectionTestResult(
|
||||||
|
resultEl,
|
||||||
|
'is-error',
|
||||||
|
(typeof window.t === 'function' ? window.t('settingsBasic.testFailed') : '连接失败') + ': ' + formatted.message,
|
||||||
|
formatted.detail || formatted.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 测试OpenAI连接
|
// 测试OpenAI连接
|
||||||
async function testOpenAIConnection() {
|
async function testOpenAIConnection() {
|
||||||
const btn = document.getElementById('test-openai-btn');
|
const btn = document.getElementById('test-openai-btn');
|
||||||
@@ -3439,17 +3674,29 @@ async function testOpenAIConnection() {
|
|||||||
const baseUrl = document.getElementById('openai-base-url').value.trim();
|
const baseUrl = document.getElementById('openai-base-url').value.trim();
|
||||||
const apiKey = document.getElementById('openai-api-key').value.trim();
|
const apiKey = document.getElementById('openai-api-key').value.trim();
|
||||||
const model = document.getElementById('openai-model').value.trim();
|
const model = document.getElementById('openai-model').value.trim();
|
||||||
|
const channelId = normalizeAIChannelId(selectedAIChannelId || currentConfig?.ai?.default_channel || 'default');
|
||||||
|
const isTestingSameChannel = () => normalizeAIChannelId(selectedAIChannelId || currentConfig?.ai?.default_channel || 'default') === channelId;
|
||||||
|
|
||||||
if (!apiKey || !model) {
|
if (!baseUrl || !apiKey || !model) {
|
||||||
resultEl.style.color = 'var(--danger-color, #e53e3e)';
|
const message = typeof window.t === 'function' ? window.t('settingsBasic.testFillRequired') : '请先填写 Base URL、API Key 和模型';
|
||||||
resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testFillRequired') : '请先填写 API Key 和模型';
|
aiChannelProbeResults[channelId] = { status: 'failed', message };
|
||||||
|
if (isTestingSameChannel()) {
|
||||||
|
setConnectionTestResult(resultEl, 'is-error', message);
|
||||||
|
}
|
||||||
|
syncSelectedAIChannelUI();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
btn.style.pointerEvents = 'none';
|
if (btn) {
|
||||||
btn.style.opacity = '0.5';
|
btn.style.pointerEvents = 'none';
|
||||||
resultEl.style.color = 'var(--text-muted, #888)';
|
btn.style.opacity = '0.5';
|
||||||
resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testing') : '测试中...';
|
}
|
||||||
|
const testingMessage = settingsT('settingsBasic.testing', '测试中...');
|
||||||
|
aiChannelProbeResults[channelId] = { status: 'testing', message: testingMessage };
|
||||||
|
if (isTestingSameChannel()) {
|
||||||
|
setConnectionTestResult(resultEl, 'is-muted', testingMessage);
|
||||||
|
}
|
||||||
|
syncSelectedAIChannelUI();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch('/api/config/test-openai', {
|
const response = await apiFetch('/api/config/test-openai', {
|
||||||
@@ -3466,20 +3713,33 @@ async function testOpenAIConnection() {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
resultEl.style.color = 'var(--success-color, #38a169)';
|
|
||||||
const latency = result.latency_ms ? ` (${result.latency_ms}ms)` : '';
|
const latency = result.latency_ms ? ` (${result.latency_ms}ms)` : '';
|
||||||
const modelInfo = result.model ? ` [${result.model}]` : '';
|
const modelInfo = result.model ? ` [${result.model}]` : '';
|
||||||
resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testSuccess') : '连接成功') + modelInfo + latency;
|
const message = (typeof window.t === 'function' ? window.t('settingsBasic.testSuccess') : '连接成功') + modelInfo + latency;
|
||||||
|
aiChannelProbeResults[channelId] = { status: 'ready', message };
|
||||||
|
if (isTestingSameChannel()) {
|
||||||
|
setConnectionTestResult(resultEl, 'is-success', message);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
resultEl.style.color = 'var(--danger-color, #e53e3e)';
|
const message = formatConnectionTestError(result.error || '未知错误').message;
|
||||||
resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testFailed') : '连接失败') + ': ' + (result.error || '未知错误');
|
aiChannelProbeResults[channelId] = { status: 'failed', message };
|
||||||
|
if (isTestingSameChannel()) {
|
||||||
|
showConnectionTestFailure(resultEl, message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
resultEl.style.color = 'var(--danger-color, #e53e3e)';
|
const message = formatConnectionTestError(error.message || '测试出错').message;
|
||||||
resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testError') : '测试出错') + ': ' + error.message;
|
aiChannelProbeResults[channelId] = { status: 'failed', message };
|
||||||
|
if (isTestingSameChannel()) {
|
||||||
|
showConnectionTestFailure(resultEl, message);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
btn.style.pointerEvents = '';
|
updateAIChannelSelectOption(channelId);
|
||||||
btn.style.opacity = '';
|
syncSelectedAIChannelUI();
|
||||||
|
if (btn) {
|
||||||
|
btn.style.pointerEvents = '';
|
||||||
|
btn.style.opacity = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4388,3 +4648,4 @@ document.addEventListener('languagechange', function () {
|
|||||||
|
|
||||||
window.initSettingsCustomSelects = initSettingsCustomSelects;
|
window.initSettingsCustomSelects = initSettingsCustomSelects;
|
||||||
window.refreshSettingsCustomSelects = refreshSettingsCustomSelects;
|
window.refreshSettingsCustomSelects = refreshSettingsCustomSelects;
|
||||||
|
window.closeAllSettingsCustomSelects = closeAllSettingsCustomSelects;
|
||||||
|
|||||||
@@ -168,6 +168,12 @@ function renderSkillsList() {
|
|||||||
? _t('skills.cardFiles', { count: skill.file_count })
|
? _t('skills.cardFiles', { count: skill.file_count })
|
||||||
: '';
|
: '';
|
||||||
const metaItems = [ver, fc, sc].filter(Boolean);
|
const metaItems = [ver, fc, sc].filter(Boolean);
|
||||||
|
const tags = Array.isArray(skill.tags) ? skill.tags.filter(Boolean) : [];
|
||||||
|
const visibleTags = tags.slice(0, 4);
|
||||||
|
const hiddenTagCount = Math.max(0, tags.length - visibleTags.length);
|
||||||
|
const tagsHtml = visibleTags.length
|
||||||
|
? `<div class="skill-card-tags">${visibleTags.map(tag => `<span>${escapeHtml(tag)}</span>`).join('')}${hiddenTagCount ? `<span>+${hiddenTagCount}</span>` : ''}</div>`
|
||||||
|
: '';
|
||||||
return `
|
return `
|
||||||
<div class="skill-card">
|
<div class="skill-card">
|
||||||
<div class="skill-card-body">
|
<div class="skill-card-body">
|
||||||
@@ -177,6 +183,7 @@ function renderSkillsList() {
|
|||||||
${metaItems.length ? `<div class="skill-card-meta">${metaItems.map(item => `<span>${escapeHtml(item)}</span>`).join('')}</div>` : ''}
|
${metaItems.length ? `<div class="skill-card-meta">${metaItems.map(item => `<span>${escapeHtml(item)}</span>`).join('')}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="skill-card-description">${escapeHtml(skill.description || _t('skills.noDescription'))}</div>
|
<div class="skill-card-description">${escapeHtml(skill.description || _t('skills.noDescription'))}</div>
|
||||||
|
${tagsHtml}
|
||||||
</div>
|
</div>
|
||||||
<div class="skill-card-actions">
|
<div class="skill-card-actions">
|
||||||
<button type="button" class="btn-secondary btn-small" data-skill-view="${escapeHtml(sid)}">${_t('common.view')}</button>
|
<button type="button" class="btn-secondary btn-small" data-skill-view="${escapeHtml(sid)}">${_t('common.view')}</button>
|
||||||
|
|||||||
@@ -1338,6 +1338,55 @@ function escapeHtmlAttr(s) {
|
|||||||
return escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''');
|
return escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function webshellFinalizationReasonLabel(reason, status) {
|
||||||
|
var key = String(reason || status || '').trim();
|
||||||
|
var labels = {
|
||||||
|
pending_tool_executions: '等待工具执行完成',
|
||||||
|
missing_execution_evidence: '缺少完成态证据',
|
||||||
|
awaiting_hitl: '等待人工确认',
|
||||||
|
empty_response: '未捕获到有效回复',
|
||||||
|
missing_finalization_contract: '缺少最终化证明',
|
||||||
|
in_progress: '仍在验证',
|
||||||
|
blocked: '检查未通过',
|
||||||
|
failed: '任务失败',
|
||||||
|
cancelled: '任务已取消',
|
||||||
|
verified: '已验证'
|
||||||
|
};
|
||||||
|
return labels[key] || key || '检查未通过';
|
||||||
|
}
|
||||||
|
|
||||||
|
function webshellFinalizationMissingCheckLabel(check) {
|
||||||
|
var s = String(check || '').trim();
|
||||||
|
if (!s) return '';
|
||||||
|
if (s.indexOf('tool execution still queued or running') !== -1) return '仍有工具执行未结束';
|
||||||
|
if (s.indexOf('execution evidence is required but no completed tool execution was recorded') !== -1) return '本轮要求执行证据,但没有 completed 工具记录';
|
||||||
|
if (s.indexOf('workflow is awaiting HITL approval') !== -1) return '工作流正在等待人工确认';
|
||||||
|
if (s.indexOf('assistant final text is empty') !== -1) return '未捕获到有效最终文本';
|
||||||
|
if (s.indexOf('agent run status is ') === 0) return '任务状态仍为 ' + s.replace('agent run status is ', '');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webshellFinalizationNotice(data, eventMessage, hasContract) {
|
||||||
|
var reason = hasContract
|
||||||
|
? webshellFinalizationReasonLabel(data && data.completionReason, data && data.status)
|
||||||
|
: webshellFinalizationReasonLabel('missing_finalization_contract');
|
||||||
|
var lines = ['仍在验证,暂不生成最终结论', '状态:' + reason];
|
||||||
|
var pending = Array.isArray(data && data.pendingExecutionIds) ? data.pendingExecutionIds.filter(Boolean).map(String) : [];
|
||||||
|
if (pending.length) {
|
||||||
|
lines.push('待完成工具:' + pending.slice(0, 3).join(', ') + (pending.length > 3 ? ' 等' : ''));
|
||||||
|
}
|
||||||
|
var missing = Array.isArray(data && data.missingChecks)
|
||||||
|
? data.missingChecks.map(webshellFinalizationMissingCheckLabel).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
if (missing.length) {
|
||||||
|
lines.push('待完成检查:' + missing.slice(0, 2).join(';') + (missing.length > 2 ? ' 等' : ''));
|
||||||
|
}
|
||||||
|
if (!hasContract && eventMessage) {
|
||||||
|
lines.push('候选输出已移入过程详情。');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function escapeSingleQuotedShellArg(value) {
|
function escapeSingleQuotedShellArg(value) {
|
||||||
var s = value == null ? '' : String(value);
|
var s = value == null ? '' : String(value);
|
||||||
return "'" + s.replace(/'/g, "'\\''") + "'";
|
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||||
@@ -3393,7 +3442,10 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
message: message,
|
message: message,
|
||||||
webshellConnectionId: conn.id,
|
webshellConnectionId: conn.id,
|
||||||
conversationId: convId,
|
conversationId: convId,
|
||||||
role: wsRole
|
role: wsRole,
|
||||||
|
finalization: {
|
||||||
|
requireExecutionEvidence: true
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if (!convId) {
|
if (!convId) {
|
||||||
var wsPid = getWebshellAiProjectSelection(conn);
|
var wsPid = getWebshellAiProjectSelection(conn);
|
||||||
@@ -3465,6 +3517,8 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
streamingTarget = '';
|
streamingTarget = '';
|
||||||
webshellStreamingTypingId += 1;
|
webshellStreamingTypingId += 1;
|
||||||
streamingTypingId = webshellStreamingTypingId;
|
streamingTypingId = webshellStreamingTypingId;
|
||||||
|
assistantDiv.dataset.finalized = 'false';
|
||||||
|
assistantDiv.classList.add('webshell-ai-candidate-output');
|
||||||
assistantDiv.textContent = '…';
|
assistantDiv.textContent = '…';
|
||||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
} else if (_et === 'response_delta') {
|
} else if (_et === 'response_delta') {
|
||||||
@@ -3485,6 +3539,23 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
}
|
}
|
||||||
} else if (_et === 'response') {
|
} else if (_et === 'response') {
|
||||||
var text = (_em != null && _em !== '') ? _em : (typeof _ed === 'string' ? _ed : '');
|
var text = (_em != null && _em !== '') ? _em : (typeof _ed === 'string' ? _ed : '');
|
||||||
|
var finalized = !!(_ed && _ed.finalized === true);
|
||||||
|
var hasFinalizationContract = !!(_ed && (
|
||||||
|
Object.prototype.hasOwnProperty.call(_ed, 'finalized') ||
|
||||||
|
Object.prototype.hasOwnProperty.call(_ed, 'finalizable') ||
|
||||||
|
Object.prototype.hasOwnProperty.call(_ed, 'completionReason') ||
|
||||||
|
Object.prototype.hasOwnProperty.call(_ed, 'evidenceVerified') ||
|
||||||
|
Object.prototype.hasOwnProperty.call(_ed, 'missingChecks')
|
||||||
|
));
|
||||||
|
assistantDiv.dataset.finalized = finalized ? 'true' : 'false';
|
||||||
|
assistantDiv.classList.toggle('webshell-ai-candidate-output', !finalized);
|
||||||
|
assistantDiv.classList.toggle('webshell-ai-finalized-output', finalized);
|
||||||
|
if (!finalized && !hasFinalizationContract && text) {
|
||||||
|
appendTimelineItem('finalization_check', '候选输出缺少最终化证明', text, Object.assign({}, _ed || {}, { missingFinalizationContract: true }));
|
||||||
|
text = webshellFinalizationNotice(_ed || {}, text, false);
|
||||||
|
} else if (!finalized && hasFinalizationContract) {
|
||||||
|
text = webshellFinalizationNotice(_ed || {}, text, true);
|
||||||
|
}
|
||||||
if (text) {
|
if (text) {
|
||||||
streamingTarget = String(text);
|
streamingTarget = String(text);
|
||||||
webshellStreamingTypingId += 1;
|
webshellStreamingTypingId += 1;
|
||||||
@@ -3493,6 +3564,11 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Terminal events ───
|
// ─── Terminal events ───
|
||||||
|
} else if (_et === 'finalization_check') {
|
||||||
|
var finalizationOk = !!(_ed && _ed.finalized === true);
|
||||||
|
appendTimelineItem('finalization_check', finalizationOk ? '最终回复检查通过' : '最终回复检查未通过', finalizationOk ? (_em || '最终回复检查通过。') : webshellFinalizationNotice(_ed || {}, _em, true), _ed);
|
||||||
|
} else if (_et === 'finalization_auto_continue') {
|
||||||
|
appendTimelineItem('progress', '继续验证', _em, _ed);
|
||||||
} else if (_et === 'error' && _em) {
|
} else if (_et === 'error' && _em) {
|
||||||
streamingTypingId += 1;
|
streamingTypingId += 1;
|
||||||
var errLabel = wsTOr('chat.error', '错误');
|
var errLabel = wsTOr('chat.error', '错误');
|
||||||
|
|||||||
+586
-4
@@ -37,6 +37,12 @@
|
|||||||
requestSignature: '',
|
requestSignature: '',
|
||||||
dragDepth: 0
|
dragDepth: 0
|
||||||
};
|
};
|
||||||
|
const workflowAiState = {
|
||||||
|
draft: null,
|
||||||
|
result: null,
|
||||||
|
activeStep: '',
|
||||||
|
animating: false
|
||||||
|
};
|
||||||
const WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY = 'csai.workflow-package.inspection-id';
|
const WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY = 'csai.workflow-package.inspection-id';
|
||||||
const WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY = 'csai.workflow-package.import-id';
|
const WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY = 'csai.workflow-package.import-id';
|
||||||
|
|
||||||
@@ -67,6 +73,18 @@
|
|||||||
const NODE_PLACEMENT_PADDING = 20;
|
const NODE_PLACEMENT_PADDING = 20;
|
||||||
|
|
||||||
const WORKFLOW_EDIT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
|
const WORKFLOW_EDIT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
|
||||||
|
const WORKFLOW_AI_TOOL_HINTS = [
|
||||||
|
{ keywords: ['子域名', 'subdomain', 'subfinder', 'amass'], tools: ['subfinder', 'amass'], label: '子域名发现' },
|
||||||
|
{ keywords: ['端口', 'port', 'nmap', 'rustscan', 'masscan'], tools: ['nmap', 'rustscan', 'masscan'], label: '端口扫描' },
|
||||||
|
{ keywords: ['漏洞', 'vuln', '漏洞扫描', 'nuclei', 'nikto', 'zap'], tools: ['nuclei', 'nikto', 'zap'], label: '漏洞扫描' },
|
||||||
|
{ keywords: ['目录', '路径', '暴露页面', 'dir', 'ffuf', 'gobuster', 'feroxbuster'], tools: ['ffuf', 'gobuster', 'feroxbuster', 'dirsearch'], label: '暴露面探测' },
|
||||||
|
{ keywords: ['证书', 'certificate', 'crt'], tools: ['subfinder'], label: '证书与域名线索收集' },
|
||||||
|
{ keywords: ['云', 'cloud', '配置审计', 'prowler', 'scout'], tools: ['prowler', 'scout-suite'], label: '云配置审计' },
|
||||||
|
{ keywords: ['容器', '镜像', 'k8s', 'kubernetes', 'trivy', 'kube'], tools: ['trivy', 'kube-bench', 'kube-hunter'], label: '容器安全检查' },
|
||||||
|
{ keywords: ['情报', '威胁情报', 'threat', 'ioc', 'virustotal', 'shodan', 'fofa'], tools: ['virustotal_search', 'shodan_search', 'fofa_search'], label: '威胁情报收集' }
|
||||||
|
];
|
||||||
|
const WORKFLOW_AI_HIGH_RISK_RE = /(隔离|封禁|加固|修复|执行|命令|脚本|删除|清理|阻断|封锁|攻击|利用|getshell|shell|payload|exploit|isolate|block|execute|script|delete|exploit|payload)/i;
|
||||||
|
const WORKFLOW_AI_PROGRESS_STEPS = ['understand', 'match', 'draft', 'audit'];
|
||||||
|
|
||||||
function esc(text) {
|
function esc(text) {
|
||||||
if (typeof escapeHtml === 'function') return escapeHtml(text == null ? '' : String(text));
|
if (typeof escapeHtml === 'function') return escapeHtml(text == null ? '' : String(text));
|
||||||
@@ -166,6 +184,10 @@
|
|||||||
function graphToElements(graph) {
|
function graphToElements(graph) {
|
||||||
const nodes = (graph.nodes || []).map((node, index) => ({
|
const nodes = (graph.nodes || []).map((node, index) => ({
|
||||||
group: 'nodes',
|
group: 'nodes',
|
||||||
|
classes: [
|
||||||
|
node.config && node.config.generated_by === 'natural_language' ? 'ai-generated' : '',
|
||||||
|
node.config && (node.config.risk_level === 'high' || node.config.requires_human_confirmation === 'true') ? 'high-risk' : ''
|
||||||
|
].filter(Boolean).join(' '),
|
||||||
data: {
|
data: {
|
||||||
id: node.id || `node-${index + 1}`,
|
id: node.id || `node-${index + 1}`,
|
||||||
label: node.label || wfNodeLabel(node.type) || node.id || _t('workflows.nodeFallback', { n: index + 1 }),
|
label: node.label || wfNodeLabel(node.type) || node.id || _t('workflows.nodeFallback', { n: index + 1 }),
|
||||||
@@ -265,6 +287,8 @@
|
|||||||
{ selector: 'node[type="hitl"]', style: { 'background-color': '#0f766e', 'border-color': '#5eead4' } },
|
{ selector: 'node[type="hitl"]', style: { 'background-color': '#0f766e', 'border-color': '#5eead4' } },
|
||||||
{ selector: 'node[type="output"]', style: { 'background-color': '#4338ca', 'border-color': '#a5b4fc' } },
|
{ selector: 'node[type="output"]', style: { 'background-color': '#4338ca', 'border-color': '#a5b4fc' } },
|
||||||
{ selector: 'node[type="end"]', style: { 'background-color': '#be123c', 'border-color': '#fb7185' } },
|
{ selector: 'node[type="end"]', style: { 'background-color': '#be123c', 'border-color': '#fb7185' } },
|
||||||
|
{ selector: 'node.ai-generated', style: { 'border-width': 2, 'border-color': '#38bdf8' } },
|
||||||
|
{ selector: 'node.high-risk', style: { 'border-width': 3, 'border-color': '#f97316' } },
|
||||||
{
|
{
|
||||||
selector: 'edge',
|
selector: 'edge',
|
||||||
style: {
|
style: {
|
||||||
@@ -1350,6 +1374,559 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workflowAiOption(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
return !!(el && el.checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiSleep(ms) {
|
||||||
|
return new Promise(function (resolve) { setTimeout(resolve, ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiStepLabel(step) {
|
||||||
|
return _t('workflows.ai.steps.' + step);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiSetProgress(activeStep, done) {
|
||||||
|
workflowAiState.activeStep = activeStep || '';
|
||||||
|
const wrap = document.getElementById('workflow-ai-progress');
|
||||||
|
if (!wrap) return;
|
||||||
|
if (!activeStep && !done) {
|
||||||
|
wrap.hidden = true;
|
||||||
|
wrap.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wrap.hidden = false;
|
||||||
|
const activeIndex = WORKFLOW_AI_PROGRESS_STEPS.indexOf(activeStep);
|
||||||
|
wrap.innerHTML = WORKFLOW_AI_PROGRESS_STEPS.map(function (step, index) {
|
||||||
|
const complete = done || (activeIndex >= 0 && index < activeIndex);
|
||||||
|
const active = !done && step === activeStep;
|
||||||
|
return `<span class="${complete ? 'is-complete' : ''} ${active ? 'is-active' : ''}">
|
||||||
|
<b>${complete ? '✓' : index + 1}</b>
|
||||||
|
<small>${esc(workflowAiStepLabel(step))}</small>
|
||||||
|
</span>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiPreviewItems(graph) {
|
||||||
|
const nodes = (graph.nodes || []).slice().sort(function (a, b) {
|
||||||
|
const ax = a.position && typeof a.position.x === 'number' ? a.position.x : 0;
|
||||||
|
const bx = b.position && typeof b.position.x === 'number' ? b.position.x : 0;
|
||||||
|
const ay = a.position && typeof a.position.y === 'number' ? a.position.y : 0;
|
||||||
|
const by = b.position && typeof b.position.y === 'number' ? b.position.y : 0;
|
||||||
|
return ax === bx ? ay - by : ax - bx;
|
||||||
|
});
|
||||||
|
return nodes.slice(0, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiPreviewHtml(graph) {
|
||||||
|
const items = workflowAiPreviewItems(graph);
|
||||||
|
if (!items.length) return '';
|
||||||
|
return `<div class="workflow-ai-preview" aria-label="${esc(_t('workflows.ai.preview'))}">
|
||||||
|
<div class="workflow-ai-preview-title">${esc(_t('workflows.ai.preview'))}</div>
|
||||||
|
<div class="workflow-ai-preview-flow">
|
||||||
|
${items.map(function (node, index) {
|
||||||
|
const type = node.type || 'tool';
|
||||||
|
const cfg = node.config || {};
|
||||||
|
const risky = cfg.risk_level === 'high' || cfg.requires_human_confirmation === 'true';
|
||||||
|
return `<span class="workflow-ai-preview-node is-${esc(type)} ${risky ? 'is-risky' : ''}">
|
||||||
|
<small>${esc(wfNodeLabel(type))}</small>
|
||||||
|
<strong>${esc(node.label || wfNodeLabel(type))}</strong>
|
||||||
|
</span>${index < items.length - 1 ? '<i aria-hidden="true">→</i>' : ''}`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiSlug(text) {
|
||||||
|
const raw = String(text || '').trim().toLowerCase();
|
||||||
|
const ascii = raw.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
if (ascii) return ascii.slice(0, 48);
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < raw.length; i++) hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
|
||||||
|
return 'ai-workflow-' + Math.abs(hash || Date.now()).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiMatchTool(toolNames) {
|
||||||
|
if (!workflowToolOptions.length) return '';
|
||||||
|
const enabled = workflowToolOptions.filter(tool => tool.enabled !== false);
|
||||||
|
const all = enabled.length ? enabled : workflowToolOptions;
|
||||||
|
for (const wanted of toolNames || []) {
|
||||||
|
const lower = String(wanted || '').toLowerCase();
|
||||||
|
const found = all.find(tool => String(tool.key || '').toLowerCase().includes(lower));
|
||||||
|
if (found) return found.key;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiDetectCapabilities(prompt) {
|
||||||
|
const lower = String(prompt || '').toLowerCase();
|
||||||
|
const capabilities = [];
|
||||||
|
WORKFLOW_AI_TOOL_HINTS.forEach(function (hint) {
|
||||||
|
if (hint.keywords.some(keyword => lower.indexOf(String(keyword).toLowerCase()) !== -1)) {
|
||||||
|
capabilities.push({
|
||||||
|
label: hint.label,
|
||||||
|
tool_name: workflowAiMatchTool(hint.tools),
|
||||||
|
tool_candidates: hint.tools.slice()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!capabilities.length) {
|
||||||
|
capabilities.push({ label: _t('workflows.ai.resultCapabilities'), tool_name: '', tool_candidates: [] });
|
||||||
|
}
|
||||||
|
return capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiNode(id, type, label, x, y, config) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
position: { x, y },
|
||||||
|
config: Object.assign(configWithDefaults(type, {}), config || {}, {
|
||||||
|
generated_by: 'natural_language',
|
||||||
|
needs_review: 'true'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiEdge(id, source, target, label, config) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
label: label || '',
|
||||||
|
config: config || {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiBuildDraft(prompt, options) {
|
||||||
|
const capabilities = workflowAiDetectCapabilities(prompt);
|
||||||
|
const wantsApproval = /(审批|确认|审核|负责人|人工|review|approve|approval|human)/i.test(prompt);
|
||||||
|
const wantsReport = /(报告|汇总|输出|通知|任务|工单|report|summary|notify|ticket)/i.test(prompt);
|
||||||
|
const wantsCondition = /(如果|发现|存在|高危|新增|失败|通过|否则|if|when|high|critical|new|fail)/i.test(prompt);
|
||||||
|
const highRisk = WORKFLOW_AI_HIGH_RISK_RE.test(prompt);
|
||||||
|
const nodes = [];
|
||||||
|
const edges = [];
|
||||||
|
const assumptions = [];
|
||||||
|
const riskWarnings = [];
|
||||||
|
let x = 120;
|
||||||
|
const y = 150;
|
||||||
|
let nodeIndex = 1;
|
||||||
|
let edgeIndex = 1;
|
||||||
|
let openConditionId = '';
|
||||||
|
|
||||||
|
function nextId(prefix) {
|
||||||
|
const id = prefix + '-' + nodeIndex;
|
||||||
|
nodeIndex += 1;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
function add(type, label, config, yy) {
|
||||||
|
const id = nextId(type);
|
||||||
|
nodes.push(workflowAiNode(id, type, label, x, yy || y, config));
|
||||||
|
x += 210;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
function connect(source, target, label, config) {
|
||||||
|
edges.push(workflowAiEdge('edge-ai-' + edgeIndex, source, target, label, config));
|
||||||
|
edgeIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = add('start', wfNodeLabel('start'), { input_keys: 'message, conversationId, projectId, target' });
|
||||||
|
let previous = start;
|
||||||
|
capabilities.forEach(function (capability) {
|
||||||
|
const hasTool = !!capability.tool_name;
|
||||||
|
const id = add(hasTool ? 'tool' : 'agent', capability.label, hasTool ? {
|
||||||
|
tool_name: capability.tool_name,
|
||||||
|
arguments: '{"target":"{{inputs.target}}","message":"{{inputs.message}}"}',
|
||||||
|
timeout_seconds: '120',
|
||||||
|
join_strategy: 'all_merge'
|
||||||
|
} : {
|
||||||
|
agent_mode: 'eino_single',
|
||||||
|
input_binding: { from: 'previous', field: 'output' },
|
||||||
|
instruction: capability.label + '。根据用户需求执行安全流程步骤,并输出结构化结果:' + prompt,
|
||||||
|
output_key: 'agent_result',
|
||||||
|
join_strategy: 'all_merge',
|
||||||
|
missing_tool_candidates: capability.tool_candidates.join(', ')
|
||||||
|
});
|
||||||
|
connect(previous, id);
|
||||||
|
previous = id;
|
||||||
|
if (!hasTool && capability.tool_candidates.length) {
|
||||||
|
assumptions.push(capability.label + ' 未匹配到已启用工具,已生成 Agent 草稿节点。');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (wantsCondition) {
|
||||||
|
const condition = add('condition', highRisk ? '是否需要高风险处置' : '是否满足触发条件', {
|
||||||
|
expression: highRisk ? '{{previous.output}} contains "高危"' : '{{previous.output}} != ""',
|
||||||
|
join_strategy: 'all_merge'
|
||||||
|
});
|
||||||
|
connect(previous, condition);
|
||||||
|
openConditionId = condition;
|
||||||
|
const report = add('output', wantsReport ? '输出报告' : wfNodeLabel('output'), {
|
||||||
|
output_key: 'result',
|
||||||
|
source_binding: { from: 'previous', field: 'output' },
|
||||||
|
static_value: '',
|
||||||
|
join_strategy: 'all_merge'
|
||||||
|
}, y + 130);
|
||||||
|
connect(condition, report, _t('workflows.edges.no'), { condition: '{{previous.matched}} == "false"', branch: 'false' });
|
||||||
|
previous = condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (highRisk) {
|
||||||
|
let insertedApproval = false;
|
||||||
|
if (!options.allowHighRisk || wantsApproval) {
|
||||||
|
const approval = add('hitl', '人工审批', {
|
||||||
|
prompt: '请确认是否允许继续执行高风险处置:' + prompt,
|
||||||
|
prompt_binding: { from: 'previous', field: 'output' },
|
||||||
|
reviewer: 'human',
|
||||||
|
join_strategy: 'all_merge',
|
||||||
|
risk_level: 'high'
|
||||||
|
});
|
||||||
|
connect(previous, approval, previous === openConditionId ? _t('workflows.edges.yes') : '', previous === openConditionId ? { condition: '{{previous.matched}} == "true"', branch: 'true' } : {});
|
||||||
|
if (previous === openConditionId) openConditionId = '';
|
||||||
|
previous = approval;
|
||||||
|
insertedApproval = true;
|
||||||
|
}
|
||||||
|
const action = add('agent', '执行受控处置', {
|
||||||
|
agent_mode: 'eino_single',
|
||||||
|
input_binding: { from: 'previous', field: 'output' },
|
||||||
|
instruction: '仅在授权范围内生成处置步骤草稿;实际执行前必须由人工确认。用户需求:' + prompt,
|
||||||
|
output_key: 'remediation_plan',
|
||||||
|
join_strategy: 'all_merge',
|
||||||
|
risk_level: 'high',
|
||||||
|
requires_human_confirmation: 'true'
|
||||||
|
});
|
||||||
|
connect(previous, action, previous === openConditionId ? _t('workflows.edges.yes') : '', previous === openConditionId ? { condition: '{{previous.matched}} == "true"', branch: 'true' } : {});
|
||||||
|
if (previous === openConditionId) openConditionId = '';
|
||||||
|
previous = action;
|
||||||
|
riskWarnings.push(insertedApproval
|
||||||
|
? '检测到高风险动作,已加入人工确认与 requires_human_confirmation 标记。'
|
||||||
|
: '检测到高风险动作,已保留为草稿并添加 requires_human_confirmation 标记。');
|
||||||
|
} else if (wantsApproval && !nodes.some(node => node.type === 'hitl')) {
|
||||||
|
const approval = add('hitl', '人工审批', {
|
||||||
|
prompt: '请审核工作流阶段结果:' + prompt,
|
||||||
|
prompt_binding: { from: 'previous', field: 'output' },
|
||||||
|
reviewer: 'human',
|
||||||
|
join_strategy: 'all_merge'
|
||||||
|
});
|
||||||
|
connect(previous, approval);
|
||||||
|
previous = approval;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = add('output', wantsReport ? '输出报告' : wfNodeLabel('output'), {
|
||||||
|
output_key: 'result',
|
||||||
|
source_binding: { from: 'previous', field: 'output' },
|
||||||
|
static_value: '',
|
||||||
|
join_strategy: 'all_merge'
|
||||||
|
});
|
||||||
|
connect(previous, output, previous === openConditionId ? _t('workflows.edges.yes') : '', previous === openConditionId ? { condition: '{{previous.matched}} == "true"', branch: 'true' } : {});
|
||||||
|
|
||||||
|
const graph = {
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
config: {
|
||||||
|
schema_version: 1,
|
||||||
|
generated_by: 'natural_language',
|
||||||
|
source_prompt: prompt,
|
||||||
|
objective: options.includeObjective ? prompt : ''
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (options.allowSchedule && /(每天|每周|定时|周期|持续|daily|weekly|schedule|monitor)/i.test(prompt)) {
|
||||||
|
graph.config.trigger_suggestion = /(每天|daily)/i.test(prompt) ? 'daily' : 'scheduled';
|
||||||
|
assumptions.push('已记录定时触发建议;保存后仍需在触发器或角色绑定处配置。');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
graph,
|
||||||
|
meta: {
|
||||||
|
id: workflowAiSlug(prompt),
|
||||||
|
name: prompt.length > 22 ? prompt.slice(0, 22) + '...' : prompt,
|
||||||
|
description: prompt,
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
generator: 'deterministic-fallback',
|
||||||
|
audit: {
|
||||||
|
risk_warnings: riskWarnings,
|
||||||
|
assumptions: assumptions,
|
||||||
|
high_risk: highRisk,
|
||||||
|
needs_hitl: nodes.some(node => node.type === 'hitl'),
|
||||||
|
savable: validateWorkflowGraph(graph).length === 0
|
||||||
|
},
|
||||||
|
assumptions,
|
||||||
|
riskWarnings,
|
||||||
|
capabilities,
|
||||||
|
stats: { nodes: nodes.length, edges: edges.length }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiAvailableToolsPayload() {
|
||||||
|
return (workflowToolOptions || []).map(function (tool) {
|
||||||
|
return {
|
||||||
|
key: tool.key || tool.name || '',
|
||||||
|
name: tool.name || '',
|
||||||
|
enabled: tool.enabled !== false
|
||||||
|
};
|
||||||
|
}).filter(function (tool) {
|
||||||
|
return tool.key || tool.name;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workflowAiGenerateDraftOnServer(prompt, options) {
|
||||||
|
const response = await apiFetch('/api/workflows/generate-draft', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: prompt,
|
||||||
|
options: {
|
||||||
|
include_objective: !!options.includeObjective,
|
||||||
|
allow_schedule: !!options.allowSchedule,
|
||||||
|
allow_high_risk: !!options.allowHighRisk
|
||||||
|
},
|
||||||
|
available_tools: workflowAiAvailableToolsPayload()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(function () { return {}; });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || _t('workflows.ai.generateFailed'));
|
||||||
|
}
|
||||||
|
const result = data.result || data;
|
||||||
|
if (!result || !result.graph) {
|
||||||
|
throw new Error(_t('workflows.ai.generateFailed'));
|
||||||
|
}
|
||||||
|
result.generator = result.generator || 'server';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiRenderResult(result) {
|
||||||
|
const target = document.getElementById('workflow-ai-result');
|
||||||
|
const applyBtn = document.getElementById('workflow-ai-apply-btn');
|
||||||
|
if (!target) return;
|
||||||
|
if (!result) {
|
||||||
|
target.innerHTML = '';
|
||||||
|
if (applyBtn) applyBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const graph = result.graph || defaultGraph();
|
||||||
|
const errors = validateWorkflowGraph(graph);
|
||||||
|
const audit = result.audit || {};
|
||||||
|
const risks = audit.risk_warnings || result.riskWarnings || [];
|
||||||
|
const assumptions = audit.assumptions || result.assumptions || [];
|
||||||
|
const missing = audit.missing_fields || [];
|
||||||
|
const stats = result.stats || {};
|
||||||
|
const generator = String(result.generator || '');
|
||||||
|
const generatorKey = generator === 'llm'
|
||||||
|
? 'workflows.ai.generatorLLM'
|
||||||
|
: (generator === 'deterministic'
|
||||||
|
? 'workflows.ai.generatorServer'
|
||||||
|
: (generator.indexOf('fallback') !== -1 ? 'workflows.ai.generatorFallback' : 'workflows.ai.generatorUnknown'));
|
||||||
|
const capabilityText = (result.capabilities || []).map(function (cap) {
|
||||||
|
return cap.label + (cap.tool_name ? ' -> ' + cap.tool_name : (cap.toolName ? ' -> ' + cap.toolName : ''));
|
||||||
|
});
|
||||||
|
target.innerHTML = `
|
||||||
|
${workflowAiPreviewHtml(graph)}
|
||||||
|
<div class="workflow-ai-result-grid">
|
||||||
|
<span>${esc(_t('workflows.ai.resultNodes'))}<strong>${stats.nodes || (graph.nodes || []).length}</strong></span>
|
||||||
|
<span>${esc(_t('workflows.ai.resultEdges'))}<strong>${stats.edges || (graph.edges || []).length}</strong></span>
|
||||||
|
<span>${esc(_t('workflows.ai.resultRisk'))}<strong>${risks.length ? esc(_t('workflows.ai.riskHigh')) : esc(_t('workflows.ai.riskLow'))}</strong></span>
|
||||||
|
<span>${esc(_t('workflows.ai.resultSaveState'))}<strong>${errors.length ? esc(_t('workflows.ai.no')) : esc(_t('workflows.ai.yes'))}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-ai-result-section"><strong>${esc(_t(generatorKey))}</strong><p>${esc(generator === 'llm' ? _t('workflows.ai.llmTitle') : (generator === 'deterministic' ? _t('workflows.ai.serverTitle') : _t('workflows.ai.fallbackTitle')))}</p></div>
|
||||||
|
${capabilityText.length ? '<div class="workflow-ai-result-section"><strong>' + esc(_t('workflows.ai.capabilityTrace')) + '</strong><ul>' + capabilityText.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
|
||||||
|
${missing.length ? '<div class="workflow-ai-result-section is-attention"><strong>' + esc(_t('workflows.ai.missingFields')) + '</strong><ul>' + missing.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
|
||||||
|
${assumptions.length ? '<div class="workflow-ai-result-section"><strong>' + esc(_t('workflows.ai.assumptions')) + '</strong><ul>' + assumptions.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
|
||||||
|
${risks.length ? '<div class="workflow-ai-result-section is-warning"><strong>' + esc(_t('workflows.ai.riskWarnings')) + '</strong><ul>' + risks.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
|
||||||
|
${errors.length ? '<div class="workflow-ai-result-section is-warning"><strong>' + esc(_t('workflows.audit.validationIssues')) + '</strong><ul>' + errors.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
|
||||||
|
`;
|
||||||
|
if (applyBtn) applyBtn.disabled = !!errors.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiApplyGraph(result) {
|
||||||
|
if (!result || !result.graph) return;
|
||||||
|
fillWorkflowForm({
|
||||||
|
id: '',
|
||||||
|
name: result.meta.name,
|
||||||
|
description: result.meta.description,
|
||||||
|
enabled: true,
|
||||||
|
graph_json: result.graph
|
||||||
|
});
|
||||||
|
syncWorkflowMetaIdField(false, result.meta.id);
|
||||||
|
updateWorkflowCanvasTitle();
|
||||||
|
if (cy && cy.nodes().length) {
|
||||||
|
cy.fit(cy.elements(), 60);
|
||||||
|
const generated = cy.nodes('.ai-generated');
|
||||||
|
if (generated.length) {
|
||||||
|
generated.addClass('just-added');
|
||||||
|
const risky = generated.filter('.high-risk');
|
||||||
|
if (risky.length) risky.select();
|
||||||
|
setTimeout(function () {
|
||||||
|
if (cy) cy.nodes('.ai-generated').removeClass('just-added');
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiSortedNodes(graph) {
|
||||||
|
return (graph.nodes || []).slice().sort(function (a, b) {
|
||||||
|
const ax = a.position && typeof a.position.x === 'number' ? a.position.x : 0;
|
||||||
|
const bx = b.position && typeof b.position.x === 'number' ? b.position.x : 0;
|
||||||
|
const ay = a.position && typeof a.position.y === 'number' ? a.position.y : 0;
|
||||||
|
const by = b.position && typeof b.position.y === 'number' ? b.position.y : 0;
|
||||||
|
return ax === bx ? ay - by : ax - bx;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workflowAiAnimateGraph(result) {
|
||||||
|
if (!result || !result.graph || workflowAiState.animating) return;
|
||||||
|
workflowAiState.animating = true;
|
||||||
|
const status = document.getElementById('workflow-ai-canvas-status');
|
||||||
|
if (status) status.hidden = false;
|
||||||
|
try {
|
||||||
|
initCy();
|
||||||
|
if (!cy) {
|
||||||
|
workflowAiApplyGraph(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const graph = parseGraph(result.graph);
|
||||||
|
syncWorkflowMetaForm({
|
||||||
|
id: '',
|
||||||
|
name: result.meta && result.meta.name ? result.meta.name : '',
|
||||||
|
description: result.meta && result.meta.description ? result.meta.description : '',
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
currentWorkflowId = '';
|
||||||
|
syncWorkflowMetaIdField(false, result.meta && result.meta.id ? result.meta.id : '');
|
||||||
|
updateWorkflowCanvasTitle();
|
||||||
|
resetSequences(graph);
|
||||||
|
cy.elements().remove();
|
||||||
|
updateEmptyState();
|
||||||
|
closeWorkflowDryRunPanel();
|
||||||
|
renderWorkflowList();
|
||||||
|
const nodeElements = graphToElements({ nodes: workflowAiSortedNodes(graph), edges: [] });
|
||||||
|
const edgeElements = graphToElements({ nodes: [], edges: graph.edges || [] });
|
||||||
|
for (const ele of nodeElements) {
|
||||||
|
const added = cy.add(ele);
|
||||||
|
selectWorkflowElement(added);
|
||||||
|
added.addClass('just-added');
|
||||||
|
cy.animate({ center: { eles: added }, duration: 180 });
|
||||||
|
await workflowAiSleep(120);
|
||||||
|
added.removeClass('just-added');
|
||||||
|
}
|
||||||
|
for (const edge of edgeElements) {
|
||||||
|
const added = cy.add(edge);
|
||||||
|
added.select();
|
||||||
|
await workflowAiSleep(80);
|
||||||
|
added.unselect();
|
||||||
|
}
|
||||||
|
if (cy.nodes().length) {
|
||||||
|
layoutWorkflowGraph(true);
|
||||||
|
await workflowAiSleep(320);
|
||||||
|
const risky = cy.nodes('.high-risk');
|
||||||
|
if (risky.length) {
|
||||||
|
selectWorkflowElement(risky.first());
|
||||||
|
} else {
|
||||||
|
selectWorkflowElement(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateEmptyState();
|
||||||
|
} finally {
|
||||||
|
workflowAiState.animating = false;
|
||||||
|
if (status) status.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openWorkflowAiModal = function () {
|
||||||
|
if (typeof requirePermission === 'function' && !requirePermission('workflow:write')) return;
|
||||||
|
workflowAiState.draft = null;
|
||||||
|
workflowAiState.result = null;
|
||||||
|
workflowAiSetProgress('', false);
|
||||||
|
workflowAiRenderResult(null);
|
||||||
|
const prompt = document.getElementById('workflow-ai-prompt');
|
||||||
|
const generateBtn = document.getElementById('workflow-ai-generate-btn');
|
||||||
|
if (generateBtn) generateBtn.disabled = false;
|
||||||
|
if (typeof openAppModal === 'function') {
|
||||||
|
openAppModal('workflow-ai-modal', { focusEl: prompt });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closeWorkflowAiModal = function () {
|
||||||
|
if (typeof closeAppModal === 'function') closeAppModal('workflow-ai-modal');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.useWorkflowAiExample = function (key) {
|
||||||
|
const prompt = document.getElementById('workflow-ai-prompt');
|
||||||
|
if (!prompt) return;
|
||||||
|
prompt.value = _t('workflows.ai.examples.' + key);
|
||||||
|
prompt.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.generateWorkflowFromNaturalLanguage = async function () {
|
||||||
|
const promptEl = document.getElementById('workflow-ai-prompt');
|
||||||
|
const generateBtn = document.getElementById('workflow-ai-generate-btn');
|
||||||
|
const prompt = promptEl ? promptEl.value.trim() : '';
|
||||||
|
if (!prompt) {
|
||||||
|
if (typeof showNotification === 'function') showNotification(_t('workflows.ai.promptRequired'), 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (generateBtn) {
|
||||||
|
generateBtn.disabled = true;
|
||||||
|
generateBtn.textContent = _t('workflows.ai.generating');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
workflowAiRenderResult(null);
|
||||||
|
workflowAiSetProgress('understand');
|
||||||
|
await workflowAiSleep(140);
|
||||||
|
await loadWorkflowTools();
|
||||||
|
workflowAiSetProgress('match');
|
||||||
|
await workflowAiSleep(140);
|
||||||
|
const options = {
|
||||||
|
includeObjective: workflowAiOption('workflow-ai-include-objective'),
|
||||||
|
allowSchedule: workflowAiOption('workflow-ai-allow-schedule'),
|
||||||
|
allowHighRisk: workflowAiOption('workflow-ai-allow-high-risk')
|
||||||
|
};
|
||||||
|
workflowAiSetProgress('draft');
|
||||||
|
const result = await workflowAiGenerateDraftOnServer(prompt, options);
|
||||||
|
workflowAiSetProgress('audit');
|
||||||
|
await workflowAiSleep(120);
|
||||||
|
workflowAiState.draft = result.graph;
|
||||||
|
workflowAiState.result = result;
|
||||||
|
workflowAiRenderResult(result);
|
||||||
|
workflowAiSetProgress('', true);
|
||||||
|
if (validateWorkflowGraph(result.graph).length && typeof showNotification === 'function') {
|
||||||
|
showNotification(_t('workflows.ai.generatedWithIssues'), 'warning');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
workflowAiState.draft = null;
|
||||||
|
workflowAiState.result = null;
|
||||||
|
workflowAiSetProgress('', false);
|
||||||
|
workflowAiRenderResult(null);
|
||||||
|
if (typeof showNotification === 'function') showNotification(error.message || _t('workflows.ai.generateFailed'), 'error');
|
||||||
|
} finally {
|
||||||
|
if (generateBtn) {
|
||||||
|
generateBtn.disabled = false;
|
||||||
|
generateBtn.textContent = _t('workflows.ai.generate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.applyWorkflowAiDraft = async function () {
|
||||||
|
if (!workflowAiState.result) return;
|
||||||
|
const applyBtn = document.getElementById('workflow-ai-apply-btn');
|
||||||
|
if (applyBtn) {
|
||||||
|
applyBtn.disabled = true;
|
||||||
|
applyBtn.textContent = _t('workflows.ai.rendering');
|
||||||
|
}
|
||||||
|
closeWorkflowAiModal();
|
||||||
|
try {
|
||||||
|
await workflowAiAnimateGraph(workflowAiState.result);
|
||||||
|
if (typeof showNotification === 'function') showNotification(_t('workflows.ai.applied'), 'success');
|
||||||
|
} finally {
|
||||||
|
if (applyBtn) {
|
||||||
|
applyBtn.disabled = false;
|
||||||
|
applyBtn.textContent = _t('workflows.ai.apply');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.refreshWorkflows = async function () {
|
window.refreshWorkflows = async function () {
|
||||||
initCy();
|
initCy();
|
||||||
const list = document.getElementById('workflow-list');
|
const list = document.getElementById('workflow-list');
|
||||||
@@ -1818,15 +2395,20 @@
|
|||||||
|
|
||||||
window.layoutWorkflowGraph = function (animate) {
|
window.layoutWorkflowGraph = function (animate) {
|
||||||
if (!cy || !cy.nodes().length) return;
|
if (!cy || !cy.nodes().length) return;
|
||||||
cy.layout({
|
const layout = cy.layout({
|
||||||
name: 'breadthfirst',
|
name: 'breadthfirst',
|
||||||
directed: true,
|
directed: true,
|
||||||
padding: 40,
|
padding: 40,
|
||||||
spacingFactor: 1.25,
|
spacingFactor: 1.25,
|
||||||
animate: animate !== false,
|
animate: animate !== false,
|
||||||
animationDuration: 250
|
animationDuration: 250
|
||||||
}).run();
|
});
|
||||||
cy.fit(undefined, 40);
|
layout.one('layoutstop', function () {
|
||||||
|
if (cy && cy.elements().length) cy.fit(cy.elements(), 40);
|
||||||
|
});
|
||||||
|
layout.run();
|
||||||
|
if (animate === false && cy.elements().length) cy.fit(cy.elements(), 40);
|
||||||
|
return layout;
|
||||||
};
|
};
|
||||||
|
|
||||||
window.updateWorkflowSelectedProperty = function () {
|
window.updateWorkflowSelectedProperty = function () {
|
||||||
@@ -2458,7 +3040,7 @@
|
|||||||
if (page && typeof window.applyTranslations === 'function') {
|
if (page && typeof window.applyTranslations === 'function') {
|
||||||
window.applyTranslations(page);
|
window.applyTranslations(page);
|
||||||
}
|
}
|
||||||
['workflow-dry-run-modal', 'workflow-package-import-modal', 'workflow-package-overwrite-modal'].forEach(function (id) {
|
['workflow-dry-run-modal', 'workflow-ai-modal', 'workflow-package-import-modal', 'workflow-package-overwrite-modal'].forEach(function (id) {
|
||||||
const modal = document.getElementById(id);
|
const modal = document.getElementById(id);
|
||||||
if (modal && typeof window.applyTranslations === 'function') window.applyTranslations(modal);
|
if (modal && typeof window.applyTranslations === 'function') window.applyTranslations(modal);
|
||||||
});
|
});
|
||||||
|
|||||||
+173
-102
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>CyberStrikeAI</title>
|
<title>CyberStrikeAI</title>
|
||||||
<link rel="icon" type="image/png" href="/static/logo.png">
|
<link rel="icon" type="image/x-icon" href="/static/favicon.ico?v=20260728">
|
||||||
<link rel="shortcut icon" type="image/png" href="/static/favicon.ico">
|
<link rel="shortcut icon" type="image/x-icon" href="/static/favicon.ico?v=20260728">
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
@@ -3088,6 +3088,7 @@
|
|||||||
<div class="page-header-actions">
|
<div class="page-header-actions">
|
||||||
<button class="btn-secondary" onclick="refreshWorkflows()" data-i18n="common.refresh">刷新</button>
|
<button class="btn-secondary" onclick="refreshWorkflows()" data-i18n="common.refresh">刷新</button>
|
||||||
<button class="btn-secondary" data-require-permission="workflow:write" type="button" onclick="openWorkflowPackageImportModal()" data-i18n="workflows.package.importLocal">导入本地包</button>
|
<button class="btn-secondary" data-require-permission="workflow:write" type="button" onclick="openWorkflowPackageImportModal()" data-i18n="workflows.package.importLocal">导入本地包</button>
|
||||||
|
<button class="btn-secondary" data-require-permission="workflow:write" type="button" onclick="openWorkflowAiModal()" data-i18n="workflows.ai.open">用自然语言创建</button>
|
||||||
<button class="btn-primary" onclick="newWorkflowDraft()" data-i18n="workflows.newGraph">新建工作流</button>
|
<button class="btn-primary" onclick="newWorkflowDraft()" data-i18n="workflows.newGraph">新建工作流</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3139,6 +3140,10 @@
|
|||||||
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5.4v13.2a1 1 0 0 0 1.55.84l9.2-6.6a1 1 0 0 0 0-1.68l-9.2-6.6A1 1 0 0 0 8 5.4Z"/></svg>
|
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5.4v13.2a1 1 0 0 0 1.55.84l9.2-6.6a1 1 0 0 0 0-1.68l-9.2-6.6A1 1 0 0 0 8 5.4Z"/></svg>
|
||||||
<span class="workflow-toolbar-label" data-i18n="workflows.dryRun">试运行</span>
|
<span class="workflow-toolbar-label" data-i18n="workflows.dryRun">试运行</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-secondary btn-small workflow-toolbar-button workflow-ai-button" data-require-permission="workflow:write" type="button" onclick="openWorkflowAiModal()">
|
||||||
|
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3Z"/><path d="M19 14l.8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8L19 14Z"/></svg>
|
||||||
|
<span class="workflow-toolbar-label" data-i18n="workflows.ai.generate">生成草稿</span>
|
||||||
|
</button>
|
||||||
<details class="workflow-more-actions" id="workflow-more-actions" data-require-permission-any="workflow:read workflow:delete">
|
<details class="workflow-more-actions" id="workflow-more-actions" data-require-permission-any="workflow:read workflow:delete">
|
||||||
<summary class="btn-secondary btn-small workflow-more-actions-trigger" aria-haspopup="menu">
|
<summary class="btn-secondary btn-small workflow-more-actions-trigger" aria-haspopup="menu">
|
||||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>
|
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>
|
||||||
@@ -3164,7 +3169,8 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="workflow-canvas-wrap" ondragover="workflowCanvasDragOver(event)" ondrop="workflowCanvasDrop(event)">
|
<section class="workflow-canvas-wrap" ondragover="workflowCanvasDragOver(event)" ondrop="workflowCanvasDrop(event)">
|
||||||
<div id="workflow-canvas"></div>
|
<div id="workflow-canvas"></div>
|
||||||
<div id="workflow-canvas-empty" class="workflow-canvas-empty" data-i18n="workflows.canvasEmpty">从左侧拖拽节点到画布,或点击节点按钮快速添加</div>
|
<div id="workflow-canvas-empty" class="workflow-canvas-empty" data-i18n="workflows.canvasEmpty">从左侧拖拽节点到画布,或用自然语言生成工作流</div>
|
||||||
|
<div id="workflow-ai-canvas-status" class="workflow-ai-canvas-status" hidden data-i18n="workflows.ai.canvasRendering">AI 正在编排画布…</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<aside class="workflow-properties">
|
<aside class="workflow-properties">
|
||||||
@@ -3446,115 +3452,146 @@
|
|||||||
<div class="ai-channel-manager-header">
|
<div class="ai-channel-manager-header">
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="settingsBasic.openaiConfig">AI 通道配置</h4>
|
<h4 data-i18n="settingsBasic.openaiConfig">AI 通道配置</h4>
|
||||||
<p id="ai-channel-save-hint" class="ai-channel-manager-hint" data-i18n="settingsBasic.aiChannelHint">已保存的通道会显示在左侧;保存后写入 ai.channels,默认通道用于新对话和未指定通道的任务。</p>
|
<p id="ai-channel-save-hint" class="ai-channel-manager-hint" data-i18n="settingsBasic.aiChannelHint">通过下拉切换已保存通道;保存后写入 ai.channels,默认通道用于新对话和未指定通道的任务。</p>
|
||||||
|
</div>
|
||||||
|
<div class="ai-channel-header-actions">
|
||||||
|
<button type="button" class="btn-secondary" onclick="createAIChannelFromForm()" data-i18n="settingsBasic.aiChannelNew">新增</button>
|
||||||
|
<button type="button" class="btn-primary ai-channel-save-btn" onclick="saveSelectedAIChannel()" data-i18n="settingsBasic.aiChannelSave">保存更改</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn-primary ai-channel-save-btn" onclick="saveSelectedAIChannel()" data-i18n="settingsBasic.aiChannelSave">保存更改</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-channel-manager-body">
|
<div class="ai-channel-manager-body">
|
||||||
<aside class="ai-channel-sidebar" aria-label="AI 通道列表" data-i18n="settingsBasic.aiChannelListAria" data-i18n-attr="aria-label">
|
<div class="ai-channel-switcher" aria-label="AI 通道选择" data-i18n="settingsBasic.aiChannelListAria" data-i18n-attr="aria-label">
|
||||||
<div class="ai-channel-sidebar-head">
|
<div class="ai-channel-switcher-field">
|
||||||
<span data-i18n="settingsBasic.aiChannelSavedList">已保存通道</span>
|
<label for="ai-channel-select" data-i18n="settingsBasic.aiChannelCurrent">当前通道</label>
|
||||||
<div class="ai-channel-bulk-actions">
|
<select id="ai-channel-select" class="ai-channel-switch-select" onchange="selectAIChannelForEditing(this.value)"></select>
|
||||||
<button type="button" class="ai-channel-bulk-btn" onclick="probeSelectedAIChannels()" title="检测所选或全部完整通道是否可用">批量探活</button>
|
</div>
|
||||||
<button type="button" class="ai-channel-bulk-btn danger" onclick="deleteCheckedAIChannels()" title="删除已勾选的非默认通道">删除所选</button>
|
<div class="ai-channel-switcher-actions">
|
||||||
<button type="button" class="ai-channel-icon-btn" onclick="createAIChannelFromForm()" title="新增通道" aria-label="新增通道">+</button>
|
<button type="button" class="btn-secondary" onclick="probeSelectedAIChannels()" data-i18n="settingsBasic.aiChannelBulkProbe" data-i18n-title="settingsBasic.aiChannelBulkProbeTitle" data-i18n-attr="title">批量探活</button>
|
||||||
</div>
|
<button type="button" class="btn-secondary" onclick="copyAIChannelFromForm()" data-i18n="settingsBasic.aiChannelCopy">复制</button>
|
||||||
|
<button type="button" class="btn-secondary" onclick="setSelectedAIChannelDefault()" data-i18n="settingsBasic.aiChannelSetDefault">设为默认</button>
|
||||||
|
<button type="button" class="btn-secondary danger" onclick="deleteSelectedAIChannel()" data-i18n="settingsBasic.aiChannelDelete">删除</button>
|
||||||
</div>
|
</div>
|
||||||
<select id="ai-channel-select" class="ai-channel-native-select" onchange="selectAIChannelForEditing(this.value)" aria-hidden="true" tabindex="-1"></select>
|
|
||||||
<div id="ai-channel-list" class="ai-channel-list" aria-live="polite"></div>
|
<div id="ai-channel-list" class="ai-channel-list" aria-live="polite"></div>
|
||||||
</aside>
|
</div>
|
||||||
<div class="ai-channel-editor">
|
<div class="ai-channel-editor">
|
||||||
<div class="ai-channel-editor-head">
|
<div class="ai-channel-editor-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="ai-channel-editor-kicker" data-i18n="settingsBasic.aiChannelEditing">正在编辑</span>
|
<span class="ai-channel-editor-kicker" data-i18n="settingsBasic.aiChannelFormContext">编辑当前选择的通道</span>
|
||||||
<h5 id="ai-channel-editor-title">-</h5>
|
<p id="ai-channel-editor-title" class="ai-channel-editor-title">-</p>
|
||||||
<p id="ai-channel-editor-meta">-</p>
|
|
||||||
</div>
|
|
||||||
<div class="ai-channel-editor-actions">
|
|
||||||
<button type="button" class="btn-secondary" onclick="setSelectedAIChannelDefault()" data-i18n="settingsBasic.aiChannelSetDefault">设为默认</button>
|
|
||||||
<button type="button" class="btn-secondary" onclick="copyAIChannelFromForm()" data-i18n="settingsBasic.aiChannelCopy">复制</button>
|
|
||||||
<button type="button" class="btn-secondary danger" onclick="deleteSelectedAIChannel()" data-i18n="settingsBasic.aiChannelDelete">删除</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div id="ai-channel-editor-meta" class="ai-channel-editor-meta" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-form ai-channel-editor-form">
|
<div class="settings-form ai-channel-editor-form">
|
||||||
<div class="form-group">
|
<section class="ai-channel-form-section">
|
||||||
<label for="ai-channel-name" data-i18n="settingsBasic.aiChannelName">通道名称</label>
|
<div class="ai-channel-form-section-head">
|
||||||
<input type="text" id="ai-channel-name" placeholder="Qwen Max" maxlength="24" />
|
<div>
|
||||||
</div>
|
<h6 data-i18n="settingsBasic.aiChannelConnectionSection">连接信息</h6>
|
||||||
<div class="form-group">
|
<p data-i18n="settingsBasic.aiChannelConnectionHint">先确认服务商、地址、密钥和模型,测试连接会使用这些信息。</p>
|
||||||
<label for="openai-provider" data-i18n="settingsBasic.apiProvider">API 提供商</label>
|
</div>
|
||||||
<select id="openai-provider" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; background: var(--card-bg, #fff); color: var(--text-color, #2d3748); font-size: 0.875rem;">
|
<div class="ai-channel-section-actions">
|
||||||
<option value="openai_compatible" data-i18n="settingsBasic.providerOpenAI">OpenAI / 兼容 OpenAI 协议</option>
|
<button type="button" id="test-openai-btn" class="btn-secondary" onclick="testOpenAIConnection()" data-i18n="settingsBasic.testConnection">测试连接</button>
|
||||||
<option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
|
<span id="test-openai-result" class="form-inline-result connection-test-result"></span>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="ai-channel-form-grid">
|
||||||
<label for="openai-base-url">Base URL <span style="color: red;">*</span></label>
|
<div class="form-group">
|
||||||
<input type="text" id="openai-base-url" data-i18n="settingsBasic.openaiBaseUrlPlaceholder" data-i18n-attr="placeholder" placeholder="https://api.openai.com/v1" required />
|
<label for="ai-channel-name" data-i18n="settingsBasic.aiChannelName">通道名称</label>
|
||||||
</div>
|
<input type="text" id="ai-channel-name" placeholder="Qwen Max" maxlength="24" />
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label for="openai-api-key">API Key <span style="color: red;">*</span></label>
|
<div class="form-group">
|
||||||
<input type="password" id="openai-api-key" data-i18n="settingsBasic.openaiApiKeyPlaceholder" data-i18n-attr="placeholder" placeholder="输入OpenAI API Key" required />
|
<label for="openai-provider" data-i18n="settingsBasic.apiProvider">API 提供商</label>
|
||||||
</div>
|
<select id="openai-provider">
|
||||||
<div class="form-group">
|
<option value="openai_compatible" data-i18n="settingsBasic.providerOpenAI">OpenAI / 兼容 OpenAI 协议</option>
|
||||||
<label for="openai-model"><span data-i18n="settingsBasic.model">模型</span> <span style="color: red;">*</span></label>
|
<option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
|
||||||
<div class="model-pick-row">
|
</select>
|
||||||
<input type="text" id="openai-model" class="model-pick-input" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
|
</div>
|
||||||
<select id="openai-model-select" class="model-pick-native" style="display: none;" title="" aria-hidden="true" tabindex="-1">
|
<div class="form-group span-2">
|
||||||
<option value="" disabled data-i18n="settingsBasic.modelsListSelectPlaceholder">请选择模型</option>
|
<label for="openai-base-url">Base URL <span class="required-mark">*</span></label>
|
||||||
</select>
|
<input type="text" id="openai-base-url" data-i18n="settingsBasic.openaiBaseUrlPlaceholder" data-i18n-attr="placeholder" placeholder="https://api.openai.com/v1" required />
|
||||||
<a href="javascript:void(0)" id="fetch-openai-models-btn" class="model-pick-fetch-link" onclick="fetchModelList('openai')" data-i18n="settingsBasic.fetchModels">获取列表</a>
|
</div>
|
||||||
</div>
|
<div class="form-group span-2">
|
||||||
<small id="fetch-openai-models-hint" class="form-hint" style="display: none; font-size: 0.75rem; margin-top: 4px;"></small>
|
<label for="openai-api-key">API Key <span class="required-mark">*</span></label>
|
||||||
<span id="fetch-openai-models-result" style="font-size: 0.75rem; margin-top: 2px; display: block;"></span>
|
<input type="password" id="openai-api-key" data-i18n="settingsBasic.openaiApiKeyPlaceholder" data-i18n-attr="placeholder" placeholder="输入OpenAI API Key" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group span-2">
|
||||||
<label for="openai-max-total-tokens"><span data-i18n="settingsBasic.maxTotalTokens">最大上下文 Token 数</span></label>
|
<label for="openai-model"><span data-i18n="settingsBasic.model">模型</span> <span class="required-mark">*</span></label>
|
||||||
<input type="number" id="openai-max-total-tokens" data-i18n="settingsBasic.maxTotalTokensPlaceholder" data-i18n-attr="placeholder" placeholder="120000" min="1000" step="1000" />
|
<div class="model-pick-row">
|
||||||
<small style="color: var(--text-muted, #718096); font-size: 0.75rem;" data-i18n="settingsBasic.maxTotalTokensHint">内存压缩和攻击链构建共用此配置,默认 120000</small>
|
<input type="text" id="openai-model" class="model-pick-input" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
|
||||||
</div>
|
<select id="openai-model-select" class="model-pick-native" style="display: none;" title="" aria-hidden="true" tabindex="-1">
|
||||||
<div class="form-group">
|
<option value="" disabled data-i18n="settingsBasic.modelsListSelectPlaceholder">请选择模型</option>
|
||||||
<label for="openai-max-completion-tokens"><span data-i18n="settingsBasic.maxCompletionTokens">最大输出 Token 数</span></label>
|
</select>
|
||||||
<input type="number" id="openai-max-completion-tokens" data-i18n="settingsBasic.maxCompletionTokensPlaceholder" data-i18n-attr="placeholder" placeholder="16384" min="1" step="256" />
|
<a href="javascript:void(0)" id="fetch-openai-models-btn" class="model-pick-fetch-link" onclick="fetchModelList('openai')" data-i18n="settingsBasic.fetchModels">获取列表</a>
|
||||||
<small style="color: var(--text-muted, #718096); font-size: 0.75rem;" data-i18n="settingsBasic.maxCompletionTokensHint">单次模型回复的输出上限,默认 16384</small>
|
</div>
|
||||||
</div>
|
<small id="fetch-openai-models-hint" class="form-hint" style="display: none;"></small>
|
||||||
<div class="form-group">
|
<span id="fetch-openai-models-result" class="form-inline-result"></span>
|
||||||
<label data-i18n="settingsBasic.openaiReasoningTitle">推理设置</label>
|
</div>
|
||||||
<small class="form-hint" data-i18n="settingsBasic.openaiReasoningHint">作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。</small>
|
</div>
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; align-items: center;">
|
</section>
|
||||||
<label for="openai-reasoning-mode" style="font-size: 0.8125rem;" data-i18n="chat.reasoningModeLabel">模式</label>
|
|
||||||
<select id="openai-reasoning-mode" style="min-width: 140px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
|
<section class="ai-channel-form-section">
|
||||||
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
|
<div class="ai-channel-form-section-head">
|
||||||
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
|
<div>
|
||||||
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
|
<h6 data-i18n="settingsBasic.aiChannelLimitsSection">额度设置</h6>
|
||||||
</select>
|
<p data-i18n="settingsBasic.aiChannelLimitsHint">Token 上限控制上下文窗口和单次输出。</p>
|
||||||
<label for="openai-reasoning-effort" style="font-size: 0.8125rem;" data-i18n="chat.reasoningEffortLabel">强度</label>
|
</div>
|
||||||
<select id="openai-reasoning-effort" style="min-width: 140px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
|
</div>
|
||||||
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
|
<div class="ai-channel-form-grid">
|
||||||
<option value="low">low</option>
|
<div class="form-group">
|
||||||
<option value="medium">medium</option>
|
<label for="openai-max-total-tokens"><span data-i18n="settingsBasic.maxTotalTokens">最大上下文 Token 数</span></label>
|
||||||
<option value="high">high</option>
|
<input type="number" id="openai-max-total-tokens" data-i18n="settingsBasic.maxTotalTokensPlaceholder" data-i18n-attr="placeholder" placeholder="120000" min="1000" step="1000" />
|
||||||
<option value="xhigh">xhigh</option>
|
<small class="form-hint" data-i18n="settingsBasic.maxTotalTokensHint">内存压缩和攻击链构建共用此配置,默认 120000</small>
|
||||||
<option value="max">max</option>
|
</div>
|
||||||
</select>
|
<div class="form-group">
|
||||||
<label for="openai-reasoning-profile" style="font-size: 0.8125rem;" data-i18n="settingsBasic.openaiReasoningProfile">线路</label>
|
<label for="openai-max-completion-tokens"><span data-i18n="settingsBasic.maxCompletionTokens">最大输出 Token 数</span></label>
|
||||||
<select id="openai-reasoning-profile" style="min-width: 220px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
|
<input type="number" id="openai-max-completion-tokens" data-i18n="settingsBasic.maxCompletionTokensPlaceholder" data-i18n-attr="placeholder" placeholder="16384" min="1" step="256" />
|
||||||
<option value="auto">auto</option>
|
<small class="form-hint" data-i18n="settingsBasic.maxCompletionTokensHint">单次模型回复的输出上限,默认 16384</small>
|
||||||
<option value="deepseek_compat">deepseek_compat</option>
|
</div>
|
||||||
<option value="openai_compat">openai_compat</option>
|
</div>
|
||||||
<option value="output_config_effort">output_config_effort</option>
|
</section>
|
||||||
</select>
|
|
||||||
</div>
|
<details class="ai-channel-form-section ai-channel-advanced-section">
|
||||||
<label class="checkbox-label" style="margin-top: 8px;">
|
<summary>
|
||||||
<input type="checkbox" id="openai-reasoning-allow-client" class="modern-checkbox" checked />
|
<span>
|
||||||
<span class="checkbox-custom"></span>
|
<strong data-i18n="settingsBasic.openaiReasoningTitle">推理设置</strong>
|
||||||
<span class="checkbox-text" data-i18n="settingsBasic.openaiReasoningAllowClient">允许对话页覆盖推理选项</span>
|
<small data-i18n="settingsBasic.openaiReasoningHint">作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。</small>
|
||||||
</label>
|
</span>
|
||||||
</div>
|
</summary>
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 2px;">
|
<div class="ai-channel-reasoning-grid">
|
||||||
<a href="javascript:void(0)" id="test-openai-btn" onclick="testOpenAIConnection()" style="font-size: 0.8125rem; color: var(--accent-color, #3182ce); text-decoration: none; cursor: pointer; user-select: none;" data-i18n="settingsBasic.testConnection">测试连接</a>
|
<div class="form-group">
|
||||||
<span id="test-openai-result" style="font-size: 0.8125rem;"></span>
|
<label for="openai-reasoning-mode" data-i18n="chat.reasoningModeLabel">模式</label>
|
||||||
</div>
|
<select id="openai-reasoning-mode">
|
||||||
|
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
|
||||||
|
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
|
||||||
|
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="openai-reasoning-effort" data-i18n="chat.reasoningEffortLabel">强度</label>
|
||||||
|
<select id="openai-reasoning-effort">
|
||||||
|
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
|
||||||
|
<option value="low">low</option>
|
||||||
|
<option value="medium">medium</option>
|
||||||
|
<option value="high">high</option>
|
||||||
|
<option value="xhigh">xhigh</option>
|
||||||
|
<option value="max">max</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="openai-reasoning-profile" data-i18n="settingsBasic.openaiReasoningProfile">线路</label>
|
||||||
|
<select id="openai-reasoning-profile">
|
||||||
|
<option value="auto">auto</option>
|
||||||
|
<option value="deepseek_compat">deepseek_compat</option>
|
||||||
|
<option value="openai_compat">openai_compat</option>
|
||||||
|
<option value="output_config_effort">output_config_effort</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="checkbox-label ai-channel-reasoning-toggle">
|
||||||
|
<input type="checkbox" id="openai-reasoning-allow-client" class="modern-checkbox" checked />
|
||||||
|
<span class="checkbox-custom"></span>
|
||||||
|
<span class="checkbox-text" data-i18n="settingsBasic.openaiReasoningAllowClient">允许对话页覆盖推理选项</span>
|
||||||
|
</label>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -5537,6 +5574,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="workflow-ai-modal" class="modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="workflow-ai-modal-title" onclick="if(event.target===this)closeWorkflowAiModal()" onkeydown="if(event.key==='Escape')closeWorkflowAiModal()">
|
||||||
|
<form class="modal-content workflow-ai-modal-content" onsubmit="event.preventDefault(); generateWorkflowFromNaturalLanguage()" onclick="event.stopPropagation()">
|
||||||
|
<div class="modal-header workflow-ai-modal-header">
|
||||||
|
<div>
|
||||||
|
<h2 id="workflow-ai-modal-title" data-i18n="workflows.ai.title">用自然语言创建工作流</h2>
|
||||||
|
<p class="workflow-ai-subtitle" data-i18n="workflows.ai.subtitle">AI 会生成可编辑草稿,不会自动保存或运行。</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="modal-close" onclick="closeWorkflowAiModal()" data-i18n="common.close" data-i18n-attr="aria-label" data-i18n-skip-text="true" aria-label="关闭"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body workflow-ai-modal-body">
|
||||||
|
<div class="form-group workflow-ai-prompt-field">
|
||||||
|
<label for="workflow-ai-prompt" data-i18n="workflows.ai.promptLabel">工作流需求</label>
|
||||||
|
<textarea id="workflow-ai-prompt" class="form-input" rows="5" data-i18n="workflows.ai.promptPlaceholder" data-i18n-attr="placeholder" placeholder="例如:持续监控一个域名的子域名、证书和暴露页面,发现新增资产后生成报告"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-ai-examples" aria-label="示例需求" data-i18n="workflows.ai.examplesLabel" data-i18n-attr="aria-label">
|
||||||
|
<button type="button" class="btn-secondary btn-small" onclick="useWorkflowAiExample('domainMonitor')" data-i18n="workflows.ai.exampleDomain">域名监控</button>
|
||||||
|
<button type="button" class="btn-secondary btn-small" onclick="useWorkflowAiExample('reportReview')" data-i18n="workflows.ai.exampleReport">报告审批</button>
|
||||||
|
<button type="button" class="btn-secondary btn-small" onclick="useWorkflowAiExample('assetTriage')" data-i18n="workflows.ai.exampleTriage">资产研判</button>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-ai-options">
|
||||||
|
<label><input type="checkbox" id="workflow-ai-include-objective" checked> <span data-i18n="workflows.ai.includeObjective">同时生成 objective 配置</span></label>
|
||||||
|
<label><input type="checkbox" id="workflow-ai-allow-schedule"> <span data-i18n="workflows.ai.allowSchedule">允许生成定时触发建议</span></label>
|
||||||
|
<label><input type="checkbox" id="workflow-ai-allow-high-risk"> <span data-i18n="workflows.ai.allowHighRisk">允许包含高风险节点草稿</span></label>
|
||||||
|
</div>
|
||||||
|
<div id="workflow-ai-progress" class="workflow-ai-progress" hidden aria-live="polite"></div>
|
||||||
|
<div id="workflow-ai-result" class="workflow-ai-result" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer workflow-ai-modal-footer">
|
||||||
|
<button type="button" class="btn-secondary btn-small" onclick="closeWorkflowAiModal()" data-i18n="common.cancel">取消</button>
|
||||||
|
<button id="workflow-ai-apply-btn" type="button" class="btn-secondary btn-small" onclick="applyWorkflowAiDraft()" disabled data-i18n="workflows.ai.apply">渲染到画布</button>
|
||||||
|
<button id="workflow-ai-generate-btn" type="submit" class="btn-primary btn-small" data-i18n="workflows.ai.generate">生成草稿</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
<div id="workflow-package-import-modal" class="modal workflow-package-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="workflow-package-import-title">
|
<div id="workflow-package-import-modal" class="modal workflow-package-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="workflow-package-import-title">
|
||||||
<div class="modal-content workflow-package-modal-content">
|
<div class="modal-content workflow-package-modal-content">
|
||||||
<div class="modal-header workflow-package-modal-header">
|
<div class="modal-header workflow-package-modal-header">
|
||||||
@@ -6645,7 +6716,7 @@
|
|||||||
<script src="/static/js/dashboard.js"></script>
|
<script src="/static/js/dashboard.js"></script>
|
||||||
<script src="/static/js/chat-scroll.js"></script>
|
<script src="/static/js/chat-scroll.js"></script>
|
||||||
<script src="/static/js/monitor.js?v=20260723-1"></script>
|
<script src="/static/js/monitor.js?v=20260723-1"></script>
|
||||||
<script src="/static/js/chat.js?v=20260723-1"></script>
|
<script src="/static/js/chat.js?v=20260724-1"></script>
|
||||||
<script src="/static/js/hitl.js"></script>
|
<script src="/static/js/hitl.js"></script>
|
||||||
<script src="/static/js/settings.js?v=20260717-1"></script>
|
<script src="/static/js/settings.js?v=20260717-1"></script>
|
||||||
<script src="/static/js/audit-datetime-picker.js"></script>
|
<script src="/static/js/audit-datetime-picker.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user