mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-01 08:37:41 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
af4b25b84e | ||
|
|
f0b1955059 | ||
|
|
f5d580bbf0 | ||
|
|
44d069da2b | ||
|
|
9297e6e6ee | ||
|
|
9bbc28c14a | ||
|
|
4943b9419e | ||
|
|
446ccd3edb | ||
|
|
a00643b9c0 | ||
|
|
5c13819f66 | ||
|
|
a7190b8399 | ||
|
|
6f0eaf7667 | ||
|
|
f285d47404 | ||
|
|
273d63413b | ||
|
|
5656315b1e | ||
|
|
cee46f40fa | ||
|
|
4c55784dbc | ||
|
|
d304ff5c99 | ||
|
|
0cd4df3518 | ||
|
|
49333b856c | ||
|
|
a83490f29c |
@@ -218,16 +218,23 @@ The `run.sh` script will automatically:
|
||||
**Networking defaults:** `run.sh` starts the server with **`--https`** and the repo **`config.yaml`** (local self-signed TLS; better for many concurrent streams). Use **`./run.sh --http`** for plain HTTP. In production, set **`server.tls_cert_path`** / **`server.tls_key_path`** in **`config.yaml`** (see comments there). For manual runs, add **`--https`** or **`CYBERSTRIKE_HTTPS=1`**; if **`-config`** is wrong, the binary prints a short usage hint on stderr.
|
||||
|
||||
**First-Time Configuration:**
|
||||
1. **Configure OpenAI-compatible API** (required before first use)
|
||||
1. **Configure AI channels** (required before first use)
|
||||
- After launch, open **`https://127.0.0.1:8080/`** (or **`https://localhost:8080/`**; replace **8080** with `server.port` in `config.yaml`) and accept the self-signed certificate warning once. If you used `./run.sh --http`, use **`http://`** instead.
|
||||
- Go to `Settings` → Fill in your API credentials:
|
||||
- Go to `System Settings` → `Basic Settings` → `AI Channel Configuration`, add or edit a channel, then fill in provider, Base URL, API key, model, and token limits. Click **Save changes**. The left channel list supports setting a default, copy, delete, and bulk probe.
|
||||
```yaml
|
||||
openai:
|
||||
api_key: "${OPENAI_API_KEY}"
|
||||
base_url: "https://api.openai.com/v1" # or https://api.deepseek.com/v1
|
||||
model: "gpt-4o" # or deepseek-chat, claude-3-opus, etc.
|
||||
ai:
|
||||
default_channel: openai-main
|
||||
channels:
|
||||
openai-main:
|
||||
name: OpenAI Main
|
||||
provider: openai_compatible
|
||||
api_key: "${OPENAI_API_KEY}"
|
||||
base_url: "https://api.openai.com/v1" # or https://api.deepseek.com/v1
|
||||
model: "gpt-4o" # or deepseek-chat, qwen3-max, etc.
|
||||
max_total_tokens: 120000
|
||||
max_completion_tokens: 16384
|
||||
```
|
||||
- Or edit `config.yaml` directly before launching
|
||||
- Or edit `config.yaml` directly before launching. `ai.default_channel` is used for new conversations and tasks that do not explicitly select a channel; the chat page can also select any saved channel per session.
|
||||
2. **Login** - On first startup the console prints an auto-generated initial `admin` password; create accounts from **Platform permissions → User management**
|
||||
3. **Install security tools (optional)** - Install tools from `tools/` as needed; missing tools are skipped or substituted at runtime. Common examples:
|
||||
|
||||
@@ -281,19 +288,23 @@ Requirements / tips:
|
||||
|
||||
## Configuration
|
||||
|
||||
Use [`config.example.yaml`](config.example.yaml) as the authoritative configuration template and copy only the values required for your environment. At minimum, configure the server and an OpenAI-compatible model provider:
|
||||
Use [`config.example.yaml`](config.example.yaml) as the authoritative configuration template and copy only the values required for your environment. At minimum, configure the server and one AI channel:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "127.0.0.1"
|
||||
port: 8080
|
||||
openai:
|
||||
api_key: "${OPENAI_API_KEY}"
|
||||
base_url: "https://api.openai.com/v1"
|
||||
model: "your-model"
|
||||
ai:
|
||||
default_channel: openai-main
|
||||
channels:
|
||||
openai-main:
|
||||
provider: openai_compatible
|
||||
api_key: "${OPENAI_API_KEY}"
|
||||
base_url: "https://api.openai.com/v1"
|
||||
model: "your-model"
|
||||
```
|
||||
|
||||
Do not commit real credentials. Review the [configuration reference](docs/en-US/configuration.md), [recommended profiles](docs/en-US/configuration-profiles.md), and [security hardening guide](docs/en-US/security-hardening.md) before exposing the service beyond localhost.
|
||||
`openai` is a backward-compatible runtime field; maintain new model settings in `ai.channels`. Do not commit real credentials. Review the [configuration reference](docs/en-US/configuration.md), [recommended profiles](docs/en-US/configuration-profiles.md), and [security hardening guide](docs/en-US/security-hardening.md) before exposing the service beyond localhost.
|
||||
|
||||
## Related documentation
|
||||
|
||||
|
||||
+24
-13
@@ -217,16 +217,23 @@ chmod +x run.sh && ./run.sh
|
||||
**网络默认:** `run.sh` 会以 **`--https`** 并传入项目根 **`config.yaml`** 启动(本机自签证书,多路流式场景更稳)。只要明文 HTTP 用 **`./run.sh --http`**。生产环境在 **`config.yaml`** 的 **`server.tls_cert_path` / `server.tls_key_path`** 配正式证书(见文件内注释)。手动启动可加 **`--https`** 或环境变量 **`CYBERSTRIKE_HTTPS=1`**;`-config` 写错时程序会在终端提示正确写法。
|
||||
|
||||
**首次配置:**
|
||||
1. **配置 AI 模型 API**(首次使用前必填)
|
||||
1. **配置 AI 通道**(首次使用前必填)
|
||||
- 启动后在浏览器打开 **`https://127.0.0.1:8080/`**(或 **`https://localhost:8080/`**;端口以 `config.yaml` 中 **`server.port`** 为准,默认 8080),并按提示信任自签证书。若使用 **`./run.sh --http`**,则改用 **`http://`** 访问。
|
||||
- 进入 `设置` → 填写 API 配置信息:
|
||||
- 进入 `系统设置` → `基本设置` → `AI 通道配置`,新增或编辑通道,填写 API 提供商、Base URL、API Key、模型和 Token 上限,点击 **保存更改**。左侧通道列表支持设为默认、复制、删除和批量探活。
|
||||
```yaml
|
||||
openai:
|
||||
api_key: "${OPENAI_API_KEY}"
|
||||
base_url: "https://api.openai.com/v1" # 或 https://api.deepseek.com/v1
|
||||
model: "gpt-4o" # 或 deepseek-chat, claude-3-opus 等
|
||||
ai:
|
||||
default_channel: openai-main
|
||||
channels:
|
||||
openai-main:
|
||||
name: OpenAI Main
|
||||
provider: openai_compatible
|
||||
api_key: "${OPENAI_API_KEY}"
|
||||
base_url: "https://api.openai.com/v1" # 或 https://api.deepseek.com/v1
|
||||
model: "gpt-4o" # 或 deepseek-chat, qwen3-max 等
|
||||
max_total_tokens: 120000
|
||||
max_completion_tokens: 16384
|
||||
```
|
||||
- 或启动前直接编辑 `config.yaml` 文件
|
||||
- 或启动前直接编辑 `config.yaml` 文件。`ai.default_channel` 会作为新对话和未显式选择通道任务的默认模型;对话页也可以在会话设置里选择某个已保存通道。
|
||||
2. **登录系统** - 首次启动时控制台会显示自动生成的 `admin` 初始密码;也可在「平台权限 → 用户管理」中创建账号
|
||||
3. **安装安全工具(可选)** - 按需安装 `tools/` 目录中的工具;未安装的工具在执行时会自动跳过或改用替代方案。常用示例:
|
||||
|
||||
@@ -279,19 +286,23 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
|
||||
## 配置
|
||||
|
||||
请以 [`config.example.yaml`](config.example.yaml) 作为权威配置模板,只复制当前环境需要的配置。最少需要配置服务监听地址和一个 OpenAI 兼容模型:
|
||||
请以 [`config.example.yaml`](config.example.yaml) 作为权威配置模板,只复制当前环境需要的配置。最少需要配置服务监听地址和一个 AI 通道:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "127.0.0.1"
|
||||
port: 8080
|
||||
openai:
|
||||
api_key: "${OPENAI_API_KEY}"
|
||||
base_url: "https://api.openai.com/v1"
|
||||
model: "your-model"
|
||||
ai:
|
||||
default_channel: openai-main
|
||||
channels:
|
||||
openai-main:
|
||||
provider: openai_compatible
|
||||
api_key: "${OPENAI_API_KEY}"
|
||||
base_url: "https://api.openai.com/v1"
|
||||
model: "your-model"
|
||||
```
|
||||
|
||||
不要提交真实凭证。将服务暴露到 localhost 之外前,请阅读[配置参考](docs/zh-CN/configuration.md)、[推荐配置画像](docs/zh-CN/configuration-profiles.md)和[安全加固指南](docs/zh-CN/security-hardening.md)。
|
||||
`openai` 是兼容旧版本的运行时字段,新配置优先维护 `ai.channels`。不要提交真实凭证。将服务暴露到 localhost 之外前,请阅读[配置参考](docs/zh-CN/configuration.md)、[推荐配置画像](docs/zh-CN/configuration-profiles.md)和[安全加固指南](docs/zh-CN/security-hardening.md)。
|
||||
|
||||
## 相关文档
|
||||
|
||||
|
||||
+91
-8
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"cyberstrike-ai/internal/app"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/logger"
|
||||
"cyberstrike-ai/internal/security"
|
||||
"cyberstrike-ai/internal/termout"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -12,17 +14,21 @@ import (
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var configPath = flag.String("config", "config.yaml", "配置文件路径")
|
||||
var httpsBootstrap = flag.Bool("https", false, "启用主站 HTTPS:未配置 tls_cert_path/tls_key_path 时使用内存自签证书(本地测试);与 run.sh 默认行为一致")
|
||||
var httpBootstrap = flag.Bool("http", false, "强制主站使用明文 HTTP:覆盖配置文件中的 tls_enabled/tls_auto_self_sign/tls_cert_path/tls_key_path")
|
||||
var configPath = flag.String("config", "config.yaml", "Path to the configuration file")
|
||||
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, "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()
|
||||
|
||||
// 环境变量兼容(便于 systemd/docker 等不传参场景)
|
||||
if *httpsBootstrap && *httpBootstrap {
|
||||
fmt.Fprintln(os.Stderr, "--http 与 --https 不能同时使用")
|
||||
fmt.Fprintln(os.Stderr, "--http and --https cannot be used together")
|
||||
os.Exit(2)
|
||||
}
|
||||
if !*httpsBootstrap && !*httpBootstrap {
|
||||
@@ -38,24 +44,32 @@ func main() {
|
||||
cp = "config.yaml"
|
||||
}
|
||||
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)
|
||||
}
|
||||
localConfig, err := config.EnsureLocalConfig(cp)
|
||||
if err != nil {
|
||||
fmt.Printf("加载配置失败: %v\n", err)
|
||||
fmt.Printf("Failed to load config: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.Load(cp)
|
||||
if err != nil {
|
||||
fmt.Printf("加载配置失败: %v\n", err)
|
||||
fmt.Printf("Failed to load config: %v\n", err)
|
||||
return
|
||||
}
|
||||
if localConfig.Created {
|
||||
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 {
|
||||
config.ApplyPlainHTTPBootstrap(cfg)
|
||||
} else if *httpsBootstrap {
|
||||
@@ -79,7 +93,7 @@ func main() {
|
||||
|
||||
// MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
+27
-22
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.7.7"
|
||||
version: "v1.7.9"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
@@ -49,35 +49,40 @@ monitor:
|
||||
# 对话相关配置
|
||||
# ============================================
|
||||
|
||||
# AI 模型配置(支持 OpenAI 兼容 API)
|
||||
# 必填项:api_key, base_url, model 必须填写才能正常运行
|
||||
# AI 通道配置(支持保存多个 OpenAI 兼容 / Claude 通道)
|
||||
# default_channel 指定新对话与未显式选择通道的任务使用哪个通道。
|
||||
# 每个 channels.<id> 必填:api_key, base_url, model。
|
||||
# 支持的 API 服务商:
|
||||
# - OpenAI: https://api.openai.com/v1
|
||||
# - DeepSeek: https://api.deepseek.com/v1
|
||||
# - 其他兼容 OpenAI 协议的 API
|
||||
# 常用模型: gpt-4, gpt-3.5-turbo, deepseek-chat, claude-3-opus 等
|
||||
# provider: 可选值 openai(默认) | claude(自动桥接到 Anthropic Claude Messages API)
|
||||
openai:
|
||||
provider: openai # API 提供商: openai(默认,兼容OpenAI协议) | claude(自动桥接到Anthropic Claude Messages API)
|
||||
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1 # API 基础 URL(必填)
|
||||
api_key: sk-xxxxxxx # API 密钥(必填)
|
||||
model: qwen3-max # 模型名称(必填)
|
||||
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
|
||||
max_completion_tokens: 16384 # 单次生成上限(含 reasoning 与可见输出),防止依赖网关隐式默认值
|
||||
# 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:
|
||||
mode: on # auto | on | off;off:OpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考)
|
||||
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
|
||||
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
||||
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
||||
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
|
||||
# provider: 可选值 openai_compatible(默认) | claude(自动桥接到 Anthropic Claude Messages API)
|
||||
ai:
|
||||
default_channel: qwen-max
|
||||
channels:
|
||||
qwen-max:
|
||||
name: Qwen Max
|
||||
provider: openai_compatible
|
||||
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
api_key: sk-xxxxxxx
|
||||
model: qwen3-max
|
||||
max_total_tokens: 120000
|
||||
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
|
||||
reasoning:
|
||||
mode: on # auto | on | off;off:OpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考)
|
||||
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
|
||||
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
||||
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
||||
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
|
||||
# 视觉分析(analyze_image MCP 工具;图片仅在单次 VL 调用中出现,Agent 上下文只保留文字摘要)
|
||||
vision:
|
||||
enabled: false # true 且 model 非空时注册 analyze_image
|
||||
model: qwen-vl # VL 模型名(enabled 时必填)
|
||||
api_key: "" # 留空则复用 openai.api_key
|
||||
base_url: "" # 留空则复用 openai.base_url
|
||||
provider: # 留空则复用 openai.provider(openai | claude)
|
||||
api_key: "" # 留空则复用默认 AI 通道 api_key
|
||||
base_url: "" # 留空则复用默认 AI 通道 base_url
|
||||
provider: # 留空则复用默认 AI 通道 provider(openai_compatible | claude)
|
||||
max_image_bytes: 5242880 # 原始文件上限(字节),默认 5MB
|
||||
max_dimension: 2048 # 长边缩放像素
|
||||
jpeg_quality: 82
|
||||
@@ -110,7 +115,7 @@ agent:
|
||||
tool_wait_timeout_seconds: 300 # 工具本轮最多等待(秒);到时返回 execution_id,worker 继续后台执行,可用 wait_tool_execution 继续等待;0=等到完成
|
||||
external_mcp_max_concurrent_per_server: 5 # 单个外部 MCP server 同时运行的工具数;0=默认2;负数=不限制
|
||||
external_mcp_max_concurrent_total: 16 # 所有外部 MCP 工具全局并发上限;0=默认16;负数=不限制
|
||||
external_mcp_circuit_failure_threshold: 5 # 单个外部 MCP server 连续失败多少次后熔断;0=默认3;负数=关闭熔断
|
||||
external_mcp_circuit_failure_threshold: 15 # 单个外部 MCP server 连续失败多少次后熔断;0=默认3;负数=关闭熔断
|
||||
external_mcp_circuit_cooldown_seconds: 60 # 熔断冷却秒数;0=默认60
|
||||
shell_no_output_timeout_seconds: 1200 # execute/exec 连续无新输出则终止(秒);通用防挂死;0=默认300;-1=关闭
|
||||
workspace_root_dir: "" # 会话工作目录根路径(curl/wget 下载、read_file/glob/grep 本地分析);空=tmp/workspace,其下按 projects/{id} 或 conversations/{id} 隔离;勿用系统 /tmp
|
||||
|
||||
@@ -21,7 +21,7 @@ vision:
|
||||
timeout_seconds: 60
|
||||
```
|
||||
|
||||
Empty `api_key`, `base_url`, or `provider` inherits from `openai`.
|
||||
Empty `api_key`, `base_url`, or `provider` inherits from the resolved default AI channel.
|
||||
|
||||
## Data Handling
|
||||
|
||||
|
||||
@@ -45,6 +45,18 @@ Multi-agent:
|
||||
|
||||
`orchestration` may be `deep`, `plan_execute`, or `supervisor`.
|
||||
|
||||
Common request body fields:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `message` | User message, required. |
|
||||
| `conversationId` | Continue an existing conversation; empty creates a new one. |
|
||||
| `projectId` | Project for a new conversation; empty may follow `config.project.default_project_id`. |
|
||||
| `role` | Use a named role. |
|
||||
| `aiChannelId` | Select a channel from `ai.channels`; empty follows `ai.default_channel`. |
|
||||
| `reasoning` | Per-session reasoning override, controlled by the channel's `reasoning.allow_client_reasoning`. |
|
||||
| `hitl` | Per-session human-in-the-loop settings. |
|
||||
|
||||
## SSE Notes
|
||||
|
||||
Streaming endpoints are long-lived. Clients should:
|
||||
@@ -55,6 +67,25 @@ Streaming endpoints are long-lived. Clients should:
|
||||
- disable proxy buffering;
|
||||
- pass `conversationId` when continuing a conversation.
|
||||
|
||||
## File Management Sources
|
||||
|
||||
The file management page and `GET /api/chat-uploads` group conversation-related files by source. Directory names still use project IDs or conversation IDs for stability, while the UI prefers project names or conversation titles and keeps the full ID available in tooltips or copied paths.
|
||||
|
||||
| Source | `source` | Typical directory | Meaning | Mutability |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Workspace files | `workspace` | `tmp/workspace/projects/<projectId>/...`, `tmp/workspace/conversations/<conversationId>/...` | The Agent workspace for downloaded files, analysis scripts, intermediate results, and generated CSV/XLSX/Markdown files. If an AI-generated file is missing from the UI, check this source first. | Read-only listing; supports copy path, download, and export. |
|
||||
| Conversation artifacts | `conversation_artifact` | `data/conversation_artifacts/<conversationId>/...` | Conversation-scoped deliverables or archived artifacts such as summaries, reports, or middleware-generated artifacts. | Read-only listing; supports copy path, download, and export. |
|
||||
| Tool outputs | `reduction` | `tmp/reduction/projects/<projectId>/...`, `tmp/reduction/conversations/<conversationId>/...` | Persisted full tool outputs, scan raw data, or outputs saved before truncation. Useful for reviewing long command or scan results. | Read-only listing; supports copy path, download, and export. |
|
||||
| Chat uploads | `upload` | `chat_uploads/<date>/<conversationId>/...` | Files manually uploaded in chat or from the file management page. Copy the server absolute path into chat when the AI should reference a file. | Supports upload, mkdir, text edit, rename, delete, copy path, download, and export. |
|
||||
|
||||
Related endpoints:
|
||||
|
||||
- `GET /api/chat-uploads`: list files filtered by source, project, conversation, or filename.
|
||||
- `GET /api/chat-uploads/path`: resolve a file-management relative path or internal virtual path to a server absolute path for copy actions.
|
||||
- `GET /api/chat-uploads/download`: download a file.
|
||||
- `GET /api/chat-uploads/export`: export the current filtered result as a ZIP.
|
||||
- `POST /api/chat-uploads`: upload into the chat uploads directory.
|
||||
|
||||
## Asset Management and Bulk Import
|
||||
|
||||
Asset endpoints:
|
||||
|
||||
@@ -16,11 +16,15 @@ server:
|
||||
# - https://trusted-integration.example
|
||||
auth:
|
||||
session_duration_hours: 12
|
||||
openai:
|
||||
provider: openai
|
||||
base_url: https://api.openai.com/v1
|
||||
api_key: sk-...
|
||||
model: gpt-4.1
|
||||
ai:
|
||||
default_channel: openai-main
|
||||
channels:
|
||||
openai-main:
|
||||
name: OpenAI Main
|
||||
provider: openai_compatible
|
||||
base_url: https://api.openai.com/v1
|
||||
api_key: sk-...
|
||||
model: gpt-4.1
|
||||
agent:
|
||||
max_iterations: 12000
|
||||
tool_timeout_minutes: 60
|
||||
@@ -30,13 +34,64 @@ Change the initial `admin` password from the Web UI after first login. Use HTTPS
|
||||
|
||||
Valid Chromium `chrome-extension://<32-character-extension-id>` origins are recognized automatically. The extension must still obtain host permission and authenticate with a password and Bearer token. `server.cors_allowed_origins` remains available as an exact allowlist for other trusted Web integrations; wildcards are not accepted, and changing it requires a restart.
|
||||
|
||||
## AI Channels
|
||||
|
||||
`ai` is the recommended model configuration entry. In the Web UI, use **System Settings → Basic Settings → AI Channel Configuration**. Saving that form writes `ai.default_channel` and `ai.channels`. The legacy `openai` field remains as a backward-compatible runtime field; on load, CyberStrikeAI ensures a default channel exists and synchronizes the resolved `ai.default_channel` into runtime `openai`.
|
||||
|
||||
```yaml
|
||||
ai:
|
||||
default_channel: openai-main
|
||||
channels:
|
||||
openai-main:
|
||||
name: OpenAI Main
|
||||
provider: openai_compatible
|
||||
base_url: https://api.openai.com/v1
|
||||
api_key: sk-...
|
||||
model: gpt-4.1
|
||||
max_total_tokens: 120000
|
||||
max_completion_tokens: 16384
|
||||
reasoning:
|
||||
mode: on
|
||||
effort: high
|
||||
allow_client_reasoning: true
|
||||
profile: openai_compat
|
||||
claude-main:
|
||||
name: Claude Main
|
||||
provider: claude
|
||||
base_url: https://api.anthropic.com/v1
|
||||
api_key: sk-ant-...
|
||||
model: claude-sonnet-4-5
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `ai.default_channel` | Default channel ID for new conversations and requests without an explicit channel. |
|
||||
| `ai.channels.<id>` | Channel config. IDs are normalized to lowercase letters, digits, and hyphens. |
|
||||
| `name` | Display name in the Web UI; falls back to the ID. |
|
||||
| `provider` | `openai_compatible` or `claude`. OpenAI-compatible channels map to runtime `openai`; Claude channels bridge to Anthropic Messages API. |
|
||||
| `base_url/api_key/model` | Required. Base URL usually includes a version path such as `/v1`. |
|
||||
| `max_total_tokens` | Shared context budget for compression, attack-chain generation, multi-agent summaries, and similar paths. |
|
||||
| `max_completion_tokens` | Per-response output cap; default is used when empty. |
|
||||
| `reasoning` | Default reasoning fields for the channel. Gateway support varies; try `mode: off` first when a provider rejects requests. |
|
||||
|
||||
The chat page reads saved channels into the “AI Channel” selector. A non-empty request `aiChannelId` selects a channel for that run/session without sending API credentials through the prompt path. Empty `aiChannelId` follows `ai.default_channel`.
|
||||
|
||||
Common Web UI operations:
|
||||
|
||||
- Add: click `+`, fill required fields, then save.
|
||||
- Set default: select a channel, click **Set as default**, then save/apply.
|
||||
- Copy: duplicate the current form, useful for the same provider with a different model.
|
||||
- Delete: keep at least one channel; the default channel is protected from bulk delete.
|
||||
- Probe: use **Test connection** or **Bulk probe** to validate API key, Base URL, and model.
|
||||
|
||||
## Hot-Apply Boundaries
|
||||
|
||||
`POST /api/config/apply` coordinates model config, tool description mode, MCP tool registration, knowledge components, robot restarts, and C2 runtime reconciliation. It does not make every field instantly effective.
|
||||
|
||||
| Section | Usually hot-applies | Extra action |
|
||||
| --- | --- | --- |
|
||||
| `openai` | new requests use new model settings | running streams keep their current state |
|
||||
| `ai.default_channel` / `ai.channels` | new requests use the resolved default or selected channel | running streams keep their current state; reload config for the frontend channel list |
|
||||
| `openai` | compatibility field, usually synchronized from the default AI channel | prefer maintaining new config in `ai.channels` |
|
||||
| `agent.max_iterations` | new tasks | existing tasks continue |
|
||||
| `hitl.tool_whitelist` | new approval checks | pending approvals are not re-decided |
|
||||
| `knowledge.enabled` | initializes/updates components | scan and index are still required |
|
||||
@@ -47,8 +102,8 @@ Valid Chromium `chrome-extension://<32-character-extension-id>` origins are reco
|
||||
|
||||
## Fallback Relationships
|
||||
|
||||
- `vision.api_key/base_url/provider` can inherit from `openai`.
|
||||
- `hitl.audit_model` can inherit from `openai`.
|
||||
- `vision.api_key/base_url/provider` can inherit from the resolved default AI channel.
|
||||
- `hitl.audit_model` can inherit from the resolved default AI channel.
|
||||
- `knowledge.embedding.base_url/api_key` can inherit from model settings.
|
||||
- rerank config can inherit from embedding/openai.
|
||||
- `database.knowledge_db_path` can be separate or reuse the main DB.
|
||||
|
||||
@@ -10,7 +10,7 @@ CyberStrikeAI can run as a local testing tool, an internal team service, or a pr
|
||||
- Python for some MCP servers and tool scripts.
|
||||
- SQLite files under `data/`; no external DB is required by default.
|
||||
- Actual security tools installed in PATH. YAML files under `tools/` only describe commands.
|
||||
- An OpenAI-compatible model endpoint, or `openai.provider: claude` for the Claude bridge.
|
||||
- At least one `ai.channels` entry. Use `provider: openai_compatible` for OpenAI-compatible endpoints, or `provider: claude` for the Claude bridge.
|
||||
|
||||
Important persistent paths:
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ hitl:
|
||||
provider: ""
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
model: "" # set a small model here; blank reuses openai.model
|
||||
model: "" # set a small model here; blank reuses the default AI channel model
|
||||
retention_days: 90
|
||||
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
|
||||
```
|
||||
|
||||
`audit_model` supports partial configuration. Empty fields inherit from the main `openai` config, so the common setup is to fill only `model` and run approvals on a cheaper small model.
|
||||
`audit_model` supports partial configuration. Empty fields inherit from the resolved default AI channel, so the common setup is to fill only `model` and run approvals on a cheaper small model.
|
||||
|
||||
## Recommended Approval Strategy
|
||||
|
||||
|
||||
@@ -46,20 +46,29 @@ Login fails:
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
- wrong `base_url` path;
|
||||
- selected AI channel does not exist; empty selection follows `ai.default_channel`;
|
||||
- wrong `ai.channels.<id>.base_url` path;
|
||||
- invalid API key;
|
||||
- model unavailable;
|
||||
- reasoning fields unsupported by gateway. Try `openai.reasoning.mode: off`.
|
||||
- reasoning fields unsupported by gateway. Try `ai.channels.<id>.reasoning.mode: off`.
|
||||
|
||||
Streaming stalls:
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
## 核心概念与编排
|
||||
|
||||
- [架构说明](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)
|
||||
|
||||
## 功能指南
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
vision:
|
||||
enabled: true
|
||||
model: qwen-vl-max # 必填
|
||||
api_key: # 留空 → openai.api_key
|
||||
base_url: # 留空 → openai.base_url
|
||||
provider: # 留空 → openai.provider
|
||||
api_key: # 留空 → 默认 AI 通道 api_key
|
||||
base_url: # 留空 → 默认 AI 通道 base_url
|
||||
provider: # 留空 → 默认 AI 通道 provider
|
||||
max_image_bytes: 5242880
|
||||
max_dimension: 2048
|
||||
jpeg_quality: 82
|
||||
@@ -28,7 +28,7 @@ vision:
|
||||
|
||||
## Web 设置
|
||||
|
||||
**系统设置 → 基本设置 → 视觉分析(analyze_image)** 可配置启用开关、视觉模型、API Key/Base URL(留空复用 OpenAI)、预处理参数;**保存并应用** 后写入 `config.yaml` 并重新注册 MCP 工具。
|
||||
**系统设置 → 基本设置 → 视觉分析(analyze_image)** 可配置启用开关、视觉模型、API Key/Base URL(留空复用默认 AI 通道)、预处理参数;**保存并应用** 后写入 `config.yaml` 并重新注册 MCP 工具。
|
||||
|
||||
## 路径
|
||||
|
||||
|
||||
@@ -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) 当最终回复。
|
||||
```
|
||||
@@ -52,6 +52,18 @@ Content-Type: application/json
|
||||
- `plan_execute`
|
||||
- `supervisor`
|
||||
|
||||
常用请求体字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `message` | 用户消息,必填。 |
|
||||
| `conversationId` | 继续已有对话;为空时创建新对话。 |
|
||||
| `projectId` | 新对话绑定项目;为空时可跟随 `config.project.default_project_id`。 |
|
||||
| `role` | 使用指定角色。 |
|
||||
| `aiChannelId` | 选择 `ai.channels` 中的通道 ID;为空时使用 `ai.default_channel`。 |
|
||||
| `reasoning` | 会话级推理覆盖,受通道 `reasoning.allow_client_reasoning` 控制。 |
|
||||
| `hitl` | 会话级人机协同配置。 |
|
||||
|
||||
对话管理:
|
||||
|
||||
- `POST /api/conversations`
|
||||
@@ -62,6 +74,25 @@ Content-Type: application/json
|
||||
- `POST /api/conversations/:id/delete-turn`
|
||||
- `GET /api/messages/:id/process-details`
|
||||
|
||||
## 文件管理来源
|
||||
|
||||
文件管理页面和 `/api/chat-uploads` 列表接口会把对话相关文件按来源归类。底层目录仍使用项目 ID 或会话 ID 保持稳定,界面会优先显示项目名或对话标题,完整 ID 可在提示或路径中查看。
|
||||
|
||||
| 来源 | `source` | 典型目录 | 说明 | 可变更性 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 工作目录 | `workspace` | `tmp/workspace/projects/<projectId>/...`、`tmp/workspace/conversations/<conversationId>/...` | Agent 执行任务时保存下载文件、分析脚本、中间结果和生成的 CSV/XLSX/Markdown 等。用户反馈“AI 生成的文件找不到”时,通常先看这里。 | 只读展示;支持复制路径、下载、导出。 |
|
||||
| 会话产物 | `conversation_artifact` | `data/conversation_artifacts/<conversationId>/...` | 系统按会话归档的交付物或会话级产物,例如总结、报告、模型中间件生成的归档内容。 | 只读展示;支持复制路径、下载、导出。 |
|
||||
| 工具输出 | `reduction` | `tmp/reduction/projects/<projectId>/...`、`tmp/reduction/conversations/<conversationId>/...` | 超长工具输出、扫描原文或被截断前落盘的结果缓存。适合回看完整工具输出。 | 只读展示;支持复制路径、下载、导出。 |
|
||||
| 对话附件 | `upload` | `chat_uploads/<date>/<conversationId>/...` | 用户在对话或文件管理页手动上传的附件。需要让 AI 引用某文件时,可复制服务器绝对路径粘贴到对话中。 | 可上传、新建目录、编辑文本文件、重命名、删除、复制路径、下载、导出。 |
|
||||
|
||||
相关接口:
|
||||
|
||||
- `GET /api/chat-uploads`:按来源、项目、会话、文件名筛选文件。
|
||||
- `GET /api/chat-uploads/path`:把文件管理中的相对路径或内部虚拟路径解析为服务器绝对路径,用于复制文件或目录路径。
|
||||
- `GET /api/chat-uploads/download`:下载指定文件。
|
||||
- `GET /api/chat-uploads/export`:导出当前筛选结果为 ZIP。
|
||||
- `POST /api/chat-uploads`:上传到对话附件目录。
|
||||
|
||||
## 项目、漏洞、攻击链
|
||||
|
||||
项目:
|
||||
|
||||
+50
-18
@@ -29,26 +29,57 @@ log:
|
||||
- `auth.session_duration_hours`:登录会话有效期(小时)。登录密码由 RBAC 用户管理,首次启动时在控制台输出 `admin` 初始密码。
|
||||
- `log.output`:可以是 `stdout`、`stderr` 或文件路径。
|
||||
|
||||
## 模型配置
|
||||
## AI 通道与模型配置
|
||||
|
||||
```yaml
|
||||
openai:
|
||||
provider: openai
|
||||
base_url: https://api.openai.com/v1
|
||||
api_key: sk-...
|
||||
model: gpt-4.1
|
||||
max_total_tokens: 120000
|
||||
reasoning:
|
||||
mode: on
|
||||
effort: high
|
||||
allow_client_reasoning: true
|
||||
profile: openai_compat
|
||||
ai:
|
||||
default_channel: openai-main
|
||||
channels:
|
||||
openai-main:
|
||||
name: OpenAI Main
|
||||
provider: openai_compatible
|
||||
base_url: https://api.openai.com/v1
|
||||
api_key: sk-...
|
||||
model: gpt-4.1
|
||||
max_total_tokens: 120000
|
||||
max_completion_tokens: 16384
|
||||
reasoning:
|
||||
mode: on
|
||||
effort: high
|
||||
allow_client_reasoning: true
|
||||
profile: openai_compat
|
||||
claude-main:
|
||||
name: Claude Main
|
||||
provider: claude
|
||||
base_url: https://api.anthropic.com/v1
|
||||
api_key: sk-ant-...
|
||||
model: claude-sonnet-4-5
|
||||
```
|
||||
|
||||
- `provider`:`openai` 表示 OpenAI 兼容接口;`claude` 会桥接到 Anthropic Claude Messages API。
|
||||
- `base_url/api_key/model`:主模型配置。
|
||||
- `max_total_tokens`:上下文压缩、攻击链构建、多代理摘要等共用的总预算。
|
||||
- `reasoning`:控制推理扩展字段。不同网关支持差异较大,异常时先尝试 `mode: off`。
|
||||
`ai` 是推荐的模型配置入口。系统设置页对应路径是 **系统设置 → 基本设置 → AI 通道配置**,保存后写入 `ai.default_channel` 和 `ai.channels`。旧版 `openai` 字段仍保留为兼容运行时字段;加载配置时会确保至少有一个默认通道,并把 `ai.default_channel` 解析后的配置同步到运行时 `openai`。
|
||||
|
||||
通道字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `ai.default_channel` | 默认通道 ID。新对话、机器人、批量任务和未显式选择通道的请求使用它。 |
|
||||
| `ai.channels.<id>` | 通道配置。ID 会归一化为小写、数字和短横线,例如 `Qwen_Max` 会变成 `qwen-max`。 |
|
||||
| `name` | Web UI 展示名。留空时使用通道 ID。 |
|
||||
| `provider` | `openai_compatible` 或 `claude`。`openai_compatible` 会在运行时映射为 `openai`;`claude` 会桥接到 Anthropic Messages API。 |
|
||||
| `base_url/api_key/model` | 必填。Base URL 通常需要包含版本路径,如 OpenAI/兼容网关的 `/v1`。 |
|
||||
| `max_total_tokens` | 上下文压缩、攻击链构建、多代理摘要等共用的总预算。 |
|
||||
| `max_completion_tokens` | 单次模型输出上限;未填时使用默认值。 |
|
||||
| `reasoning` | 该通道的默认推理扩展字段。不同网关支持差异较大,异常时先尝试 `mode: off`。 |
|
||||
|
||||
对话页的“AI 通道”下拉框会读取已保存通道。请求体中的 `aiChannelId` 非空时仅对本次/本会话运行配置生效,不会把 API Key 发送给模型;为空时跟随 `ai.default_channel`。
|
||||
|
||||
常用操作:
|
||||
|
||||
- 新增:点击左侧 `+`,填写必填字段后保存。
|
||||
- 设默认:选中通道后点击“设为默认”,保存并应用后新请求生效。
|
||||
- 复制:以当前表单内容创建副本,适合为同一服务商配置不同模型。
|
||||
- 删除:默认通道不能作为批量删除目标;删除后需保留至少一个通道。
|
||||
- 探活:单通道“测试连接”或左侧“批量探活”会调用模型测试接口,适合验证 Key、Base URL 和模型名。
|
||||
|
||||
## Agent
|
||||
|
||||
@@ -213,7 +244,8 @@ project:
|
||||
|
||||
| 配置段 | 应用后通常立即生效 | 需要额外动作 |
|
||||
| --- | --- | --- |
|
||||
| `openai` | 新请求使用新模型配置 | 旧的流式请求不会被强制切换 |
|
||||
| `ai.default_channel` / `ai.channels` | 新请求使用解析后的默认或选定通道 | 旧的流式请求不会被强制切换;前端通道列表需要重新读取配置 |
|
||||
| `openai` | 兼容字段;通常由默认 AI 通道同步 | 新配置优先维护 `ai.channels` |
|
||||
| `agent.max_iterations` | 新 Agent 任务生效 | 已运行任务按启动时状态继续 |
|
||||
| `security.tool_description_mode` | 工具重新暴露时生效 | 模型已有上下文不会回滚 |
|
||||
| `hitl.tool_whitelist` | 新工具调用审批判断生效 | 已挂起审批不自动重判 |
|
||||
@@ -228,7 +260,7 @@ project:
|
||||
几个字段有“留空复用”的关系:
|
||||
|
||||
- `vision.api_key/base_url/provider` 留空时复用 `openai`。
|
||||
- `hitl.audit_model` 留空时复用 `openai`。
|
||||
- `hitl.audit_model` 留空时复用默认 AI 通道解析后的 `openai`。
|
||||
- `knowledge.embedding.base_url/api_key` 留空时复用主模型或 embedding 默认配置。
|
||||
- `knowledge.retrieval.rerank.base_url/api_key` 留空时复用 embedding/openai。
|
||||
- `database.knowledge_db_path` 留空时可以复用主会话数据库,但独立文件更利于备份。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
- Python:部分 MCP 服务或工具脚本需要 Python 运行环境。
|
||||
- SQLite:默认使用文件型数据库,无需单独服务。
|
||||
- 安全工具:`tools/` 中的 YAML 只是工具定义,实际命令如 `nmap`、`sqlmap`、`nuclei` 仍需安装到系统 PATH。
|
||||
- 模型服务:需要 OpenAI 兼容 API,或配置 `openai.provider: claude` 走 Claude 桥接。
|
||||
- 模型服务:至少配置一个 `ai.channels` 通道;`provider: openai_compatible` 适用于 OpenAI 兼容 API,`provider: claude` 会走 Claude 桥接。
|
||||
|
||||
建议目录:
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ hitl:
|
||||
provider: ""
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
model: "" # 可填小模型;留空复用 openai.model
|
||||
model: "" # 可填小模型;留空复用默认 AI 通道的模型
|
||||
retention_days: 90
|
||||
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
|
||||
```
|
||||
|
||||
`audit_model` 的字段可以只填一部分。空字段会自动继承主 `openai` 配置,因此常见做法是只填 `model`,让审计 Agent 使用更便宜的小模型。
|
||||
`audit_model` 的字段可以只填一部分。空字段会自动继承默认 AI 通道解析后的模型配置,因此常见做法是只填 `model`,让审计 Agent 使用更便宜的小模型。
|
||||
|
||||
## 推荐审批策略
|
||||
|
||||
|
||||
@@ -32,29 +32,40 @@ https://127.0.0.1:8080/
|
||||
|
||||
如果仍有其他具备 `rbac:write` 权限的管理员账号,优先在 **平台权限 → 用户管理** 中重置密码。
|
||||
|
||||
如果没有可用的管理员会话,可在服务器上紧急重置内置 `admin` 账号。先停止 CyberStrikeAI 服务并备份数据库,然后在项目根目录执行以下命令,按提示输入并确认新密码:
|
||||
如果没有可用的管理员会话,可在服务器上紧急重置内置 `admin` 账号。在项目根目录执行:
|
||||
|
||||
```bash
|
||||
./run.sh --reset-admin-password
|
||||
```
|
||||
|
||||
按提示输入并确认新密码。脚本会隐藏输入并写入 bcrypt 哈希。如果服务正在运行,完成后重新启动服务,使原有登录会话失效。
|
||||
|
||||
如果无法使用 `run.sh`,也可以手动执行以下命令,按提示输入并确认新密码:
|
||||
|
||||
```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();"
|
||||
```
|
||||
|
||||
输出 `1` 表示修改成功。该命令需要 `sqlite3` 和 `htpasswd`;如果 `config.yaml` 中的 `database.path` 不是默认值,请替换 `data/conversations.db`。密码输入不会显示,也不会写入 Shell 历史,并以 bcrypt 哈希保存。完成后重新启动服务,使原有登录会话失效。
|
||||
输出 `1` 表示修改成功。该命令需要 `sqlite3` 和 `htpasswd`;如果 `config.yaml` 中的 `database.path` 不是默认值,请替换 `data/conversations.db`。密码输入不会显示,也不会写入 Shell 历史。
|
||||
|
||||
## 模型无响应
|
||||
|
||||
检查:
|
||||
|
||||
- `openai.base_url` 是否包含正确路径,如 `/v1`。
|
||||
- `openai.api_key` 是否有效。
|
||||
- `openai.model` 是否存在。
|
||||
- 服务商是否支持当前 `reasoning` 字段。
|
||||
- 当前对话选择的 AI 通道是否存在;为空时会使用 `ai.default_channel`。
|
||||
- `ai.channels.<id>.base_url` 是否包含正确路径,如 `/v1`。
|
||||
- `ai.channels.<id>.api_key` 是否有效。
|
||||
- `ai.channels.<id>.model` 是否存在。
|
||||
- 服务商是否支持当前通道的 `reasoning` 字段。
|
||||
|
||||
可在系统设置中使用模型测试。若网关报 400,先尝试:
|
||||
|
||||
```yaml
|
||||
openai:
|
||||
reasoning:
|
||||
mode: off
|
||||
ai:
|
||||
channels:
|
||||
your-channel:
|
||||
reasoning:
|
||||
mode: off
|
||||
```
|
||||
|
||||
## 流式输出中断
|
||||
|
||||
@@ -37,6 +37,7 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.34.0
|
||||
go.uber.org/zap v1.26.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/time v0.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
+14
-22
@@ -514,6 +514,14 @@ type ToolExecutionResult struct {
|
||||
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执行工具
|
||||
// 即使工具执行失败,也返回结果而不是错误,让AI能够处理错误情况
|
||||
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 {
|
||||
detail := err.Error()
|
||||
timeoutMinutes := 10
|
||||
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
|
||||
timeoutMinutes = a.agentConfig.ToolTimeoutMinutes
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
detail = "工具调用已被手动终止(MCP 监控页)。智能体将携带此结果继续后续步骤,整条任务不会因此被停止。"
|
||||
} else if errors.Is(err, context.DeadlineExceeded) {
|
||||
min := 10
|
||||
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
|
||||
min = a.agentConfig.ToolTimeoutMinutes
|
||||
}
|
||||
detail = fmt.Sprintf("工具执行超过 %d 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)", min)
|
||||
detail = fmt.Sprintf("工具执行超过 %d 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)", timeoutMinutes)
|
||||
}
|
||||
errorMsg := fmt.Sprintf(`工具调用失败
|
||||
|
||||
工具名称: %s
|
||||
错误类型: 系统错误
|
||||
错误详情: %s
|
||||
|
||||
可能的原因:
|
||||
- 工具 "%s" 不存在或未启用
|
||||
- 单次执行超时(agent.tool_timeout_minutes)
|
||||
- 系统配置问题
|
||||
- 网络或权限问题
|
||||
|
||||
建议:
|
||||
- 检查工具名称是否正确
|
||||
- 若需更长执行时间,可适当增大 agent.tool_timeout_minutes
|
||||
- 尝试使用其他替代工具
|
||||
- 如果这是必需的工具,请向用户说明情况`, toolName, detail, toolName)
|
||||
errorMsg := buildToolFailureMessage(toolName, detail, err)
|
||||
|
||||
return &ToolExecutionResult{
|
||||
Result: errorMsg,
|
||||
|
||||
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"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) {
|
||||
ag := setupTestAgent(t)
|
||||
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)
|
||||
}
|
||||
}
|
||||
+84
-9
@@ -1313,6 +1313,7 @@ func setupRoutes(
|
||||
c2Routes.GET("/sessions/:id", c2Handler.GetSession)
|
||||
c2Routes.DELETE("/sessions/:id", c2Handler.DeleteSession)
|
||||
c2Routes.PUT("/sessions/:id/sleep", c2Handler.SetSessionSleep)
|
||||
c2Routes.PUT("/sessions/:id/note", c2Handler.SetSessionNote)
|
||||
c2Routes.GET("/tasks", c2Handler.ListTasks)
|
||||
c2Routes.DELETE("/tasks", c2Handler.DeleteTasks)
|
||||
c2Routes.GET("/tasks/:id", c2Handler.GetTask)
|
||||
@@ -1339,6 +1340,7 @@ func setupRoutes(
|
||||
protected.GET("/chat-uploads", chatUploadsHandler.List)
|
||||
protected.GET("/chat-uploads/export", chatUploadsHandler.Export)
|
||||
protected.GET("/chat-uploads/download", chatUploadsHandler.Download)
|
||||
protected.GET("/chat-uploads/path", chatUploadsHandler.ResolvePath)
|
||||
protected.GET("/chat-uploads/content", chatUploadsHandler.GetContent)
|
||||
protected.POST("/chat-uploads", chatUploadsHandler.Upload)
|
||||
protected.POST("/chat-uploads/mkdir", chatUploadsHandler.Mkdir)
|
||||
@@ -1574,22 +1576,62 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
logger.Warn("跳过 WebShell 管理工具注册:db 为空")
|
||||
return
|
||||
}
|
||||
projectIDFromToolArgs := func(ctx context.Context, args map[string]interface{}) string {
|
||||
projectID, _ := args["project_id"].(string)
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" {
|
||||
projectID = strings.TrimSpace(mcp.MCPProjectIDFromContext(ctx))
|
||||
}
|
||||
return projectID
|
||||
}
|
||||
explicitProjectIDFromToolArgs := func(args map[string]interface{}) string {
|
||||
projectID, _ := args["project_id"].(string)
|
||||
return strings.TrimSpace(projectID)
|
||||
}
|
||||
authorizeWebshellToolProject := func(principal authctx.Principal, permission, projectID string) *mcp.ToolResult {
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" {
|
||||
return nil
|
||||
}
|
||||
if projectID == database.ProjectFilterUnbound {
|
||||
return nil
|
||||
}
|
||||
if !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID) {
|
||||
return &mcp.ToolResult{
|
||||
Content: []mcp.Content{{Type: "text", Text: "无权访问项目: " + projectID}},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// manage_webshell_list - 列出所有 webshell 连接
|
||||
listTool := mcp.Tool{
|
||||
Name: builtin.ToolManageWebshellList,
|
||||
Description: "列出所有已保存的 WebShell 连接,返回连接ID、URL、类型、备注等信息。",
|
||||
Description: "列出已保存的 WebShell 连接,返回连接ID、URL、类型、所属项目、备注等信息。默认按当前对话项目边界过滤:项目对话看本项目,未绑定项目的对话看未绑定连接;显式传 project_id 时按指定项目过滤。",
|
||||
ShortDescription: "列出所有 WebShell 连接",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"project_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "项目 ID;不填时在项目会话中默认使用当前项目。",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
listHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
connections := []database.WebShellConnection{}
|
||||
var err error
|
||||
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
|
||||
connections, err = db.ListWebshellConnectionsForAccess(principal.UserID, principal.ScopeFor("webshell:read"))
|
||||
projectID := explicitProjectIDFromToolArgs(args)
|
||||
if projectID == "" {
|
||||
projectID = mcpEffectiveProjectFilter(ctx, db)
|
||||
}
|
||||
if result := authorizeWebshellToolProject(principal, "webshell:read", projectID); result != nil {
|
||||
return result, nil
|
||||
}
|
||||
connections, err = db.ListWebshellConnectionsForAccess(principal.UserID, principal.ScopeFor("webshell:read"), projectID)
|
||||
} else {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "缺少认证身份"}}, IsError: true}, nil
|
||||
}
|
||||
@@ -1613,6 +1655,11 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
sb.WriteString(fmt.Sprintf(" 类型: %s\n", conn.Type))
|
||||
sb.WriteString(fmt.Sprintf(" 请求方式: %s\n", conn.Method))
|
||||
sb.WriteString(fmt.Sprintf(" 命令参数: %s\n", conn.CmdParam))
|
||||
if conn.ProjectID != "" {
|
||||
sb.WriteString(fmt.Sprintf(" 项目ID: %s\n", conn.ProjectID))
|
||||
} else {
|
||||
sb.WriteString(" 项目: 未绑定\n")
|
||||
}
|
||||
if conn.Remark != "" {
|
||||
sb.WriteString(fmt.Sprintf(" 备注: %s\n", conn.Remark))
|
||||
}
|
||||
@@ -1687,6 +1734,14 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
cmdParam = "cmd"
|
||||
}
|
||||
remark, _ := args["remark"].(string)
|
||||
principal, ok := authctx.PrincipalFromContext(ctx)
|
||||
if !ok {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "缺少认证身份"}}, IsError: true}, nil
|
||||
}
|
||||
projectID := projectIDFromToolArgs(ctx, args)
|
||||
if result := authorizeWebshellToolProject(principal, "webshell:write", projectID); result != nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 生成连接ID
|
||||
connID := "ws_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12]
|
||||
@@ -1698,6 +1753,7 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
Method: strings.ToLower(method),
|
||||
CmdParam: cmdParam,
|
||||
Remark: remark,
|
||||
ProjectID: projectID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -1707,15 +1763,17 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
|
||||
_ = db.SetResourceOwner("webshell", conn.ID, principal.UserID)
|
||||
_ = db.AssignResourceToUser(principal.UserID, "webshell", conn.ID)
|
||||
_ = db.SetResourceOwner("webshell", conn.ID, principal.UserID)
|
||||
_ = db.AssignResourceToUser(principal.UserID, "webshell", conn.ID)
|
||||
projectLine := "项目: 未绑定"
|
||||
if conn.ProjectID != "" {
|
||||
projectLine = "项目ID: " + conn.ProjectID
|
||||
}
|
||||
|
||||
return &mcp.ToolResult{
|
||||
Content: []mcp.Content{{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("WebShell 连接添加成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n请求方式: %s\n命令参数: %s", conn.ID, conn.URL, conn.Type, conn.Method, conn.CmdParam),
|
||||
Text: fmt.Sprintf("WebShell 连接添加成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n请求方式: %s\n命令参数: %s\n%s", conn.ID, conn.URL, conn.Type, conn.Method, conn.CmdParam, projectLine),
|
||||
}},
|
||||
IsError: false,
|
||||
}, nil
|
||||
@@ -1760,6 +1818,10 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
"type": "string",
|
||||
"description": "新的备注",
|
||||
},
|
||||
"project_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "新的所属项目 ID;传空字符串可取消绑定。",
|
||||
},
|
||||
},
|
||||
"required": []string{"connection_id"},
|
||||
},
|
||||
@@ -1801,6 +1863,19 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
if remark, ok := args["remark"].(string); ok {
|
||||
existing.Remark = remark
|
||||
}
|
||||
if projectID, ok := args["project_id"].(string); ok {
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID != "" {
|
||||
principal, ok := authctx.PrincipalFromContext(ctx)
|
||||
if !ok {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "缺少认证身份"}}, IsError: true}, nil
|
||||
}
|
||||
if result := authorizeWebshellToolProject(principal, "webshell:write", projectID); result != nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
existing.ProjectID = projectID
|
||||
}
|
||||
|
||||
if err := db.UpdateWebshellConnection(existing); err != nil {
|
||||
return &mcp.ToolResult{
|
||||
@@ -1812,7 +1887,7 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
return &mcp.ToolResult{
|
||||
Content: []mcp.Content{{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("WebShell 连接更新成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n请求方式: %s\n命令参数: %s\n备注: %s", existing.ID, existing.URL, existing.Type, existing.Method, existing.CmdParam, existing.Remark),
|
||||
Text: fmt.Sprintf("WebShell 连接更新成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n请求方式: %s\n命令参数: %s\n项目ID: %s\n备注: %s", existing.ID, existing.URL, existing.Type, existing.Method, existing.CmdParam, existing.ProjectID, existing.Remark),
|
||||
}},
|
||||
IsError: false,
|
||||
}, nil
|
||||
|
||||
@@ -75,6 +75,7 @@ tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登
|
||||
"bind_host": map[string]interface{}{"type": "string", "description": "绑定地址,默认 127.0.0.1;外网监听常用 0.0.0.0"},
|
||||
"callback_host": map[string]interface{}{"type": "string", "description": "可选:植入端/Payload 回连主机名(公网 IP 或域名)。写入 config_json;生成 oneliner/beacon 时优先于 bind_host。update 时传入空字符串可清除"},
|
||||
"bind_port": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("绑定端口(create 必填)。须 ≠ %d(当前本服务 Web/API 端口,配置 server.port)", webListenPort), "minimum": 1, "maximum": 65535},
|
||||
"project_id": map[string]interface{}{"type": "string", "description": "所属项目 ID。create 省略时默认使用当前对话绑定项目;未绑定项目的对话则创建未绑定监听器"},
|
||||
"profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"},
|
||||
"remark": map[string]interface{}{"type": "string", "description": "备注"},
|
||||
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用。tcp_reverse 可选 allow_legacy_shell:true 允许未加密经典 shell(默认 false)"},
|
||||
@@ -87,7 +88,7 @@ tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登
|
||||
|
||||
switch action {
|
||||
case "list":
|
||||
listeners, err := m.DB().ListC2ListenersForAccess(c2ToolAccess(ctx))
|
||||
listeners, err := m.DB().ListC2ListenersForAccess(c2ToolAccess(ctx), mcpEffectiveProjectFilter(ctx, m.DB()))
|
||||
if err != nil {
|
||||
return makeC2Result(nil, err)
|
||||
}
|
||||
@@ -116,6 +117,13 @@ tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登
|
||||
cfg = &c2.ListenerConfig{}
|
||||
_ = json.Unmarshal(cfgBytes, cfg)
|
||||
}
|
||||
projectID := strings.TrimSpace(getString(params, "project_id"))
|
||||
if projectID == "" {
|
||||
projectID = mcpEffectiveProjectFilter(ctx, m.DB())
|
||||
if projectID == database.ProjectFilterUnbound {
|
||||
projectID = ""
|
||||
}
|
||||
}
|
||||
input := c2.CreateListenerInput{
|
||||
Name: getString(params, "name"),
|
||||
Type: getString(params, "type"),
|
||||
@@ -123,6 +131,7 @@ tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登
|
||||
BindPort: int(getFloat64(params, "bind_port")),
|
||||
ProfileID: getString(params, "profile_id"),
|
||||
Remark: getString(params, "remark"),
|
||||
ProjectID: projectID,
|
||||
Config: cfg,
|
||||
CallbackHost: getString(params, "callback_host"),
|
||||
}
|
||||
@@ -260,6 +269,7 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
||||
case "list":
|
||||
filter := database.ListC2SessionsFilter{
|
||||
ListenerID: getString(params, "listener_id"),
|
||||
ProjectID: mcpEffectiveProjectFilter(ctx, m.DB()),
|
||||
Status: getString(params, "status"),
|
||||
OS: getString(params, "os"),
|
||||
Search: getString(params, "search"),
|
||||
@@ -495,6 +505,7 @@ func registerC2TaskManageTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
||||
case "list":
|
||||
filter := database.ListC2TasksFilter{
|
||||
SessionID: getString(params, "session_id"),
|
||||
ProjectID: mcpEffectiveProjectFilter(ctx, m.DB()),
|
||||
Status: getString(params, "status"),
|
||||
}
|
||||
if limit := int(getFloat64(params, "limit")); limit > 0 {
|
||||
@@ -645,6 +656,7 @@ func registerC2EventTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
||||
filter := database.ListC2EventsFilter{
|
||||
Level: getString(params, "level"),
|
||||
Category: getString(params, "category"),
|
||||
ProjectID: mcpEffectiveProjectFilter(ctx, m.DB()),
|
||||
SessionID: getString(params, "session_id"),
|
||||
TaskID: getString(params, "task_id"),
|
||||
Limit: int(getFloat64(params, "limit")),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/authctx"
|
||||
"cyberstrike-ai/internal/c2"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestC2ListenerCreateInheritsConversationProject(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "c2-tools.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
user, err := db.CreateRBACUser("c2-agent", "C2 Agent", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := db.CreateProject(&database.Project{Name: "engagement"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AssignResourceToUser(user.ID, "project", project.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation, err := db.CreateConversation("project chat", database.ConversationCreateMeta{ProjectID: project.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
principal := authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAssigned, map[string]bool{
|
||||
"c2:read": true, "c2:write": true,
|
||||
})
|
||||
ctx := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), conversation.ID), principal)
|
||||
server := mcp.NewServer(zap.NewNop())
|
||||
server.SetToolAuthorizer(mcpToolAuthorizer(db))
|
||||
registerC2Tools(server, c2.NewManager(db, zap.NewNop(), t.TempDir()), zap.NewNop(), 8080)
|
||||
|
||||
result, _, err := server.CallTool(ctx, builtin.ToolC2Listener, map[string]interface{}{
|
||||
"action": "create",
|
||||
"name": "tcp-reverse-2222",
|
||||
"type": "tcp_reverse",
|
||||
"bind_host": "0.0.0.0",
|
||||
"bind_port": 2222,
|
||||
})
|
||||
if err != nil || result == nil || result.IsError {
|
||||
t.Fatalf("create listener result=%#v err=%v text=%q", result, err, toolResultText(result))
|
||||
}
|
||||
|
||||
listeners, err := db.ListC2Listeners()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listeners) != 1 {
|
||||
t.Fatalf("listener count=%d, want 1", len(listeners))
|
||||
}
|
||||
if listeners[0].ProjectID != project.ID {
|
||||
t.Fatalf("listener project_id=%q, want %q", listeners[0].ProjectID, project.ID)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ func mcpToolAuthorizer(db *database.DB) func(context.Context, string, map[string
|
||||
if id == "" || db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), resourceType, id) {
|
||||
return fmt.Errorf("no access to %s %s", resourceType, id)
|
||||
}
|
||||
if err := authorizeMCPProjectResourceBoundary(ctx, db, resourceType, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
toolExecutionResource := func(permission string) error {
|
||||
@@ -141,20 +144,26 @@ func mcpToolAuthorizer(db *database.DB) func(context.Context, string, map[string
|
||||
builtin.ToolBatchTaskScheduleEnabled, builtin.ToolBatchTaskAdd, builtin.ToolBatchTaskUpdate:
|
||||
return resource("tasks:write", "batch_task", "queue_id")
|
||||
case builtin.ToolC2Listener:
|
||||
return authorizeC2Action(principal, db, args, "c2_listener", "listener_id")
|
||||
return authorizeC2Action(ctx, principal, db, args, "c2_listener", "listener_id")
|
||||
case builtin.ToolC2Session, builtin.ToolC2Task, builtin.ToolC2File:
|
||||
if toolName == builtin.ToolC2File && mcpAuthorizationString(args, "action") == "get_result" {
|
||||
return authorizeC2Action(principal, db, args, "c2_task", "task_id")
|
||||
return authorizeC2Action(ctx, principal, db, args, "c2_task", "task_id")
|
||||
}
|
||||
return authorizeC2Action(principal, db, args, "c2_session", "session_id")
|
||||
return authorizeC2Action(ctx, principal, db, args, "c2_session", "session_id")
|
||||
case builtin.ToolC2TaskManage:
|
||||
return authorizeC2Action(principal, db, args, "c2_task", "task_id")
|
||||
return authorizeC2Action(ctx, principal, db, args, "c2_task", "task_id")
|
||||
case builtin.ToolC2Payload:
|
||||
return resource("c2:write", "c2_listener", "listener_id")
|
||||
case builtin.ToolC2Event:
|
||||
if id := mcpAuthorizationString(args, "session_id"); id != "" {
|
||||
return resource("c2:read", "c2_session", "session_id")
|
||||
}
|
||||
if id := mcpAuthorizationString(args, "task_id"); id != "" {
|
||||
return resource("c2:read", "c2_task", "task_id")
|
||||
}
|
||||
if filter := mcpEffectiveProjectFilter(ctx, db); filter != "" {
|
||||
return require("c2:read")
|
||||
}
|
||||
if principal.ScopeFor("c2:read") != database.RBACScopeAll {
|
||||
return fmt.Errorf("unfiltered C2 event list requires global scope")
|
||||
}
|
||||
@@ -207,7 +216,7 @@ func externalMCPToolAuthorizer() func(context.Context, string, map[string]interf
|
||||
}
|
||||
}
|
||||
|
||||
func authorizeC2Action(principal authctx.Principal, db *database.DB, args map[string]interface{}, resourceType, argument string) error {
|
||||
func authorizeC2Action(ctx context.Context, principal authctx.Principal, db *database.DB, args map[string]interface{}, resourceType, argument string) error {
|
||||
action := mcpAuthorizationString(args, "action")
|
||||
permission := "c2:write"
|
||||
if action == "list" || action == "get" || action == "get_result" || action == "wait" {
|
||||
@@ -228,11 +237,27 @@ func authorizeC2Action(principal authctx.Principal, db *database.DB, args map[st
|
||||
if db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), resourceType, candidate) {
|
||||
return fmt.Errorf("no access to %s %s", resourceType, candidate)
|
||||
}
|
||||
if err := authorizeMCPProjectResourceBoundary(ctx, db, resourceType, candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if id == "" {
|
||||
if action == "create" || action == "list" {
|
||||
if action == "create" {
|
||||
projectID := mcpAuthorizationString(args, "project_id")
|
||||
if projectID == "" {
|
||||
projectID = mcpEffectiveProjectFilter(ctx, db)
|
||||
if projectID == database.ProjectFilterUnbound {
|
||||
projectID = ""
|
||||
}
|
||||
}
|
||||
if projectID != "" && (db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID)) {
|
||||
return fmt.Errorf("no access to project %s", projectID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if action == "list" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("missing resource identifier %s", argument)
|
||||
@@ -240,9 +265,93 @@ func authorizeC2Action(principal authctx.Principal, db *database.DB, args map[st
|
||||
if db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), resourceType, id) {
|
||||
return fmt.Errorf("no access to %s %s", resourceType, id)
|
||||
}
|
||||
if err := authorizeMCPProjectResourceBoundary(ctx, db, resourceType, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authorizeMCPProjectResourceBoundary(ctx context.Context, db *database.DB, resourceType, resourceID string) error {
|
||||
filter := mcpEffectiveProjectFilter(ctx, db)
|
||||
if filter == "" || db == nil {
|
||||
return nil
|
||||
}
|
||||
projectID, ok, err := mcpResourceProjectID(db, resourceType, resourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if filter == database.ProjectFilterUnbound {
|
||||
if projectID != "" {
|
||||
return fmt.Errorf("resource %s %s belongs to project %s, current conversation is unbound", resourceType, resourceID, projectID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if projectID != filter {
|
||||
if projectID == "" {
|
||||
return fmt.Errorf("resource %s %s is unbound, current conversation project is %s", resourceType, resourceID, filter)
|
||||
}
|
||||
return fmt.Errorf("resource %s %s belongs to project %s, current conversation project is %s", resourceType, resourceID, projectID, filter)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mcpResourceProjectID(db *database.DB, resourceType, resourceID string) (string, bool, error) {
|
||||
switch resourceType {
|
||||
case "webshell":
|
||||
conn, err := db.GetWebshellConnection(resourceID)
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
if conn == nil {
|
||||
return "", true, fmt.Errorf("webshell not found")
|
||||
}
|
||||
return strings.TrimSpace(conn.ProjectID), true, nil
|
||||
case "c2_listener":
|
||||
listener, err := db.GetC2Listener(resourceID)
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
if listener == nil {
|
||||
return "", true, fmt.Errorf("listener not found")
|
||||
}
|
||||
return strings.TrimSpace(listener.ProjectID), true, nil
|
||||
case "c2_session":
|
||||
session, err := db.GetC2Session(resourceID)
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
if session == nil {
|
||||
return "", true, fmt.Errorf("session not found")
|
||||
}
|
||||
return mcpResourceProjectID(db, "c2_listener", session.ListenerID)
|
||||
case "c2_task":
|
||||
task, err := db.GetC2Task(resourceID)
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
if task == nil {
|
||||
return "", true, fmt.Errorf("task not found")
|
||||
}
|
||||
return mcpResourceProjectIDFromC2Session(db, task.SessionID)
|
||||
default:
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func mcpResourceProjectIDFromC2Session(db *database.DB, sessionID string) (string, bool, error) {
|
||||
session, err := db.GetC2Session(sessionID)
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
if session == nil {
|
||||
return "", true, fmt.Errorf("session not found")
|
||||
}
|
||||
return mcpResourceProjectID(db, "c2_listener", session.ListenerID)
|
||||
}
|
||||
|
||||
func mcpAuthorizationStrings(args map[string]interface{}, key string) []string {
|
||||
values := []string{}
|
||||
switch raw := args[key].(type) {
|
||||
@@ -273,7 +382,13 @@ func authorizeProjectTool(ctx context.Context, principal authctx.Principal, db *
|
||||
return fmt.Errorf("no access to conversation %s", 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 nil
|
||||
|
||||
@@ -49,6 +49,91 @@ func TestMCPToolAuthorizerEnforcesPermissionAndResource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPToolAuthorizerEnforcesConversationProjectBoundary(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "mcp-project-boundary.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
user, err := db.CreateRBACUser("boundary-user", "Boundary User", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := db.CreateProject(&database.Project{Name: "Project 123"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectConv, err := db.CreateConversation("project conversation", database.ConversationCreateMeta{ProjectID: project.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unboundConv, err := db.CreateConversation("unbound conversation", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wsProject := database.WebShellConnection{ID: "ws_project", ProjectID: project.ID, URL: "http://127.0.0.1/project.php", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
wsUnbound := database.WebShellConnection{ID: "ws_unbound", URL: "http://127.0.0.1/unbound.php", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
if err := db.CreateWebshellConnection(&wsProject); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateWebshellConnection(&wsUnbound); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{wsProject.ID, wsUnbound.ID} {
|
||||
if err := db.AssignResourceToUser(user.ID, "webshell", id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
listener := &database.C2Listener{ID: "l_project", ProjectID: project.ID, Name: "project listener", Type: "tcp_reverse", BindHost: "127.0.0.1", BindPort: 5555, OwnerUserID: user.ID, CreatedAt: now}
|
||||
if err := db.CreateC2Listener(listener); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AssignResourceToUser(user.ID, "c2_listener", listener.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := &database.C2Session{ID: "s_project", ListenerID: listener.ID, ImplantUUID: "implant-project", Status: "active", FirstSeenAt: now, LastCheckIn: now}
|
||||
if err := db.UpsertC2Session(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
principal := authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAssigned, map[string]bool{
|
||||
"webshell:read": true, "webshell:write": true,
|
||||
"c2:read": true, "c2:write": true,
|
||||
})
|
||||
authorize := mcpToolAuthorizer(db)
|
||||
unboundCtx := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), unboundConv.ID), principal)
|
||||
projectCtx := authctx.WithPrincipal(mcp.WithMCPProjectID(mcp.WithMCPConversationID(context.Background(), projectConv.ID), project.ID), principal)
|
||||
projectCtxFromConversationOnly := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), projectConv.ID), principal)
|
||||
|
||||
if err := authorize(unboundCtx, builtin.ToolWebshellExec, map[string]interface{}{"connection_id": wsProject.ID}); err == nil {
|
||||
t.Fatal("unbound conversation was allowed to use project-bound webshell")
|
||||
}
|
||||
if err := authorize(unboundCtx, builtin.ToolWebshellExec, map[string]interface{}{"connection_id": wsUnbound.ID}); err != nil {
|
||||
t.Fatalf("unbound webshell denied in unbound conversation: %v", err)
|
||||
}
|
||||
if err := authorize(projectCtx, builtin.ToolWebshellExec, map[string]interface{}{"connection_id": wsProject.ID}); err != nil {
|
||||
t.Fatalf("project webshell denied in project conversation: %v", err)
|
||||
}
|
||||
if err := authorize(projectCtxFromConversationOnly, builtin.ToolWebshellExec, map[string]interface{}{"connection_id": wsProject.ID}); err != nil {
|
||||
t.Fatalf("project webshell denied when only conversation id is present: %v", err)
|
||||
}
|
||||
if err := authorize(projectCtx, builtin.ToolWebshellExec, map[string]interface{}{"connection_id": wsUnbound.ID}); err == nil {
|
||||
t.Fatal("project conversation was allowed to use unbound webshell by id")
|
||||
}
|
||||
if err := authorize(unboundCtx, builtin.ToolC2Session, map[string]interface{}{"action": "get", "session_id": session.ID}); err == nil {
|
||||
t.Fatal("unbound conversation was allowed to use project-bound c2 session")
|
||||
}
|
||||
if err := authorize(projectCtx, builtin.ToolC2Session, map[string]interface{}{"action": "get", "session_id": session.ID}); err != nil {
|
||||
t.Fatalf("project c2 session denied in project conversation: %v", err)
|
||||
}
|
||||
if err := authorize(projectCtxFromConversationOnly, builtin.ToolC2Session, map[string]interface{}{"action": "get", "session_id": session.ID}); err != nil {
|
||||
t.Fatalf("project c2 session denied when only conversation id is present: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryBuiltinMCPToolHasExplicitAuthorizationPolicy(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "mcp-policy-inventory.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
)
|
||||
|
||||
func mcpEffectiveProjectFilter(ctx context.Context, db *database.DB) string {
|
||||
if projectID := strings.TrimSpace(mcp.MCPProjectIDFromContext(ctx)); projectID != "" {
|
||||
return projectID
|
||||
}
|
||||
if conversationID := mcpAuthorizationConversationID(ctx); conversationID != "" {
|
||||
if db != nil {
|
||||
if projectID, err := db.GetConversationProjectID(conversationID); err == nil {
|
||||
if projectID = strings.TrimSpace(projectID); projectID != "" {
|
||||
return projectID
|
||||
}
|
||||
}
|
||||
}
|
||||
return database.ProjectFilterUnbound
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -34,9 +34,9 @@ type Manager struct {
|
||||
runningListeners map[string]Listener // listener_id → 已 Start 的 listener 实例
|
||||
storageDir string // 大结果(截图/下载)落盘根目录
|
||||
|
||||
hitlBridge HITLBridge // 危险任务在 EnqueueTask 时调它发起审批(nil 表示不接 HITL)
|
||||
hitlBridge HITLBridge // 危险任务在 EnqueueTask 时调它发起审批(nil 表示不接 HITL)
|
||||
hitlDangerousGate func(conversationID, mcpToolName string) bool // 与人机协同一致:为 nil 或返回 false 时不走桥
|
||||
hooks Hooks // 扩展挂钩:会话上线 / 任务完成 时通知漏洞库与攻击链
|
||||
hooks Hooks // 扩展挂钩:会话上线 / 任务完成 时通知漏洞库与攻击链
|
||||
}
|
||||
|
||||
// MCPToolC2Task 与 MCP builtin、c2_task 工具名一致,供 HITL 白名单与 Agent 侧对齐。
|
||||
@@ -63,7 +63,7 @@ type HITLApprovalRequest struct {
|
||||
|
||||
// Hooks 给上层(漏洞管理 / 攻击链)注入回调
|
||||
type Hooks struct {
|
||||
OnSessionFirstSeen func(session *database.C2Session) // 新会话首次上线
|
||||
OnSessionFirstSeen func(session *database.C2Session) // 新会话首次上线
|
||||
OnTaskCompleted func(task *database.C2Task, sessionID string) // 任务完成(success/failed)
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ func (m *Manager) Close() {
|
||||
// CreateListenerInput Web/MCP 创建监听器的入参(已校验 + 已 trim)
|
||||
type CreateListenerInput struct {
|
||||
Name string
|
||||
ProjectID string
|
||||
Type string
|
||||
BindHost string
|
||||
BindPort int
|
||||
@@ -195,6 +196,7 @@ func (m *Manager) CreateListener(in CreateListenerInput) (*database.C2Listener,
|
||||
|
||||
listener := &database.C2Listener{
|
||||
ID: "l_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14],
|
||||
ProjectID: strings.TrimSpace(in.ProjectID),
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Type: strings.ToLower(strings.TrimSpace(in.Type)),
|
||||
BindHost: bindHost,
|
||||
|
||||
+140
-6
@@ -21,7 +21,8 @@ type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
MCP MCPConfig `yaml:"mcp"`
|
||||
OpenAI OpenAIConfig `yaml:"openai"`
|
||||
AI AIConfig `yaml:"ai,omitempty" json:"ai,omitempty"`
|
||||
OpenAI OpenAIConfig `yaml:"openai,omitempty" json:"openai,omitempty"`
|
||||
FOFA FofaConfig `yaml:"fofa,omitempty" json:"fofa,omitempty"`
|
||||
ZoomEye SpaceSearchConfig `yaml:"zoomeye,omitempty" json:"zoomeye,omitempty"`
|
||||
Quake SpaceSearchConfig `yaml:"quake,omitempty" json:"quake,omitempty"`
|
||||
@@ -840,6 +841,130 @@ type OpenAIConfig struct {
|
||||
Reasoning OpenAIReasoningConfig `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
|
||||
}
|
||||
|
||||
// AIConfig stores first-class model channels. Runtime callers resolve a channel
|
||||
// into OpenAIConfig at the edge instead of moving API credentials through chat requests.
|
||||
type AIConfig struct {
|
||||
DefaultChannel string `yaml:"default_channel,omitempty" json:"default_channel,omitempty"`
|
||||
Channels map[string]AIChannelConfig `yaml:"channels,omitempty" json:"channels,omitempty"`
|
||||
}
|
||||
|
||||
type AIChannelConfig struct {
|
||||
Name string `yaml:"name,omitempty" json:"name,omitempty"`
|
||||
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
|
||||
APIKey string `yaml:"api_key" json:"api_key"`
|
||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||
Model string `yaml:"model" json:"model"`
|
||||
MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"`
|
||||
MaxCompletionTokens int `yaml:"max_completion_tokens,omitempty" json:"max_completion_tokens,omitempty"`
|
||||
Reasoning OpenAIReasoningConfig `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
|
||||
}
|
||||
|
||||
func (c AIChannelConfig) ToOpenAIConfig() OpenAIConfig {
|
||||
provider := strings.TrimSpace(c.Provider)
|
||||
if provider == "" || provider == "openai_compatible" {
|
||||
provider = "openai"
|
||||
}
|
||||
return OpenAIConfig{
|
||||
Provider: provider,
|
||||
APIKey: c.APIKey,
|
||||
BaseURL: c.BaseURL,
|
||||
Model: c.Model,
|
||||
MaxTotalTokens: c.MaxTotalTokens,
|
||||
MaxCompletionTokens: c.MaxCompletionTokens,
|
||||
Reasoning: c.Reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
func AIChannelFromOpenAI(id, name string, oa OpenAIConfig) AIChannelConfig {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = id
|
||||
}
|
||||
return AIChannelConfig{
|
||||
Name: name,
|
||||
Provider: oa.Provider,
|
||||
APIKey: oa.APIKey,
|
||||
BaseURL: oa.BaseURL,
|
||||
Model: oa.Model,
|
||||
MaxTotalTokens: oa.MaxTotalTokens,
|
||||
MaxCompletionTokens: oa.MaxCompletionTokens,
|
||||
Reasoning: oa.Reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeAIChannelID(s string) string {
|
||||
id := strings.ToLower(strings.TrimSpace(s))
|
||||
id = strings.ReplaceAll(id, "_", "-")
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range id {
|
||||
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
||||
if ok {
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if out == "" {
|
||||
return "default"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *AIConfig) EnsureDefaultFromOpenAI(openAI OpenAIConfig) {
|
||||
if c.Channels == nil {
|
||||
c.Channels = make(map[string]AIChannelConfig)
|
||||
}
|
||||
def := NormalizeAIChannelID(c.DefaultChannel)
|
||||
if def == "default" && strings.TrimSpace(c.DefaultChannel) == "" {
|
||||
def = "default"
|
||||
}
|
||||
c.DefaultChannel = def
|
||||
if _, ok := c.Channels[def]; !ok {
|
||||
c.Channels[def] = AIChannelFromOpenAI(def, "Default", openAI)
|
||||
}
|
||||
}
|
||||
|
||||
func (c AIConfig) ResolveChannel(channelID string) (OpenAIConfig, string, bool) {
|
||||
id := NormalizeAIChannelID(channelID)
|
||||
if strings.TrimSpace(channelID) == "" {
|
||||
id = NormalizeAIChannelID(c.DefaultChannel)
|
||||
}
|
||||
if id == "" {
|
||||
id = "default"
|
||||
}
|
||||
if c.Channels != nil {
|
||||
if ch, ok := c.Channels[id]; ok {
|
||||
return ch.ToOpenAIConfig(), id, true
|
||||
}
|
||||
}
|
||||
return OpenAIConfig{}, id, false
|
||||
}
|
||||
|
||||
func (c *Config) ResolveAIChannel(channelID string) (OpenAIConfig, string, bool) {
|
||||
if c == nil {
|
||||
return OpenAIConfig{}, "", false
|
||||
}
|
||||
if oa, id, ok := c.AI.ResolveChannel(channelID); ok {
|
||||
return oa, id, true
|
||||
}
|
||||
return c.OpenAI, NormalizeAIChannelID(channelID), strings.TrimSpace(c.OpenAI.Model) != "" || strings.TrimSpace(c.OpenAI.BaseURL) != ""
|
||||
}
|
||||
|
||||
func (c *Config) ApplyDefaultAIChannel() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.AI.EnsureDefaultFromOpenAI(c.OpenAI)
|
||||
if oa, _, ok := c.AI.ResolveChannel(c.AI.DefaultChannel); ok {
|
||||
c.OpenAI = oa
|
||||
}
|
||||
}
|
||||
|
||||
func (c OpenAIConfig) MaxCompletionTokensEffective() int {
|
||||
if c.MaxCompletionTokens > 0 {
|
||||
return c.MaxCompletionTokens
|
||||
@@ -1224,6 +1349,7 @@ func Load(path string) (*Config, error) {
|
||||
if cfg.Audit.MaxDetailBytes <= 0 {
|
||||
cfg.Audit.MaxDetailBytes = 8192
|
||||
}
|
||||
cfg.ApplyDefaultAIChannel()
|
||||
if err := validateModelOutputLimits(cfg.OpenAI, cfg.MultiAgent.EinoMiddleware); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1720,12 +1846,20 @@ func Default() *Config {
|
||||
Host: "127.0.0.1",
|
||||
Port: 8081,
|
||||
},
|
||||
OpenAI: OpenAIConfig{
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-4",
|
||||
MaxTotalTokens: 120000,
|
||||
MaxCompletionTokens: DefaultMaxCompletionTokens,
|
||||
AI: AIConfig{
|
||||
DefaultChannel: "default",
|
||||
Channels: map[string]AIChannelConfig{
|
||||
"default": {
|
||||
Name: "Default",
|
||||
Provider: "openai_compatible",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-4",
|
||||
MaxTotalTokens: 120000,
|
||||
MaxCompletionTokens: DefaultMaxCompletionTokens,
|
||||
},
|
||||
},
|
||||
},
|
||||
OpenAI: OpenAIConfig{},
|
||||
Agent: AgentConfig{
|
||||
MaxIterations: 30, // 默认最大迭代次数
|
||||
ToolTimeoutMinutes: 10, // 单次工具执行默认最多 10 分钟,避免异常长时间占用
|
||||
|
||||
@@ -95,6 +95,48 @@ func TestHitlAuditModelEffectiveFallsBackToMainConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesAIDefaultChannelAsRuntimeOpenAI(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
initial := strings.Join([]string{
|
||||
"ai:",
|
||||
" default_channel: deepseek",
|
||||
" channels:",
|
||||
" qwen:",
|
||||
" name: Qwen",
|
||||
" provider: openai_compatible",
|
||||
" base_url: https://dashscope.example/v1",
|
||||
" api_key: qwen-key",
|
||||
" model: qwen-max",
|
||||
" deepseek:",
|
||||
" name: DeepSeek",
|
||||
" provider: openai_compatible",
|
||||
" base_url: https://deepseek.example/v1",
|
||||
" api_key: deepseek-key",
|
||||
" model: deepseek-chat",
|
||||
" max_total_tokens: 64000",
|
||||
"server:",
|
||||
" host: 127.0.0.1",
|
||||
" port: 8080",
|
||||
"",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(path, []byte(initial), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.OpenAI.Model != "deepseek-chat" || cfg.OpenAI.APIKey != "deepseek-key" || cfg.OpenAI.MaxTotalTokens != 64000 {
|
||||
t.Fatalf("runtime OpenAI config did not follow ai.default_channel: %+v", cfg.OpenAI)
|
||||
}
|
||||
oa, id, ok := cfg.ResolveAIChannel("qwen")
|
||||
if !ok || id != "qwen" || oa.Model != "qwen-max" || oa.APIKey != "qwen-key" {
|
||||
t.Fatalf("ResolveAIChannel(qwen) = (%+v, %q, %v)", oa, id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizationUserIntentLedgerRunesEffective(t *testing.T) {
|
||||
var zero MultiAgentEinoMiddlewareConfig
|
||||
if got := zero.SummarizationUserIntentLedgerMaxRunesEffective(); got != DefaultSummarizationUserIntentLedgerMaxRunes {
|
||||
|
||||
+152
-14
@@ -46,6 +46,7 @@ func validC2TextIDForDelete(id string) bool {
|
||||
// C2Listener 监听器实体
|
||||
type C2Listener struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // tcp_reverse|http_beacon|https_beacon|websocket|dns
|
||||
BindHost string `json:"bindHost"` // 默认 127.0.0.1
|
||||
@@ -165,12 +166,12 @@ func (db *DB) CreateC2Listener(l *C2Listener) error {
|
||||
l.ConfigJSON = "{}"
|
||||
}
|
||||
query := `
|
||||
INSERT INTO c2_listeners (id, name, type, bind_host, bind_port, profile_id, encryption_key,
|
||||
INSERT INTO c2_listeners (id, project_id, name, type, bind_host, bind_port, profile_id, encryption_key,
|
||||
implant_token, status, config_json, remark, owner_user_id, created_at, started_at, last_error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
_, err := db.Exec(query,
|
||||
l.ID, l.Name, l.Type, l.BindHost, l.BindPort, l.ProfileID, l.EncryptionKey,
|
||||
l.ID, strings.TrimSpace(l.ProjectID), l.Name, l.Type, l.BindHost, l.BindPort, l.ProfileID, l.EncryptionKey,
|
||||
l.ImplantToken, l.Status, l.ConfigJSON, l.Remark, l.OwnerUserID, l.CreatedAt, l.StartedAt, l.LastError,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -190,12 +191,12 @@ func (db *DB) UpdateC2Listener(l *C2Listener) error {
|
||||
}
|
||||
query := `
|
||||
UPDATE c2_listeners SET
|
||||
name = ?, type = ?, bind_host = ?, bind_port = ?, profile_id = ?, encryption_key = ?,
|
||||
project_id = ?, name = ?, type = ?, bind_host = ?, bind_port = ?, profile_id = ?, encryption_key = ?,
|
||||
implant_token = ?, status = ?, config_json = ?, remark = ?, owner_user_id = ?, started_at = ?, last_error = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
res, err := db.Exec(query,
|
||||
l.Name, l.Type, l.BindHost, l.BindPort, l.ProfileID, l.EncryptionKey,
|
||||
strings.TrimSpace(l.ProjectID), l.Name, l.Type, l.BindHost, l.BindPort, l.ProfileID, l.EncryptionKey,
|
||||
l.ImplantToken, l.Status, l.ConfigJSON, l.Remark, l.OwnerUserID, l.StartedAt, l.LastError, l.ID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -229,7 +230,7 @@ func (db *DB) SetC2ListenerStatus(id, status, lastError string, startedAt *time.
|
||||
// GetC2Listener 单条查询
|
||||
func (db *DB) GetC2Listener(id string) (*C2Listener, error) {
|
||||
query := `
|
||||
SELECT id, name, type, bind_host, bind_port, COALESCE(profile_id, ''),
|
||||
SELECT id, COALESCE(project_id, ''), name, type, bind_host, bind_port, COALESCE(profile_id, ''),
|
||||
COALESCE(encryption_key, ''), COALESCE(implant_token, ''), status,
|
||||
COALESCE(config_json, '{}'), COALESCE(remark, ''),
|
||||
COALESCE(owner_user_id, ''), created_at, started_at, COALESCE(last_error, '')
|
||||
@@ -238,7 +239,7 @@ func (db *DB) GetC2Listener(id string) (*C2Listener, error) {
|
||||
var l C2Listener
|
||||
var startedAt sql.NullTime
|
||||
err := db.QueryRow(query, id).Scan(
|
||||
&l.ID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
|
||||
&l.ID, &l.ProjectID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
|
||||
&l.EncryptionKey, &l.ImplantToken, &l.Status,
|
||||
&l.ConfigJSON, &l.Remark,
|
||||
&l.OwnerUserID, &l.CreatedAt, &startedAt, &l.LastError,
|
||||
@@ -259,7 +260,7 @@ func (db *DB) GetC2Listener(id string) (*C2Listener, error) {
|
||||
// ListC2Listeners 全量列表,按创建时间倒序
|
||||
func (db *DB) ListC2Listeners() ([]*C2Listener, error) {
|
||||
query := `
|
||||
SELECT id, name, type, bind_host, bind_port, COALESCE(profile_id, ''),
|
||||
SELECT id, COALESCE(project_id, ''), name, type, bind_host, bind_port, COALESCE(profile_id, ''),
|
||||
COALESCE(encryption_key, ''), COALESCE(implant_token, ''), status,
|
||||
COALESCE(config_json, '{}'), COALESCE(remark, ''),
|
||||
COALESCE(owner_user_id, ''), created_at, started_at, COALESCE(last_error, '')
|
||||
@@ -275,7 +276,7 @@ func (db *DB) ListC2Listeners() ([]*C2Listener, error) {
|
||||
var l C2Listener
|
||||
var startedAt sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&l.ID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
|
||||
&l.ID, &l.ProjectID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
|
||||
&l.EncryptionKey, &l.ImplantToken, &l.Status,
|
||||
&l.ConfigJSON, &l.Remark,
|
||||
&l.OwnerUserID, &l.CreatedAt, &startedAt, &l.LastError,
|
||||
@@ -293,12 +294,18 @@ func (db *DB) ListC2Listeners() ([]*C2Listener, error) {
|
||||
}
|
||||
|
||||
// ListC2ListenersForAccess lists listeners visible to the resolved RBAC scope.
|
||||
func (db *DB) ListC2ListenersForAccess(access RBACListAccess) ([]*C2Listener, error) {
|
||||
func (db *DB) ListC2ListenersForAccess(access RBACListAccess, projectID string) ([]*C2Listener, error) {
|
||||
conditions := []string{"1=1"}
|
||||
args := []interface{}{}
|
||||
if projectID = strings.TrimSpace(projectID); projectID == ProjectFilterUnbound {
|
||||
conditions = append(conditions, "COALESCE(project_id, '') = ''")
|
||||
} else if projectID != "" {
|
||||
conditions = append(conditions, "COALESCE(project_id, '') = ?")
|
||||
args = append(args, projectID)
|
||||
}
|
||||
appendC2ListenerAccessFilter(&conditions, &args, access)
|
||||
query := `
|
||||
SELECT id, name, type, bind_host, bind_port, COALESCE(profile_id, ''),
|
||||
SELECT id, COALESCE(project_id, ''), name, type, bind_host, bind_port, COALESCE(profile_id, ''),
|
||||
COALESCE(encryption_key, ''), COALESCE(implant_token, ''), status,
|
||||
COALESCE(config_json, '{}'), COALESCE(remark, ''),
|
||||
COALESCE(owner_user_id, ''), created_at, started_at, COALESCE(last_error, '')
|
||||
@@ -316,7 +323,7 @@ func (db *DB) ListC2ListenersForAccess(access RBACListAccess) ([]*C2Listener, er
|
||||
var l C2Listener
|
||||
var startedAt sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&l.ID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
|
||||
&l.ID, &l.ProjectID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
|
||||
&l.EncryptionKey, &l.ImplantToken, &l.Status,
|
||||
&l.ConfigJSON, &l.Remark, &l.OwnerUserID,
|
||||
&l.CreatedAt, &startedAt, &l.LastError,
|
||||
@@ -535,6 +542,7 @@ func (db *DB) queryC2SessionWhere(whereClause string, args ...interface{}) (*C2S
|
||||
// ListC2SessionsFilter 列表过滤参数
|
||||
type ListC2SessionsFilter struct {
|
||||
ListenerID string
|
||||
ProjectID string
|
||||
Status string // active|sleeping|dead|killed;空表示全部
|
||||
OS string
|
||||
Search string // 模糊匹配 hostname/username/internal_ip
|
||||
@@ -550,6 +558,18 @@ func (db *DB) ListC2Sessions(filter ListC2SessionsFilter) ([]*C2Session, error)
|
||||
conditions = append(conditions, "listener_id = ?")
|
||||
args = append(args, filter.ListenerID)
|
||||
}
|
||||
if strings.TrimSpace(filter.ProjectID) == ProjectFilterUnbound {
|
||||
conditions = append(conditions, `EXISTS (
|
||||
SELECT 1 FROM c2_listeners l
|
||||
WHERE l.id = c2_sessions.listener_id AND COALESCE(l.project_id, '') = ''
|
||||
)`)
|
||||
} else if strings.TrimSpace(filter.ProjectID) != "" {
|
||||
conditions = append(conditions, `EXISTS (
|
||||
SELECT 1 FROM c2_listeners l
|
||||
WHERE l.id = c2_sessions.listener_id AND COALESCE(l.project_id, '') = ?
|
||||
)`)
|
||||
args = append(args, strings.TrimSpace(filter.ProjectID))
|
||||
}
|
||||
if filter.Status != "" {
|
||||
conditions = append(conditions, "status = ?")
|
||||
args = append(args, filter.Status)
|
||||
@@ -645,6 +665,18 @@ func buildC2SessionsWhere(filter ListC2SessionsFilter) ([]string, []interface{})
|
||||
conditions = append(conditions, "listener_id = ?")
|
||||
args = append(args, filter.ListenerID)
|
||||
}
|
||||
if strings.TrimSpace(filter.ProjectID) == ProjectFilterUnbound {
|
||||
conditions = append(conditions, `EXISTS (
|
||||
SELECT 1 FROM c2_listeners l
|
||||
WHERE l.id = c2_sessions.listener_id AND COALESCE(l.project_id, '') = ''
|
||||
)`)
|
||||
} else if strings.TrimSpace(filter.ProjectID) != "" {
|
||||
conditions = append(conditions, `EXISTS (
|
||||
SELECT 1 FROM c2_listeners l
|
||||
WHERE l.id = c2_sessions.listener_id AND COALESCE(l.project_id, '') = ?
|
||||
)`)
|
||||
args = append(args, strings.TrimSpace(filter.ProjectID))
|
||||
}
|
||||
if filter.Status != "" {
|
||||
conditions = append(conditions, "status = ?")
|
||||
args = append(args, filter.Status)
|
||||
@@ -947,7 +979,10 @@ func (db *DB) GetC2Task(id string) (*C2Task, error) {
|
||||
// ListC2TasksFilter 任务过滤
|
||||
type ListC2TasksFilter struct {
|
||||
SessionID string
|
||||
ProjectID string
|
||||
Status string
|
||||
TaskType string
|
||||
Since *time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
@@ -959,10 +994,32 @@ func buildC2TasksWhere(filter ListC2TasksFilter) (where string, args []interface
|
||||
conditions = append(conditions, "session_id = ?")
|
||||
args = append(args, filter.SessionID)
|
||||
}
|
||||
if strings.TrimSpace(filter.ProjectID) == ProjectFilterUnbound {
|
||||
conditions = append(conditions, `EXISTS (
|
||||
SELECT 1 FROM c2_sessions s
|
||||
JOIN c2_listeners l ON l.id = s.listener_id
|
||||
WHERE s.id = c2_tasks.session_id AND COALESCE(l.project_id, '') = ''
|
||||
)`)
|
||||
} else if strings.TrimSpace(filter.ProjectID) != "" {
|
||||
conditions = append(conditions, `EXISTS (
|
||||
SELECT 1 FROM c2_sessions s
|
||||
JOIN c2_listeners l ON l.id = s.listener_id
|
||||
WHERE s.id = c2_tasks.session_id AND COALESCE(l.project_id, '') = ?
|
||||
)`)
|
||||
args = append(args, strings.TrimSpace(filter.ProjectID))
|
||||
}
|
||||
if filter.Status != "" {
|
||||
conditions = append(conditions, "status = ?")
|
||||
args = append(args, filter.Status)
|
||||
}
|
||||
if strings.TrimSpace(filter.TaskType) != "" {
|
||||
conditions = append(conditions, "task_type = ?")
|
||||
args = append(args, strings.TrimSpace(filter.TaskType))
|
||||
}
|
||||
if filter.Since != nil {
|
||||
conditions = append(conditions, sqliteEpochGE("created_at", ">="))
|
||||
args = append(args, formatSQLiteUTC(*filter.Since))
|
||||
}
|
||||
return strings.Join(conditions, " AND "), args
|
||||
}
|
||||
|
||||
@@ -1016,6 +1073,43 @@ func (db *DB) CountC2TasksForAccess(filter ListC2TasksFilter, access RBACListAcc
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CountC2TasksByStatusForAccess 与 ListC2Tasks 相同过滤条件下按状态统计
|
||||
func (db *DB) CountC2TasksByStatusForAccess(filter ListC2TasksFilter, access RBACListAccess) (map[string]int64, error) {
|
||||
where, args := buildC2TasksWhereForAccess(filter, access)
|
||||
query := `SELECT status, COUNT(*) FROM c2_tasks WHERE ` + where + ` GROUP BY status`
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
counts := map[string]int64{
|
||||
"queued": 0,
|
||||
"sent": 0,
|
||||
"running": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"cancelled": 0,
|
||||
"pending": 0,
|
||||
}
|
||||
var legacyPending int64
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var n int64
|
||||
if err := rows.Scan(&status, &n); err != nil {
|
||||
continue
|
||||
}
|
||||
if status == "pending" {
|
||||
legacyPending = n
|
||||
continue
|
||||
}
|
||||
if _, ok := counts[status]; ok {
|
||||
counts[status] = n
|
||||
}
|
||||
}
|
||||
counts["pending"] = counts["queued"] + counts["sent"] + counts["running"] + legacyPending
|
||||
return counts, rows.Err()
|
||||
}
|
||||
|
||||
// CountC2TasksQueuedOrPending 统计 queued/pending 状态任务数(仪表盘「待审任务」)
|
||||
func (db *DB) CountC2TasksQueuedOrPending(sessionID string) (int64, error) {
|
||||
conditions := []string{"status IN ('queued', 'pending')"}
|
||||
@@ -1030,8 +1124,8 @@ func (db *DB) CountC2TasksQueuedOrPending(sessionID string) (int64, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (db *DB) CountC2TasksQueuedOrPendingForAccess(sessionID string, access RBACListAccess) (int64, error) {
|
||||
filter := ListC2TasksFilter{SessionID: sessionID}
|
||||
func (db *DB) CountC2TasksQueuedOrPendingForAccess(sessionID, projectID string, access RBACListAccess) (int64, error) {
|
||||
filter := ListC2TasksFilter{SessionID: sessionID, ProjectID: projectID}
|
||||
where, args := buildC2TasksWhereForAccess(filter, access)
|
||||
query := `SELECT COUNT(*) FROM c2_tasks WHERE status IN ('queued', 'pending') AND ` + where
|
||||
var n int64
|
||||
@@ -1412,6 +1506,7 @@ func (db *DB) AppendC2Event(e *C2Event) error {
|
||||
type ListC2EventsFilter struct {
|
||||
Level string
|
||||
Category string
|
||||
ProjectID string
|
||||
SessionID string
|
||||
TaskID string
|
||||
Since *time.Time
|
||||
@@ -1430,6 +1525,49 @@ func buildC2EventsWhere(filter ListC2EventsFilter) (where string, args []interfa
|
||||
conditions = append(conditions, "category = ?")
|
||||
args = append(args, filter.Category)
|
||||
}
|
||||
if strings.TrimSpace(filter.ProjectID) == ProjectFilterUnbound {
|
||||
conditions = append(conditions, `(
|
||||
EXISTS (
|
||||
SELECT 1 FROM c2_sessions s
|
||||
JOIN c2_listeners l ON l.id = s.listener_id
|
||||
WHERE s.id = c2_events.session_id AND COALESCE(l.project_id, '') = ''
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM c2_tasks t
|
||||
JOIN c2_sessions s ON s.id = t.session_id
|
||||
JOIN c2_listeners l ON l.id = s.listener_id
|
||||
WHERE t.id = c2_events.task_id AND COALESCE(l.project_id, '') = ''
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM c2_listeners l
|
||||
WHERE json_valid(c2_events.data_json)
|
||||
AND l.id = json_extract(c2_events.data_json, '$.listener_id')
|
||||
AND COALESCE(l.project_id, '') = ''
|
||||
)
|
||||
)`)
|
||||
} else if strings.TrimSpace(filter.ProjectID) != "" {
|
||||
conditions = append(conditions, `(
|
||||
EXISTS (
|
||||
SELECT 1 FROM c2_sessions s
|
||||
JOIN c2_listeners l ON l.id = s.listener_id
|
||||
WHERE s.id = c2_events.session_id AND COALESCE(l.project_id, '') = ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM c2_tasks t
|
||||
JOIN c2_sessions s ON s.id = t.session_id
|
||||
JOIN c2_listeners l ON l.id = s.listener_id
|
||||
WHERE t.id = c2_events.task_id AND COALESCE(l.project_id, '') = ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM c2_listeners l
|
||||
WHERE json_valid(c2_events.data_json)
|
||||
AND l.id = json_extract(c2_events.data_json, '$.listener_id')
|
||||
AND COALESCE(l.project_id, '') = ?
|
||||
)
|
||||
)`)
|
||||
pid := strings.TrimSpace(filter.ProjectID)
|
||||
args = append(args, pid, pid, pid)
|
||||
}
|
||||
if filter.SessionID != "" {
|
||||
conditions = append(conditions, "session_id = ?")
|
||||
args = append(args, filter.SessionID)
|
||||
|
||||
@@ -23,6 +23,7 @@ type Conversation struct {
|
||||
Title string `json:"title"`
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
RoleName string `json:"roleName,omitempty"`
|
||||
AgentMode string `json:"agentMode,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
@@ -59,29 +60,30 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
|
||||
}
|
||||
}
|
||||
roleName := normalizeConversationRoleName(meta.RoleName)
|
||||
agentMode := normalizeConversationAgentMode(meta.AgentMode)
|
||||
|
||||
var err error
|
||||
wsID := strings.TrimSpace(webshellConnectionID)
|
||||
switch {
|
||||
case wsID != "" && projectID != "":
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id, role_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
id, title, now, now, wsID, projectID, roleName,
|
||||
"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, agentMode,
|
||||
)
|
||||
case wsID != "":
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, role_name) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
id, title, now, now, wsID, roleName,
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
id, title, now, now, wsID, roleName, agentMode,
|
||||
)
|
||||
case projectID != "":
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, project_id, role_name) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
id, title, now, now, projectID, roleName,
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, project_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
id, title, now, now, projectID, roleName, agentMode,
|
||||
)
|
||||
default:
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, role_name) VALUES (?, ?, ?, ?, ?)",
|
||||
id, title, now, now, roleName,
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
id, title, now, now, roleName, agentMode,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -93,6 +95,7 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
|
||||
Title: title,
|
||||
ProjectID: projectID,
|
||||
RoleName: roleName,
|
||||
AgentMode: agentMode,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
@@ -240,10 +243,11 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
||||
|
||||
var projectID sql.NullString
|
||||
var roleName sql.NullString
|
||||
var agentMode sql.NullString
|
||||
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,
|
||||
).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 == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("对话不存在")
|
||||
@@ -256,6 +260,9 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
||||
if roleName.Valid {
|
||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||
}
|
||||
if agentMode.Valid {
|
||||
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||
}
|
||||
|
||||
// 尝试多种时间格式解析
|
||||
var err1, err2 error
|
||||
@@ -330,10 +337,11 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
||||
|
||||
var projectID sql.NullString
|
||||
var roleName sql.NullString
|
||||
var agentMode sql.NullString
|
||||
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,
|
||||
).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 == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("对话不存在")
|
||||
@@ -346,6 +354,9 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
||||
if roleName.Valid {
|
||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||
}
|
||||
if agentMode.Valid {
|
||||
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||
}
|
||||
|
||||
// 尝试多种时间格式解析
|
||||
var err1, err2 error
|
||||
@@ -384,6 +395,17 @@ func normalizeConversationRoleName(roleName string) string {
|
||||
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 {
|
||||
roleName = normalizeConversationRoleName(roleName)
|
||||
_, err := db.Exec(
|
||||
@@ -396,6 +418,18 @@ func (db *DB) SetConversationRoleName(id, roleName string) error {
|
||||
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 {
|
||||
if alias != "" {
|
||||
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")
|
||||
args = append(args, limit, offset)
|
||||
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+`
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`,
|
||||
@@ -536,7 +570,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
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...,
|
||||
)
|
||||
}
|
||||
@@ -564,7 +598,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
|
||||
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
||||
args = append(args, limit, offset)
|
||||
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+`
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`, args...)
|
||||
@@ -579,7 +613,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
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...)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -597,7 +631,8 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
|
||||
var pinned int
|
||||
var projectID 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)
|
||||
}
|
||||
if projectID.Valid {
|
||||
@@ -606,6 +641,9 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
|
||||
if roleName.Valid {
|
||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||
}
|
||||
if agentMode.Valid {
|
||||
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||
}
|
||||
var err1, err2 error
|
||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
||||
if err1 != nil {
|
||||
@@ -665,7 +703,7 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID st
|
||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||
args = append(args, limit, offset)
|
||||
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+`
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`,
|
||||
@@ -689,7 +727,7 @@ func (db *DB) ListUngroupedConversationsForAccess(limit, offset int, sortBy, pro
|
||||
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
||||
args = append(args, limit, offset)
|
||||
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+`
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`,
|
||||
@@ -835,6 +873,11 @@ func (db *DB) ConversationArtifactsBaseDir() string {
|
||||
return strings.TrimSpace(db.conversationArtifactsDir)
|
||||
}
|
||||
|
||||
// EinoWorkspaceBaseDir returns the configured agent workspace root.
|
||||
func (db *DB) EinoWorkspaceBaseDir() string {
|
||||
return db.einoWorkspaceBaseDir()
|
||||
}
|
||||
|
||||
func (db *DB) einoWorkspaceBaseDir() string {
|
||||
if db == nil {
|
||||
return ""
|
||||
@@ -1419,7 +1462,8 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
||||
seenExecIDs := make(map[string]bool)
|
||||
// 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
|
||||
// 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)
|
||||
lastMatchedToolIndexByCallID := make(map[string]int)
|
||||
matchedToolIndexes := make([]bool, 0)
|
||||
@@ -1494,7 +1538,7 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
if idx < 0 && toolCallID != "" {
|
||||
for nextUnmatchedToolIdx < len(matchedToolIndexes) && matchedToolIndexes[nextUnmatchedToolIdx] {
|
||||
nextUnmatchedToolIdx++
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ type ConversationCreateMeta struct {
|
||||
WebShellConnectionID string
|
||||
ProjectID string
|
||||
RoleName string
|
||||
AgentMode string
|
||||
ClientIP string
|
||||
SessionHint string
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ func (db *DB) initTables() error {
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
role_name TEXT NOT NULL DEFAULT '默认',
|
||||
agent_mode TEXT NOT NULL DEFAULT 'eino_single',
|
||||
last_react_input TEXT,
|
||||
last_react_output TEXT
|
||||
);`
|
||||
@@ -494,6 +495,7 @@ func (db *DB) initTables() error {
|
||||
createWebshellConnectionsTable := `
|
||||
CREATE TABLE IF NOT EXISTS webshell_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
url TEXT NOT NULL,
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT 'php',
|
||||
@@ -520,6 +522,7 @@ func (db *DB) initTables() error {
|
||||
createC2ListenersTable := `
|
||||
CREATE TABLE IF NOT EXISTS c2_listeners (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
bind_host TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
@@ -756,8 +759,10 @@ func (db *DB) initTables() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_batch_task_queues_created_at ON batch_task_queues(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_batch_task_queues_title ON batch_task_queues(title);
|
||||
CREATE INDEX IF NOT EXISTS idx_webshell_connections_created_at ON webshell_connections(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_webshell_connections_project_id ON webshell_connections(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webshell_connection_states_updated_at ON webshell_connection_states(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_c2_listeners_created_at ON c2_listeners(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_c2_listeners_project_id ON c2_listeners(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_c2_listeners_status ON c2_listeners(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_c2_sessions_listener ON c2_sessions(listener_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_c2_sessions_status ON c2_sessions(status);
|
||||
@@ -956,6 +961,9 @@ func (db *DB) initTables() error {
|
||||
db.logger.Warn("迁移webshell_connections表失败", zap.Error(err))
|
||||
// 不返回错误,允许继续运行
|
||||
}
|
||||
if err := db.migrateC2ListenersTable(); err != nil {
|
||||
db.logger.Warn("迁移c2_listeners表失败", zap.Error(err))
|
||||
}
|
||||
if err := db.migrateWorkflowRunsTable(); err != nil {
|
||||
db.logger.Warn("迁移workflow_runs表失败", zap.Error(err))
|
||||
}
|
||||
@@ -1167,6 +1175,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
|
||||
}
|
||||
|
||||
@@ -1610,6 +1633,7 @@ func (db *DB) migrateWebshellConnectionsTable() error {
|
||||
name string
|
||||
stmt string
|
||||
}{
|
||||
{name: "project_id", stmt: "ALTER TABLE webshell_connections ADD COLUMN project_id TEXT"},
|
||||
{name: "encoding", stmt: "ALTER TABLE webshell_connections ADD COLUMN encoding TEXT NOT NULL DEFAULT ''"},
|
||||
{name: "os", stmt: "ALTER TABLE webshell_connections ADD COLUMN os TEXT NOT NULL DEFAULT ''"},
|
||||
}
|
||||
@@ -1635,6 +1659,10 @@ func (db *DB) migrateWebshellConnectionsTable() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) migrateC2ListenersTable() error {
|
||||
return db.addColumnIfMissing("c2_listeners", "project_id", "ALTER TABLE c2_listeners ADD COLUMN project_id TEXT")
|
||||
}
|
||||
|
||||
// NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表)
|
||||
func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
||||
sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1&_busy_timeout=5000&_synchronous=NORMAL")
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestProcessDetailsSummaryPairsMixedIdentifiedAndIDLessResults(t *testing.T) {
|
||||
func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
|
||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||
for _, id := range []string{"call-1", "call-2", "call-3", "call-4"} {
|
||||
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
|
||||
@@ -32,14 +32,24 @@ func TestProcessDetailsSummaryPairsMixedIdentifiedAndIDLessResults(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if len(summary.ToolExecutions) != 4 {
|
||||
t.Fatalf("tool executions = %d, want 4", len(summary.ToolExecutions))
|
||||
if len(summary.ToolExecutions) != 6 {
|
||||
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" {
|
||||
t.Fatalf("execution %d status = %q, want completed", i, execution.Status)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryPairsRepeatedToolCallIDsFIFO(t *testing.T) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"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(项目内唯一标识)。
|
||||
func ValidateFactKey(key string) error {
|
||||
@@ -22,7 +22,7 @@ func ValidateFactKey(key string) error {
|
||||
return fmt.Errorf("fact_key 过长(最多 128 字符)")
|
||||
}
|
||||
if !factKeyPattern.MatchString(key) {
|
||||
return fmt.Errorf("fact_key 格式无效,仅允许小写字母、数字及 . _ / -,且须以小写字母或数字开头")
|
||||
return fmt.Errorf("fact_key 格式无效,仅允许字母、数字及 . _ / -,且须以字母或数字开头(支持驼峰命名)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -111,6 +111,19 @@ func (db *DB) GetProject(id string) (*Project, error) {
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// GetProjectName returns a project display name without loading the full record.
|
||||
func (db *DB) GetProjectName(id string) (string, error) {
|
||||
var name string
|
||||
err := db.QueryRow(`SELECT name FROM projects WHERE id = ?`, id).Scan(&name)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fmt.Errorf("项目不存在")
|
||||
}
|
||||
return "", fmt.Errorf("获取项目名称失败: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(name), nil
|
||||
}
|
||||
|
||||
func projectListSearchPattern(q string) string {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
@@ -263,7 +276,7 @@ func (db *DB) UpdateProject(p *Project) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProject 删除项目(级联删除事实;对话 project_id 置空由 FK 处理;漏洞 project_id 置空)。
|
||||
// DeleteProject 删除项目(级联删除事实;对话 project_id 置空由 FK 处理;其他资源 project_id 置空)。
|
||||
func (db *DB) DeleteProject(id string) error {
|
||||
if _, err := db.Exec(`UPDATE vulnerabilities SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("解除漏洞项目关联失败: %w", err)
|
||||
@@ -271,6 +284,12 @@ func (db *DB) DeleteProject(id string) error {
|
||||
if _, err := db.Exec(`UPDATE assets SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("解除资产项目关联失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE webshell_connections SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("解除 WebShell 项目关联失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE c2_listeners SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("解除 C2 监听器项目关联失败: %w", err)
|
||||
}
|
||||
_, err := db.Exec(`DELETE FROM projects WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除项目失败: %w", err)
|
||||
|
||||
@@ -373,22 +373,46 @@ func TestRBACBatchResourceAssignmentValidationAndAtomicity(t *testing.T) {
|
||||
|
||||
func TestRBACWebshellAndBatchListAccess(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
ws1 := WebShellConnection{ID: "ws_visible", URL: "http://a", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
ws2 := WebShellConnection{ID: "ws_hidden", URL: "http://b", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
ws1 := WebShellConnection{ID: "ws_visible", ProjectID: "p1", URL: "http://a", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
ws2 := WebShellConnection{ID: "ws_hidden", ProjectID: "p2", URL: "http://b", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
ws3 := WebShellConnection{ID: "ws_other_project", ProjectID: "p2", URL: "http://c", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
ws4 := WebShellConnection{ID: "ws_unbound", URL: "http://d", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
if err := db.CreateWebshellConnection(&ws1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateWebshellConnection(&ws2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateWebshellConnection(&ws3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateWebshellConnection(&ws4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = db.SetResourceOwner("webshell", ws1.ID, "u1")
|
||||
_ = db.SetResourceOwner("webshell", ws2.ID, "u2")
|
||||
webshells, err := db.ListWebshellConnectionsForAccess("u1", RBACScopeOwn)
|
||||
_ = db.SetResourceOwner("webshell", ws3.ID, "u1")
|
||||
_ = db.SetResourceOwner("webshell", ws4.ID, "u1")
|
||||
webshells, err := db.ListWebshellConnectionsForAccess("u1", RBACScopeOwn, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(webshells) != 3 {
|
||||
t.Fatalf("webshells = %#v, want 3 owned webshells including unbound", webshells)
|
||||
}
|
||||
webshells, err = db.ListWebshellConnectionsForAccess("u1", RBACScopeOwn, "p1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(webshells) != 1 || webshells[0].ID != ws1.ID {
|
||||
t.Fatalf("webshells = %#v, want only %s", webshells, ws1.ID)
|
||||
t.Fatalf("webshells scoped to p1 = %#v, want only %s", webshells, ws1.ID)
|
||||
}
|
||||
webshells, err = db.ListWebshellConnectionsForAccess("u1", RBACScopeOwn, ProjectFilterUnbound)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(webshells) != 1 || webshells[0].ID != ws4.ID {
|
||||
t.Fatalf("unbound webshells = %#v, want only %s", webshells, ws4.ID)
|
||||
}
|
||||
|
||||
if err := db.CreateBatchQueue("q_visible", "visible", "", "eino_single", "manual", "", nil, "", 1, []map[string]interface{}{{"id": "t1", "message": "a"}}); err != nil {
|
||||
@@ -411,61 +435,143 @@ func TestRBACWebshellAndBatchListAccess(t *testing.T) {
|
||||
func TestRBACC2AccessInheritsListener(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
now := time.Now()
|
||||
l1 := &C2Listener{ID: "l_visible", Name: "visible", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9001, OwnerUserID: "u1", CreatedAt: now}
|
||||
l2 := &C2Listener{ID: "l_hidden", Name: "hidden", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9002, OwnerUserID: "u2", CreatedAt: now}
|
||||
l1 := &C2Listener{ID: "l_visible", ProjectID: "p1", Name: "visible", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9001, OwnerUserID: "u1", CreatedAt: now}
|
||||
l2 := &C2Listener{ID: "l_hidden", ProjectID: "p2", Name: "hidden", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9002, OwnerUserID: "u2", CreatedAt: now}
|
||||
l3 := &C2Listener{ID: "l_other_project", ProjectID: "p2", Name: "other project", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9003, OwnerUserID: "u1", CreatedAt: now}
|
||||
l4 := &C2Listener{ID: "l_unbound", Name: "unbound", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9004, OwnerUserID: "u1", CreatedAt: now}
|
||||
if err := db.CreateC2Listener(l1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Listener(l2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Listener(l3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Listener(l4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.UpsertC2Session(&C2Session{ID: "s_visible", ListenerID: l1.ID, ImplantUUID: "implant-visible", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.UpsertC2Session(&C2Session{ID: "s_hidden", ListenerID: l2.ID, ImplantUUID: "implant-hidden", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.UpsertC2Session(&C2Session{ID: "s_other_project", ListenerID: l3.ID, ImplantUUID: "implant-other-project", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.UpsertC2Session(&C2Session{ID: "s_unbound", ListenerID: l4.ID, ImplantUUID: "implant-unbound", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Task(&C2Task{ID: "t_visible", SessionID: "s_visible", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Task(&C2Task{ID: "t_hidden", SessionID: "s_hidden", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Task(&C2Task{ID: "t_other_project", SessionID: "s_other_project", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Task(&C2Task{ID: "t_unbound", SessionID: "s_unbound", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AppendC2Event(&C2Event{ID: "e_visible", Level: "info", Category: "task", SessionID: "s_visible", TaskID: "t_visible", Message: "visible", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AppendC2Event(&C2Event{ID: "e_hidden", Level: "info", Category: "task", SessionID: "s_hidden", TaskID: "t_hidden", Message: "hidden", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AppendC2Event(&C2Event{ID: "e_other_project", Level: "info", Category: "task", SessionID: "s_other_project", TaskID: "t_other_project", Message: "other project", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AppendC2Event(&C2Event{ID: "e_unbound", Level: "info", Category: "task", SessionID: "s_unbound", TaskID: "t_unbound", Message: "unbound", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
access := RBACListAccess{UserID: "u1", Scope: RBACScopeOwn}
|
||||
listeners, err := db.ListC2ListenersForAccess(access)
|
||||
listeners, err := db.ListC2ListenersForAccess(access, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listeners) != 3 {
|
||||
t.Fatalf("listeners = %#v, want 3 owned listeners including unbound", listeners)
|
||||
}
|
||||
listeners, err = db.ListC2ListenersForAccess(access, "p1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listeners) != 1 || listeners[0].ID != l1.ID {
|
||||
t.Fatalf("listeners = %#v, want only %s", listeners, l1.ID)
|
||||
t.Fatalf("listeners scoped to p1 = %#v, want only %s", listeners, l1.ID)
|
||||
}
|
||||
listeners, err = db.ListC2ListenersForAccess(access, ProjectFilterUnbound)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listeners) != 1 || listeners[0].ID != l4.ID {
|
||||
t.Fatalf("unbound listeners = %#v, want only %s", listeners, l4.ID)
|
||||
}
|
||||
sessions, err := db.ListC2SessionsForAccess(ListC2SessionsFilter{}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sessions) != 3 {
|
||||
t.Fatalf("sessions = %#v, want 3 owned sessions including unbound", sessions)
|
||||
}
|
||||
sessions, err = db.ListC2SessionsForAccess(ListC2SessionsFilter{ProjectID: "p1"}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sessions) != 1 || sessions[0].ID != "s_visible" {
|
||||
t.Fatalf("sessions = %#v, want only s_visible", sessions)
|
||||
t.Fatalf("sessions scoped to p1 = %#v, want only s_visible", sessions)
|
||||
}
|
||||
sessions, err = db.ListC2SessionsForAccess(ListC2SessionsFilter{ProjectID: ProjectFilterUnbound}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sessions) != 1 || sessions[0].ID != "s_unbound" {
|
||||
t.Fatalf("unbound sessions = %#v, want only s_unbound", sessions)
|
||||
}
|
||||
tasks, err := db.ListC2TasksForAccess(ListC2TasksFilter{}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) != 3 {
|
||||
t.Fatalf("tasks = %#v, want 3 owned tasks including unbound", tasks)
|
||||
}
|
||||
tasks, err = db.ListC2TasksForAccess(ListC2TasksFilter{ProjectID: "p1"}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) != 1 || tasks[0].ID != "t_visible" {
|
||||
t.Fatalf("tasks = %#v, want only t_visible", tasks)
|
||||
t.Fatalf("tasks scoped to p1 = %#v, want only t_visible", tasks)
|
||||
}
|
||||
tasks, err = db.ListC2TasksForAccess(ListC2TasksFilter{ProjectID: ProjectFilterUnbound}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) != 1 || tasks[0].ID != "t_unbound" {
|
||||
t.Fatalf("unbound tasks = %#v, want only t_unbound", tasks)
|
||||
}
|
||||
events, err := db.ListC2EventsForAccess(ListC2EventsFilter{}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("events = %#v, want 3 owned events including unbound", events)
|
||||
}
|
||||
events, err = db.ListC2EventsForAccess(ListC2EventsFilter{ProjectID: "p1"}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 || events[0].ID != "e_visible" {
|
||||
t.Fatalf("events = %#v, want only e_visible", events)
|
||||
t.Fatalf("events scoped to p1 = %#v, want only e_visible", events)
|
||||
}
|
||||
events, err = db.ListC2EventsForAccess(ListC2EventsFilter{ProjectID: ProjectFilterUnbound}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 || events[0].ID != "e_unbound" {
|
||||
t.Fatalf("unbound events = %#v, want only e_unbound", events)
|
||||
}
|
||||
if !db.UserCanAccessResource("u1", RBACScopeOwn, "c2_task", "t_visible") {
|
||||
t.Fatalf("expected listener ownership to allow task detail")
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
// WebShellConnection WebShell 连接配置
|
||||
type WebShellConnection struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Password string `json:"password"`
|
||||
Type string `json:"type"`
|
||||
@@ -60,17 +61,24 @@ func (db *DB) UpsertWebshellConnectionState(connectionID, stateJSON string) erro
|
||||
|
||||
// ListWebshellConnections 列出所有 WebShell 连接,按创建时间倒序
|
||||
func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
|
||||
return db.ListWebshellConnectionsForAccess("", "")
|
||||
return db.ListWebshellConnectionsForAccess("", "", "")
|
||||
}
|
||||
|
||||
func (db *DB) ListWebshellConnectionsForAccess(userID, scope string) ([]WebShellConnection, error) {
|
||||
func (db *DB) ListWebshellConnectionsForAccess(userID, scope, projectID string) ([]WebShellConnection, error) {
|
||||
query := `
|
||||
SELECT id, url, password, type, method, cmd_param, remark,
|
||||
SELECT id, COALESCE(project_id, '') AS project_id, url, password, type, method, cmd_param, remark,
|
||||
COALESCE(encoding, '') AS encoding, COALESCE(os, '') AS os, created_at
|
||||
FROM webshell_connections
|
||||
WHERE 1=1
|
||||
`
|
||||
args := []interface{}{}
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == ProjectFilterUnbound {
|
||||
query += ` AND COALESCE(project_id, '') = ''`
|
||||
} else if projectID != "" {
|
||||
query += ` AND COALESCE(project_id, '') = ?`
|
||||
args = append(args, projectID)
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID != "" && scope != RBACScopeAll {
|
||||
query += ` AND (
|
||||
@@ -93,7 +101,7 @@ func (db *DB) ListWebshellConnectionsForAccess(userID, scope string) ([]WebShell
|
||||
var list []WebShellConnection
|
||||
for rows.Next() {
|
||||
var c WebShellConnection
|
||||
err := rows.Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
|
||||
err := rows.Scan(&c.ID, &c.ProjectID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
|
||||
if err != nil {
|
||||
db.logger.Warn("扫描 WebShell 连接行失败", zap.Error(err))
|
||||
continue
|
||||
@@ -106,12 +114,12 @@ func (db *DB) ListWebshellConnectionsForAccess(userID, scope string) ([]WebShell
|
||||
// GetWebshellConnection 根据 ID 获取一条连接
|
||||
func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
|
||||
query := `
|
||||
SELECT id, url, password, type, method, cmd_param, remark,
|
||||
SELECT id, COALESCE(project_id, '') AS project_id, url, password, type, method, cmd_param, remark,
|
||||
COALESCE(encoding, '') AS encoding, COALESCE(os, '') AS os, created_at
|
||||
FROM webshell_connections WHERE id = ?
|
||||
`
|
||||
var c WebShellConnection
|
||||
err := db.QueryRow(query, id).Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
|
||||
err := db.QueryRow(query, id).Scan(&c.ID, &c.ProjectID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -125,10 +133,10 @@ func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
|
||||
// CreateWebshellConnection 创建 WebShell 连接
|
||||
func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
|
||||
query := `
|
||||
INSERT INTO webshell_connections (id, url, password, type, method, cmd_param, remark, encoding, os, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO webshell_connections (id, project_id, url, password, type, method, cmd_param, remark, encoding, os, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
_, err := db.Exec(query, c.ID, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.CreatedAt)
|
||||
_, err := db.Exec(query, c.ID, strings.TrimSpace(c.ProjectID), c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.CreatedAt)
|
||||
if err != nil {
|
||||
db.logger.Error("创建 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
|
||||
return err
|
||||
@@ -140,10 +148,10 @@ func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
|
||||
func (db *DB) UpdateWebshellConnection(c *WebShellConnection) error {
|
||||
query := `
|
||||
UPDATE webshell_connections
|
||||
SET url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ?, encoding = ?, os = ?
|
||||
SET project_id = ?, url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ?, encoding = ?, os = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
result, err := db.Exec(query, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.ID)
|
||||
result, err := db.Exec(query, strings.TrimSpace(c.ProjectID), c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.ID)
|
||||
if err != nil {
|
||||
db.logger.Error("更新 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
|
||||
return err
|
||||
|
||||
+52
-19
@@ -333,20 +333,41 @@ type ChatReasoningRequest struct {
|
||||
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 聊天请求
|
||||
type ChatRequest struct {
|
||||
Message string `json:"message" binding:"required"`
|
||||
ConversationID string `json:"conversationId,omitempty"`
|
||||
ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id)
|
||||
Role string `json:"role,omitempty"` // 角色名称
|
||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
||||
Hitl *HITLRequest `json:"hitl,omitempty"`
|
||||
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
|
||||
Message string `json:"message" binding:"required"`
|
||||
ConversationID string `json:"conversationId,omitempty"`
|
||||
ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id)
|
||||
Role string `json:"role,omitempty"` // 角色名称
|
||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
||||
AIChannelID string `json:"aiChannelId,omitempty"` // 会话级 AI 通道;空则使用 ai.default_channel
|
||||
Hitl *HITLRequest `json:"hitl,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 string `json:"orchestration,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AgentHandler) configForAIChannel(channelID string) (*config.Config, string, error) {
|
||||
if h == nil || h.config == nil {
|
||||
return nil, "", fmt.Errorf("服务器配置未加载")
|
||||
}
|
||||
oa, resolvedID, ok := h.config.ResolveAIChannel(channelID)
|
||||
if !ok {
|
||||
return nil, resolvedID, fmt.Errorf("AI 通道不存在: %s", resolvedID)
|
||||
}
|
||||
cfgCopy := *h.config
|
||||
cfgCopy.OpenAI = oa
|
||||
return &cfgCopy, resolvedID, nil
|
||||
}
|
||||
|
||||
func chatReasoningToClientIntent(r *ChatReasoningRequest) *reasoning.ClientIntent {
|
||||
if r == nil {
|
||||
return nil
|
||||
@@ -654,10 +675,18 @@ func (h *AgentHandler) mergeAssistantMessagePartialOnCancel(messageID, partial s
|
||||
|
||||
// ChatResponse 聊天响应
|
||||
type ChatResponse struct {
|
||||
Response string `json:"response"`
|
||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
||||
ConversationID string `json:"conversationId"` // 对话ID
|
||||
Time time.Time `json:"time"`
|
||||
Response string `json:"response"`
|
||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
||||
ConversationID string `json:"conversationId"` // 对话ID
|
||||
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) {
|
||||
@@ -673,19 +702,20 @@ func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMes
|
||||
}
|
||||
|
||||
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
||||
if assistantMessageID != "" {
|
||||
if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resultMA.Response, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)); errU != nil {
|
||||
h.logger.Warn("机器人:更新助手消息失败", zap.Error(errU))
|
||||
}
|
||||
} else {
|
||||
if _, err := h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil {
|
||||
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput), true)
|
||||
responseText := decision.FinalText
|
||||
if !decision.Finalizable {
|
||||
responseText = finalizationBlockedMessage(decision)
|
||||
}
|
||||
if assistantMessageID == "" {
|
||||
if _, err := h.db.AddMessage(conversationID, "assistant", responseText, resultMA.MCPExecutionIDs); err != nil {
|
||||
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
if 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(
|
||||
@@ -816,6 +846,9 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform stri
|
||||
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, nil)
|
||||
|
||||
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 {
|
||||
case "eino_single":
|
||||
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
|
||||
batchOrch = "deep"
|
||||
}
|
||||
if useBatchMulti {
|
||||
_ = h.db.SetConversationAgentMode(conversationID, batchOrch)
|
||||
} else {
|
||||
_ = h.db.SetConversationAgentMode(conversationID, "eino_single")
|
||||
}
|
||||
|
||||
var resultMA *multiagent.RunResult
|
||||
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))
|
||||
|
||||
resText := resultMA.Response
|
||||
mcpIDs := resultMA.MCPExecutionIDs
|
||||
lastIn := resultMA.LastAgentTraceInput
|
||||
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 updateErr := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resText, mcpIDs, multiagent.AggregatedReasoningFromTraceJSON(lastIn)); updateErr != nil {
|
||||
h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
|
||||
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))
|
||||
}
|
||||
}
|
||||
} else if _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs); err != nil {
|
||||
if assistantMessageID == "" {
|
||||
_, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs)
|
||||
} else if !decision.Finalizable {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+85
-2
@@ -60,7 +60,7 @@ func (h *C2Handler) SetManager(m *c2.Manager) {
|
||||
|
||||
// ListListeners 获取监听器列表
|
||||
func (h *C2Handler) ListListeners(c *gin.Context) {
|
||||
listeners, err := h.mgr().DB().ListC2ListenersForAccess(c2AccessFromContext(c))
|
||||
listeners, err := h.mgr().DB().ListC2ListenersForAccess(c2AccessFromContext(c), c.Query("project_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -77,6 +77,7 @@ func (h *C2Handler) ListListeners(c *gin.Context) {
|
||||
func (h *C2Handler) CreateListener(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
BindHost string `json:"bind_host"`
|
||||
BindPort int `json:"bind_port"`
|
||||
@@ -92,6 +93,7 @@ func (h *C2Handler) CreateListener(c *gin.Context) {
|
||||
|
||||
input := c2.CreateListenerInput{
|
||||
Name: req.Name,
|
||||
ProjectID: req.ProjectID,
|
||||
Type: req.Type,
|
||||
BindHost: req.BindHost,
|
||||
BindPort: req.BindPort,
|
||||
@@ -100,6 +102,10 @@ func (h *C2Handler) CreateListener(c *gin.Context) {
|
||||
Config: req.Config,
|
||||
CallbackHost: strings.TrimSpace(req.CallbackHost),
|
||||
}
|
||||
if !h.canAccessProject(c, input.ProjectID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "project access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
listener, err := h.mgr().CreateListener(input)
|
||||
if err != nil {
|
||||
@@ -158,6 +164,7 @@ func (h *C2Handler) UpdateListener(c *gin.Context) {
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
ProjectID string `json:"project_id"`
|
||||
BindHost string `json:"bind_host"`
|
||||
BindPort int `json:"bind_port"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
@@ -179,6 +186,7 @@ func (h *C2Handler) UpdateListener(c *gin.Context) {
|
||||
}
|
||||
|
||||
listener.Name = req.Name
|
||||
listener.ProjectID = strings.TrimSpace(req.ProjectID)
|
||||
listener.BindHost = req.BindHost
|
||||
listener.BindPort = req.BindPort
|
||||
listener.ProfileID = req.ProfileID
|
||||
@@ -187,6 +195,10 @@ func (h *C2Handler) UpdateListener(c *gin.Context) {
|
||||
cfgJSON, _ := json.Marshal(req.Config)
|
||||
listener.ConfigJSON = string(cfgJSON)
|
||||
}
|
||||
if !h.canAccessProject(c, listener.ProjectID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "project access denied"})
|
||||
return
|
||||
}
|
||||
if req.CallbackHost != nil {
|
||||
cfg := &c2.ListenerConfig{}
|
||||
raw := strings.TrimSpace(listener.ConfigJSON)
|
||||
@@ -275,6 +287,7 @@ func (h *C2Handler) StopListener(c *gin.Context) {
|
||||
func (h *C2Handler) ListSessions(c *gin.Context) {
|
||||
filter := database.ListC2SessionsFilter{
|
||||
ListenerID: c.Query("listener_id"),
|
||||
ProjectID: c.Query("project_id"),
|
||||
Status: c.Query("status"),
|
||||
OS: c.Query("os"),
|
||||
Search: c.Query("search"),
|
||||
@@ -404,6 +417,47 @@ func (h *C2Handler) SetSessionSleep(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// SetSessionNote 更新会话备注(仅服务端元数据,不下发植入体)
|
||||
func (h *C2Handler) SetSessionNote(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
note := strings.TrimSpace(req.Note)
|
||||
if len(note) > 2000 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "note too long (max 2000 characters)"})
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.mgr().DB().GetC2Session(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.mgr().DB().SetC2SessionNote(id, note); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "c2", "session_note", "更新 C2 会话备注", "c2_session", id, map[string]interface{}{
|
||||
"note_len": len(note),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"updated": true,
|
||||
"note": note,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 任务 API
|
||||
// ============================================================================
|
||||
@@ -412,7 +466,14 @@ func (h *C2Handler) SetSessionSleep(c *gin.Context) {
|
||||
func (h *C2Handler) ListTasks(c *gin.Context) {
|
||||
filter := database.ListC2TasksFilter{
|
||||
SessionID: c.Query("session_id"),
|
||||
ProjectID: c.Query("project_id"),
|
||||
Status: c.Query("status"),
|
||||
TaskType: c.Query("task_type"),
|
||||
}
|
||||
if since := c.Query("since"); since != "" {
|
||||
if t, err := database.ParseRFC3339Time(since); err == nil {
|
||||
filter.Since = &t
|
||||
}
|
||||
}
|
||||
|
||||
paginated := false
|
||||
@@ -447,7 +508,7 @@ func (h *C2Handler) ListTasks(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 仪表盘「待审任务」为全局 queued/pending 数量,与列表 session 过滤无关
|
||||
pendingN, _ := h.mgr().DB().CountC2TasksQueuedOrPendingForAccess("", access)
|
||||
pendingN, _ := h.mgr().DB().CountC2TasksQueuedOrPendingForAccess("", filter.ProjectID, access)
|
||||
|
||||
if !paginated {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -462,9 +523,15 @@ func (h *C2Handler) ListTasks(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
statusCounts, err := h.mgr().DB().CountC2TasksByStatusForAccess(filter, access)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tasks": tasks,
|
||||
"total": total,
|
||||
"status_counts": statusCounts,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"pending_queued_count": pendingN,
|
||||
@@ -784,6 +851,7 @@ func (h *C2Handler) ListEvents(c *gin.Context) {
|
||||
filter := database.ListC2EventsFilter{
|
||||
Level: c.Query("level"),
|
||||
Category: c.Query("category"),
|
||||
ProjectID: c.Query("project_id"),
|
||||
SessionID: c.Query("session_id"),
|
||||
TaskID: c.Query("task_id"),
|
||||
}
|
||||
@@ -1121,6 +1189,21 @@ func c2AccessFromContext(c *gin.Context) database.RBACListAccess {
|
||||
return database.RBACListAccess{UserID: session.UserID, Scope: session.Scope}
|
||||
}
|
||||
|
||||
func (h *C2Handler) canAccessProject(c *gin.Context, projectID string) bool {
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" {
|
||||
return true
|
||||
}
|
||||
session, ok := security.CurrentSession(c)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if session.Scope == database.RBACScopeAll {
|
||||
return true
|
||||
}
|
||||
return h.mgr().DB().UserCanAccessResource(session.UserID, session.Scope, "project", projectID)
|
||||
}
|
||||
|
||||
func (h *C2Handler) c2ResourceAllowed(c *gin.Context, resourceType, resourceID string) bool {
|
||||
session, ok := security.CurrentSession(c)
|
||||
if !ok {
|
||||
|
||||
@@ -27,11 +27,14 @@ import (
|
||||
const (
|
||||
chatUploadsRootDirName = "chat_uploads"
|
||||
reductionRootDirName = "tmp/reduction"
|
||||
workspaceRootDirName = "tmp/workspace"
|
||||
artifactsRootDirName = "data/conversation_artifacts"
|
||||
reductionVirtualPrefix = "__reduction__/"
|
||||
workspaceVirtualPrefix = "__workspace__/"
|
||||
artifactVirtualPrefix = "__conversation_artifact__/"
|
||||
chatUploadSourceUpload = "upload"
|
||||
chatUploadSourceReduction = "reduction"
|
||||
chatUploadSourceWorkspace = "workspace"
|
||||
chatUploadSourceConversation = "conversation_artifact"
|
||||
maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限
|
||||
)
|
||||
@@ -104,6 +107,11 @@ func (h *ChatUploadsHandler) reductionPathAllowed(c *gin.Context, scope, id stri
|
||||
|
||||
func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativePath string) bool {
|
||||
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), reductionVirtualPrefix)
|
||||
rel = strings.Trim(filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))), "/")
|
||||
if rel == "projects" || rel == "conversations" {
|
||||
_, ok := security.CurrentSession(c)
|
||||
return ok
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
@@ -111,6 +119,24 @@ func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativ
|
||||
return h.reductionPathAllowed(c, parts[0], parts[1])
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) workspacePathAllowed(c *gin.Context, scope, id string) bool {
|
||||
return h.reductionPathAllowed(c, scope, id)
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) workspaceVirtualPathAllowed(c *gin.Context, relativePath string) bool {
|
||||
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), workspaceVirtualPrefix)
|
||||
rel = strings.Trim(filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))), "/")
|
||||
if rel == "projects" || rel == "conversations" {
|
||||
_, ok := security.CurrentSession(c)
|
||||
return ok
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
return h.workspacePathAllowed(c, parts[0], parts[1])
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) conversationArtifactPathAllowed(c *gin.Context, conversationID string) bool {
|
||||
session, ok := security.CurrentSession(c)
|
||||
if !ok || h.db == nil {
|
||||
@@ -163,6 +189,26 @@ func (h *ChatUploadsHandler) absReductionRoot() (string, error) {
|
||||
return filepath.Abs(filepath.Join(cwd, reductionRootDirName))
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) absWorkspaceRoot() (string, error) {
|
||||
if h.db != nil {
|
||||
if base := strings.TrimSpace(h.db.EinoWorkspaceBaseDir()); base != "" {
|
||||
if filepath.IsAbs(base) {
|
||||
return filepath.Abs(base)
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Abs(filepath.Join(cwd, base))
|
||||
}
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Abs(filepath.Join(cwd, workspaceRootDirName))
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) absConversationArtifactsRoot() (string, error) {
|
||||
if h.db != nil {
|
||||
if base := strings.TrimSpace(h.db.ConversationArtifactsBaseDir()); base != "" {
|
||||
@@ -211,15 +257,17 @@ func (h *ChatUploadsHandler) resolveUnderChatUploads(relativePath string) (abs s
|
||||
|
||||
// ChatUploadFileItem 列表项
|
||||
type ChatUploadFileItem struct {
|
||||
RelativePath string `json:"relativePath"`
|
||||
AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致)
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedUnix int64 `json:"modifiedUnix"`
|
||||
Date string `json:"date"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
RelativePath string `json:"relativePath"`
|
||||
AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致)
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedUnix int64 `json:"modifiedUnix"`
|
||||
Date string `json:"date"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
ConversationTitle string `json:"conversationTitle,omitempty"`
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
ProjectName string `json:"projectName,omitempty"`
|
||||
// SubPath 为日期、会话目录之下的子路径(不含文件名),如 date/conv/a/b/file 则为 "a/b";无嵌套则为 ""。
|
||||
SubPath string `json:"subPath"`
|
||||
}
|
||||
@@ -240,6 +288,38 @@ func (h *ChatUploadsHandler) conversationProjectID(conversationID string, cache
|
||||
return projectID
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) conversationTitle(conversationID string, cache map[string]string) string {
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if conversationID == "" || conversationID == "_manual" || conversationID == "_new" || h.db == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := cache[conversationID]; ok {
|
||||
return v
|
||||
}
|
||||
title, err := h.db.GetConversationTitle(conversationID)
|
||||
if err != nil {
|
||||
title = ""
|
||||
}
|
||||
cache[conversationID] = title
|
||||
return title
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) projectName(projectID string, cache map[string]string) string {
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" || h.db == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := cache[projectID]; ok {
|
||||
return v
|
||||
}
|
||||
name, err := h.db.GetProjectName(projectID)
|
||||
if err != nil {
|
||||
name = ""
|
||||
}
|
||||
cache[projectID] = name
|
||||
return name
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, []string, error) {
|
||||
root, err := h.absRoot()
|
||||
if err != nil {
|
||||
@@ -252,6 +332,8 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr
|
||||
var files []ChatUploadFileItem
|
||||
var folders []string
|
||||
projectCache := make(map[string]string)
|
||||
projectNameCache := make(map[string]string)
|
||||
conversationTitleCache := make(map[string]string)
|
||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
@@ -293,16 +375,18 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr
|
||||
}
|
||||
absPath, _ := filepath.Abs(path)
|
||||
files = append(files, ChatUploadFileItem{
|
||||
RelativePath: relSlash,
|
||||
AbsolutePath: absPath,
|
||||
Name: d.Name(),
|
||||
Source: chatUploadSourceUpload,
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: dateStr,
|
||||
ConversationID: convID,
|
||||
ProjectID: projectID,
|
||||
SubPath: subPath,
|
||||
RelativePath: relSlash,
|
||||
AbsolutePath: absPath,
|
||||
Name: d.Name(),
|
||||
Source: chatUploadSourceUpload,
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: dateStr,
|
||||
ConversationID: convID,
|
||||
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
|
||||
ProjectID: projectID,
|
||||
ProjectName: h.projectName(projectID, projectNameCache),
|
||||
SubPath: subPath,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
@@ -354,6 +438,12 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr
|
||||
} else if len(reductionFiles) > 0 {
|
||||
files = append(files, reductionFiles...)
|
||||
}
|
||||
workspaceFiles, err := h.collectWorkspaceFiles(c, conversationFilter, projectFilter)
|
||||
if err != nil {
|
||||
h.logger.Warn("列举 workspace 产物失败", zap.Error(err))
|
||||
} else if len(workspaceFiles) > 0 {
|
||||
files = append(files, workspaceFiles...)
|
||||
}
|
||||
artifactFiles, err := h.collectConversationArtifactFiles(c, conversationFilter, projectFilter)
|
||||
if err != nil {
|
||||
h.logger.Warn("列举 conversation_artifacts 产物失败", zap.Error(err))
|
||||
@@ -375,6 +465,8 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF
|
||||
return nil, nil
|
||||
}
|
||||
projectCache := make(map[string]string)
|
||||
projectNameCache := make(map[string]string)
|
||||
conversationTitleCache := make(map[string]string)
|
||||
files := make([]ChatUploadFileItem, 0)
|
||||
for _, scope := range []string{"conversations", "projects"} {
|
||||
scopeRoot := filepath.Join(root, scope)
|
||||
@@ -403,7 +495,10 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF
|
||||
projectID = ownerID
|
||||
}
|
||||
if conversationFilter != "" && convID != conversationFilter {
|
||||
return nil
|
||||
if scope != "projects" || h.conversationProjectID(conversationFilter, projectCache) != projectID {
|
||||
return nil
|
||||
}
|
||||
convID = conversationFilter
|
||||
}
|
||||
if projectFilter != "" && projectID != projectFilter {
|
||||
return nil
|
||||
@@ -418,16 +513,90 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF
|
||||
}
|
||||
abs, _ := filepath.Abs(path)
|
||||
files = append(files, ChatUploadFileItem{
|
||||
RelativePath: reductionVirtualPrefix + relSlash,
|
||||
AbsolutePath: abs,
|
||||
Name: name,
|
||||
Source: chatUploadSourceReduction,
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: info.ModTime().Format("2006-01-02"),
|
||||
ConversationID: convID,
|
||||
ProjectID: projectID,
|
||||
SubPath: strings.Join(parts[2:len(parts)-1], "/"),
|
||||
RelativePath: reductionVirtualPrefix + relSlash,
|
||||
AbsolutePath: abs,
|
||||
Name: name,
|
||||
Source: chatUploadSourceReduction,
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: info.ModTime().Format("2006-01-02"),
|
||||
ConversationID: convID,
|
||||
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
|
||||
ProjectID: projectID,
|
||||
ProjectName: h.projectName(projectID, projectNameCache),
|
||||
SubPath: strings.Join(parts[2:len(parts)-1], "/"),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) collectWorkspaceFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, error) {
|
||||
root, err := h.absWorkspaceRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if st, err := os.Stat(root); err != nil || !st.IsDir() {
|
||||
return nil, nil
|
||||
}
|
||||
projectCache := make(map[string]string)
|
||||
projectNameCache := make(map[string]string)
|
||||
conversationTitleCache := make(map[string]string)
|
||||
files := make([]ChatUploadFileItem, 0)
|
||||
for _, scope := range []string{"conversations", "projects"} {
|
||||
scopeRoot := filepath.Join(root, scope)
|
||||
_ = filepath.WalkDir(scopeRoot, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil || d == nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
parts := strings.Split(relSlash, "/")
|
||||
if len(parts) < 3 {
|
||||
return nil
|
||||
}
|
||||
ownerID := parts[1]
|
||||
if !h.workspacePathAllowed(c, scope, ownerID) {
|
||||
return nil
|
||||
}
|
||||
var convID, projectID string
|
||||
if scope == "conversations" {
|
||||
convID = ownerID
|
||||
projectID = h.conversationProjectID(convID, projectCache)
|
||||
} else {
|
||||
projectID = ownerID
|
||||
}
|
||||
if conversationFilter != "" && convID != conversationFilter {
|
||||
if scope != "projects" || h.conversationProjectID(conversationFilter, projectCache) != projectID {
|
||||
return nil
|
||||
}
|
||||
convID = conversationFilter
|
||||
}
|
||||
if projectFilter != "" && projectID != projectFilter {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
abs, _ := filepath.Abs(path)
|
||||
files = append(files, ChatUploadFileItem{
|
||||
RelativePath: workspaceVirtualPrefix + relSlash,
|
||||
AbsolutePath: abs,
|
||||
Name: d.Name(),
|
||||
Source: chatUploadSourceWorkspace,
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: info.ModTime().Format("2006-01-02"),
|
||||
ConversationID: convID,
|
||||
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
|
||||
ProjectID: projectID,
|
||||
ProjectName: h.projectName(projectID, projectNameCache),
|
||||
SubPath: strings.Join(parts[2:len(parts)-1], "/"),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
@@ -444,6 +613,8 @@ func (h *ChatUploadsHandler) collectConversationArtifactFiles(c *gin.Context, co
|
||||
return nil, nil
|
||||
}
|
||||
projectCache := make(map[string]string)
|
||||
projectNameCache := make(map[string]string)
|
||||
conversationTitleCache := make(map[string]string)
|
||||
files := make([]ChatUploadFileItem, 0)
|
||||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil || d == nil || d.IsDir() {
|
||||
@@ -479,16 +650,18 @@ func (h *ChatUploadsHandler) collectConversationArtifactFiles(c *gin.Context, co
|
||||
}
|
||||
abs, _ := filepath.Abs(path)
|
||||
files = append(files, ChatUploadFileItem{
|
||||
RelativePath: artifactVirtualPrefix + relSlash,
|
||||
AbsolutePath: abs,
|
||||
Name: name,
|
||||
Source: chatUploadSourceConversation,
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: info.ModTime().Format("2006-01-02"),
|
||||
ConversationID: convID,
|
||||
ProjectID: projectID,
|
||||
SubPath: strings.Join(parts[1:len(parts)-1], "/"),
|
||||
RelativePath: artifactVirtualPrefix + relSlash,
|
||||
AbsolutePath: abs,
|
||||
Name: name,
|
||||
Source: chatUploadSourceConversation,
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: info.ModTime().Format("2006-01-02"),
|
||||
ConversationID: convID,
|
||||
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
|
||||
ProjectID: projectID,
|
||||
ProjectName: h.projectName(projectID, projectNameCache),
|
||||
SubPath: strings.Join(parts[1:len(parts)-1], "/"),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
@@ -516,6 +689,27 @@ func (h *ChatUploadsHandler) resolveReductionVirtualPath(relativePath string) (s
|
||||
return full, nil
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) resolveWorkspaceVirtualPath(relativePath string) (string, error) {
|
||||
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), workspaceVirtualPrefix)
|
||||
rel = filepath.Clean(filepath.FromSlash(rel))
|
||||
if rel == "." || strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
}
|
||||
root, err := h.absWorkspaceRoot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
full, err := filepath.Abs(filepath.Join(root, rel))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rootAbs, _ := filepath.Abs(root)
|
||||
if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("path escapes workspace root")
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath string) (string, error) {
|
||||
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), artifactVirtualPrefix)
|
||||
rel = filepath.Clean(filepath.FromSlash(rel))
|
||||
@@ -539,8 +733,10 @@ func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath
|
||||
|
||||
func chatUploadItemIsInternal(item ChatUploadFileItem) bool {
|
||||
return item.Source == chatUploadSourceReduction ||
|
||||
item.Source == chatUploadSourceWorkspace ||
|
||||
item.Source == chatUploadSourceConversation ||
|
||||
strings.HasPrefix(item.RelativePath, reductionVirtualPrefix) ||
|
||||
strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix) ||
|
||||
strings.HasPrefix(item.RelativePath, artifactVirtualPrefix)
|
||||
}
|
||||
|
||||
@@ -548,6 +744,8 @@ func (h *ChatUploadsHandler) resolveListedFilePath(item ChatUploadFileItem) (str
|
||||
switch {
|
||||
case item.Source == chatUploadSourceReduction || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix):
|
||||
return h.resolveReductionVirtualPath(item.RelativePath)
|
||||
case item.Source == chatUploadSourceWorkspace || strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix):
|
||||
return h.resolveWorkspaceVirtualPath(item.RelativePath)
|
||||
case item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix):
|
||||
return h.resolveConversationArtifactVirtualPath(item.RelativePath)
|
||||
default:
|
||||
@@ -705,7 +903,7 @@ func (h *ChatUploadsHandler) Export(c *gin.Context) {
|
||||
"fileCount": len(files),
|
||||
"files": files,
|
||||
"layout": "chat uploads are stored under conversations/<conversationId>/; internal outputs are stored under internal/<source>/",
|
||||
"sourceDirectory": []string{chatUploadsRootDirName, reductionRootDirName, artifactsRootDirName},
|
||||
"sourceDirectory": []string{chatUploadsRootDirName, reductionRootDirName, workspaceRootDirName, artifactsRootDirName},
|
||||
}
|
||||
manifestBytes, _ := json.MarshalIndent(manifest, "", " ")
|
||||
mw, err := zw.Create("manifest.json")
|
||||
@@ -732,6 +930,9 @@ func (h *ChatUploadsHandler) Export(c *gin.Context) {
|
||||
if filepath.Ext(zipName) == "" {
|
||||
zipName += ".txt"
|
||||
}
|
||||
} else if item.Source == chatUploadSourceWorkspace || strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix) {
|
||||
rel := strings.TrimPrefix(item.RelativePath, workspaceVirtualPrefix)
|
||||
zipName = filepath.ToSlash(filepath.Join("internal", "workspace", rel))
|
||||
} else if item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix) {
|
||||
rel := strings.TrimPrefix(item.RelativePath, artifactVirtualPrefix)
|
||||
zipName = filepath.ToSlash(filepath.Join("internal", "conversation_artifacts", rel))
|
||||
@@ -800,6 +1001,24 @@ func (h *ChatUploadsHandler) Download(c *gin.Context) {
|
||||
c.FileAttachment(abs, name)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(p), workspaceVirtualPrefix) {
|
||||
if !h.workspaceVirtualPathAllowed(c, p) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err := h.resolveWorkspaceVirtualPath(p)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil || st.IsDir() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
c.FileAttachment(abs, filepath.Base(abs))
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(p), artifactVirtualPrefix) {
|
||||
if !h.conversationArtifactVirtualPathAllowed(c, p) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
@@ -839,6 +1058,93 @@ func (h *ChatUploadsHandler) Download(c *gin.Context) {
|
||||
c.FileAttachment(abs, filepath.Base(abs))
|
||||
}
|
||||
|
||||
// ResolvePath GET /api/chat-uploads/path?path=...&kind=file|directory
|
||||
func (h *ChatUploadsHandler) ResolvePath(c *gin.Context) {
|
||||
p := strings.TrimSpace(c.Query("path"))
|
||||
kind := strings.TrimSpace(c.Query("kind"))
|
||||
if kind == "" {
|
||||
kind = "file"
|
||||
}
|
||||
var abs string
|
||||
var err error
|
||||
switch {
|
||||
case strings.HasPrefix(p, reductionVirtualPrefix):
|
||||
if strings.Trim(strings.TrimPrefix(p, reductionVirtualPrefix), "/") == "" {
|
||||
if _, ok := security.CurrentSession(c); !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err = h.absReductionRoot()
|
||||
break
|
||||
}
|
||||
if !h.reductionVirtualPathAllowed(c, p) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err = h.resolveReductionVirtualPath(p)
|
||||
case strings.HasPrefix(p, workspaceVirtualPrefix):
|
||||
if strings.Trim(strings.TrimPrefix(p, workspaceVirtualPrefix), "/") == "" {
|
||||
if _, ok := security.CurrentSession(c); !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err = h.absWorkspaceRoot()
|
||||
break
|
||||
}
|
||||
if !h.workspaceVirtualPathAllowed(c, p) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err = h.resolveWorkspaceVirtualPath(p)
|
||||
case strings.HasPrefix(p, artifactVirtualPrefix):
|
||||
if strings.Trim(strings.TrimPrefix(p, artifactVirtualPrefix), "/") == "" {
|
||||
if _, ok := security.CurrentSession(c); !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err = h.absConversationArtifactsRoot()
|
||||
break
|
||||
}
|
||||
if !h.conversationArtifactVirtualPathAllowed(c, p) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err = h.resolveConversationArtifactVirtualPath(p)
|
||||
default:
|
||||
if p == "" || p == "." {
|
||||
if _, ok := security.CurrentSession(c); !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err = h.absRoot()
|
||||
break
|
||||
}
|
||||
if !h.pathAllowed(c, p) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err = h.resolveUnderChatUploads(p)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "path not found"})
|
||||
return
|
||||
}
|
||||
if kind == "directory" && !st.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||
return
|
||||
}
|
||||
if kind != "directory" && st.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a file"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"absolutePath": abs, "isDir": st.IsDir()})
|
||||
}
|
||||
|
||||
type chatUploadPathBody struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
@@ -258,6 +258,7 @@ func (h *ConfigHandler) ApplyWechatRobotBinding(wc config.RobotWechatConfig) err
|
||||
|
||||
// GetConfigResponse 获取配置响应
|
||||
type GetConfigResponse struct {
|
||||
AI config.AIConfig `json:"ai"`
|
||||
OpenAI config.OpenAIConfig `json:"openai"`
|
||||
Vision config.VisionConfig `json:"vision"`
|
||||
FOFA config.FofaConfig `json:"fofa"`
|
||||
@@ -363,6 +364,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, GetConfigResponse{
|
||||
AI: h.config.AI,
|
||||
OpenAI: h.config.OpenAI,
|
||||
Vision: h.config.Vision,
|
||||
FOFA: h.config.FOFA,
|
||||
@@ -706,6 +708,7 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
||||
|
||||
// UpdateConfigRequest 更新配置请求
|
||||
type UpdateConfigRequest struct {
|
||||
AI *config.AIConfig `json:"ai,omitempty"`
|
||||
OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
|
||||
Vision *config.VisionConfig `json:"vision,omitempty"`
|
||||
FOFA *config.FofaConfig `json:"fofa,omitempty"`
|
||||
@@ -785,8 +788,20 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
defer h.mu.Unlock()
|
||||
|
||||
// 更新OpenAI配置
|
||||
if req.AI != nil {
|
||||
h.config.AI = *req.AI
|
||||
h.config.ApplyDefaultAIChannel()
|
||||
h.logger.Info("更新 AI 通道配置",
|
||||
zap.String("default_channel", h.config.AI.DefaultChannel),
|
||||
zap.Int("channels", len(h.config.AI.Channels)),
|
||||
)
|
||||
}
|
||||
if req.OpenAI != nil {
|
||||
h.config.OpenAI = *req.OpenAI
|
||||
h.config.AI.EnsureDefaultFromOpenAI(h.config.OpenAI)
|
||||
if def := config.NormalizeAIChannelID(h.config.AI.DefaultChannel); def != "" {
|
||||
h.config.AI.Channels[def] = config.AIChannelFromOpenAI(def, "Default", h.config.OpenAI)
|
||||
}
|
||||
h.logger.Info("更新OpenAI配置",
|
||||
zap.String("base_url", h.config.OpenAI.BaseURL),
|
||||
zap.String("model", h.config.OpenAI.Model),
|
||||
@@ -1683,7 +1698,8 @@ func (h *ConfigHandler) saveConfig() error {
|
||||
|
||||
updateAgentConfig(root, h.config.Agent)
|
||||
updateMCPConfig(root, h.config.MCP)
|
||||
updateOpenAIConfig(root, h.config.OpenAI)
|
||||
updateAIConfig(root, h.config.AI)
|
||||
removeKeyFromMap(root.Content[0], "openai")
|
||||
updateVisionConfig(root, h.config.Vision)
|
||||
updateFOFAConfig(root, h.config.FOFA)
|
||||
updateSpaceSearchConfig(root, "zoomeye", h.config.ZoomEye)
|
||||
@@ -1877,6 +1893,69 @@ func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
func updateAIConfig(doc *yaml.Node, cfg config.AIConfig) {
|
||||
root := doc.Content[0]
|
||||
aiNode := ensureMap(root, "ai")
|
||||
if strings.TrimSpace(cfg.DefaultChannel) != "" {
|
||||
setStringInMap(aiNode, "default_channel", config.NormalizeAIChannelID(cfg.DefaultChannel))
|
||||
}
|
||||
channelsNode := ensureMap(aiNode, "channels")
|
||||
channelsNode.Content = nil
|
||||
normalized := make(map[string]config.AIChannelConfig, len(cfg.Channels))
|
||||
ids := make([]string, 0, len(cfg.Channels))
|
||||
for id, ch := range cfg.Channels {
|
||||
nid := config.NormalizeAIChannelID(id)
|
||||
if nid == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := normalized[nid]; !exists {
|
||||
ids = append(ids, nid)
|
||||
}
|
||||
normalized[nid] = ch
|
||||
}
|
||||
sort.Strings(ids)
|
||||
seen := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
ch := normalized[id]
|
||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: id}
|
||||
channelNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
channelsNode.Content = append(channelsNode.Content, keyNode, channelNode)
|
||||
setStringInMap(channelNode, "name", ch.Name)
|
||||
if strings.TrimSpace(ch.Provider) != "" {
|
||||
setStringInMap(channelNode, "provider", ch.Provider)
|
||||
}
|
||||
setStringInMap(channelNode, "api_key", ch.APIKey)
|
||||
setStringInMap(channelNode, "base_url", ch.BaseURL)
|
||||
setStringInMap(channelNode, "model", ch.Model)
|
||||
if ch.MaxTotalTokens > 0 {
|
||||
setIntInMap(channelNode, "max_total_tokens", ch.MaxTotalTokens)
|
||||
}
|
||||
if ch.MaxCompletionTokens > 0 {
|
||||
setIntInMap(channelNode, "max_completion_tokens", ch.MaxCompletionTokens)
|
||||
}
|
||||
rn := ensureMap(channelNode, "reasoning")
|
||||
if strings.TrimSpace(ch.Reasoning.Mode) != "" {
|
||||
setStringInMap(rn, "mode", ch.Reasoning.Mode)
|
||||
}
|
||||
if strings.TrimSpace(ch.Reasoning.Effort) != "" {
|
||||
setStringInMap(rn, "effort", ch.Reasoning.Effort)
|
||||
}
|
||||
if ch.Reasoning.AllowClientReasoning != nil {
|
||||
setBoolInMap(rn, "allow_client_reasoning", *ch.Reasoning.AllowClientReasoning)
|
||||
}
|
||||
if strings.TrimSpace(ch.Reasoning.Profile) != "" {
|
||||
setStringInMap(rn, "profile", ch.Reasoning.Profile)
|
||||
}
|
||||
if len(rn.Content) == 0 {
|
||||
removeKeyFromMap(channelNode, "reasoning")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateFOFAConfig(doc *yaml.Node, cfg config.FofaConfig) {
|
||||
root := doc.Content[0]
|
||||
fofaNode := ensureMap(root, "fofa")
|
||||
|
||||
@@ -68,15 +68,15 @@ func (h *AgentHandler) tryContinueOnEinoEmptyResponse(
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
|
||||
inject := multiagent.FormatEmptyResponseContinueUserMessage()
|
||||
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, inject)
|
||||
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, "")
|
||||
if progressCallback != nil {
|
||||
progressCallback("eino_empty_response_continue", "已恢复上下文,正在续跑…", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"attempt": *attempt,
|
||||
"maxAttempts": maxAttempts,
|
||||
"contextSource": "empty_response_continue",
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"attempt": *attempt,
|
||||
"maxAttempts": maxAttempts,
|
||||
"contextSource": "empty_response_continue",
|
||||
"contextInjection": false,
|
||||
})
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/agentfinalizer"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
@@ -149,6 +150,14 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return
|
||||
}
|
||||
runCfg, resolvedAIChannelID, err := h.configForAIChannel(req.AIChannelID)
|
||||
if err != nil {
|
||||
taskStatus = "failed"
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
sendEvent("error", err.Error(), nil)
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return
|
||||
}
|
||||
|
||||
var result *multiagent.RunResult
|
||||
var runErr error
|
||||
@@ -181,6 +190,8 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||
var mainIterationOffset int
|
||||
var emptyResponseContinueAttempt int
|
||||
var finalizationAutoContinueAttempt int
|
||||
var decision agentfinalizer.Decision
|
||||
|
||||
for {
|
||||
segmentMainIterationMax := 0
|
||||
@@ -222,8 +233,8 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
|
||||
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
||||
taskCtxLoop,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
runCfg,
|
||||
&runCfg.MultiAgent,
|
||||
h.agent,
|
||||
h.db,
|
||||
h.logger,
|
||||
@@ -236,6 +247,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
chatReasoningToClientIntent(req.Reasoning),
|
||||
h.agentSessionContextBlock(conversationID),
|
||||
)
|
||||
_ = resolvedAIChannelID
|
||||
|
||||
if result != nil && len(result.MCPExecutionIDs) > 0 {
|
||||
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
|
||||
@@ -249,6 +261,13 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||
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()
|
||||
break
|
||||
}
|
||||
@@ -349,9 +368,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
|
||||
timeoutCancel()
|
||||
|
||||
if assistantMessageID != "" {
|
||||
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
||||
if decision.CompletionReason == "" {
|
||||
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 err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||
@@ -359,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,
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": "eino_single",
|
||||
})
|
||||
}))
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
}
|
||||
|
||||
@@ -410,16 +437,24 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器配置未加载"})
|
||||
return
|
||||
}
|
||||
runCfg, _, err := h.configForAIChannel(req.AIChannelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
curHist := prep.History
|
||||
curMsg := prep.FinalMessage
|
||||
var result *multiagent.RunResult
|
||||
var runErr error
|
||||
var emptyResponseContinueAttempt int
|
||||
var finalizationAutoContinueAttempt int
|
||||
var decision agentfinalizer.Decision
|
||||
for {
|
||||
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
||||
taskCtx,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
runCfg,
|
||||
&runCfg.MultiAgent,
|
||||
h.agent,
|
||||
h.db,
|
||||
h.logger,
|
||||
@@ -432,28 +467,46 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
chatReasoningToClientIntent(req.Reasoning),
|
||||
h.agentSessionContextBlock(prep.ConversationID),
|
||||
)
|
||||
if runErr == nil {
|
||||
break
|
||||
if runErr != nil {
|
||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
|
||||
return
|
||||
}
|
||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||
mw := &h.config.MultiAgent.EinoMiddleware
|
||||
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||
continue
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
|
||||
return
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, "eino_single", result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if prep.AssistantMessageID != "" {
|
||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
||||
}
|
||||
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, "eino_single", result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||
if 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{
|
||||
"response": result.Response,
|
||||
"conversationId": prep.ConversationID,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"assistantMessageId": prep.AssistantMessageID,
|
||||
"agentMode": "eino_single",
|
||||
"response": responseText,
|
||||
"conversationId": prep.ConversationID,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"assistantMessageId": prep.AssistantMessageID,
|
||||
"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
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/agentfinalizer"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
@@ -158,6 +159,12 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
|
||||
stopKeepalive := runSSEKeepalive(c, &sseWriteMu)
|
||||
defer stopKeepalive()
|
||||
runCfg, _, err := h.configForAIChannel(req.AIChannelID)
|
||||
if err != nil {
|
||||
sendEvent("error", err.Error(), nil)
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return
|
||||
}
|
||||
|
||||
var result *multiagent.RunResult
|
||||
var runErr error
|
||||
@@ -191,6 +198,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||
var mainIterationOffset 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 {
|
||||
segmentMainIterationMax := 0
|
||||
@@ -232,8 +246,8 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
|
||||
result, runErr = multiagent.RunDeepAgent(
|
||||
taskCtxLoop,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
runCfg,
|
||||
&runCfg.MultiAgent,
|
||||
h.agent,
|
||||
h.db,
|
||||
h.logger,
|
||||
@@ -261,6 +275,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||
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()
|
||||
break
|
||||
}
|
||||
@@ -361,9 +382,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
|
||||
timeoutCancel()
|
||||
|
||||
if assistantMessageID != "" {
|
||||
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
||||
if decision.CompletionReason == "" {
|
||||
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 err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||
@@ -371,16 +393,19 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
|
||||
if o := strings.TrimSpace(req.Orchestration); o != "" {
|
||||
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
||||
responseText := decision.FinalText
|
||||
if !decision.Finalizable {
|
||||
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,
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": "eino_" + effectiveOrch,
|
||||
})
|
||||
"agentMode": agentMode,
|
||||
}))
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
}
|
||||
|
||||
@@ -421,16 +446,29 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
|
||||
return h.interceptHITLForEinoTool(ctx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil, toolName, arguments)
|
||||
})
|
||||
runCfg, _, err := h.configForAIChannel(req.AIChannelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
curHist := prep.History
|
||||
curMsg := prep.FinalMessage
|
||||
var result *multiagent.RunResult
|
||||
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 {
|
||||
result, runErr = multiagent.RunDeepAgent(
|
||||
taskCtx,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
runCfg,
|
||||
&runCfg.MultiAgent,
|
||||
h.agent,
|
||||
h.db,
|
||||
h.logger,
|
||||
@@ -445,24 +483,30 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
chatReasoningToClientIntent(req.Reasoning),
|
||||
h.agentSessionContextBlock(prep.ConversationID),
|
||||
)
|
||||
if runErr == nil {
|
||||
break
|
||||
if runErr != nil {
|
||||
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) {
|
||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||
mw := &h.config.MultiAgent.EinoMiddleware
|
||||
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, agentMode, result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||
continue
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
||||
return
|
||||
break
|
||||
}
|
||||
|
||||
if prep.AssistantMessageID != "" {
|
||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
||||
}
|
||||
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, agentMode, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||
|
||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||
if err := h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||
@@ -470,11 +514,23 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
responseText := decision.FinalText
|
||||
if !decision.Finalizable {
|
||||
responseText = finalizationBlockedMessage(decision)
|
||||
}
|
||||
c.JSON(http.StatusOK, ChatResponse{
|
||||
Response: result.Response,
|
||||
MCPExecutionIDs: result.MCPExecutionIDs,
|
||||
ConversationID: prep.ConversationID,
|
||||
Time: time.Now(),
|
||||
Response: responseText,
|
||||
MCPExecutionIDs: result.MCPExecutionIDs,
|
||||
ConversationID: prep.ConversationID,
|
||||
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/audit"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
"cyberstrike-ai/internal/security"
|
||||
@@ -25,6 +26,13 @@ type multiAgentPrepared struct {
|
||||
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) {
|
||||
if len(req.Attachments) > 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.ProjectID = projectID
|
||||
meta.RoleName = req.Role
|
||||
meta.AgentMode = chatRequestAgentMode(req, source)
|
||||
if webshellID != "" {
|
||||
meta.Source = source + "_webshell"
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
|
||||
+142
-18
@@ -35,6 +35,17 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
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{}{
|
||||
"openapi": "3.0.0",
|
||||
"info": map[string]interface{}{
|
||||
@@ -85,6 +96,70 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
"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{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@@ -1581,6 +1656,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"conversationId": map[string]interface{}{"type": "string"},
|
||||
"role": map[string]interface{}{"type": "string"},
|
||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||
"finalization": finalizationRequestSchema,
|
||||
},
|
||||
"required": []string{"message"},
|
||||
},
|
||||
@@ -1588,7 +1664,14 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
},
|
||||
"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": "参数错误"},
|
||||
"401": map[string]interface{}{"description": "未授权"},
|
||||
"500": map[string]interface{}{"description": "执行失败"},
|
||||
@@ -1599,7 +1682,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"对话交互"},
|
||||
"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",
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
@@ -1612,6 +1695,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"conversationId": map[string]interface{}{"type": "string"},
|
||||
"role": map[string]interface{}{"type": "string"},
|
||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||
"finalization": finalizationRequestSchema,
|
||||
},
|
||||
"required": []string{"message"},
|
||||
},
|
||||
@@ -1625,7 +1709,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"text/event-stream": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"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",
|
||||
"description": "WebShell 连接 ID(可选,与 Eino 单/多代理流式行为一致)",
|
||||
},
|
||||
"finalization": finalizationRequestSchema,
|
||||
"orchestration": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Eino 预置编排:deep | plan_execute | supervisor;缺省 deep",
|
||||
@@ -1676,7 +1761,12 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
"responses": 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": "参数错误"},
|
||||
"401": map[string]interface{}{"description": "未授权"},
|
||||
@@ -1689,7 +1779,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"对话交互"},
|
||||
"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",
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
@@ -1702,6 +1792,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"conversationId": map[string]interface{}{"type": "string"},
|
||||
"role": map[string]interface{}{"type": "string"},
|
||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||
"finalization": finalizationRequestSchema,
|
||||
"orchestration": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "deep | plan_execute | supervisor;缺省 deep",
|
||||
@@ -1720,7 +1811,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"text/event-stream": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "SSE 流",
|
||||
"description": "SSE 流。终态 response 事件 data 包含 finalized、finalizable、status、completionReason、evidenceVerified、evidenceRefs、pendingExecutionIds、missingChecks;过程事件可能包含 finalization_auto_continue。",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -5803,7 +5894,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"parameters": []map[string]interface{}{
|
||||
{"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "conversation_artifact"}}},
|
||||
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/workspace/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "workspace", "conversation_artifact"}}},
|
||||
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "page", "in": "query", "required": false, "description": "页码,从1开始", "schema": map[string]interface{}{"type": "integer", "default": 1}},
|
||||
{"name": "pageSize", "in": "query", "required": false, "description": "每页数量,传 all 返回全部", "schema": map[string]interface{}{"oneOf": []map[string]interface{}{{"type": "integer"}, {"type": "string", "enum": []string{"all"}}}}},
|
||||
@@ -5821,16 +5912,18 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"items": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"relativePath": map[string]interface{}{"type": "string"},
|
||||
"absolutePath": map[string]interface{}{"type": "string"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
"size": map[string]interface{}{"type": "integer"},
|
||||
"modifiedUnix": map[string]interface{}{"type": "integer"},
|
||||
"date": map[string]interface{}{"type": "string"},
|
||||
"conversationId": map[string]interface{}{"type": "string"},
|
||||
"projectId": map[string]interface{}{"type": "string"},
|
||||
"subPath": map[string]interface{}{"type": "string"},
|
||||
"source": map[string]interface{}{"type": "string", "description": "upload/reduction/conversation_artifact"},
|
||||
"relativePath": map[string]interface{}{"type": "string"},
|
||||
"absolutePath": map[string]interface{}{"type": "string"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
"size": map[string]interface{}{"type": "integer"},
|
||||
"modifiedUnix": map[string]interface{}{"type": "integer"},
|
||||
"date": map[string]interface{}{"type": "string"},
|
||||
"conversationId": map[string]interface{}{"type": "string"},
|
||||
"conversationTitle": map[string]interface{}{"type": "string"},
|
||||
"projectId": map[string]interface{}{"type": "string"},
|
||||
"projectName": map[string]interface{}{"type": "string"},
|
||||
"subPath": map[string]interface{}{"type": "string"},
|
||||
"source": map[string]interface{}{"type": "string", "description": "upload/reduction/workspace/conversation_artifact"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -5923,7 +6016,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"parameters": []map[string]interface{}{
|
||||
{"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "conversation_artifact"}}},
|
||||
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/workspace/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "workspace", "conversation_artifact"}}},
|
||||
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
@@ -5962,6 +6055,37 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat-uploads/path": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"对话附件"},
|
||||
"summary": "解析附件路径",
|
||||
"description": "将文件管理中的相对路径或内部虚拟路径解析为服务器绝对路径,用于复制文件/目录路径。",
|
||||
"operationId": "resolveChatUploadPath",
|
||||
"parameters": []map[string]interface{}{
|
||||
{"name": "path", "in": "query", "required": true, "description": "相对路径或虚拟路径(如 __workspace__/projects/<id>/csv)", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "kind", "in": "query", "required": false, "description": "路径类型:file/directory,默认 file", "schema": map[string]interface{}{"type": "string", "enum": []string{"file", "directory"}}},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "解析成功",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"absolutePath": map[string]interface{}{"type": "string"},
|
||||
"isDir": map[string]interface{}{"type": "boolean"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": map[string]interface{}{"description": "未授权"},
|
||||
"403": map[string]interface{}{"description": "无权访问"},
|
||||
"404": map[string]interface{}{"description": "路径不存在"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat-uploads/content": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"对话附件"},
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -146,6 +148,131 @@ func TestChatUploadPathAuthorizationFollowsConversationAccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatUploadsListIncludesAuthorizedProjectWorkspaceFiles(t *testing.T) {
|
||||
db, user := setupConversationRBACTest(t)
|
||||
fsBase := t.TempDir()
|
||||
workspaceBase := filepath.Join(fsBase, "workspace")
|
||||
reductionBase := filepath.Join(fsBase, "reduction")
|
||||
db.SetEinoConversationDirs("", "", reductionBase, workspaceBase)
|
||||
allowedProject, _ := db.CreateProject(&database.Project{Name: "allowed"})
|
||||
hiddenProject, _ := db.CreateProject(&database.Project{Name: "hidden"})
|
||||
conversation, _ := db.CreateConversation("project conversation", database.ConversationCreateMeta{ProjectID: allowedProject.ID})
|
||||
if err := db.AssignResourceToUser(user.ID, "project", allowedProject.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowedFile := filepath.Join(workspaceBase, "projects", allowedProject.ID, "csv", "assets.csv")
|
||||
hiddenFile := filepath.Join(workspaceBase, "projects", hiddenProject.ID, "csv", "secret.csv")
|
||||
for _, path := range []string{allowedFile, hiddenFile} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("name\nexample\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
h := NewChatUploadsHandler(zap.NewNop(), db)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads?source=workspace&pageSize=all&conversation="+conversation.ID, nil)
|
||||
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||
h.List(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Files []ChatUploadFileItem `json:"files"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Total != 1 || len(response.Files) != 1 {
|
||||
t.Fatalf("files = %#v, total = %d, want only authorized workspace file", response.Files, response.Total)
|
||||
}
|
||||
got := response.Files[0]
|
||||
if got.Source != chatUploadSourceWorkspace || got.Name != "assets.csv" || got.ProjectID != allowedProject.ID {
|
||||
t.Fatalf("workspace file = %#v", got)
|
||||
}
|
||||
if got.ProjectName != allowedProject.Name {
|
||||
t.Fatalf("projectName = %q, want %q", got.ProjectName, allowedProject.Name)
|
||||
}
|
||||
if got.ConversationID != conversation.ID {
|
||||
t.Fatalf("conversationId = %q, want %q", got.ConversationID, conversation.ID)
|
||||
}
|
||||
if got.ConversationTitle != conversation.Title {
|
||||
t.Fatalf("conversationTitle = %q, want %q", got.ConversationTitle, conversation.Title)
|
||||
}
|
||||
if got.AbsolutePath != allowedFile {
|
||||
t.Fatalf("absolutePath = %q, want %q", got.AbsolutePath, allowedFile)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
c, _ = gin.CreateTestContext(w)
|
||||
resolveURL := "/api/chat-uploads/path?kind=directory&path=__workspace__%2Fprojects%2F" + allowedProject.ID
|
||||
c.Request = httptest.NewRequest(http.MethodGet, resolveURL, nil)
|
||||
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||
h.ResolvePath(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("resolve status = %d, want 200: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resolved struct {
|
||||
AbsolutePath string `json:"absolutePath"`
|
||||
IsDir bool `json:"isDir"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDir := filepath.Join(workspaceBase, "projects", allowedProject.ID)
|
||||
if !resolved.IsDir || resolved.AbsolutePath != wantDir {
|
||||
t.Fatalf("resolved = %#v, want dir %q", resolved, wantDir)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
c, _ = gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads/path?kind=directory&path=__workspace__%2Fprojects", nil)
|
||||
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||
h.ResolvePath(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("resolve projects container status = %d, want 200: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantContainer := filepath.Join(workspaceBase, "projects")
|
||||
if !resolved.IsDir || resolved.AbsolutePath != wantContainer {
|
||||
t.Fatalf("resolved container = %#v, want dir %q", resolved, wantContainer)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"__workspace__/", workspaceBase},
|
||||
{"__reduction__/", reductionBase},
|
||||
{"__conversation_artifact__/", db.ConversationArtifactsBaseDir()},
|
||||
} {
|
||||
if err := os.MkdirAll(tc.want, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
c, _ = gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads/path?kind=directory&path="+url.QueryEscape(tc.path), nil)
|
||||
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||
h.ResolvePath(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("resolve root %q status = %d, want 200: %s", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantAbs, _ := filepath.Abs(tc.want)
|
||||
if !resolved.IsDir || resolved.AbsolutePath != wantAbs {
|
||||
t.Fatalf("resolved root %q = %#v, want dir %q", tc.path, resolved, wantAbs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareMultiAgentSessionRejectsForeignConversation(t *testing.T) {
|
||||
db, user := setupConversationRBACTest(t)
|
||||
hidden, _ := db.CreateConversation("hidden", database.ConversationCreateMeta{})
|
||||
|
||||
@@ -352,26 +352,28 @@ func NewWebShellHandler(logger *zap.Logger, db *database.DB) *WebShellHandler {
|
||||
|
||||
// CreateConnectionRequest 创建连接请求
|
||||
type CreateConnectionRequest struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
Type string `json:"type"`
|
||||
Method string `json:"method"`
|
||||
CmdParam string `json:"cmd_param"`
|
||||
Remark string `json:"remark"`
|
||||
Encoding string `json:"encoding"`
|
||||
OS string `json:"os"`
|
||||
ProjectID string `json:"project_id"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
Type string `json:"type"`
|
||||
Method string `json:"method"`
|
||||
CmdParam string `json:"cmd_param"`
|
||||
Remark string `json:"remark"`
|
||||
Encoding string `json:"encoding"`
|
||||
OS string `json:"os"`
|
||||
}
|
||||
|
||||
// UpdateConnectionRequest 更新连接请求
|
||||
type UpdateConnectionRequest struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
Type string `json:"type"`
|
||||
Method string `json:"method"`
|
||||
CmdParam string `json:"cmd_param"`
|
||||
Remark string `json:"remark"`
|
||||
Encoding string `json:"encoding"`
|
||||
OS string `json:"os"`
|
||||
ProjectID string `json:"project_id"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
Type string `json:"type"`
|
||||
Method string `json:"method"`
|
||||
CmdParam string `json:"cmd_param"`
|
||||
Remark string `json:"remark"`
|
||||
Encoding string `json:"encoding"`
|
||||
OS string `json:"os"`
|
||||
}
|
||||
|
||||
// ListConnections 列出所有 WebShell 连接(GET /api/webshell/connections)
|
||||
@@ -381,7 +383,7 @@ func (h *WebShellHandler) ListConnections(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
session, _ := security.CurrentSession(c)
|
||||
list, err := h.db.ListWebshellConnectionsForAccess(session.UserID, session.Scope)
|
||||
list, err := h.db.ListWebshellConnectionsForAccess(session.UserID, session.Scope, c.Query("project_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -412,6 +414,11 @@ func (h *WebShellHandler) CreateConnection(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
|
||||
return
|
||||
}
|
||||
projectID := strings.TrimSpace(req.ProjectID)
|
||||
if !h.canAccessProject(c, projectID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "project access denied"})
|
||||
return
|
||||
}
|
||||
method := strings.ToLower(strings.TrimSpace(req.Method))
|
||||
if method != "get" && method != "post" {
|
||||
method = "post"
|
||||
@@ -422,6 +429,7 @@ func (h *WebShellHandler) CreateConnection(c *gin.Context) {
|
||||
}
|
||||
conn := &database.WebShellConnection{
|
||||
ID: "ws_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12],
|
||||
ProjectID: projectID,
|
||||
URL: req.URL,
|
||||
Password: strings.TrimSpace(req.Password),
|
||||
Type: shellType,
|
||||
@@ -477,6 +485,11 @@ func (h *WebShellHandler) UpdateConnection(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
|
||||
return
|
||||
}
|
||||
projectID := strings.TrimSpace(req.ProjectID)
|
||||
if !h.canAccessProject(c, projectID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "project access denied"})
|
||||
return
|
||||
}
|
||||
method := strings.ToLower(strings.TrimSpace(req.Method))
|
||||
if method != "get" && method != "post" {
|
||||
method = "post"
|
||||
@@ -486,15 +499,16 @@ func (h *WebShellHandler) UpdateConnection(c *gin.Context) {
|
||||
shellType = "php"
|
||||
}
|
||||
conn := &database.WebShellConnection{
|
||||
ID: id,
|
||||
URL: req.URL,
|
||||
Password: strings.TrimSpace(req.Password),
|
||||
Type: shellType,
|
||||
Method: method,
|
||||
CmdParam: strings.TrimSpace(req.CmdParam),
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
Encoding: normalizeWebshellEncoding(req.Encoding),
|
||||
OS: normalizeWebshellOS(req.OS),
|
||||
ID: id,
|
||||
ProjectID: projectID,
|
||||
URL: req.URL,
|
||||
Password: strings.TrimSpace(req.Password),
|
||||
Type: shellType,
|
||||
Method: method,
|
||||
CmdParam: strings.TrimSpace(req.CmdParam),
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
Encoding: normalizeWebshellEncoding(req.Encoding),
|
||||
OS: normalizeWebshellOS(req.OS),
|
||||
}
|
||||
if err := h.db.UpdateWebshellConnection(conn); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -940,6 +954,21 @@ func (h *WebShellHandler) authorizedWebshellConnection(c *gin.Context, connectio
|
||||
return conn, true
|
||||
}
|
||||
|
||||
func (h *WebShellHandler) canAccessProject(c *gin.Context, projectID string) bool {
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" || h.db == nil {
|
||||
return true
|
||||
}
|
||||
session, ok := security.CurrentSession(c)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if session.Scope == database.RBACScopeAll {
|
||||
return true
|
||||
}
|
||||
return h.db.UserCanAccessResource(session.UserID, session.Scope, "project", projectID)
|
||||
}
|
||||
|
||||
// ExecWithConnection 在指定 WebShell 连接上执行命令(供 MCP/Agent 等非 HTTP 调用)
|
||||
func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection, command string) (output string, ok bool, errMsg string) {
|
||||
if conn == nil {
|
||||
|
||||
@@ -152,20 +152,37 @@ func (h *AgentHandler) runRoleWorkflowStreamIfBound(
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return true
|
||||
}
|
||||
if prep.AssistantMessageID != "" {
|
||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
|
||||
decision := h.finalizeCandidateForDeliveryWithPolicy(
|
||||
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,
|
||||
"messageId": prep.AssistantMessageID,
|
||||
"agentMode": "workflow",
|
||||
"workflowRunId": result.RunID,
|
||||
}
|
||||
})
|
||||
if result.AwaitingHITL {
|
||||
payload["workflowStatus"] = "awaiting_hitl"
|
||||
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})
|
||||
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})
|
||||
return true
|
||||
}
|
||||
if prep.AssistantMessageID != "" {
|
||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
|
||||
decision := h.finalizeCandidateForDeliveryWithPolicy(
|
||||
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{
|
||||
"response": result.Response,
|
||||
"conversationId": prep.ConversationID,
|
||||
"assistantMessageId": prep.AssistantMessageID,
|
||||
"agentMode": "workflow",
|
||||
"workflowRunId": result.RunID,
|
||||
"workflowStatus": result.Status,
|
||||
"awaitingHitl": result.AwaitingHITL,
|
||||
"response": responseText,
|
||||
"conversationId": prep.ConversationID,
|
||||
"assistantMessageId": prep.AssistantMessageID,
|
||||
"agentMode": "workflow",
|
||||
"workflowRunId": result.RunID,
|
||||
"workflowStatus": result.Status,
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -674,48 +674,6 @@ func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) {
|
||||
|
||||
// CallTool 调用外部MCP工具(返回执行ID)
|
||||
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 {
|
||||
m.executionService = NewExecutionService(m.storage, m.logger)
|
||||
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 {
|
||||
ownerUserID = principal.UserID
|
||||
}
|
||||
var mcpName, actualToolName string
|
||||
var client ExternalMCPClient
|
||||
handle, err := m.executionService.Submit(ctx, ExecutionRequest{
|
||||
ToolName: toolName,
|
||||
Arguments: args,
|
||||
ConversationID: MCPConversationIDFromContext(ctx),
|
||||
OwnerUserID: ownerUserID,
|
||||
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)
|
||||
if acquireErr != nil {
|
||||
return nil, acquireErr
|
||||
@@ -746,7 +749,9 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
|
||||
},
|
||||
OnDone: func(exec *ToolExecution) {
|
||||
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled
|
||||
m.recordExternalMCPResult(mcpName, failed)
|
||||
if mcpName != "" {
|
||||
m.recordExternalMCPResult(mcpName, failed)
|
||||
}
|
||||
m.updateStats(toolName, failed)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -20,10 +20,20 @@ func TestExternalManagerEnforcesConfiguredAuthorizer(t *testing.T) {
|
||||
return errors.New("denied by policy")
|
||||
})
|
||||
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") {
|
||||
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) {
|
||||
|
||||
+15
-19
@@ -895,25 +895,6 @@ func (s *Server) GetAllTools() []Tool {
|
||||
|
||||
// CallTool 直接调用工具(用于内部调用)
|
||||
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 {
|
||||
s.executionService = NewExecutionService(s.storage, s.logger)
|
||||
s.executionService.ConfigureToolResultMaxBytes(s.toolResultMaxBytes)
|
||||
@@ -929,6 +910,21 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
|
||||
ConversationID: MCPConversationIDFromContext(ctx),
|
||||
OwnerUserID: ownerUserID,
|
||||
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)
|
||||
},
|
||||
OnDone: func(exec *ToolExecution) {
|
||||
|
||||
@@ -23,9 +23,20 @@ func TestToolAuthorizerIsUniversalAndExecutionKeepsOwner(t *testing.T) {
|
||||
}
|
||||
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")
|
||||
}
|
||||
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}))
|
||||
_, executionID, err := server.CallTool(ctx, "echo", nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -60,6 +60,7 @@ func isEinoTransientRunError(err error) bool {
|
||||
"bad gateway",
|
||||
"gateway timeout",
|
||||
"internal server error",
|
||||
"unexpected internal error",
|
||||
"connection reset",
|
||||
"connection refused",
|
||||
"connection closed",
|
||||
@@ -72,6 +73,7 @@ func isEinoTransientRunError(err error) bool {
|
||||
"dial tcp",
|
||||
"tls handshake timeout",
|
||||
"stream error",
|
||||
"failed to receive stream chunk",
|
||||
"goaway", // http2: server sent GOAWAY and closed the connection
|
||||
"unexpected 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") ||
|
||||
strings.Contains(lower, "capacity") ||
|
||||
strings.Contains(lower, "temporarily unavailable") ||
|
||||
strings.Contains(lower, "service unavailable"):
|
||||
strings.Contains(lower, "service unavailable") ||
|
||||
strings.Contains(lower, "unexpected internal error"):
|
||||
kind = "upstream_busy"
|
||||
case strings.Contains(lower, "connection reset") ||
|
||||
strings.Contains(lower, "connection refused") ||
|
||||
@@ -153,6 +156,7 @@ func einoTransientRunErrorUserDetail(err error) (kind, summary string) {
|
||||
strings.Contains(lower, "unexpected eof"):
|
||||
kind = "network"
|
||||
case strings.Contains(lower, "stream error") ||
|
||||
strings.Contains(lower, "failed to receive stream chunk") ||
|
||||
strings.Contains(lower, "unexpected end of json"):
|
||||
kind = "stream"
|
||||
default:
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestIsEinoTransientRunError(t *testing.T) {
|
||||
{"rate limit", errors.New(`{"error":"rate limit exceeded"}`), 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},
|
||||
{"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},
|
||||
{"503", errors.New("upstream returned 503"), true},
|
||||
{"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"},
|
||||
{"network", errors.New("read tcp: connection reset by peer"), "network"},
|
||||
{"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 {
|
||||
tc := tc
|
||||
|
||||
@@ -3,6 +3,7 @@ package multiagent
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
@@ -65,11 +66,51 @@ func hitlClearReturnDirectlyIfTransfer(ctx context.Context, toolName string) {
|
||||
})
|
||||
}
|
||||
|
||||
func hitlEditedArgumentsNotice(original, edited string) string {
|
||||
original = strings.TrimSpace(original)
|
||||
edited = strings.TrimSpace(edited)
|
||||
if edited == "" || edited == original {
|
||||
return ""
|
||||
}
|
||||
return "[HITL] Human reviewer approved this tool call with edited arguments.\n" +
|
||||
"Original arguments: " + original + "\n" +
|
||||
"Executed arguments: " + edited + "\n\n"
|
||||
}
|
||||
|
||||
func hitlPrependEditedArgumentsNotice(result, original, edited string) string {
|
||||
notice := hitlEditedArgumentsNotice(original, edited)
|
||||
if notice == "" {
|
||||
return result
|
||||
}
|
||||
return notice + result
|
||||
}
|
||||
|
||||
func hitlCollectStringStream(sr *schema.StreamReader[string]) (string, error) {
|
||||
if sr == nil {
|
||||
return "", nil
|
||||
}
|
||||
defer sr.Close()
|
||||
var b strings.Builder
|
||||
for {
|
||||
chunk, err := sr.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return b.String(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return b.String(), err
|
||||
}
|
||||
b.WriteString(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
func hitlInvokableToolCallMiddleware() compose.InvokableToolMiddleware {
|
||||
return func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint {
|
||||
return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
||||
originalArgs := ""
|
||||
editedArgs := ""
|
||||
if input != nil {
|
||||
if fn, ok := ctx.Value(hitlInterceptorKey{}).(HITLToolInterceptor); ok && fn != nil {
|
||||
originalArgs = input.Arguments
|
||||
edited, err := fn(ctx, input.Name, input.Arguments)
|
||||
if err != nil {
|
||||
if IsHumanRejectError(err) {
|
||||
@@ -85,11 +126,17 @@ func hitlInvokableToolCallMiddleware() compose.InvokableToolMiddleware {
|
||||
return nil, err
|
||||
}
|
||||
if edited != "" {
|
||||
editedArgs = edited
|
||||
input.Arguments = edited
|
||||
}
|
||||
}
|
||||
}
|
||||
return next(ctx, input)
|
||||
out, err := next(ctx, input)
|
||||
if err != nil || out == nil {
|
||||
return out, err
|
||||
}
|
||||
out.Result = hitlPrependEditedArgumentsNotice(out.Result, originalArgs, editedArgs)
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,8 +144,11 @@ func hitlInvokableToolCallMiddleware() compose.InvokableToolMiddleware {
|
||||
func hitlStreamableToolCallMiddleware() compose.StreamableToolMiddleware {
|
||||
return func(next compose.StreamableToolEndpoint) compose.StreamableToolEndpoint {
|
||||
return func(ctx context.Context, input *compose.ToolInput) (*compose.StreamToolOutput, error) {
|
||||
originalArgs := ""
|
||||
editedArgs := ""
|
||||
if input != nil {
|
||||
if fn, ok := ctx.Value(hitlInterceptorKey{}).(HITLToolInterceptor); ok && fn != nil {
|
||||
originalArgs = input.Arguments
|
||||
edited, err := fn(ctx, input.Name, input.Arguments)
|
||||
if err != nil {
|
||||
if IsHumanRejectError(err) {
|
||||
@@ -111,11 +161,26 @@ func hitlStreamableToolCallMiddleware() compose.StreamableToolMiddleware {
|
||||
return nil, err
|
||||
}
|
||||
if edited != "" {
|
||||
editedArgs = edited
|
||||
input.Arguments = edited
|
||||
}
|
||||
}
|
||||
}
|
||||
return next(ctx, input)
|
||||
out, err := next(ctx, input)
|
||||
if err != nil || out == nil {
|
||||
return out, err
|
||||
}
|
||||
if hitlEditedArgumentsNotice(originalArgs, editedArgs) == "" {
|
||||
return out, nil
|
||||
}
|
||||
result, collectErr := hitlCollectStringStream(out.Result)
|
||||
if collectErr != nil {
|
||||
return nil, collectErr
|
||||
}
|
||||
out.Result = schema.StreamReaderFromArray([]string{
|
||||
hitlPrependEditedArgumentsNotice(result, originalArgs, editedArgs),
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,17 @@ func (m *modelOutputGuardMiddleware) AfterModelRewriteState(
|
||||
badIndex := -1
|
||||
argumentBytes := 0
|
||||
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 {
|
||||
for i, tc := range last.ToolCalls {
|
||||
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) {
|
||||
for _, arguments := range []string{"", `[]`, `{"command":`} {
|
||||
t.Run(arguments, func(t *testing.T) {
|
||||
|
||||
@@ -40,6 +40,13 @@ type RunResult struct {
|
||||
MCPExecutionIDs []string
|
||||
LastAgentTraceInput string // 已序列化的消息带(JSON):原生循环或 Eino 均写入,供续跑/攻击链等恢复上下文
|
||||
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
|
||||
|
||||
@@ -893,7 +893,7 @@ func NewEinoHTTPClient(cfg *config.OpenAIConfig, base *http.Client) *http.Client
|
||||
if transport == nil {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
transport = &reasoningToolChoiceCompatRoundTripper{base: transport}
|
||||
transport = &reasoningToolChoiceCompatRoundTripper{base: transport, cfg: cfg}
|
||||
if isClaudeProvider(cfg) {
|
||||
transport = &claudeRoundTripper{
|
||||
base: transport,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
@@ -52,6 +54,28 @@ func StripReasoningIfForcedToolChoice(rawBody []byte) ([]byte, error) {
|
||||
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 {
|
||||
changed := false
|
||||
for _, key := range reasoningPayloadKeys {
|
||||
@@ -77,3 +101,17 @@ func forcedToolChoiceIncompatibleWithThinking(payload map[string]any) bool {
|
||||
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"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
)
|
||||
|
||||
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) {
|
||||
var gotBody string
|
||||
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)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
)
|
||||
|
||||
// reasoningToolChoiceCompatRoundTripper strips thinking/reasoning fields from
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
// when thinking mode is enabled on the same request.
|
||||
type reasoningToolChoiceCompatRoundTripper struct {
|
||||
base http.RoundTripper
|
||||
cfg *config.OpenAIConfig
|
||||
}
|
||||
|
||||
func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
@@ -32,7 +35,13 @@ func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (*
|
||||
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 {
|
||||
patched = body
|
||||
}
|
||||
@@ -41,3 +50,19 @@ func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (*
|
||||
req.Header.Set("Content-Length", strconv.Itoa(len(patched)))
|
||||
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 (
|
||||
bodyDepFactLine = regexp.MustCompile(`(?im)^[\s\-*]*依赖事实\s*[::]\s*([a-z0-9][a-z0-9._/-]*)`)
|
||||
bodyRelFactLine = regexp.MustCompile(`(?im)^[\s\-*]*相关\s*fact_key\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-zA-Z0-9][a-zA-Z0-9._/-]*)`)
|
||||
bodyAssocSection = regexp.MustCompile(`(?im)^##\s*关联\s*$`)
|
||||
bodySyncLinksHead = "结构化关系边(自动同步)"
|
||||
)
|
||||
|
||||
@@ -61,25 +61,30 @@ show_progress() {
|
||||
printf "\r"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " CyberStrikeAI Deploy & Start Script"
|
||||
echo " (HTTPS with self-signed cert by default; plain HTTP: $0 --http)"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
print_banner() {
|
||||
local show_mirrors="${1:-1}"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " CyberStrikeAI Deploy & Start Script"
|
||||
echo " (HTTPS with self-signed cert by default; plain HTTP: $0 --http)"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Show temporary mirror/proxy info
|
||||
echo ""
|
||||
warning "Note: this script uses temporary mirrors to speed up downloads"
|
||||
echo ""
|
||||
info "Python pip temporary mirror:"
|
||||
echo " ${PIP_INDEX_URL}"
|
||||
info "Go temporary proxy:"
|
||||
echo " ${GOPROXY}"
|
||||
echo ""
|
||||
note "These settings apply only while this script runs and do not change system config"
|
||||
echo ""
|
||||
sleep 1
|
||||
if [ "$show_mirrors" -eq 1 ]; then
|
||||
# Show temporary mirror/proxy info
|
||||
echo ""
|
||||
warning "Note: this script uses temporary mirrors to speed up downloads"
|
||||
echo ""
|
||||
info "Python pip temporary mirror:"
|
||||
echo " ${PIP_INDEX_URL}"
|
||||
info "Go temporary proxy:"
|
||||
echo " ${GOPROXY}"
|
||||
echo ""
|
||||
note "These settings apply only while this script runs and do not change system config"
|
||||
echo ""
|
||||
sleep 1
|
||||
fi
|
||||
}
|
||||
|
||||
CONFIG_FILE="$ROOT_DIR/config.yaml"
|
||||
EXAMPLE_CONFIG_FILE="$ROOT_DIR/config.example.yaml"
|
||||
@@ -136,6 +141,28 @@ check_go() {
|
||||
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
|
||||
setup_python_env() {
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
@@ -331,6 +358,34 @@ build_go_project() {
|
||||
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
|
||||
need_rebuild() {
|
||||
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.
|
||||
main() {
|
||||
USE_HTTPS=1
|
||||
RESET_ADMIN_PASSWORD=0
|
||||
FORWARD_ARGS=()
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--http" ]; then
|
||||
@@ -361,9 +417,33 @@ main() {
|
||||
USE_HTTPS=1
|
||||
continue
|
||||
fi
|
||||
if [ "$arg" = "--reset-admin-password" ]; then
|
||||
RESET_ADMIN_PASSWORD=1
|
||||
continue
|
||||
fi
|
||||
FORWARD_ARGS+=("$arg")
|
||||
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
|
||||
info "Checking runtime environment..."
|
||||
check_python
|
||||
@@ -417,5 +497,5 @@ main() {
|
||||
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 "$@"
|
||||
|
||||
+1338
-288
File diff suppressed because it is too large
Load Diff
+1816
-42
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 |
@@ -708,6 +708,12 @@
|
||||
"reasoningCompactAria": "Open model reasoning options",
|
||||
"reasoningPanelTitle": "Model reasoning",
|
||||
"reasoningPanelHint": "Only Eino single- and multi-agent requests use these; merged with defaults in Settings.",
|
||||
"sessionSettingsTitle": "Session settings",
|
||||
"sessionSettingsAria": "Open session settings",
|
||||
"sessionSettingsHint": "AI channel, reasoning, and HITL settings only affect future messages.",
|
||||
"aiChannelLabel": "AI channel",
|
||||
"aiChannelDefault": "Use default channel",
|
||||
"aiChannelDefaultShort": "Default channel",
|
||||
"reasoningSummaryFollow": "System",
|
||||
"reasoningSummaryDash": "—",
|
||||
"agentModeOrchPlanExecute": "Plan-Exec",
|
||||
@@ -729,7 +735,7 @@
|
||||
"hitlWhitelistHint": "Separate with commas or new lines; shown merged with the global allowlist in config.",
|
||||
"hitlApply": "Apply",
|
||||
"hitlApplyOkSync": "HITL settings saved and synced to the server.",
|
||||
"hitlApplyOkWhitelistYaml": "Tool whitelist merged into config.yaml and active. Mode and timeout still require selecting a conversation and clicking Apply to sync session settings to the server.",
|
||||
"hitlApplyOkWhitelistYaml": "Tool whitelist merged into config.yaml and active. Session settings are saved automatically.",
|
||||
"hitlApplyOkLocal": "Saved in this browser.",
|
||||
"hitlApplyFail": "Failed to sync to server",
|
||||
"hitlStatusOff": "Human-in-the-loop: Off"
|
||||
@@ -1100,6 +1106,7 @@
|
||||
"importValidRows": "Import valid rows",
|
||||
"importValidRowsCount": "Import {{count}} valid rows",
|
||||
"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",
|
||||
"fileTypeInvalid": "Only .xlsx and .csv files are supported",
|
||||
"fileTooLarge": "The file must not exceed 100 MB",
|
||||
@@ -2363,6 +2370,7 @@
|
||||
"sourceAll": "All",
|
||||
"sourceUpload": "Chat uploads",
|
||||
"sourceReduction": "Tool outputs",
|
||||
"sourceWorkspace": "Workspace files",
|
||||
"sourceConversationArtifact": "Conversation artifacts",
|
||||
"groupBy": "Group by",
|
||||
"groupNone": "None (flat list)",
|
||||
@@ -2379,6 +2387,7 @@
|
||||
"browseRoot": "Files",
|
||||
"treeUploadsRoot": "Chat uploads",
|
||||
"treeReductionRoot": "Tool outputs",
|
||||
"treeWorkspaceRoot": "Workspace files",
|
||||
"treeArtifactsRoot": "Conversation artifacts",
|
||||
"browseUp": "Up",
|
||||
"enterFolderTitle": "Open folder",
|
||||
@@ -2588,11 +2597,54 @@
|
||||
},
|
||||
"settingsBasic": {
|
||||
"basicTitle": "Basic settings",
|
||||
"openaiConfig": "OpenAI config",
|
||||
"openaiConfig": "AI channel config",
|
||||
"aiChannelCurrent": "Current channel",
|
||||
"aiChannelSave": "Save changes",
|
||||
"aiChannelSetDefault": "Set default",
|
||||
"aiChannelNew": "New",
|
||||
"aiChannelCopy": "Copy",
|
||||
"aiChannelDelete": "Delete",
|
||||
"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",
|
||||
"aiChannelListAria": "AI channel list",
|
||||
"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",
|
||||
"aiChannelReady": "Ready",
|
||||
"aiChannelReadyWithLatency": "Ready{latency}",
|
||||
"aiChannelComplete": "Complete",
|
||||
"aiChannelDraft": "Incomplete",
|
||||
"aiChannelDefaultMeta": "Default channel",
|
||||
"aiChannelCustomMeta": "Custom channel",
|
||||
"aiChannelOpenAICompat": "OpenAI compatible",
|
||||
"aiChannelModelMissing": "Model missing",
|
||||
"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",
|
||||
"aiChannelDeleteConfirm": "Delete AI channel \"{name}\"?",
|
||||
"aiChannelSaved": "Channel saved",
|
||||
"aiChannelDefaultSaved": "Default channel 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",
|
||||
"providerOpenAI": "OpenAI / OpenAI-compatible API",
|
||||
"providerClaude": "Claude (Anthropic Messages API)",
|
||||
"visionProviderReuseOpenAI": "Reuse OpenAI config (leave empty)",
|
||||
"visionProviderReuseOpenAI": "Reuse default AI channel",
|
||||
"fofaConfig": "FOFA config",
|
||||
"fofaConfigHint": "Used for asset discovery and import; only an API key is required.",
|
||||
"agentConfig": "Agent config",
|
||||
@@ -2613,8 +2665,11 @@
|
||||
"maxTotalTokens": "Max Context Tokens",
|
||||
"maxTotalTokensPlaceholder": "120000",
|
||||
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
|
||||
"openaiReasoningTitle": "Model reasoning",
|
||||
"openaiReasoningHint": "Works with the reasoning controls on the chat page.",
|
||||
"maxCompletionTokens": "Max Output Tokens",
|
||||
"maxCompletionTokensPlaceholder": "32768",
|
||||
"maxCompletionTokensHint": "Maximum tokens for a single model response. Default: 32768",
|
||||
"openaiReasoningTitle": "Reasoning settings",
|
||||
"openaiReasoningHint": "Default reasoning settings for this AI channel; chat Session settings can override them.",
|
||||
"openaiReasoningProfile": "Wire profile",
|
||||
"openaiReasoningAllowClient": "Allow chat page to override reasoning options",
|
||||
"fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all (optional)",
|
||||
@@ -2761,11 +2816,16 @@
|
||||
"visionTimeout": "Timeout (seconds)",
|
||||
"visionTestFillRequired": "Enter vision model and ensure API Key is available (or reuse OpenAI)",
|
||||
"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...",
|
||||
"testSuccess": "Connection successful",
|
||||
"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": {
|
||||
"title": "Terminal",
|
||||
@@ -3839,6 +3899,11 @@
|
||||
"infoLastCheckin": "Last check-in",
|
||||
"infoNote": "Note",
|
||||
"infoNoteEmpty": "No notes",
|
||||
"infoCopy": "Copy",
|
||||
"noteEdit": "Edit",
|
||||
"notePlaceholder": "Add a note to identify this session…",
|
||||
"toastNoteSaved": "Note saved",
|
||||
"toastNoteTooLong": "Note must be at most 2000 characters",
|
||||
"infoSectionIdentity": "Identity",
|
||||
"infoSectionSystem": "System",
|
||||
"infoSectionNetwork": "Network & beacon",
|
||||
@@ -3952,7 +4017,20 @@
|
||||
"paginationPrev": "Previous",
|
||||
"paginationPage": "Page {{current}} / {{total}}",
|
||||
"paginationNext": "Next",
|
||||
"paginationLast": "Last"
|
||||
"paginationLast": "Last",
|
||||
"statTotal": "Filtered",
|
||||
"timePresets": "Quick",
|
||||
"preset15m": "Last 15 min",
|
||||
"preset1h": "Last 1 hour",
|
||||
"preset24h": "Last 24 hours",
|
||||
"preset7d": "Last 7 days",
|
||||
"presetAll": "All",
|
||||
"filterAllStatuses": "All statuses",
|
||||
"filterAllTypes": "All types",
|
||||
"filterSession": "Session ID",
|
||||
"filterSessionPlaceholder": "s_xxxxxxxx",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset"
|
||||
},
|
||||
"payloads": {
|
||||
"386": "386",
|
||||
|
||||
@@ -696,6 +696,12 @@
|
||||
"reasoningCompactAria": "打开模型推理选项",
|
||||
"reasoningPanelTitle": "模型推理",
|
||||
"reasoningPanelHint": "仅 Eino 单代理与多代理请求会带上这些参数;与系统设置中的默认值合并。",
|
||||
"sessionSettingsTitle": "会话设置",
|
||||
"sessionSettingsAria": "打开会话设置",
|
||||
"sessionSettingsHint": "AI 通道、推理设置与人机协同只影响后续消息。",
|
||||
"aiChannelLabel": "AI 通道",
|
||||
"aiChannelDefault": "跟随默认通道",
|
||||
"aiChannelDefaultShort": "默认通道",
|
||||
"reasoningSummaryFollow": "系统",
|
||||
"reasoningSummaryDash": "—",
|
||||
"agentModeOrchPlanExecute": "Plan-Exec",
|
||||
@@ -717,7 +723,7 @@
|
||||
"hitlWhitelistHint": "白名单内工具免审批;每行一个或逗号分隔,与 config 全局白名单合并。",
|
||||
"hitlApply": "应用",
|
||||
"hitlApplyOkSync": "人机协同配置已保存并同步到服务器。",
|
||||
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。协同模式、超时等仍须选中会话后再点「应用」才会写入服务器。",
|
||||
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。",
|
||||
"hitlApplyOkLocal": "已保存到本浏览器。",
|
||||
"hitlApplyFail": "同步到服务器失败",
|
||||
"hitlStatusOff": "人机协同:关闭"
|
||||
@@ -1088,6 +1094,7 @@
|
||||
"importValidRows": "导入有效数据",
|
||||
"importValidRowsCount": "导入 {{count}} 条有效数据",
|
||||
"importPreviewSummary": "共 {{total}} 行,{{valid}} 行有效,{{invalid}} 行需修正",
|
||||
"importBackendRowError": "Excel 第 {{row}} 行(提交有效数据第 {{index}} 条):{{message}}",
|
||||
"previewLimited": "仅展示前 100 行;提交时将处理全部 {{count}} 行",
|
||||
"fileTypeInvalid": "仅支持 .xlsx 和 .csv 文件",
|
||||
"fileTooLarge": "文件不能超过 100 MB",
|
||||
@@ -2351,6 +2358,7 @@
|
||||
"sourceAll": "全部",
|
||||
"sourceUpload": "对话附件",
|
||||
"sourceReduction": "工具输出",
|
||||
"sourceWorkspace": "工作目录",
|
||||
"sourceConversationArtifact": "会话产物",
|
||||
"groupBy": "分组方式",
|
||||
"groupNone": "不分组(平铺)",
|
||||
@@ -2367,6 +2375,7 @@
|
||||
"browseRoot": "文件",
|
||||
"treeUploadsRoot": "对话附件",
|
||||
"treeReductionRoot": "工具输出",
|
||||
"treeWorkspaceRoot": "工作目录",
|
||||
"treeArtifactsRoot": "会话产物",
|
||||
"browseUp": "上级",
|
||||
"enterFolderTitle": "进入此文件夹",
|
||||
@@ -2576,11 +2585,54 @@
|
||||
},
|
||||
"settingsBasic": {
|
||||
"basicTitle": "基本设置",
|
||||
"openaiConfig": "OpenAI 配置",
|
||||
"openaiConfig": "AI 通道配置",
|
||||
"aiChannelCurrent": "当前通道",
|
||||
"aiChannelSave": "保存更改",
|
||||
"aiChannelSetDefault": "设为默认",
|
||||
"aiChannelNew": "新增",
|
||||
"aiChannelCopy": "复制",
|
||||
"aiChannelDelete": "删除",
|
||||
"aiChannelHint": "通过下拉切换已保存通道;保存后写入 ai.channels,默认通道用于新对话和未指定通道的任务。",
|
||||
"aiChannelSavedList": "已保存通道",
|
||||
"aiChannelListAria": "AI 通道列表",
|
||||
"aiChannelEditing": "正在编辑",
|
||||
"aiChannelFormContext": "编辑当前选择的通道",
|
||||
"aiChannelFormContextHint": "表单保存后会更新该通道配置。",
|
||||
"aiChannelBulkProbe": "批量探活",
|
||||
"aiChannelBulkProbeTitle": "检测全部完整通道是否可用",
|
||||
"aiChannelSelectAria": "选择 {name}",
|
||||
"aiChannelDefaultBadge": "默认",
|
||||
"aiChannelReady": "可用",
|
||||
"aiChannelReadyWithLatency": "可用{latency}",
|
||||
"aiChannelComplete": "配置完整",
|
||||
"aiChannelDraft": "待完善",
|
||||
"aiChannelDefaultMeta": "默认通道",
|
||||
"aiChannelCustomMeta": "自定义通道",
|
||||
"aiChannelOpenAICompat": "OpenAI 兼容",
|
||||
"aiChannelModelMissing": "未填写模型",
|
||||
"aiChannelName": "通道名称",
|
||||
"aiChannelConnectionSection": "连接信息",
|
||||
"aiChannelConnectionHint": "先确认服务商、地址、密钥和模型,测试连接会使用这些信息。",
|
||||
"aiChannelModelSection": "模型与额度",
|
||||
"aiChannelModelHint": "模型名决定请求路由,Token 上限控制上下文和单次输出。",
|
||||
"aiChannelLimitsSection": "额度设置",
|
||||
"aiChannelLimitsHint": "Token 上限控制上下文窗口和单次输出。",
|
||||
"aiChannelUntitled": "新通道",
|
||||
"aiChannelDeleteConfirm": "确定删除 AI 通道「{name}」吗?",
|
||||
"aiChannelSaved": "通道已保存",
|
||||
"aiChannelDefaultSaved": "已设为默认通道",
|
||||
"aiChannelCount": "已保存 {count} 个通道",
|
||||
"aiChannelSaving": "正在保存通道...",
|
||||
"aiChannelNewUnsaved": "新通道尚未保存,填写后点击「保存更改」。",
|
||||
"aiChannelCopyUnsaved": "复制的通道尚未保存,确认后点击「保存更改」。",
|
||||
"aiChannelDeleted": "通道已删除",
|
||||
"aiChannelProbeNoComplete": "没有可探活的完整通道,请先填写 Base URL、API Key 和模型",
|
||||
"aiChannelProbing": "正在探活 {count} 个通道...",
|
||||
"aiChannelProbeDone": "探活完成:{ok}/{total} 可用",
|
||||
"apiProvider": "API 提供商",
|
||||
"providerOpenAI": "OpenAI / 兼容 OpenAI 协议",
|
||||
"providerClaude": "Claude (Anthropic Messages API)",
|
||||
"visionProviderReuseOpenAI": "OpenAI 配置(留空复用)",
|
||||
"visionProviderReuseOpenAI": "默认 AI 通道(留空复用)",
|
||||
"fofaConfig": "FOFA 配置",
|
||||
"fofaConfigHint": "用于资产发现与导入,仅需配置 API Key。",
|
||||
"agentConfig": "Agent 配置",
|
||||
@@ -2601,8 +2653,11 @@
|
||||
"maxTotalTokens": "最大上下文 Token 数",
|
||||
"maxTotalTokensPlaceholder": "120000",
|
||||
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
|
||||
"openaiReasoningTitle": "模型推理",
|
||||
"openaiReasoningHint": "与对话页「模型推理」下拉配合使用。",
|
||||
"maxCompletionTokens": "最大输出 Token 数",
|
||||
"maxCompletionTokensPlaceholder": "32768",
|
||||
"maxCompletionTokensHint": "单次模型回复的输出上限,默认 32768",
|
||||
"openaiReasoningTitle": "推理设置",
|
||||
"openaiReasoningHint": "作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。",
|
||||
"openaiReasoningProfile": "线路 profile",
|
||||
"openaiReasoningAllowClient": "允许对话页覆盖推理选项",
|
||||
"fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all(可选)",
|
||||
@@ -2749,11 +2804,16 @@
|
||||
"visionTimeout": "超时(秒)",
|
||||
"visionTestFillRequired": "请填写视觉模型,并确保 API Key 可用(可复用 OpenAI)",
|
||||
"testConnection": "测试连接",
|
||||
"testFillRequired": "请先填写 API Key 和模型",
|
||||
"testFillRequired": "请先填写 Base URL、API Key 和模型",
|
||||
"testing": "测试中...",
|
||||
"testSuccess": "连接成功",
|
||||
"testFailed": "连接失败",
|
||||
"testError": "测试出错"
|
||||
"testError": "测试出错",
|
||||
"testErrorInvalidApiKey": "API Key 无效或无权限,请检查密钥是否填写正确",
|
||||
"testErrorUnauthorized": "认证失败,请检查 API Key",
|
||||
"testErrorForbidden": "请求被拒绝,请检查账号权限或模型访问权限",
|
||||
"testErrorModelUnavailable": "模型不可用或模型名不正确,请检查模型名称",
|
||||
"testErrorBaseUrl": "Base URL 不可用,请检查地址是否正确"
|
||||
},
|
||||
"settingsTerminal": {
|
||||
"title": "终端",
|
||||
@@ -3827,6 +3887,11 @@
|
||||
"infoLastCheckin": "上次心跳",
|
||||
"infoNote": "备注",
|
||||
"infoNoteEmpty": "暂无备注",
|
||||
"infoCopy": "复制",
|
||||
"noteEdit": "编辑",
|
||||
"notePlaceholder": "添加备注,便于识别该会话…",
|
||||
"toastNoteSaved": "备注已保存",
|
||||
"toastNoteTooLong": "备注最多 2000 字",
|
||||
"infoSectionIdentity": "身份信息",
|
||||
"infoSectionSystem": "系统环境",
|
||||
"infoSectionNetwork": "网络与信标",
|
||||
@@ -3940,7 +4005,20 @@
|
||||
"paginationPrev": "上一页",
|
||||
"paginationPage": "第 {{current}} / {{total}} 页",
|
||||
"paginationNext": "下一页",
|
||||
"paginationLast": "末页"
|
||||
"paginationLast": "末页",
|
||||
"statTotal": "当前筛选",
|
||||
"timePresets": "快捷",
|
||||
"preset15m": "最近15分钟",
|
||||
"preset1h": "最近1小时",
|
||||
"preset24h": "最近24小时",
|
||||
"preset7d": "最近7天",
|
||||
"presetAll": "全部",
|
||||
"filterAllStatuses": "全部状态",
|
||||
"filterAllTypes": "全部类型",
|
||||
"filterSession": "会话 ID",
|
||||
"filterSessionPlaceholder": "s_xxxxxxxx",
|
||||
"applyFilters": "应用筛选",
|
||||
"resetFilters": "重置"
|
||||
},
|
||||
"payloads": {
|
||||
"386": "386",
|
||||
|
||||
+28
-3
@@ -57,6 +57,12 @@ function syncAssetSelect(selectOrId) {
|
||||
if (typeof syncSettingsCustomSelect === 'function') syncSettingsCustomSelect(select);
|
||||
}
|
||||
|
||||
function closeAssetCustomSelects() {
|
||||
if (typeof closeAllSettingsCustomSelects === 'function') {
|
||||
closeAllSettingsCustomSelects();
|
||||
}
|
||||
}
|
||||
|
||||
function assetT(key, fallback, options) {
|
||||
if (window.i18next && typeof window.i18next.t === 'function') {
|
||||
const value = window.i18next.t(key, options || {});
|
||||
@@ -352,7 +358,8 @@ function renderAssetImportPreview() {
|
||||
|
||||
async function submitAssetImport() {
|
||||
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;
|
||||
setAssetImportError('');
|
||||
setAssetImportBusy(true);
|
||||
@@ -361,7 +368,7 @@ async function submitAssetImport() {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
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 invalid = assetPageState.importRows.length - assets.length;
|
||||
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() {
|
||||
try {
|
||||
const response = await apiFetch('/api/assets/stats?days=' + assetOverviewDays);
|
||||
@@ -868,12 +889,13 @@ async function openAssetProjectModal() {
|
||||
}
|
||||
const subtitle = document.getElementById('asset-project-subtitle');
|
||||
if (subtitle) subtitle.textContent = assetT('assets.bindProjectCount', `将更新 ${assets.length} 个资产`, { count: assets.length });
|
||||
if (typeof openAppModal === 'function') openAppModal('asset-project-modal');
|
||||
if (typeof openAppModal === 'function') openAppModal('asset-project-modal', { display: 'flex' });
|
||||
else document.getElementById('asset-project-modal').style.display = 'flex';
|
||||
if (select) select.focus();
|
||||
}
|
||||
|
||||
function closeAssetProjectModal() {
|
||||
closeAssetCustomSelects();
|
||||
if (typeof closeAppModal === 'function') closeAppModal('asset-project-modal');
|
||||
else document.getElementById('asset-project-modal').style.display = 'none';
|
||||
}
|
||||
@@ -922,6 +944,7 @@ function openAssetBulkEdit() {
|
||||
}
|
||||
|
||||
function closeAssetBulkEdit() {
|
||||
closeAssetCustomSelects();
|
||||
if (typeof closeAppModal === 'function') closeAppModal('asset-bulk-edit-modal');
|
||||
else document.getElementById('asset-bulk-edit-modal').style.display = 'none';
|
||||
}
|
||||
@@ -1124,6 +1147,7 @@ async function sendAssetsToChat(assets, template) {
|
||||
input.value = message;
|
||||
if (typeof adjustTextareaHeight === 'function') adjustTextareaHeight(input);
|
||||
// 消息流可能持续很久;启动发送即可返回,让提交弹窗立即关闭。
|
||||
window.__csNextChatFinalizationPolicy = { requireExecutionEvidence: true };
|
||||
void sendMessage();
|
||||
}
|
||||
|
||||
@@ -1279,6 +1303,7 @@ async function openAssetEditor(indexOrAsset) {
|
||||
|
||||
function closeAssetEditor(force) {
|
||||
if (!force && assetPageState.editorDirty && !confirm(assetT('assets.discardChanges', '放弃尚未保存的更改吗?'))) return;
|
||||
closeAssetCustomSelects();
|
||||
if (typeof closeAppModal === 'function') closeAppModal('asset-editor-modal');
|
||||
else document.getElementById('asset-editor-modal').style.display = 'none';
|
||||
const returnFocus = assetPageState.editorReturnFocus;
|
||||
|
||||
+593
-88
@@ -14,6 +14,8 @@
|
||||
tasksPageSize: 10,
|
||||
tasksTotal: 0,
|
||||
tasksPendingQueuedCount: null,
|
||||
tasksStatusCounts: { success: 0, failed: 0, pending: 0, cancelled: 0 },
|
||||
tasksFilter: { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' },
|
||||
events: [],
|
||||
eventsPage: 1,
|
||||
eventsPageSize: 10,
|
||||
@@ -47,6 +49,111 @@
|
||||
// API 基础路径
|
||||
const API_BASE = '/api/c2';
|
||||
|
||||
function activeC2ProjectId() {
|
||||
try {
|
||||
return (typeof getActiveProjectId === 'function' ? getActiveProjectId() : '') || '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function c2ResourceProjectId(item) {
|
||||
return String((item && (item.project_id || item.projectId)) || '').trim();
|
||||
}
|
||||
|
||||
function c2LooksLikeUuid(value) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
function c2ProjectNameMap() {
|
||||
try {
|
||||
return (window.projectNameById && typeof window.projectNameById === 'object')
|
||||
? window.projectNameById
|
||||
: {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 下拉展示用项目名:优先可读名称,绝不回退成 UUID */
|
||||
function c2ProjectDisplayName(id, nameHint) {
|
||||
const idStr = String(id || '').trim();
|
||||
if (!idStr) return '';
|
||||
let name = String(nameHint || '').trim();
|
||||
if (!name || name === idStr || c2LooksLikeUuid(name)) {
|
||||
const mapped = String(c2ProjectNameMap()[idStr] || '').trim();
|
||||
if (mapped && mapped !== idStr && !c2LooksLikeUuid(mapped)) name = mapped;
|
||||
}
|
||||
if (!name || name === idStr || c2LooksLikeUuid(name)) {
|
||||
if (typeof window.getProjectName === 'function') {
|
||||
const viaFn = String(window.getProjectName(idStr) || '').trim();
|
||||
if (viaFn && viaFn !== idStr && !c2LooksLikeUuid(viaFn)) name = viaFn;
|
||||
}
|
||||
}
|
||||
if (!name || name === idStr || c2LooksLikeUuid(name)) {
|
||||
return c2t('batchManageModal.unknownProject') || '未知项目';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function c2ProjectOptionsHtml(selectedId, projectList) {
|
||||
const selected = String(selectedId || '').trim();
|
||||
let html = `<option value="">${escapeHtml(c2t('assets.unboundProject') || '暂不绑定')}</option>`;
|
||||
let entries = [];
|
||||
if (Array.isArray(projectList) && projectList.length) {
|
||||
entries = projectList
|
||||
.filter((p) => p && p.id)
|
||||
.map((p) => [String(p.id), String(p.name || '').trim()]);
|
||||
} else {
|
||||
entries = Object.entries(c2ProjectNameMap());
|
||||
}
|
||||
const seen = new Set();
|
||||
entries.sort((a, b) => c2ProjectDisplayName(a[0], a[1]).localeCompare(c2ProjectDisplayName(b[0], b[1]), undefined, { sensitivity: 'base' }));
|
||||
entries.forEach(([id, name]) => {
|
||||
if (!id || seen.has(id)) return;
|
||||
seen.add(id);
|
||||
const label = c2ProjectDisplayName(id, name);
|
||||
html += `<option value="${escapeHtml(id)}"${id === selected ? ' selected' : ''}>${escapeHtml(label)}</option>`;
|
||||
});
|
||||
if (selected && !seen.has(selected)) {
|
||||
html += `<option value="${escapeHtml(selected)}" selected>${escapeHtml(c2ProjectDisplayName(selected))}</option>`;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function ensureC2ProjectsLoaded() {
|
||||
if (typeof window.ensureProjectsLoaded === 'function') return window.ensureProjectsLoaded();
|
||||
if (typeof window.fetchAllProjects === 'function') {
|
||||
return window.fetchAllProjects(false).then((list) => {
|
||||
if (typeof window.rebuildProjectNameMap === 'function') window.rebuildProjectNameMap(list || []);
|
||||
return list || [];
|
||||
});
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
/** 确保当前选中项目有可读名称(孤儿 / 未进缓存的 ID) */
|
||||
function ensureC2SelectedProjectNamed(projectId) {
|
||||
const id = String(projectId || '').trim();
|
||||
if (!id) return Promise.resolve();
|
||||
const mapped = String(c2ProjectNameMap()[id] || '').trim();
|
||||
if (mapped && mapped !== id && !c2LooksLikeUuid(mapped)) return Promise.resolve();
|
||||
if (typeof window.fetchProjectSummary !== 'function') return Promise.resolve();
|
||||
return window.fetchProjectSummary(id).then((p) => {
|
||||
if (p && p.id && typeof window.rememberProjectsInNameMap === 'function') {
|
||||
window.rememberProjectsInNameMap([p]);
|
||||
}
|
||||
}).catch(function () { /* ignore */ });
|
||||
}
|
||||
|
||||
function c2ProjectBindSelectHtml(listener) {
|
||||
return `<select class="c2-project-bind-select" data-id="${escapeHtml(listener.id || '')}" title="${escapeHtml(c2t('assets.project') || '所属项目')}" onclick="event.stopPropagation()" onchange="C2.bindListenerProject(this.dataset.id, this.value)">${c2ProjectOptionsHtml(c2ResourceProjectId(listener))}</select>`;
|
||||
}
|
||||
|
||||
function withC2ProjectQuery(url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
window.__c2DownloadPayload = function(filename) {
|
||||
const url = `${API_BASE}/payloads/${filename}/download`;
|
||||
const fetchFn = (typeof apiFetch === 'function') ? apiFetch : fetch;
|
||||
@@ -339,56 +446,116 @@
|
||||
return String(os || '?').substring(0, 3).toLowerCase();
|
||||
}
|
||||
|
||||
function isEmptyInfoValue(value) {
|
||||
if (value == null) return true;
|
||||
const s = String(value).trim();
|
||||
if (!s || s === '-') return true;
|
||||
const lower = s.toLowerCase();
|
||||
return lower === 'unknown' || lower === 'n/a' || lower === 'null' || lower === 'undefined';
|
||||
}
|
||||
|
||||
function displayInfoValue(value, fallback) {
|
||||
if (isEmptyInfoValue(value)) return fallback != null ? fallback : '—';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function sessionMetaLine(s) {
|
||||
const user = displayInfoValue(s.username, '');
|
||||
const os = displayInfoValue(s.os, '');
|
||||
const arch = displayInfoValue(s.arch, '');
|
||||
const right = [os, arch].filter(Boolean).join('/') || '—';
|
||||
if (!user) return right;
|
||||
return user + '@' + right;
|
||||
}
|
||||
|
||||
function sessionInfoRow(label, value, opts) {
|
||||
const empty = isEmptyInfoValue(value);
|
||||
const display = empty ? '—' : String(value);
|
||||
const valueClasses = ['c2-session-info-dl__value'];
|
||||
if (opts && opts.mono) valueClasses.push('is-mono');
|
||||
if (opts && opts.accent) valueClasses.push('is-accent');
|
||||
if (opts && opts.warn) valueClasses.push('is-warn');
|
||||
if (opts && opts.accent && !empty) valueClasses.push('is-accent');
|
||||
if (opts && opts.warn && !empty) valueClasses.push('is-warn');
|
||||
if (empty) valueClasses.push('is-empty');
|
||||
const rowCls = opts && opts.full ? ' c2-session-info-dl__row--full' : '';
|
||||
const copyBtn = (opts && opts.copy && !empty)
|
||||
? `<button type="button" class="c2-session-info-copy" title="${escapeHtml(c2t('c2.sessions.infoCopy'))}" aria-label="${escapeHtml(c2t('c2.sessions.infoCopy'))}" onclick="event.stopPropagation(); C2.copyText(${JSON.stringify(String(value))})">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
|
||||
</button>`
|
||||
: '';
|
||||
return `
|
||||
<div class="c2-session-info-dl__row${rowCls}">
|
||||
<dt class="c2-session-info-dl__label">${escapeHtml(label)}</dt>
|
||||
<dd class="${valueClasses.join(' ')}">${escapeHtml(value == null || value === '' ? '-' : String(value))}</dd>
|
||||
<dd class="${valueClasses.join(' ')}">
|
||||
<span class="c2-session-info-dl__text" title="${escapeHtml(empty ? '' : display)}">${escapeHtml(display)}</span>
|
||||
${copyBtn}
|
||||
</dd>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderSessionInfoPanel(s, adminVal, sleepLine) {
|
||||
const lastCheckin = formatTime(s.lastCheckIn);
|
||||
const lastRel = formatRelativeTime(s.lastCheckIn);
|
||||
const checkinDisplay = lastRel ? `${lastCheckin} (${lastRel})` : lastCheckin;
|
||||
const checkinDisplay = lastRel ? `${lastCheckin} · ${lastRel}` : lastCheckin;
|
||||
const noteText = s.note && String(s.note).trim() ? String(s.note) : '';
|
||||
const noteEmpty = !noteText;
|
||||
return `
|
||||
<div class="c2-session-info-panel">
|
||||
<section class="c2-session-info-block">
|
||||
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionIdentity'))}</div>
|
||||
<dl class="c2-session-info-dl">
|
||||
${sessionInfoRow(c2t('c2.sessions.infoSessionId'), s.id, { mono: true, accent: true, full: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoImplantUuid'), s.implantUuid, { mono: true, full: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoHostname'), s.hostname)}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoUsername'), s.username)}
|
||||
</dl>
|
||||
</section>
|
||||
<section class="c2-session-info-block">
|
||||
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionSystem'))}</div>
|
||||
<dl class="c2-session-info-dl">
|
||||
${sessionInfoRow(c2t('c2.sessions.infoOs'), s.os)}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoArch'), s.arch, { mono: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoPid'), s.pid, { mono: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoProcess'), s.processName || '-', { mono: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoAdmin'), adminVal, { warn: s.isAdmin, full: true })}
|
||||
</dl>
|
||||
</section>
|
||||
<section class="c2-session-info-block">
|
||||
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionNetwork'))}</div>
|
||||
<dl class="c2-session-info-dl">
|
||||
${sessionInfoRow(c2t('c2.sessions.infoInternalIp'), s.internalIp || '-', { mono: true, accent: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoSleep'), sleepLine)}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoFirstSeen'), formatTime(s.firstSeenAt), { mono: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoLastCheckin'), checkinDisplay, { mono: true, accent: true })}
|
||||
</dl>
|
||||
</section>
|
||||
<section class="c2-session-info-block c2-session-info-block--note">
|
||||
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionNote'))}</div>
|
||||
<div class="c2-session-info-note">${escapeHtml(s.note || c2t('c2.sessions.infoNoteEmpty'))}</div>
|
||||
<div class="c2-session-info-grid">
|
||||
<section class="c2-session-info-block">
|
||||
<div class="c2-session-info-block__head">
|
||||
<span class="c2-session-info-block__icon c2-session-info-block__icon--id" aria-hidden="true"></span>
|
||||
<span>${escapeHtml(c2t('c2.sessions.infoSectionIdentity'))}</span>
|
||||
</div>
|
||||
<dl class="c2-session-info-dl">
|
||||
${sessionInfoRow(c2t('c2.sessions.infoSessionId'), s.id, { mono: true, accent: true, copy: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoImplantUuid'), s.implantUuid, { mono: true, copy: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoHostname'), s.hostname)}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoUsername'), s.username)}
|
||||
</dl>
|
||||
</section>
|
||||
<section class="c2-session-info-block">
|
||||
<div class="c2-session-info-block__head">
|
||||
<span class="c2-session-info-block__icon c2-session-info-block__icon--sys" aria-hidden="true"></span>
|
||||
<span>${escapeHtml(c2t('c2.sessions.infoSectionSystem'))}</span>
|
||||
</div>
|
||||
<dl class="c2-session-info-dl">
|
||||
${sessionInfoRow(c2t('c2.sessions.infoOs'), s.os)}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoArch'), s.arch, { mono: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoPid'), s.pid, { mono: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoProcess'), s.processName || '-', { mono: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoAdmin'), adminVal, { warn: !!s.isAdmin })}
|
||||
</dl>
|
||||
</section>
|
||||
<section class="c2-session-info-block">
|
||||
<div class="c2-session-info-block__head">
|
||||
<span class="c2-session-info-block__icon c2-session-info-block__icon--net" aria-hidden="true"></span>
|
||||
<span>${escapeHtml(c2t('c2.sessions.infoSectionNetwork'))}</span>
|
||||
</div>
|
||||
<dl class="c2-session-info-dl">
|
||||
${sessionInfoRow(c2t('c2.sessions.infoInternalIp'), s.internalIp || '-', { mono: true, accent: true, copy: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoSleep'), sleepLine)}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoFirstSeen'), formatTime(s.firstSeenAt), { mono: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoLastCheckin'), checkinDisplay, { mono: true, accent: true })}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
<section class="c2-session-info-block c2-session-info-block--note${noteEmpty ? ' is-empty' : ''}" id="c2-session-note-block">
|
||||
<div class="c2-session-info-block__head">
|
||||
<span class="c2-session-info-block__icon c2-session-info-block__icon--note" aria-hidden="true"></span>
|
||||
<span>${escapeHtml(c2t('c2.sessions.infoSectionNote'))}</span>
|
||||
<button type="button" class="c2-session-note-edit-btn" data-require-permission="c2:write" onclick='C2.beginEditSessionNote(${JSON.stringify(s.id)})'>${escapeHtml(c2t('c2.sessions.noteEdit'))}</button>
|
||||
</div>
|
||||
<div class="c2-session-info-note${noteEmpty ? ' is-empty' : ''}" id="c2-session-note-view">${escapeHtml(noteText || c2t('c2.sessions.infoNoteEmpty'))}</div>
|
||||
<div class="c2-session-note-editor" id="c2-session-note-editor" hidden>
|
||||
<textarea id="c2-session-note-input" class="c2-session-note-textarea" maxlength="2000" rows="4" placeholder="${escapeHtml(c2t('c2.sessions.notePlaceholder'))}">${escapeHtml(noteText)}</textarea>
|
||||
<div class="c2-session-note-editor__footer">
|
||||
<span class="c2-session-note-counter" id="c2-session-note-counter">${noteText.length}/2000</span>
|
||||
<div class="c2-session-note-editor__actions">
|
||||
<button type="button" class="btn-secondary btn-sm" onclick="C2.cancelEditSessionNote()">${escapeHtml(c2t('common.cancel'))}</button>
|
||||
<button type="button" class="btn-primary btn-sm" id="c2-session-note-save" onclick='C2.saveSessionNote(${JSON.stringify(s.id)})'>${escapeHtml(c2t('common.save'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>`;
|
||||
}
|
||||
@@ -398,6 +565,7 @@
|
||||
// ============================================================================
|
||||
|
||||
function apiRequest(method, url, data) {
|
||||
if (method === 'GET') url = withC2ProjectQuery(url);
|
||||
const options = {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
@@ -448,6 +616,21 @@
|
||||
return c2t('c2.fmt.durationMin', { n: (ms / 60000).toFixed(1) });
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
const num = Number(n);
|
||||
if (!Number.isFinite(num) || num < 0) return String(n == null ? '-' : n);
|
||||
if (num < 1024) return String(Math.round(num)) + ' B';
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let v = num / 1024;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
const digits = v >= 100 ? 0 : (v >= 10 ? 1 : 2);
|
||||
return v.toFixed(digits) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
@@ -556,6 +739,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
C2.copyText = function(text) {
|
||||
if (text == null || text === '') return;
|
||||
copyToClipboard(String(text));
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 页面初始化
|
||||
// ============================================================================
|
||||
@@ -611,7 +799,8 @@
|
||||
C2.loadListeners = function() {
|
||||
Promise.all([
|
||||
apiRequest('GET', `${API_BASE}/listeners`),
|
||||
apiRequest('GET', `${API_BASE}/profiles`).catch(function() { return {}; })
|
||||
apiRequest('GET', `${API_BASE}/profiles`).catch(function() { return {}; }),
|
||||
ensureC2ProjectsLoaded().catch(function() { return []; })
|
||||
]).then(function(results) {
|
||||
var ldata = results[0];
|
||||
var pdata = results[1];
|
||||
@@ -670,6 +859,7 @@
|
||||
? '<div class="c2-listener-kv"><span class="c2-listener-kv-label">' + escapeHtml(c2t('c2.listeners.callbackShort')) + '</span><span class="c2-listener-kv-val c2-listener-mono">' + escapeHtml(cb) + '</span></div>'
|
||||
: '';
|
||||
const remarkRow = l.remark ? '<div class="c2-listener-remark">' + escapeHtml(l.remark) + '</div>' : '';
|
||||
const projectRow = '<div class="c2-listener-kv"><span class="c2-listener-kv-label">' + escapeHtml(c2t('assets.project') || '所属项目') + '</span><span class="c2-listener-kv-val">' + c2ProjectBindSelectHtml(l) + '</span></div>';
|
||||
const startedHtml = formatListenerStartedHtml(l.startedAt);
|
||||
const pillLabel = escapeHtml(listenerCardStatusPillLabel(st));
|
||||
const typeMark = escapeHtml(listenerTypeShortLabel(l.type));
|
||||
@@ -697,6 +887,7 @@
|
||||
<span class="c2-listener-kv-val c2-listener-mono"><span class="c2-status-dot ${escapeHtml(st)}"></span>${bindVal}</span>
|
||||
</div>
|
||||
${cbRow}
|
||||
${projectRow}
|
||||
${profileBadge}
|
||||
${remarkRow}
|
||||
${startedHtml}
|
||||
@@ -751,8 +942,15 @@
|
||||
</div>
|
||||
`;
|
||||
|
||||
C2.ensureProfilesLoaded().then(() => {
|
||||
const createSelectedProjectId = activeC2ProjectId();
|
||||
Promise.all([
|
||||
C2.ensureProfilesLoaded(),
|
||||
ensureC2ProjectsLoaded().catch(() => []),
|
||||
ensureC2SelectedProjectNamed(createSelectedProjectId)
|
||||
]).then(function (results) {
|
||||
const projects = results[1] || [];
|
||||
const profileOpts = listenerProfileSelectHtml('');
|
||||
const projectOpts = c2ProjectOptionsHtml(createSelectedProjectId, projects);
|
||||
const emptyProfHintCreate = (C2.profiles && C2.profiles.length > 0)
|
||||
? ''
|
||||
: `<div class="form-hint form-hint--warning" style="margin-bottom:6px;">${escapeHtml(c2t('c2.listeners.malleableProfileEmptyListHint'))}</div>`;
|
||||
@@ -777,6 +975,10 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c2-form-group">
|
||||
<label>${escapeHtml(c2t('assets.project') || '所属项目')}</label>
|
||||
<select id="c2-listener-project-id" class="form-control c2-form-select-native">${projectOpts}</select>
|
||||
</div>
|
||||
<div class="c2-form-row">
|
||||
<div class="c2-form-group">
|
||||
<label>${escapeHtml(c2t('c2.listeners.bindHost'))}</label>
|
||||
@@ -864,6 +1066,7 @@
|
||||
const body = {
|
||||
name, type, bind_host: bindHost, bind_port: bindPort, remark,
|
||||
callback_host: callbackHost,
|
||||
project_id: (document.getElementById('c2-listener-project-id')?.value || '').trim(),
|
||||
profile_id: profileId
|
||||
};
|
||||
if (type === 'tcp_reverse' && legacyShell) {
|
||||
@@ -909,6 +1112,38 @@
|
||||
});
|
||||
};
|
||||
|
||||
C2.bindListenerProject = function(id, projectId) {
|
||||
if (typeof requirePermission === 'function' && !requirePermission('c2:write')) {
|
||||
C2.renderListeners();
|
||||
return;
|
||||
}
|
||||
const listener = C2.listeners.find(x => x.id === id);
|
||||
if (!listener) return;
|
||||
const cfg = C2.getListenerConfig(listener);
|
||||
const body = {
|
||||
name: listener.name || '',
|
||||
bind_host: listener.bindHost || '127.0.0.1',
|
||||
bind_port: parseInt(listener.bindPort, 10) || 0,
|
||||
remark: listener.remark || '',
|
||||
callback_host: C2.getListenerCallbackHost(listener),
|
||||
project_id: String(projectId || '').trim(),
|
||||
profile_id: listenerResolvedProfileId(listener),
|
||||
config: cfg
|
||||
};
|
||||
apiRequest('PUT', `${API_BASE}/listeners/${id}`, body).then(data => {
|
||||
if (data && data.error) {
|
||||
showToast(data.error, 'error');
|
||||
C2.renderListeners();
|
||||
return;
|
||||
}
|
||||
showToast(c2t('projects.projectBound') || '已绑定项目', 'success');
|
||||
C2.loadListeners();
|
||||
}).catch(err => {
|
||||
showToast(err && err.message ? err.message : '绑定项目失败', 'error');
|
||||
C2.renderListeners();
|
||||
});
|
||||
};
|
||||
|
||||
C2.editListener = function(id) {
|
||||
const l = C2.listeners.find(x => x.id === id);
|
||||
if (!l) return;
|
||||
@@ -931,9 +1166,16 @@
|
||||
</div>
|
||||
`;
|
||||
|
||||
C2.ensureProfilesLoaded().then(() => {
|
||||
const editSelectedProjectId = c2ResourceProjectId(l);
|
||||
Promise.all([
|
||||
C2.ensureProfilesLoaded(),
|
||||
ensureC2ProjectsLoaded().catch(() => []),
|
||||
ensureC2SelectedProjectNamed(editSelectedProjectId)
|
||||
]).then(function (results) {
|
||||
const projects = results[1] || [];
|
||||
const resolvedPid = listenerResolvedProfileId(l);
|
||||
const profileOpts = listenerProfileSelectHtml(resolvedPid);
|
||||
const projectOpts = c2ProjectOptionsHtml(editSelectedProjectId, projects);
|
||||
const lt = String(l.type || '').toLowerCase();
|
||||
const httpHint = (lt === 'http_beacon' || lt === 'https_beacon')
|
||||
? ''
|
||||
@@ -951,6 +1193,10 @@
|
||||
<label>${escapeHtml(c2t('c2.listeners.name'))}</label>
|
||||
<input type="text" id="c2-listener-name" class="form-control" value="${escapeHtml(l.name)}">
|
||||
</div>
|
||||
<div class="c2-form-group">
|
||||
<label>${escapeHtml(c2t('assets.project') || '所属项目')}</label>
|
||||
<select id="c2-listener-project-id" class="form-control c2-form-select-native">${projectOpts}</select>
|
||||
</div>
|
||||
<div class="c2-form-row">
|
||||
<div class="c2-form-group">
|
||||
<label>${escapeHtml(c2t('c2.listeners.bindHost'))}</label>
|
||||
@@ -1009,6 +1255,7 @@
|
||||
const body = {
|
||||
name, bind_host: bindHost, bind_port: bindPort, remark,
|
||||
callback_host: callbackHost,
|
||||
project_id: (document.getElementById('c2-listener-project-id')?.value || '').trim(),
|
||||
profile_id: profileId
|
||||
};
|
||||
if (legacyEl) {
|
||||
@@ -1119,9 +1366,16 @@
|
||||
const batchBtn = document.getElementById('c2-sessions-batch-delete');
|
||||
const filteredBtn = document.getElementById('c2-sessions-delete-filtered');
|
||||
const ids = C2.collectCheckedSessionIds();
|
||||
if (batchBtn) batchBtn.disabled = ids.length === 0;
|
||||
if (batchBtn) {
|
||||
batchBtn.disabled = ids.length === 0;
|
||||
batchBtn.classList.toggle('btn-danger', ids.length > 0);
|
||||
batchBtn.classList.toggle('btn-secondary', ids.length === 0);
|
||||
}
|
||||
const total = (C2.sessions || []).length;
|
||||
if (filteredBtn) filteredBtn.disabled = total === 0;
|
||||
if (filteredBtn) {
|
||||
filteredBtn.disabled = total === 0;
|
||||
filteredBtn.classList.toggle('is-danger-soft', total > 0);
|
||||
}
|
||||
const countEl = document.getElementById('c2-sessions-count');
|
||||
if (countEl) {
|
||||
countEl.textContent = c2t('c2.sessions.filterCount', { n: total, selected: ids.length });
|
||||
@@ -1191,7 +1445,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = C2.sessions.map(s => `
|
||||
list.innerHTML = C2.sessions.map(s => {
|
||||
const userLabel = displayInfoValue(s.username, '—');
|
||||
const osArch = [displayInfoValue(s.os, ''), displayInfoValue(s.arch, '')].filter(Boolean).join('/') || '—';
|
||||
const userEmpty = isEmptyInfoValue(s.username);
|
||||
const osEmpty = isEmptyInfoValue(s.os) && isEmptyInfoValue(s.arch);
|
||||
return `
|
||||
<div class="c2-session-item ${s.id === C2.selectedSessionId ? 'active' : ''}"
|
||||
data-status="${escapeHtml(s.status || '')}"
|
||||
onclick="C2.selectSession('${s.id}')">
|
||||
@@ -1206,21 +1465,21 @@
|
||||
<span class="c2-session-status ${s.status}">${escapeHtml(sessionStatusLabel(s.status))}</span>
|
||||
</div>
|
||||
<div class="c2-session-chips">
|
||||
<span class="c2-session-chip">${escapeHtml(s.username)}</span>
|
||||
<span class="c2-session-chip c2-session-chip--mono">${escapeHtml(s.os)}/${escapeHtml(s.arch)}</span>
|
||||
<span class="c2-session-chip${userEmpty ? ' c2-session-chip--empty' : ''}">${escapeHtml(userLabel)}</span>
|
||||
<span class="c2-session-chip c2-session-chip--mono${osEmpty ? ' c2-session-chip--empty' : ''}">${escapeHtml(osArch)}</span>
|
||||
${s.isAdmin ? '<span class="c2-session-chip c2-session-chip--warn">' + escapeHtml(c2t('c2.sessions.rootBadge')) + '</span>' : ''}
|
||||
</div>
|
||||
<div class="c2-session-chips c2-session-chips--sub">
|
||||
<span class="c2-session-chip c2-session-chip--dim">${escapeHtml(s.internalIp || '-')}</span>
|
||||
<span class="c2-session-chip c2-session-chip--dim">PID ${s.pid}</span>
|
||||
<span class="c2-session-chip c2-session-chip--dim">${escapeHtml(s.internalIp || '—')}</span>
|
||||
<span class="c2-session-chip c2-session-chip--dim">PID ${escapeHtml(String(s.pid != null ? s.pid : '—'))}</span>
|
||||
</div>
|
||||
<div class="c2-session-item-footer">
|
||||
<span class="c2-session-meta c2-session-item-time" title="${escapeHtml(formatTime(s.lastCheckIn))}">${escapeHtml(formatRelativeTime(s.lastCheckIn) || formatTime(s.lastCheckIn))}</span>
|
||||
<button type="button" class="c2-session-card-delete" data-require-permission="c2:delete" onclick="event.stopPropagation(); C2.deleteSessionRecord('${s.id}');">${escapeHtml(c2t('c2.sessions.cardDeleteSession'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
if (C2.selectedSessionId && !C2.sessions.find(s => s.id === C2.selectedSessionId)) {
|
||||
C2.selectedSessionId = null;
|
||||
@@ -1270,11 +1529,11 @@
|
||||
<span class="c2-session-badge ${escapeHtml(s.status || '')}">${escapeHtml(sessionStatusLabel(s.status))}</span>
|
||||
${s.isAdmin ? '<span class="c2-session-hero-root">' + escapeHtml(c2t('c2.sessions.rootBadge')) + '</span>' : ''}
|
||||
</div>
|
||||
<div class="c2-session-hero__sub">${escapeHtml(s.username)}@${escapeHtml(s.os)}/${escapeHtml(s.arch)}</div>
|
||||
<div class="c2-session-hero__sub${isEmptyInfoValue(s.username) && isEmptyInfoValue(s.os) ? ' is-muted' : ''}">${escapeHtml(sessionMetaLine(s))}</div>
|
||||
<div class="c2-session-hero__chips">
|
||||
<span class="c2-session-hero-chip is-mono" title="${escapeHtml(c2t('c2.sessions.infoSessionId'))}">${escapeHtml(s.id)}</span>
|
||||
<span class="c2-session-hero-chip">${escapeHtml(s.internalIp || '-')}</span>
|
||||
<span class="c2-session-hero-chip">PID ${s.pid}</span>
|
||||
<span class="c2-session-hero-chip">${escapeHtml(s.internalIp || '—')}</span>
|
||||
<span class="c2-session-hero-chip">PID ${escapeHtml(String(s.pid != null ? s.pid : '—'))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1601,6 +1860,88 @@
|
||||
});
|
||||
};
|
||||
|
||||
C2.beginEditSessionNote = function(id) {
|
||||
const block = document.getElementById('c2-session-note-block');
|
||||
const view = document.getElementById('c2-session-note-view');
|
||||
const editor = document.getElementById('c2-session-note-editor');
|
||||
const input = document.getElementById('c2-session-note-input');
|
||||
const editBtn = block && block.querySelector('.c2-session-note-edit-btn');
|
||||
if (!view || !editor || !input) return;
|
||||
const s = (C2.sessions || []).find(x => x.id === id);
|
||||
const note = s && s.note ? String(s.note) : '';
|
||||
input.value = note;
|
||||
view.hidden = true;
|
||||
editor.hidden = false;
|
||||
if (block) block.classList.remove('is-empty');
|
||||
if (editBtn) editBtn.hidden = true;
|
||||
C2.syncSessionNoteCounter();
|
||||
if (!input.dataset.boundCounter) {
|
||||
input.dataset.boundCounter = '1';
|
||||
input.addEventListener('input', function () { C2.syncSessionNoteCounter(); });
|
||||
}
|
||||
input.focus();
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
};
|
||||
|
||||
C2.syncSessionNoteCounter = function() {
|
||||
const input = document.getElementById('c2-session-note-input');
|
||||
const counter = document.getElementById('c2-session-note-counter');
|
||||
if (!input || !counter) return;
|
||||
counter.textContent = String(input.value.length) + '/2000';
|
||||
};
|
||||
|
||||
C2.cancelEditSessionNote = function() {
|
||||
const block = document.getElementById('c2-session-note-block');
|
||||
const view = document.getElementById('c2-session-note-view');
|
||||
const editor = document.getElementById('c2-session-note-editor');
|
||||
const editBtn = block && block.querySelector('.c2-session-note-edit-btn');
|
||||
if (!view || !editor) return;
|
||||
editor.hidden = true;
|
||||
view.hidden = false;
|
||||
if (editBtn) editBtn.hidden = false;
|
||||
if (block) {
|
||||
const s = (C2.sessions || []).find(x => x.id === C2.selectedSessionId);
|
||||
const empty = !(s && s.note && String(s.note).trim());
|
||||
block.classList.toggle('is-empty', empty);
|
||||
}
|
||||
};
|
||||
|
||||
C2.saveSessionNote = function(id) {
|
||||
if (!id) return;
|
||||
const input = document.getElementById('c2-session-note-input');
|
||||
const saveBtn = document.getElementById('c2-session-note-save');
|
||||
if (!input) return;
|
||||
const note = String(input.value || '').trim();
|
||||
if (note.length > 2000) {
|
||||
showToast(c2t('c2.sessions.toastNoteTooLong'), 'warn');
|
||||
return;
|
||||
}
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
apiRequest('PUT', `${API_BASE}/sessions/${encodeURIComponent(id)}/note`, { note: note }).then(data => {
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
if (data.error) {
|
||||
showToast(String(data.error), 'error');
|
||||
return;
|
||||
}
|
||||
const saved = data.note != null ? String(data.note) : note;
|
||||
const s = (C2.sessions || []).find(x => x.id === id);
|
||||
if (s) s.note = saved;
|
||||
showToast(c2t('c2.sessions.toastNoteSaved'), 'success');
|
||||
const block = document.getElementById('c2-session-note-block');
|
||||
const view = document.getElementById('c2-session-note-view');
|
||||
const empty = !saved.trim();
|
||||
if (view) {
|
||||
view.textContent = empty ? c2t('c2.sessions.infoNoteEmpty') : saved;
|
||||
view.classList.toggle('is-empty', empty);
|
||||
}
|
||||
if (block) block.classList.toggle('is-empty', empty);
|
||||
C2.cancelEditSessionNote();
|
||||
}).catch(err => {
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
showToast(err.message || String(err), 'error');
|
||||
});
|
||||
};
|
||||
|
||||
C2.killSession = function(id) {
|
||||
if (!confirm(c2t('c2.sessions.confirmExitSession'))) return;
|
||||
apiRequest('POST', `${API_BASE}/tasks`, {
|
||||
@@ -2072,6 +2413,8 @@
|
||||
});
|
||||
container.setAttribute('tabindex', '0');
|
||||
|
||||
C2.bindTerminalScrollbarAutoHide(container);
|
||||
|
||||
C2.terminalInstance = term;
|
||||
|
||||
if (C2.terminalResizeObserver) {
|
||||
@@ -2090,6 +2433,42 @@
|
||||
});
|
||||
};
|
||||
|
||||
C2.bindTerminalScrollbarAutoHide = function(container) {
|
||||
if (!container) return;
|
||||
if (C2._terminalScrollbarHideTimer) {
|
||||
clearTimeout(C2._terminalScrollbarHideTimer);
|
||||
C2._terminalScrollbarHideTimer = null;
|
||||
}
|
||||
const show = function () {
|
||||
container.classList.add('is-scrollbar-visible');
|
||||
if (C2._terminalScrollbarHideTimer) clearTimeout(C2._terminalScrollbarHideTimer);
|
||||
C2._terminalScrollbarHideTimer = setTimeout(function () {
|
||||
container.classList.remove('is-scrollbar-visible');
|
||||
C2._terminalScrollbarHideTimer = null;
|
||||
}, 900);
|
||||
};
|
||||
const bindViewport = function () {
|
||||
const vp = container.querySelector('.xterm-viewport');
|
||||
if (!vp) return false;
|
||||
if (vp.dataset.scrollbarBound === '1') return true;
|
||||
vp.dataset.scrollbarBound = '1';
|
||||
vp.addEventListener('scroll', show, { passive: true });
|
||||
return true;
|
||||
};
|
||||
if (!container.dataset.scrollbarUiBound) {
|
||||
container.dataset.scrollbarUiBound = '1';
|
||||
container.addEventListener('wheel', show, { passive: true });
|
||||
container.addEventListener('touchmove', show, { passive: true });
|
||||
}
|
||||
if (!bindViewport()) {
|
||||
const mo = new MutationObserver(function () {
|
||||
if (bindViewport()) mo.disconnect();
|
||||
});
|
||||
mo.observe(container, { childList: true, subtree: true });
|
||||
setTimeout(function () { mo.disconnect(); }, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
C2.fitTerminal = function() {
|
||||
const container = document.getElementById('c2-terminal-container');
|
||||
if (!container || !C2.terminalFitAddon || !C2.terminalInstance) return;
|
||||
@@ -2392,18 +2771,22 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
${entries.map(function(entry) {
|
||||
const sizeLabel = entry.isDir ? '—' : formatBytes(entry.size);
|
||||
const iconCls = entry.isDir ? 'c2-file-icon c2-file-icon--dir' : 'c2-file-icon c2-file-icon--file';
|
||||
return `
|
||||
<tr>
|
||||
<td class="c2-file-name">
|
||||
<span class="c2-file-icon">${entry.isDir ? '📁' : '📄'}</span>
|
||||
${escapeHtml(entry.name)}
|
||||
<td>
|
||||
<div class="c2-file-name">
|
||||
<span class="${iconCls}" aria-hidden="true"></span>
|
||||
<span class="c2-file-name-text" title="${escapeHtml(entry.name)}">${escapeHtml(entry.name)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>${escapeHtml(entry.size)}</td>
|
||||
<td title="${escapeHtml(String(entry.size || ''))}">${escapeHtml(sizeLabel)}</td>
|
||||
<td>${escapeHtml(entry.mode)}</td>
|
||||
<td>
|
||||
${entry.isDir
|
||||
? `<button class="btn-ghost btn-sm" onclick='C2.openDirectory(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.open'))}</button>`
|
||||
: `<button class="btn-ghost btn-sm" onclick='C2.downloadFile(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.download'))}</button>`
|
||||
? `<button class="btn-ghost btn-sm c2-file-action-btn" onclick='C2.openDirectory(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.open'))}</button>`
|
||||
: `<button class="btn-ghost btn-sm c2-file-action-btn" onclick='C2.downloadFile(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.download'))}</button>`
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -2717,16 +3100,20 @@
|
||||
// ============================================================================
|
||||
|
||||
C2.loadTasks = function(page) {
|
||||
bindTasksFilterUiOnce();
|
||||
readTasksFilterFromDom();
|
||||
const p = page != null ? page : (C2.tasksPage || 1);
|
||||
C2.tasksPage = p;
|
||||
const ps = C2.tasksPageSize || 10;
|
||||
apiRequest('GET', `${API_BASE}/tasks?page=${encodeURIComponent(String(p))}&page_size=${encodeURIComponent(String(ps))}`).then(data => {
|
||||
const qs = buildTasksQueryParams(p, ps);
|
||||
apiRequest('GET', `${API_BASE}/tasks?${qs}`).then(data => {
|
||||
if (data.error) {
|
||||
showToast(String(data.error), 'error');
|
||||
return;
|
||||
}
|
||||
C2.tasks = data.tasks || [];
|
||||
C2.tasksTotal = typeof data.total === 'number' ? data.total : (C2.tasks.length || 0);
|
||||
C2.tasksStatusCounts = data.status_counts || { success: 0, failed: 0, pending: 0, cancelled: 0 };
|
||||
if (typeof data.pending_queued_count === 'number') {
|
||||
C2.tasksPendingQueuedCount = data.pending_queued_count;
|
||||
}
|
||||
@@ -2736,8 +3123,10 @@
|
||||
return;
|
||||
}
|
||||
C2.renderTasks();
|
||||
C2.renderTasksSummary();
|
||||
C2.renderTasksPagination();
|
||||
C2.syncTasksToolbar();
|
||||
syncTasksTimePresetButtons();
|
||||
}).catch(err => {
|
||||
showToast(err.message || String(err), 'error');
|
||||
});
|
||||
@@ -2857,6 +3246,105 @@
|
||||
}).catch(err => showToast(err.message || String(err), 'error'));
|
||||
};
|
||||
|
||||
function readTasksFilterFromDom() {
|
||||
const statusEl = document.getElementById('c2-tasks-filter-status');
|
||||
const typeEl = document.getElementById('c2-tasks-filter-type');
|
||||
const sessionEl = document.getElementById('c2-tasks-filter-session');
|
||||
C2.tasksFilter = C2.tasksFilter || { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' };
|
||||
C2.tasksFilter.status = statusEl ? statusEl.value.trim() : '';
|
||||
C2.tasksFilter.task_type = typeEl ? typeEl.value.trim() : '';
|
||||
C2.tasksFilter.session_id = sessionEl ? sessionEl.value.trim() : '';
|
||||
}
|
||||
|
||||
function buildTasksQueryParams(page, pageSize) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('page_size', String(pageSize));
|
||||
const f = C2.tasksFilter || {};
|
||||
if (f.status) params.set('status', f.status);
|
||||
if (f.task_type) params.set('task_type', f.task_type);
|
||||
if (f.session_id) params.set('session_id', f.session_id);
|
||||
if (f.since) params.set('since', f.since);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function tasksTimePresetToSince(preset) {
|
||||
if (!preset || preset === 'all') return '';
|
||||
const now = Date.now();
|
||||
const map = { '15m': 15 * 60 * 1000, '1h': 60 * 60 * 1000, '24h': 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000 };
|
||||
const ms = map[preset];
|
||||
if (!ms) return '';
|
||||
return new Date(now - ms).toISOString();
|
||||
}
|
||||
|
||||
function syncTasksTimePresetButtons() {
|
||||
const wrap = document.getElementById('c2-tasks-time-presets');
|
||||
if (!wrap) return;
|
||||
const active = (C2.tasksFilter && C2.tasksFilter.timePreset) || 'all';
|
||||
wrap.querySelectorAll('.c2-tasks-time-preset-btn').forEach(btn => {
|
||||
btn.classList.toggle('is-active', btn.getAttribute('data-preset') === active);
|
||||
});
|
||||
}
|
||||
|
||||
function bindTasksFilterUiOnce() {
|
||||
if (document.documentElement.dataset.c2TasksFilterBound === '1') return;
|
||||
document.documentElement.dataset.c2TasksFilterBound = '1';
|
||||
|
||||
const presets = document.getElementById('c2-tasks-time-presets');
|
||||
if (presets) {
|
||||
presets.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.c2-tasks-time-preset-btn');
|
||||
if (!btn) return;
|
||||
const preset = btn.getAttribute('data-preset') || 'all';
|
||||
C2.tasksFilter = C2.tasksFilter || { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' };
|
||||
C2.tasksFilter.timePreset = preset;
|
||||
C2.tasksFilter.since = tasksTimePresetToSince(preset);
|
||||
syncTasksTimePresetButtons();
|
||||
C2.loadTasks(1);
|
||||
});
|
||||
}
|
||||
|
||||
const sessionInput = document.getElementById('c2-tasks-filter-session');
|
||||
if (sessionInput) {
|
||||
sessionInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') C2.applyTasksFilters();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
C2.applyTasksFilters = function() {
|
||||
readTasksFilterFromDom();
|
||||
C2.loadTasks(1);
|
||||
};
|
||||
|
||||
C2.resetTasksFilters = function() {
|
||||
C2.tasksFilter = { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' };
|
||||
const statusEl = document.getElementById('c2-tasks-filter-status');
|
||||
const typeEl = document.getElementById('c2-tasks-filter-type');
|
||||
const sessionEl = document.getElementById('c2-tasks-filter-session');
|
||||
if (statusEl) statusEl.value = '';
|
||||
if (typeEl) typeEl.value = '';
|
||||
if (sessionEl) sessionEl.value = '';
|
||||
syncTasksTimePresetButtons();
|
||||
C2.loadTasks(1);
|
||||
};
|
||||
|
||||
C2.renderTasksSummary = function() {
|
||||
const summary = document.getElementById('c2-tasks-summary');
|
||||
if (!summary) return;
|
||||
const total = C2.tasksTotal || 0;
|
||||
const counts = C2.tasksStatusCounts || { success: 0, failed: 0, pending: 0 };
|
||||
const set = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = String(val);
|
||||
};
|
||||
set('c2-tasks-stat-total', total);
|
||||
set('c2-tasks-stat-success', counts.success || 0);
|
||||
set('c2-tasks-stat-failed', counts.failed || 0);
|
||||
set('c2-tasks-stat-pending', counts.pending || 0);
|
||||
summary.hidden = total === 0;
|
||||
};
|
||||
|
||||
C2.loadSessionTasks = function(sessionId) {
|
||||
apiRequest('GET', `${API_BASE}/tasks?session_id=${encodeURIComponent(sessionId)}&limit=50`).then(data => {
|
||||
const container = document.getElementById('c2-session-tasks-list');
|
||||
@@ -2941,7 +3429,7 @@
|
||||
}
|
||||
|
||||
if (C2.tasks.length === 0) {
|
||||
container.innerHTML = '<div class="c2-empty">' + escapeHtml(c2t('c2.tasks.emptyAll')) + '</div>';
|
||||
container.innerHTML = '<div class="c2-tasks-empty">' + escapeHtml(c2t('c2.tasks.emptyAll')) + '</div>';
|
||||
if (selAll) selAll.disabled = true;
|
||||
C2.syncTasksToolbar();
|
||||
return;
|
||||
@@ -2949,59 +3437,76 @@
|
||||
if (selAll) selAll.disabled = false;
|
||||
|
||||
const delTitle = escapeHtml(c2t('c2.tasks.deleteOne'));
|
||||
const cancelTitle = escapeHtml(c2t('c2.tasks.cancelBtn'));
|
||||
const dash = '<span class="c2-tasks-cell-muted">—</span>';
|
||||
const deleteIcon = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="c2-task-table">
|
||||
<table class="c2-tasks-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="c2-task-table-col-check"></th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colTask'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colSession'))}</th>
|
||||
<th class="c2-tasks-table-col-check">
|
||||
<label class="c2-task-check-label" title="${escapeHtml(c2t('c2.tasks.selectAll'))}">
|
||||
<input type="checkbox" id="c2-tasks-select-all" onchange="C2.onTasksSelectAll(this.checked)">
|
||||
</label>
|
||||
</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colCreated'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colStatus'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colType'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colCommand'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colStatus'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colSession'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colTask'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colDuration'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colCreated'))}</th>
|
||||
<th class="c2-task-table-col-actions">${escapeHtml(c2t('c2.tasks.colActions'))}</th>
|
||||
<th class="c2-tasks-table-col-actions">${escapeHtml(c2t('c2.tasks.colActions'))}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${C2.tasks.map(t => {
|
||||
const rawId = t.id || '';
|
||||
const shortTaskId = rawId.length > 14 ? escapeHtml(rawId.substring(0, 12)) + '\u2026' : escapeHtml(rawId);
|
||||
const sid = t.sessionId ? escapeHtml(String(t.sessionId).substring(0, 8)) + '\u2026' : '-';
|
||||
const tid = escapeHtml(rawId);
|
||||
const status = String(t.status || '');
|
||||
const rowStatus = escapeHtml(status || 'queued');
|
||||
const typeCat = taskTypeCategory(t.taskType);
|
||||
const sessionFull = t.sessionId ? String(t.sessionId) : '';
|
||||
const sessionShort = sessionFull
|
||||
? escapeHtml(sessionFull.substring(0, 10)) + (sessionFull.length > 10 ? '\u2026' : '')
|
||||
: '';
|
||||
const taskShort = rawId
|
||||
? escapeHtml(rawId.substring(0, 10)) + (rawId.length > 10 ? '\u2026' : '')
|
||||
: '';
|
||||
const cmd = formatTaskCommand(t);
|
||||
const cmdShort = truncateCommand(cmd, 48);
|
||||
const cmdEsc = escapeHtml(cmd);
|
||||
const canCancel = status === 'queued' || status === 'sent';
|
||||
return `
|
||||
<tr>
|
||||
<td class="c2-task-table-col-check">
|
||||
<label class="c2-task-check-label" onclick="event.stopPropagation();">
|
||||
<input type="checkbox" class="c2-task-row-check" data-id="${escapeHtml(rawId)}" onchange="C2.syncTasksToolbar()">
|
||||
<tr class="c2-tasks-row c2-tasks-row--${rowStatus}" data-task-id="${tid}" onclick="C2.viewTask('${tid}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();C2.viewTask('${tid}')}" role="button" tabindex="0">
|
||||
<td class="c2-tasks-table-col-check" onclick="event.stopPropagation();">
|
||||
<label class="c2-task-check-label">
|
||||
<input type="checkbox" class="c2-task-row-check" data-id="${tid}" onchange="C2.syncTasksToolbar()">
|
||||
</label>
|
||||
</td>
|
||||
<td>${shortTaskId}</td>
|
||||
<td>${sid}</td>
|
||||
<td>${escapeHtml(t.taskType || '')}</td>
|
||||
<td class="c2-task-command-cell" title="${escapeHtml(cmd)}">${cmdShort ? escapeHtml(cmdShort) : '<span class="c2-muted">-</span>'}</td>
|
||||
<td><span class="c2-status-badge ${escapeHtml(t.status || '')}">${escapeHtml(taskStatusLabel(t.status))}</span></td>
|
||||
<td>${formatDuration(t.durationMs)}</td>
|
||||
<td>${formatTime(t.createdAt)}</td>
|
||||
<td class="c2-task-table-col-actions">
|
||||
<div class="c2-task-table-actions">
|
||||
<button type="button" class="btn-secondary btn-small" data-c2-task-action="view" data-task-id="${escapeHtml(rawId)}">${escapeHtml(c2t('c2.tasks.view'))}</button>
|
||||
${t.status === 'queued' || t.status === 'sent'
|
||||
? `<button type="button" class="btn-danger btn-small" data-c2-task-action="cancel" data-task-id="${escapeHtml(rawId)}">${escapeHtml(c2t('c2.tasks.cancelBtn'))}</button>`
|
||||
: ''}
|
||||
<button type="button" class="btn-danger btn-small" data-c2-task-action="delete" data-task-id="${escapeHtml(rawId)}" title="${delTitle}" aria-label="${delTitle}">${escapeHtml(c2t('c2.tasks.deleteBtn'))}</button>
|
||||
<td class="c2-tasks-col-time">${escapeHtml(formatTime(t.createdAt))}</td>
|
||||
<td><span class="c2-status-badge ${escapeHtml(status)}">${escapeHtml(taskStatusLabel(status))}</span></td>
|
||||
<td><span class="c2-task-type-badge c2-task-type-badge--${typeCat}">${escapeHtml(t.taskType || '-')}</span></td>
|
||||
<td class="c2-tasks-col-command" title="${cmdEsc}">${cmdEsc || dash}</td>
|
||||
<td class="c2-tasks-col-mono" title="${escapeHtml(sessionFull)}">${sessionShort || dash}</td>
|
||||
<td class="c2-tasks-col-mono" title="${tid}">${taskShort || dash}</td>
|
||||
<td class="c2-tasks-col-duration">${formatDuration(t.durationMs)}</td>
|
||||
<td class="c2-tasks-table-col-actions" onclick="event.stopPropagation();">
|
||||
<div class="c2-tasks-row-actions">
|
||||
${canCancel
|
||||
? `<button type="button" class="c2-tasks-cancel-btn" data-c2-task-action="cancel" data-task-id="${tid}" title="${cancelTitle}" aria-label="${cancelTitle}">${escapeHtml(c2t('c2.tasks.cancelBtn'))}</button>`
|
||||
: ''}
|
||||
<button type="button" class="c2-tasks-delete-btn" data-require-permission="c2:delete" data-c2-task-action="delete" data-task-id="${tid}" title="${delTitle}" aria-label="${delTitle}">${deleteIcon}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
C2.syncTasksToolbar();
|
||||
if (typeof applyTranslations === 'function') applyTranslations(container);
|
||||
if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(container);
|
||||
};
|
||||
|
||||
C2.viewTask = function(id) {
|
||||
|
||||
+134
-28
@@ -3,6 +3,8 @@
|
||||
let chatFilesCache = [];
|
||||
/** 后端 GET /api/chat-uploads 返回的目录相对路径(含空文件夹),与 files 合并成树 */
|
||||
let chatFilesFoldersCache = [];
|
||||
const chatFilesProjectNameById = {};
|
||||
const chatFilesConversationTitleById = {};
|
||||
let chatFilesDisplayed = [];
|
||||
let chatFilesEditRelativePath = '';
|
||||
let chatFilesRenameRelativePath = '';
|
||||
@@ -16,6 +18,7 @@ const CHAT_FILES_BROWSE_PATH_KEY = 'csai_chat_files_browse_path';
|
||||
const CHAT_FILES_PAGE_SIZE_STORAGE_KEY = 'csai_chat_files_page_size';
|
||||
const CHAT_FILES_TREE_UPLOAD_ROOT = 'uploads';
|
||||
const CHAT_FILES_TREE_REDUCTION_ROOT = 'tool_outputs';
|
||||
const CHAT_FILES_TREE_WORKSPACE_ROOT = 'workspace';
|
||||
const CHAT_FILES_TREE_ARTIFACT_ROOT = 'conversation_artifacts';
|
||||
|
||||
/** 按文件夹浏览模式下的当前路径(虚拟根段数组),如 ['uploads','2024-03-21','uuid'] */
|
||||
@@ -39,6 +42,19 @@ function closeAllChatFilesFilterSelects() {
|
||||
});
|
||||
}
|
||||
|
||||
function chatFilesRememberDisplayNames(files) {
|
||||
Object.keys(chatFilesProjectNameById).forEach(function (k) { delete chatFilesProjectNameById[k]; });
|
||||
Object.keys(chatFilesConversationTitleById).forEach(function (k) { delete chatFilesConversationTitleById[k]; });
|
||||
(Array.isArray(files) ? files : []).forEach(function (f) {
|
||||
const pid = String((f && f.projectId) || '').trim();
|
||||
const pname = String((f && f.projectName) || '').trim();
|
||||
if (pid && pname) chatFilesProjectNameById[pid] = pname;
|
||||
const cid = String((f && f.conversationId) || '').trim();
|
||||
const ctitle = String((f && f.conversationTitle) || '').trim();
|
||||
if (cid && ctitle) chatFilesConversationTitleById[cid] = ctitle;
|
||||
});
|
||||
}
|
||||
|
||||
function syncChatFilesFilterSelect(selectId) {
|
||||
const reg = chatFilesFilterSelectMap[selectId];
|
||||
if (!reg) return;
|
||||
@@ -389,6 +405,7 @@ async function loadChatFilesPage() {
|
||||
}
|
||||
const data = await res.json();
|
||||
chatFilesCache = Array.isArray(data.files) ? data.files : [];
|
||||
chatFilesRememberDisplayNames(chatFilesCache);
|
||||
chatFilesFoldersCache = Array.isArray(data.folders) ? data.folders : [];
|
||||
chatFilesTotal = Number.isFinite(Number(data.total)) ? Number(data.total) : chatFilesCache.length;
|
||||
chatFilesPage = Number.isFinite(Number(data.page)) ? Math.max(1, Number(data.page)) : chatFilesPage;
|
||||
@@ -708,6 +725,10 @@ function chatFilesTreePathForFile(f) {
|
||||
rp = rp.replace(/^__reduction__\//, '');
|
||||
return CHAT_FILES_TREE_REDUCTION_ROOT + '/' + rp;
|
||||
}
|
||||
if (source === 'workspace') {
|
||||
rp = rp.replace(/^__workspace__\//, '');
|
||||
return CHAT_FILES_TREE_WORKSPACE_ROOT + '/' + rp;
|
||||
}
|
||||
if (source === 'conversation_artifact') {
|
||||
rp = rp.replace(/^__conversation_artifact__\//, '');
|
||||
return CHAT_FILES_TREE_ARTIFACT_ROOT + '/' + rp;
|
||||
@@ -768,6 +789,7 @@ function chatFilesEnsureSourceRoots(root) {
|
||||
const roots = [];
|
||||
if (source === 'all' || source === 'upload') roots.push(CHAT_FILES_TREE_UPLOAD_ROOT);
|
||||
if (source === 'all' || source === 'reduction') roots.push(CHAT_FILES_TREE_REDUCTION_ROOT);
|
||||
if (source === 'all' || source === 'workspace') roots.push(CHAT_FILES_TREE_WORKSPACE_ROOT);
|
||||
if (source === 'all' || source === 'conversation_artifact') roots.push(CHAT_FILES_TREE_ARTIFACT_ROOT);
|
||||
roots.forEach(function (name) {
|
||||
if (!root.dirs[name]) root.dirs[name] = chatFilesTreeMakeNode();
|
||||
@@ -776,13 +798,16 @@ function chatFilesEnsureSourceRoots(root) {
|
||||
|
||||
function chatFilesIsInternalSource(f) {
|
||||
const source = f && f.source;
|
||||
return source === 'reduction' || source === 'conversation_artifact';
|
||||
return source === 'reduction' || source === 'workspace' || source === 'conversation_artifact';
|
||||
}
|
||||
|
||||
function chatFilesSourceLabel(source) {
|
||||
if (source === 'reduction') {
|
||||
return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceReduction') : '工具输出';
|
||||
}
|
||||
if (source === 'workspace') {
|
||||
return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceWorkspace') : '工作目录';
|
||||
}
|
||||
if (source === 'conversation_artifact') {
|
||||
return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceConversationArtifact') : '会话产物';
|
||||
}
|
||||
@@ -808,12 +833,66 @@ function chatFilesTreeDisplayName(name) {
|
||||
if (name === CHAT_FILES_TREE_REDUCTION_ROOT) {
|
||||
return (typeof window.t === 'function') ? window.t('chatFilesPage.treeReductionRoot') : '工具输出';
|
||||
}
|
||||
if (name === CHAT_FILES_TREE_WORKSPACE_ROOT) {
|
||||
return (typeof window.t === 'function') ? window.t('chatFilesPage.treeWorkspaceRoot') : '工作目录';
|
||||
}
|
||||
if (name === CHAT_FILES_TREE_ARTIFACT_ROOT) {
|
||||
return (typeof window.t === 'function') ? window.t('chatFilesPage.treeArtifactsRoot') : '会话产物';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function chatFilesIDDisplay(id, labelMap, emptyLabel) {
|
||||
const raw = id == null ? '' : String(id);
|
||||
if (!raw || raw === '—') {
|
||||
return { text: emptyLabel || '—', title: '' };
|
||||
}
|
||||
const label = String((labelMap && labelMap[raw]) || '').trim();
|
||||
if (label) {
|
||||
return { text: label, title: label + ' (' + raw + ')' };
|
||||
}
|
||||
if (raw.length > 36) {
|
||||
return { text: raw.slice(0, 8) + '…' + raw.slice(-6), title: raw };
|
||||
}
|
||||
return { text: raw, title: raw };
|
||||
}
|
||||
|
||||
function chatFilesConversationDisplay(id) {
|
||||
const c = id == null ? '' : String(id);
|
||||
if (typeof window.t === 'function') {
|
||||
if (c === '_manual') {
|
||||
return { text: window.t('chatFilesPage.convManual'), title: '_manual' };
|
||||
}
|
||||
if (c === '_new') {
|
||||
return { text: window.t('chatFilesPage.convNew'), title: '_new' };
|
||||
}
|
||||
}
|
||||
return chatFilesIDDisplay(c, chatFilesConversationTitleById);
|
||||
}
|
||||
|
||||
function chatFilesProjectDisplay(id) {
|
||||
const empty = (typeof window.t === 'function') ? window.t('chatFilesPage.projectUnbound') : '未绑定项目';
|
||||
return chatFilesIDDisplay(id, chatFilesProjectNameById, empty);
|
||||
}
|
||||
|
||||
function chatFilesTreePathDisplayName(pathParts) {
|
||||
const parts = Array.isArray(pathParts) ? pathParts : [];
|
||||
const name = parts.length ? parts[parts.length - 1] : '';
|
||||
const root = parts[0] || '';
|
||||
if ((root === CHAT_FILES_TREE_WORKSPACE_ROOT || root === CHAT_FILES_TREE_REDUCTION_ROOT) && parts.length === 3) {
|
||||
if (parts[1] === 'projects') return chatFilesProjectDisplay(name);
|
||||
if (parts[1] === 'conversations') return chatFilesConversationDisplay(name);
|
||||
}
|
||||
if (root === CHAT_FILES_TREE_ARTIFACT_ROOT && parts.length === 2) {
|
||||
return chatFilesConversationDisplay(name);
|
||||
}
|
||||
if (root === CHAT_FILES_TREE_UPLOAD_ROOT && parts.length === 3) {
|
||||
return chatFilesConversationDisplay(name);
|
||||
}
|
||||
const text = chatFilesTreeDisplayName(name);
|
||||
return { text: text, title: String(name || '') };
|
||||
}
|
||||
|
||||
function chatFilesUploadRelativeDirFromBrowsePath(path) {
|
||||
const parts = Array.isArray(path) ? path.slice() : [];
|
||||
if (parts[0] === CHAT_FILES_TREE_UPLOAD_ROOT) {
|
||||
@@ -893,32 +972,15 @@ function chatFilesBuildGroups(files, mode) {
|
||||
|
||||
/** 分组标题:长 ID 缩短展示,完整值放在 title */
|
||||
function chatFilesGroupHeadingID(key, emptyLabel) {
|
||||
const c = key == null ? '' : String(key);
|
||||
if (c === '' || c === '—') {
|
||||
return { text: emptyLabel || '—', title: '' };
|
||||
}
|
||||
if (c.length > 36) {
|
||||
return { text: c.slice(0, 8) + '…' + c.slice(-6), title: c };
|
||||
}
|
||||
return { text: c, title: c };
|
||||
return chatFilesIDDisplay(key, null, emptyLabel);
|
||||
}
|
||||
|
||||
function chatFilesGroupHeadingConversation(key) {
|
||||
const c = key == null ? '' : String(key);
|
||||
if (typeof window.t === 'function') {
|
||||
if (c === '_manual') {
|
||||
return { text: window.t('chatFilesPage.convManual'), title: '_manual' };
|
||||
}
|
||||
if (c === '_new') {
|
||||
return { text: window.t('chatFilesPage.convNew'), title: '_new' };
|
||||
}
|
||||
}
|
||||
return chatFilesGroupHeadingID(key);
|
||||
return chatFilesConversationDisplay(key);
|
||||
}
|
||||
|
||||
function chatFilesGroupHeadingProject(key) {
|
||||
const empty = (typeof window.t === 'function') ? window.t('chatFilesPage.projectUnbound') : '未绑定项目';
|
||||
return chatFilesGroupHeadingID(key, empty);
|
||||
return chatFilesProjectDisplay(key);
|
||||
}
|
||||
|
||||
function renderChatFilesTable() {
|
||||
@@ -965,7 +1027,9 @@ function renderChatFilesTable() {
|
||||
const isInternal = chatFilesIsInternalSource(f);
|
||||
const sourceBadge = isInternal ? '<span class="chat-files-source-badge">' + escapeHtml(chatFilesSourceLabel(f.source)) + '</span>' : '';
|
||||
const conv = f.conversationId || '';
|
||||
const convEsc = escapeHtml(conv);
|
||||
const convDisplay = chatFilesConversationDisplay(conv);
|
||||
const convEsc = escapeHtml(convDisplay.text || conv);
|
||||
const convTitleEsc = escapeHtml(convDisplay.title || conv);
|
||||
const dt = f.modifiedUnix ? new Date(f.modifiedUnix * 1000).toLocaleString() : '—';
|
||||
const canOpenChat = conv && conv !== '_manual' && conv !== '_new';
|
||||
|
||||
@@ -1011,7 +1075,7 @@ function renderChatFilesTable() {
|
||||
|
||||
return `<tr>
|
||||
<td>${escapeHtml(f.date || '—')}</td>
|
||||
<td class="chat-files-cell-conv"><code title="${convEsc}">${convEsc}</code></td>
|
||||
<td class="chat-files-cell-conv"><code title="${convTitleEsc}">${convEsc}</code></td>
|
||||
<td class="chat-files-cell-subpath" title="${escapeHtml(subRaw || '')}">${subCellInner}</td>
|
||||
<td class="chat-files-cell-name" title="${escapeHtml(pathForTitle)}">${nameEsc}${sourceBadge}</td>
|
||||
<td>${formatChatFileBytes(f.size || 0)}</td>
|
||||
@@ -1072,11 +1136,14 @@ function renderChatFilesTable() {
|
||||
for (bi = 0; bi < chatFilesBrowsePath.length; bi++) {
|
||||
const seg = chatFilesBrowsePath[bi];
|
||||
const isLast = bi === chatFilesBrowsePath.length - 1;
|
||||
const display = chatFilesTreePathDisplayName(chatFilesBrowsePath.slice(0, bi + 1));
|
||||
const displayText = escapeHtml(display.text);
|
||||
const displayTitle = display.title ? ' title="' + escapeHtml(display.title) + '"' : '';
|
||||
breadcrumbHtml += '<span class="chat-files-breadcrumb-sep">/</span>';
|
||||
if (isLast) {
|
||||
breadcrumbHtml += '<span class="chat-files-breadcrumb-current">' + escapeHtml(chatFilesTreeDisplayName(seg)) + '</span>';
|
||||
breadcrumbHtml += '<span class="chat-files-breadcrumb-current"' + displayTitle + '>' + displayText + '</span>';
|
||||
} else {
|
||||
breadcrumbHtml += '<button type="button" class="chat-files-breadcrumb-link" onclick="chatFilesNavigateBreadcrumb(' + bi + ')">' + escapeHtml(chatFilesTreeDisplayName(seg)) + '</button>';
|
||||
breadcrumbHtml += '<button type="button" class="chat-files-breadcrumb-link" onclick="chatFilesNavigateBreadcrumb(' + bi + ')"' + displayTitle + '>' + displayText + '</button>';
|
||||
}
|
||||
}
|
||||
breadcrumbHtml += '</nav>';
|
||||
@@ -1098,6 +1165,8 @@ function renderChatFilesTable() {
|
||||
function rowHtmlBrowseFolder(name) {
|
||||
const nameAttr = encodeURIComponent(String(name));
|
||||
const folderPath = chatFilesBrowsePath.concat([name]);
|
||||
const folderDisplay = chatFilesTreePathDisplayName(folderPath);
|
||||
const folderTitle = folderDisplay.title || tEnter;
|
||||
const relToFolder = folderPath.join('/');
|
||||
const uploadDirAttr = encodeURIComponent(relToFolder);
|
||||
const canUploadFolder = chatFilesBrowseCanUploadToPath(folderPath);
|
||||
@@ -1109,8 +1178,8 @@ function renderChatFilesTable() {
|
||||
? `<button type="button" class="btn-icon btn-danger" title="${tDeleteFolder}" data-chat-folder-name="${nameAttr}" onclick="chatFilesDeleteFolderFromBtn(event, this)">${svgTrash}</button>`
|
||||
: '';
|
||||
return `<tr class="chat-files-tr-folder chat-files-tr-folder--nav" role="button" tabindex="0" data-chat-folder-name="${nameAttr}" onclick="chatFilesOnFolderRowClick(event)" onkeydown="chatFilesOnFolderRowKeydown(event)">
|
||||
<td class="chat-files-tree-name-cell chat-files-tree-name-cell--folder" title="${tEnter}">
|
||||
<span class="chat-files-tree-name-inner">${svgFolder}<span class="chat-files-tree-name-text">${escapeHtml(chatFilesTreeDisplayName(name))}</span></span>
|
||||
<td class="chat-files-tree-name-cell chat-files-tree-name-cell--folder" title="${escapeHtml(folderTitle)}">
|
||||
<span class="chat-files-tree-name-inner">${svgFolder}<span class="chat-files-tree-name-text">${escapeHtml(folderDisplay.text)}</span></span>
|
||||
</td>
|
||||
<td class="chat-files-tree-muted">—</td>
|
||||
<td class="chat-files-tree-muted">—</td>
|
||||
@@ -1351,7 +1420,25 @@ function chatFilesDeleteFolderFromBtn(ev, btn) {
|
||||
|
||||
async function copyChatFolderPathFromBrowse(folderName) {
|
||||
const segs = chatFilesBrowsePath.concat([folderName]);
|
||||
const text = chatFilesClipboardFolderPath(segs);
|
||||
const relativePath = chatFilesRelativePathFromTreeSegments(segs);
|
||||
let text = '';
|
||||
if (relativePath) {
|
||||
try {
|
||||
const res = await apiFetch('/api/chat-uploads/path?kind=directory&path=' + encodeURIComponent(relativePath));
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
throw new Error(raw || String(res.status));
|
||||
}
|
||||
const data = await res.json();
|
||||
text = String((data && data.absolutePath) || '').trim();
|
||||
} catch (e) {
|
||||
alert((e && e.message) ? e.message : String(e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!text) {
|
||||
text = chatFilesClipboardFolderPath(segs);
|
||||
}
|
||||
const ok = await chatFilesCopyText(text);
|
||||
if (ok) {
|
||||
const msg = (typeof window.t === 'function') ? window.t('chatFilesPage.folderPathCopied') : '目录路径已复制';
|
||||
@@ -1362,6 +1449,25 @@ async function copyChatFolderPathFromBrowse(folderName) {
|
||||
}
|
||||
}
|
||||
|
||||
function chatFilesRelativePathFromTreeSegments(segs) {
|
||||
const parts = Array.isArray(segs) ? segs.slice() : [];
|
||||
if (!parts.length) return '';
|
||||
const root = parts.shift();
|
||||
if (root === CHAT_FILES_TREE_UPLOAD_ROOT) {
|
||||
return parts.length ? parts.join('/') : '.';
|
||||
}
|
||||
if (root === CHAT_FILES_TREE_REDUCTION_ROOT) {
|
||||
return '__reduction__/' + parts.join('/');
|
||||
}
|
||||
if (root === CHAT_FILES_TREE_WORKSPACE_ROOT) {
|
||||
return '__workspace__/' + parts.join('/');
|
||||
}
|
||||
if (root === CHAT_FILES_TREE_ARTIFACT_ROOT) {
|
||||
return '__conversation_artifact__/' + parts.join('/');
|
||||
}
|
||||
return [root].concat(parts).join('/');
|
||||
}
|
||||
|
||||
function chatFilesClipboardFolderPath(segs) {
|
||||
const parts = Array.isArray(segs) ? segs.slice() : [];
|
||||
if (!parts.length) return '';
|
||||
|
||||
+403
-46
@@ -80,11 +80,17 @@ let chatAttachmentSeq = 0;
|
||||
|
||||
// 对话模式: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_CONVERSATION_STORAGE_PREFIX = 'cyberstrike-chat-agent-mode:conversation';
|
||||
const AI_CHANNEL_STORAGE_KEY = 'cyberstrike-chat-ai-channel';
|
||||
const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
|
||||
const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort';
|
||||
const CHAT_AI_CHANNEL_SUMMARY_NAME_MAX = 10;
|
||||
const CHAT_AGENT_MODE_EINO_SINGLE = 'eino_single';
|
||||
const CHAT_AGENT_EINO_MODES = ['deep', 'plan_execute', 'supervisor'];
|
||||
let multiAgentAPIEnabled = false;
|
||||
let chatAIChannels = {};
|
||||
let chatDefaultAIChannel = '';
|
||||
let chatAIChannelIdByNormalizedId = {};
|
||||
|
||||
// 人机协同(HITL)会话级配置
|
||||
const HITL_STORAGE_PREFIX = 'cyberstrike-chat-hitl';
|
||||
@@ -96,6 +102,190 @@ const HITL_MODE_APPROVAL = 'approval';
|
||||
const HITL_MODE_REVIEW_EDIT = 'review_edit';
|
||||
const HITL_MODE_OPTIONS = [HITL_MODE_OFF, HITL_MODE_APPROVAL, HITL_MODE_REVIEW_EDIT];
|
||||
let hitlApplyFeedbackTimer = null;
|
||||
let hitlAutoSaveTimer = null;
|
||||
const sessionSettingsSelects = new Map();
|
||||
let sessionSettingsSelectDocBound = false;
|
||||
|
||||
function sessionSettingsSelectLabel(option) {
|
||||
return option ? (option.textContent || option.label || option.value || '') : '';
|
||||
}
|
||||
|
||||
function syncSessionSettingsSelect(select) {
|
||||
const reg = sessionSettingsSelects.get(select);
|
||||
if (!reg) return;
|
||||
const selected = select.options[select.selectedIndex];
|
||||
reg.value.textContent = sessionSettingsSelectLabel(selected);
|
||||
reg.trigger.disabled = !!select.disabled;
|
||||
reg.wrapper.classList.toggle('is-disabled', !!select.disabled);
|
||||
reg.menu.innerHTML = '';
|
||||
|
||||
Array.prototype.forEach.call(select.options, function (option, index) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'session-settings-select-option';
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-index', String(index));
|
||||
item.setAttribute('aria-selected', option.selected ? 'true' : 'false');
|
||||
item.disabled = !!option.disabled;
|
||||
item.classList.toggle('is-selected', !!option.selected);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'session-settings-select-option-label';
|
||||
label.textContent = sessionSettingsSelectLabel(option);
|
||||
item.appendChild(label);
|
||||
reg.menu.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function closeSessionSettingsSelect(select) {
|
||||
const reg = sessionSettingsSelects.get(select);
|
||||
if (!reg) return;
|
||||
reg.wrapper.classList.remove('open');
|
||||
reg.trigger.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
function closeAllSessionSettingsSelects() {
|
||||
sessionSettingsSelects.forEach(function (_reg, select) {
|
||||
closeSessionSettingsSelect(select);
|
||||
});
|
||||
}
|
||||
|
||||
function enhanceSessionSettingsSelect(select) {
|
||||
if (!select || select.dataset.sessionSettingsSelect === '1') {
|
||||
if (select) syncSessionSettingsSelect(select);
|
||||
return;
|
||||
}
|
||||
const panel = select.closest && select.closest('.conversation-reasoning-card');
|
||||
if (!panel) return;
|
||||
|
||||
select.dataset.sessionSettingsSelect = '1';
|
||||
select.classList.add('session-settings-native-select');
|
||||
select.tabIndex = -1;
|
||||
select.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'session-settings-select';
|
||||
const trigger = document.createElement('button');
|
||||
trigger.type = 'button';
|
||||
trigger.className = 'session-settings-select-trigger';
|
||||
trigger.setAttribute('aria-haspopup', 'listbox');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
const value = document.createElement('span');
|
||||
value.className = 'session-settings-select-value';
|
||||
const caret = document.createElement('span');
|
||||
caret.className = 'session-settings-select-caret';
|
||||
caret.setAttribute('aria-hidden', 'true');
|
||||
caret.textContent = '⌄';
|
||||
trigger.appendChild(value);
|
||||
trigger.appendChild(caret);
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'session-settings-select-menu';
|
||||
menu.setAttribute('role', 'listbox');
|
||||
|
||||
select.parentNode.insertBefore(wrapper, select);
|
||||
wrapper.appendChild(trigger);
|
||||
wrapper.appendChild(menu);
|
||||
wrapper.appendChild(select);
|
||||
sessionSettingsSelects.set(select, { wrapper: wrapper, trigger: trigger, value: value, menu: menu });
|
||||
|
||||
trigger.addEventListener('click', function (event) {
|
||||
event.stopPropagation();
|
||||
if (select.disabled) return;
|
||||
const willOpen = !wrapper.classList.contains('open');
|
||||
closeAllSessionSettingsSelects();
|
||||
wrapper.classList.toggle('open', willOpen);
|
||||
trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
||||
});
|
||||
|
||||
trigger.addEventListener('keydown', function (event) {
|
||||
if (select.disabled) return;
|
||||
const enabled = Array.prototype.filter.call(select.options, function (option) { return !option.disabled; });
|
||||
if (!enabled.length) return;
|
||||
const currentOption = select.options[select.selectedIndex];
|
||||
const current = Math.max(0, enabled.indexOf(currentOption));
|
||||
let next = current;
|
||||
if (event.key === 'ArrowDown') next = Math.min(enabled.length - 1, current + 1);
|
||||
else if (event.key === 'ArrowUp') next = Math.max(0, current - 1);
|
||||
else if (event.key === 'Home') next = 0;
|
||||
else if (event.key === 'End') next = enabled.length - 1;
|
||||
else if (event.key === 'Escape') {
|
||||
closeSessionSettingsSelect(select);
|
||||
return;
|
||||
} else if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
wrapper.classList.add('open');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const nextOption = enabled[next];
|
||||
if (nextOption && select.value !== nextOption.value) {
|
||||
select.value = nextOption.value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
syncSessionSettingsSelect(select);
|
||||
});
|
||||
|
||||
menu.addEventListener('click', function (event) {
|
||||
const item = event.target.closest('.session-settings-select-option');
|
||||
if (!item || item.disabled) return;
|
||||
event.stopPropagation();
|
||||
const option = select.options[Number(item.dataset.index)];
|
||||
if (option && !option.disabled && select.value !== option.value) {
|
||||
select.value = option.value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
syncSessionSettingsSelect(select);
|
||||
closeSessionSettingsSelect(select);
|
||||
});
|
||||
|
||||
select.addEventListener('change', function () {
|
||||
syncSessionSettingsSelect(select);
|
||||
});
|
||||
syncSessionSettingsSelect(select);
|
||||
}
|
||||
|
||||
function initSessionSettingsSelects() {
|
||||
const panel = document.getElementById('conversation-reasoning-body');
|
||||
if (!panel) return;
|
||||
panel.querySelectorAll('select').forEach(enhanceSessionSettingsSelect);
|
||||
if (!sessionSettingsSelectDocBound) {
|
||||
document.addEventListener('click', closeAllSessionSettingsSelects);
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape') closeAllSessionSettingsSelects();
|
||||
});
|
||||
sessionSettingsSelectDocBound = true;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSessionSettingsSelects() {
|
||||
sessionSettingsSelects.forEach(function (_reg, select) {
|
||||
syncSessionSettingsSelect(select);
|
||||
});
|
||||
}
|
||||
|
||||
function syncChatReasoningBarHeight() {
|
||||
const inputBar = document.getElementById('chat-input-container');
|
||||
const reasoning = document.getElementById('chat-reasoning-wrapper');
|
||||
if (!inputBar || !reasoning) return;
|
||||
const h = Math.ceil(inputBar.getBoundingClientRect().height || 0);
|
||||
if (h > 0) {
|
||||
reasoning.style.setProperty('--chat-input-bar-height', h + 'px');
|
||||
}
|
||||
}
|
||||
|
||||
function initChatReasoningBarHeightSync() {
|
||||
syncChatReasoningBarHeight();
|
||||
window.addEventListener('resize', syncChatReasoningBarHeight);
|
||||
const inputBar = document.getElementById('chat-input-container');
|
||||
if (inputBar && typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(syncChatReasoningBarHeight);
|
||||
ro.observe(inputBar);
|
||||
}
|
||||
}
|
||||
|
||||
/** 非阻塞提示(与 chat-files-toast 样式共用) */
|
||||
function showChatToast(message, type) {
|
||||
@@ -391,7 +581,7 @@ function readHitlConfigFromForm() {
|
||||
}
|
||||
|
||||
function updateHitlStatusUI(_cfg) {
|
||||
/* 侧栏已改为「应用」按钮生效,不再用角标展示模式 */
|
||||
/* 侧栏已改为自动保存,不再用角标展示模式 */
|
||||
}
|
||||
|
||||
function applyHitlConfigToUI(cfg) {
|
||||
@@ -409,6 +599,7 @@ function applyHitlConfigToUI(cfg) {
|
||||
}
|
||||
if (toolsEl) toolsEl.value = toolsVal;
|
||||
updateHitlStatusUI(conf);
|
||||
refreshSessionSettingsSelects();
|
||||
}
|
||||
|
||||
function bindHitlSidebarModeListener() {
|
||||
@@ -417,6 +608,9 @@ function bindHitlSidebarModeListener() {
|
||||
modeEl.dataset.hitlModeBound = '1';
|
||||
modeEl.addEventListener('change', function () {
|
||||
applyHitlConfigToUI(readHitlConfigFromForm());
|
||||
refreshSessionSettingsSelects();
|
||||
scheduleHitlSidebarAutosave(0);
|
||||
updateChatReasoningSummary();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -458,7 +652,7 @@ function showHitlApplyFeedback(text, isError, partial) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 侧栏人机协同:修改模式/白名单后点此写入本地、合并展示并同步服务端 */
|
||||
/** 侧栏人机协同:自动写入本地、合并展示并尽量同步服务端 */
|
||||
async function applyHitlSidebarConfig() {
|
||||
const btn = document.getElementById('hitl-apply-btn');
|
||||
showHitlApplyFeedback('', false);
|
||||
@@ -486,7 +680,7 @@ async function applyHitlSidebarConfig() {
|
||||
const ok = typeof window.t === 'function' ? window.t('chat.hitlApplyOkSync') : '人机协同配置已保存并同步到服务器。';
|
||||
showHitlApplyFeedback(ok, false);
|
||||
} else if (yamlMerged) {
|
||||
const okYaml = typeof window.t === 'function' ? window.t('chat.hitlApplyOkWhitelistYaml') : '免审批工具已合并进 config.yaml 并生效。协同模式、超时等仍须选中会话后再点「应用」才会写入服务器。';
|
||||
const okYaml = typeof window.t === 'function' ? window.t('chat.hitlApplyOkWhitelistYaml') : '免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。';
|
||||
showHitlApplyFeedback(okYaml, false);
|
||||
} else {
|
||||
const localOnly = typeof window.t === 'function' ? window.t('chat.hitlApplyOkLocal') : '已保存到本浏览器。';
|
||||
@@ -505,6 +699,29 @@ async function applyHitlSidebarConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleHitlSidebarAutosave(delayMs) {
|
||||
if (hitlAutoSaveTimer) {
|
||||
clearTimeout(hitlAutoSaveTimer);
|
||||
hitlAutoSaveTimer = null;
|
||||
}
|
||||
hitlAutoSaveTimer = setTimeout(function () {
|
||||
hitlAutoSaveTimer = null;
|
||||
applyHitlSidebarConfig();
|
||||
}, typeof delayMs === 'number' ? delayMs : 500);
|
||||
}
|
||||
|
||||
function bindHitlSensitiveToolsAutosaveListener() {
|
||||
const toolsEl = document.getElementById('hitl-sensitive-tools');
|
||||
if (!toolsEl || toolsEl.dataset.hitlAutosaveBound === '1') return;
|
||||
toolsEl.dataset.hitlAutosaveBound = '1';
|
||||
toolsEl.addEventListener('input', function () {
|
||||
scheduleHitlSidebarAutosave(700);
|
||||
});
|
||||
toolsEl.addEventListener('blur', function () {
|
||||
scheduleHitlSidebarAutosave(0);
|
||||
});
|
||||
}
|
||||
|
||||
/** 将 localStorage 规范为 eino_single | deep | plan_execute | supervisor */
|
||||
function chatAgentModeNormalizeStored(stored, cfg) {
|
||||
const pub = cfg && cfg.multi_agent ? cfg.multi_agent : null;
|
||||
@@ -517,6 +734,44 @@ function chatAgentModeNormalizeStored(stored, cfg) {
|
||||
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') {
|
||||
window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || [];
|
||||
window.csaiHitlDefaultReviewer = window.csaiHitlDefaultReviewer || 'human';
|
||||
@@ -536,6 +791,7 @@ if (typeof window !== 'undefined') {
|
||||
window.getHitlConfigForConversation = getHitlConfigForConversation;
|
||||
bindHitlSidebarModeListener();
|
||||
bindHitlReviewerToggleListeners();
|
||||
bindHitlSensitiveToolsAutosaveListener();
|
||||
window.setHitlReviewerUI = setHitlReviewerUI;
|
||||
window.onHitlReviewerChanged = onHitlReviewerChanged;
|
||||
window.bindHitlReviewerToggleListeners = bindHitlReviewerToggleListeners;
|
||||
@@ -638,10 +894,85 @@ function syncReasoningRowVisibility(modeVal) {
|
||||
if (!show) {
|
||||
closeChatReasoningPanel();
|
||||
} else {
|
||||
syncChatReasoningBarHeight();
|
||||
updateChatReasoningSummary();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeChatAIChannelId(s) {
|
||||
return String(s || '').trim().toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function resolveChatAIChannelId(id) {
|
||||
const raw = String(id || '').trim();
|
||||
if (!raw) return '';
|
||||
if (chatAIChannels[raw]) return raw;
|
||||
const normalized = normalizeChatAIChannelId(raw);
|
||||
return normalized && chatAIChannelIdByNormalizedId[normalized] ? chatAIChannelIdByNormalizedId[normalized] : '';
|
||||
}
|
||||
|
||||
function populateChatAIChannelSelect(ai) {
|
||||
const select = document.getElementById('chat-ai-channel-select');
|
||||
if (!select) return;
|
||||
const cfg = ai && typeof ai === 'object' ? ai : {};
|
||||
chatAIChannels = cfg.channels && typeof cfg.channels === 'object' ? cfg.channels : {};
|
||||
chatAIChannelIdByNormalizedId = {};
|
||||
Object.keys(chatAIChannels).forEach(function (id) {
|
||||
const normalized = normalizeChatAIChannelId(id);
|
||||
if (normalized && !chatAIChannelIdByNormalizedId[normalized]) {
|
||||
chatAIChannelIdByNormalizedId[normalized] = id;
|
||||
}
|
||||
});
|
||||
chatDefaultAIChannel = resolveChatAIChannelId(cfg.default_channel || '');
|
||||
select.innerHTML = '';
|
||||
const fallbackOpt = document.createElement('option');
|
||||
fallbackOpt.value = '';
|
||||
fallbackOpt.textContent = typeof window.t === 'function' ? window.t('chat.aiChannelDefault') : '跟随默认通道';
|
||||
select.appendChild(fallbackOpt);
|
||||
Object.keys(chatAIChannels).sort().forEach(function (id) {
|
||||
const ch = chatAIChannels[id] || {};
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
opt.textContent = (ch.name || id) + (ch.model ? ' · ' + ch.model : '');
|
||||
select.appendChild(opt);
|
||||
});
|
||||
let stored = '';
|
||||
try { stored = localStorage.getItem(AI_CHANNEL_STORAGE_KEY) || ''; } catch (e) {}
|
||||
stored = resolveChatAIChannelId(stored);
|
||||
select.value = stored || '';
|
||||
refreshSessionSettingsSelects();
|
||||
updateChatReasoningSummary();
|
||||
}
|
||||
|
||||
function selectedChatAIChannelId() {
|
||||
const select = document.getElementById('chat-ai-channel-select');
|
||||
return resolveChatAIChannelId(select ? select.value : '');
|
||||
}
|
||||
|
||||
function currentChatAIChannelLabel() {
|
||||
const id = selectedChatAIChannelId() || chatDefaultAIChannel;
|
||||
const ch = id ? chatAIChannels[id] : null;
|
||||
if (!ch) {
|
||||
return typeof window.t === 'function' ? window.t('chat.aiChannelDefaultShort') : '默认通道';
|
||||
}
|
||||
return ch.name || id;
|
||||
}
|
||||
|
||||
function truncateChatAIChannelSummaryLabel(label) {
|
||||
const chars = Array.from(String(label || ''));
|
||||
if (chars.length <= CHAT_AI_CHANNEL_SUMMARY_NAME_MAX) return chars.join('');
|
||||
return chars.slice(0, CHAT_AI_CHANNEL_SUMMARY_NAME_MAX).join('') + '...';
|
||||
}
|
||||
|
||||
function persistChatAIChannelPref() {
|
||||
const id = selectedChatAIChannelId();
|
||||
try {
|
||||
if (id) localStorage.setItem(AI_CHANNEL_STORAGE_KEY, id);
|
||||
else localStorage.removeItem(AI_CHANNEL_STORAGE_KEY);
|
||||
} catch (e) {}
|
||||
updateChatReasoningSummary();
|
||||
}
|
||||
|
||||
function reasoningSummaryModeLabel(mode) {
|
||||
const m = (mode || 'default').trim();
|
||||
const t = (typeof window.t === 'function') ? window.t : function (k) { return k; };
|
||||
@@ -662,8 +993,18 @@ function updateChatReasoningSummary() {
|
||||
const effort = effEl && effEl.value ? String(effEl.value).trim() : '';
|
||||
const t = (typeof window.t === 'function') ? window.t : function (k) { return k; };
|
||||
const modePart = reasoningSummaryModeLabel(mode);
|
||||
const effPart = effort || t('chat.reasoningSummaryDash');
|
||||
el.textContent = modePart + ' / ' + effPart;
|
||||
const reasoningPart = effort || modePart || t('chat.reasoningSummaryDash');
|
||||
let hitlPart = '';
|
||||
try {
|
||||
const hitlCfg = readHitlConfigFromForm();
|
||||
hitlPart = getHitlModeLabel(hitlCfg.mode);
|
||||
} catch (e) {
|
||||
hitlPart = '';
|
||||
}
|
||||
const channelPart = currentChatAIChannelLabel();
|
||||
const parts = [truncateChatAIChannelSummaryLabel(channelPart), reasoningPart, hitlPart].filter(Boolean);
|
||||
el.textContent = parts.join(' / ');
|
||||
el.title = [channelPart, reasoningPart, hitlPart].filter(Boolean).join(' / ');
|
||||
}
|
||||
|
||||
function closeChatReasoningPanel() {
|
||||
@@ -677,6 +1018,7 @@ function toggleConversationReasoningCard() {
|
||||
const wrap = document.getElementById('chat-reasoning-wrapper');
|
||||
const toggle = document.getElementById('conversation-reasoning-toggle');
|
||||
if (!wrap || !toggle) return;
|
||||
syncChatReasoningBarHeight();
|
||||
wrap.classList.toggle('conversation-reasoning-collapsed');
|
||||
const collapsed = wrap.classList.contains('conversation-reasoning-collapsed');
|
||||
toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
@@ -711,6 +1053,7 @@ function restoreChatReasoningControlsFromStorage() {
|
||||
e.value = v;
|
||||
}
|
||||
}
|
||||
refreshSessionSettingsSelects();
|
||||
updateChatReasoningSummary();
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
@@ -721,6 +1064,7 @@ function persistChatReasoningPrefs() {
|
||||
const elEff = document.getElementById('chat-reasoning-effort');
|
||||
if (m) localStorage.setItem(REASONING_MODE_LS, m.value || 'default');
|
||||
if (elEff) localStorage.setItem(REASONING_EFFORT_LS, elEff.value || '');
|
||||
refreshSessionSettingsSelects();
|
||||
updateChatReasoningSummary();
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
@@ -746,12 +1090,15 @@ function buildReasoningRequestPayload() {
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.persistChatAIChannelPref = persistChatAIChannelPref;
|
||||
window.populateChatAIChannelSelect = populateChatAIChannelSelect;
|
||||
window.persistChatReasoningPrefs = persistChatReasoningPrefs;
|
||||
window.buildReasoningRequestPayload = buildReasoningRequestPayload;
|
||||
window.closeChatReasoningPanel = closeChatReasoningPanel;
|
||||
window.toggleChatReasoningPanel = toggleChatReasoningPanel;
|
||||
window.toggleConversationReasoningCard = toggleConversationReasoningCard;
|
||||
window.updateChatReasoningSummary = updateChatReasoningSummary;
|
||||
window.refreshSessionSettingsSelects = refreshSessionSettingsSelects;
|
||||
}
|
||||
|
||||
function closeAgentModePanel() {
|
||||
@@ -790,6 +1137,7 @@ function toggleAgentModePanel() {
|
||||
function selectAgentMode(mode) {
|
||||
const ok = chatAgentModeIsEinoSingle(mode) || chatAgentModeIsEino(mode);
|
||||
if (!ok) return;
|
||||
saveConversationAgentModePreference(currentConversationId, mode);
|
||||
try {
|
||||
localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode);
|
||||
} catch (e) { /* ignore */ }
|
||||
@@ -826,6 +1174,7 @@ async function initChatAgentModeFromConfig() {
|
||||
if (!r.ok) return;
|
||||
const cfg = await r.json();
|
||||
multiAgentAPIEnabled = !!(cfg.multi_agent && cfg.multi_agent.enabled);
|
||||
populateChatAIChannelSelect(cfg.ai || {});
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__csaiMultiAgentPublic = cfg.multi_agent || null;
|
||||
const tw = cfg.hitl && cfg.hitl.tool_whitelist;
|
||||
@@ -1042,10 +1391,24 @@ async function sendMessage() {
|
||||
conversationId: currentConversationId,
|
||||
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;
|
||||
const isStreamStillVisibleForRequest = function () {
|
||||
if (!document.getElementById(progressId)) return false;
|
||||
if (!streamConversationId) return currentConversationId === body.conversationId;
|
||||
return currentConversationId === streamConversationId;
|
||||
};
|
||||
if (!currentConversationId && typeof getActiveProjectId === 'function') {
|
||||
const pid = getActiveProjectId();
|
||||
if (pid) body.projectId = pid;
|
||||
}
|
||||
const aiChannelId = selectedChatAIChannelId();
|
||||
if (aiChannelId) {
|
||||
body.aiChannelId = aiChannelId;
|
||||
}
|
||||
const hitlCfg = readHitlConfigFromForm();
|
||||
if (normalizeHitlMode(hitlCfg.mode) !== HITL_MODE_OFF) {
|
||||
const sensitiveTools = hitlToolsSplitToArray(hitlCfg.sensitiveTools || '');
|
||||
@@ -1078,7 +1441,7 @@ async function sendMessage() {
|
||||
window.CyberStrikeChatScroll.onUserSendMessage();
|
||||
}
|
||||
const progressElement = document.getElementById(progressId);
|
||||
registerProgressTask(progressId, currentConversationId);
|
||||
registerProgressTask(progressId, streamConversationId);
|
||||
loadActiveTasks();
|
||||
let assistantMessageId = null;
|
||||
let mcpExecutionIds = [];
|
||||
@@ -1086,6 +1449,7 @@ async function sendMessage() {
|
||||
try {
|
||||
const modeSel = document.getElementById('agent-mode-select');
|
||||
let modeVal = modeSel ? modeSel.value : CHAT_AGENT_MODE_EINO_SINGLE;
|
||||
saveConversationAgentModePreference(streamConversationId || currentConversationId, modeVal);
|
||||
const useMulti = multiAgentAPIEnabled && chatAgentModeIsEino(modeVal);
|
||||
const streamPath = useMulti ? '/api/multi-agent/stream' : '/api/eino-agent/stream';
|
||||
if (useMulti && modeVal) {
|
||||
@@ -1105,7 +1469,7 @@ async function sendMessage() {
|
||||
|
||||
window.__csAgentLiveStream = {
|
||||
active: true,
|
||||
conversationId: currentConversationId || null,
|
||||
conversationId: streamConversationId || null,
|
||||
progressId: progressId
|
||||
};
|
||||
try {
|
||||
@@ -1117,9 +1481,26 @@ async function sendMessage() {
|
||||
if (eventData && eventData.type === 'done') {
|
||||
streamSawDone = true;
|
||||
}
|
||||
const eventConvId = eventData && eventData.data && eventData.data.conversationId
|
||||
? String(eventData.data.conversationId)
|
||||
: '';
|
||||
let justBoundConversation = false;
|
||||
if (eventConvId) {
|
||||
if (streamConversationId && streamConversationId !== eventConvId) {
|
||||
return;
|
||||
}
|
||||
if (!streamConversationId && eventData.type === 'conversation') {
|
||||
streamConversationId = eventConvId;
|
||||
justBoundConversation = true;
|
||||
}
|
||||
}
|
||||
if (!justBoundConversation && !isStreamStillVisibleForRequest()) {
|
||||
return;
|
||||
}
|
||||
handleStreamEvent(eventData, progressElement, progressId,
|
||||
() => assistantMessageId, (id) => { assistantMessageId = id; },
|
||||
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
|
||||
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; },
|
||||
{ conversationId: streamConversationId });
|
||||
};
|
||||
const processSseLines = typeof processSseDataLinesYielding === 'function'
|
||||
? processSseDataLinesYielding
|
||||
@@ -1157,13 +1538,13 @@ async function sendMessage() {
|
||||
if (typeof loadActiveTasks === 'function') {
|
||||
loadActiveTasks();
|
||||
}
|
||||
const convId = currentConversationId || (body && body.conversationId) || null;
|
||||
const convId = streamConversationId || (body && body.conversationId) || null;
|
||||
let attached = false;
|
||||
if (convId && typeof window.attachRunningTaskEventStream === 'function') {
|
||||
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
|
||||
attached = await window.attachRunningTaskEventStream(convId).catch(() => false);
|
||||
}
|
||||
if (!attached) {
|
||||
if (!attached && isStreamStillVisibleForRequest()) {
|
||||
const hint = typeof window.t === 'function'
|
||||
? window.t('chat.streamEndedWithoutDone')
|
||||
: '连接提前结束,未收到任务完成信号。任务可能仍在后端执行,请查看顶部运行中任务或刷新当前对话。';
|
||||
@@ -1186,6 +1567,12 @@ async function sendMessage() {
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (!isStreamStillVisibleForRequest()) {
|
||||
if (typeof loadActiveTasks === 'function') {
|
||||
loadActiveTasks();
|
||||
}
|
||||
return;
|
||||
}
|
||||
removeMessage(progressId);
|
||||
const msg = error && error.message != null ? String(error.message) : String(error);
|
||||
const isNetwork = /network|fetch|Failed to fetch|aborted|AbortError|load failed|NetworkError/i.test(msg);
|
||||
@@ -3230,39 +3617,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) {
|
||||
const normalizedSummaries = Array.isArray(summaries)
|
||||
? summaries.map(normalizeToolExecutionSummaryForButton)
|
||||
: [];
|
||||
const normalizedIds = normalizeMcpExecutionIds(executionIds);
|
||||
if (normalizedSummaries.length > 0) return normalizedSummaries;
|
||||
if (normalizedIds.length === 0) return normalizedSummaries;
|
||||
if (normalizedSummaries.length === 0) {
|
||||
return normalizedIds.map((executionId) => normalizeToolExecutionSummaryForButton({ executionId }));
|
||||
}
|
||||
return mergeToolExecutionSummariesWithIds(normalizedSummaries, normalizedIds);
|
||||
return normalizedIds.map((executionId) => normalizeToolExecutionSummaryForButton({ executionId }));
|
||||
}
|
||||
|
||||
function setPendingToolExecutionSummaries(messageElement, summaries) {
|
||||
@@ -3477,10 +3839,6 @@ async function findToolExecutionTimelineItem(messageElement, summary, index) {
|
||||
if (!target && 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') {
|
||||
await window.loadProcessDetailsPaginated(messageElement.id, messageElement.dataset.backendMessageId, {
|
||||
autoLoadAll: false,
|
||||
@@ -3653,11 +4011,7 @@ async function openTaskToolExecutionDetail(messageElement, item, index) {
|
||||
let detailItem = item;
|
||||
if (!detailItem.executionId) {
|
||||
const refreshedItem = await resolveToolExecutionSummaryForFocus(messageElement, '', index);
|
||||
const mergedItems = mergeToolExecutionSummariesWithIds(
|
||||
getCachedToolExecutionSummaries(messageElement),
|
||||
getCachedMcpExecutionIds(messageElement)
|
||||
);
|
||||
detailItem = mergedItems[index] || refreshedItem || detailItem;
|
||||
detailItem = refreshedItem || detailItem;
|
||||
}
|
||||
if (detailItem.executionId) {
|
||||
await showMCPDetail(detailItem.executionId);
|
||||
@@ -4536,6 +4890,7 @@ async function loadConversation(conversationId) {
|
||||
if (typeof window.setCurrentRole === 'function') {
|
||||
window.setCurrentRole(conversationRoleName || '默认');
|
||||
}
|
||||
applyConversationAgentMode(conversationId, conversation);
|
||||
try {
|
||||
window.currentConversationId = conversationId;
|
||||
} catch (e) { /* ignore */ }
|
||||
@@ -10201,6 +10556,8 @@ function applyCustomIcon() {
|
||||
|
||||
// 自定义图标输入框回车键处理
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initSessionSettingsSelects();
|
||||
initChatReasoningBarHeightSync();
|
||||
const customInput = document.getElementById('custom-icon-input');
|
||||
if (customInput) {
|
||||
customInput.addEventListener('keydown', function(e) {
|
||||
|
||||
@@ -29,6 +29,16 @@ var dashboardState = {
|
||||
lastProjectSummary: null, // 最近一次项目仪表盘摘要(供 Tab 切换时重绘)
|
||||
};
|
||||
|
||||
function dashboardProjectScopedUrl(url) {
|
||||
try {
|
||||
var pid = typeof getActiveProjectId === 'function' ? (getActiveProjectId() || '') : '';
|
||||
if (!pid) return url;
|
||||
return url + (url.indexOf('?') === -1 ? '?' : '&') + 'project_id=' + encodeURIComponent(pid);
|
||||
} catch (e) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDashboard() {
|
||||
const runningEl = document.getElementById('dashboard-running-tasks');
|
||||
const vulnTotalEl = document.getElementById('dashboard-vuln-total');
|
||||
@@ -143,11 +153,11 @@ async function refreshDashboard() {
|
||||
// External MCP 健康度
|
||||
fetchJson('/api/external-mcp/stats'),
|
||||
// WebShell 已建立的连接(pentest 落地后的 foothold,对运营场景非常关键)
|
||||
fetchJson('/api/webshell/connections'),
|
||||
fetchJson(dashboardProjectScopedUrl('/api/webshell/connections')),
|
||||
// C2 仪表盘条:监听器 / 会话 / 待处理任务(任务接口含 pending_queued_count)
|
||||
fetchJson('/api/c2/listeners'),
|
||||
fetchJson('/api/c2/sessions?limit=500'),
|
||||
fetchJson('/api/c2/tasks?page=1&page_size=1'),
|
||||
fetchJson(dashboardProjectScopedUrl('/api/c2/listeners')),
|
||||
fetchJson(dashboardProjectScopedUrl('/api/c2/sessions?limit=500')),
|
||||
fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')),
|
||||
fetchJson('/api/projects/dashboard-summary?fact_limit=10'),
|
||||
selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
@@ -615,6 +615,28 @@ function reconcileHitlUiState() {
|
||||
|
||||
let hitlFollowRunSeq = 0;
|
||||
|
||||
function hitlAutoResizeTextarea(textarea) {
|
||||
if (!textarea) return;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.max(textarea.scrollHeight, textarea.offsetHeight || 0) + 'px';
|
||||
}
|
||||
|
||||
function bindHitlAutoResizeTextareas(root) {
|
||||
const scope = root || document;
|
||||
if (!scope || !scope.querySelectorAll) return;
|
||||
scope.querySelectorAll('.hitl-edit-args').forEach(function (textarea) {
|
||||
if (textarea.__hitlAutoResizeBound) {
|
||||
hitlAutoResizeTextarea(textarea);
|
||||
return;
|
||||
}
|
||||
textarea.__hitlAutoResizeBound = true;
|
||||
hitlAutoResizeTextarea(textarea);
|
||||
textarea.addEventListener('input', function () {
|
||||
hitlAutoResizeTextarea(textarea);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批提交后原 SSE 已断开:轮询任务列表,运行中则拉取过程详情;任务结束后再整页加载会话以对齐终态。
|
||||
*/
|
||||
@@ -855,6 +877,7 @@ async function refreshHitlPending() {
|
||||
} else {
|
||||
container.innerHTML = workflowHtml + (workflowHtml && toolHtml ? '<div class="hitl-pending-section-divider"></div>' : '') + (toolHtml || '');
|
||||
}
|
||||
bindHitlAutoResizeTextareas(container);
|
||||
renderHitlPendingPagination();
|
||||
} catch (e) {
|
||||
hitlPendingLoaded = false;
|
||||
|
||||
@@ -1334,6 +1334,7 @@ function scanFofaRow(encodedRowJson, clickEvent) {
|
||||
}
|
||||
if (autoSend) {
|
||||
if (typeof sendMessage === 'function') {
|
||||
window.__csNextChatFinalizationPolicy = { requireExecutionEvidence: true };
|
||||
sendMessage();
|
||||
} else {
|
||||
alert(_t('infoCollect.noSendMessage'));
|
||||
|
||||
+451
-48
@@ -4,6 +4,7 @@ let userInterruptModalPending = null;
|
||||
let activeTaskInterval = null;
|
||||
const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次
|
||||
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
|
||||
const hitlInterruptToolItemMap = new Map();
|
||||
|
||||
/**
|
||||
* 主对话 POST 流仍在读取时,禁止再挂 task-events 补流,否则同一事件会画两遍(与 HITL 是否开启无关)。
|
||||
@@ -19,6 +20,13 @@ function syncAgentLiveStreamConversationId(cid) {
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function setCurrentConversationIdFromStream(cid) {
|
||||
currentConversationId = cid;
|
||||
try {
|
||||
window.currentConversationId = cid;
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function shouldSkipTaskEventReplayAttach(conversationId) {
|
||||
try {
|
||||
const live = window.__csAgentLiveStream;
|
||||
@@ -190,6 +198,92 @@ function resolveFinalAssistantResponseText(finalMessage, streamState) {
|
||||
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 落库类型一致),
|
||||
* 避免 integrateProgressToMCPSection 快照前删除占位导致「助手输出」仅刷新后才出现。
|
||||
@@ -1984,7 +2078,32 @@ function formatEinoRunRetryTitle(data) {
|
||||
|
||||
// 处理流式事件
|
||||
function handleStreamEvent(event, progressElement, progressId,
|
||||
getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
|
||||
getAssistantId, setAssistantId, getMcpIds, setMcpIds, options) {
|
||||
const expectedConversationId = options && options.conversationId
|
||||
? String(options.conversationId)
|
||||
: '';
|
||||
const eventConversationId = event && event.data && event.data.conversationId
|
||||
? String(event.data.conversationId)
|
||||
: '';
|
||||
if (expectedConversationId) {
|
||||
if (eventConversationId && eventConversationId !== expectedConversationId) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof window.currentConversationId === 'string' &&
|
||||
window.currentConversationId &&
|
||||
window.currentConversationId !== expectedConversationId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const progressNode = progressId ? document.getElementById(progressId) : null;
|
||||
const progressConversationId = progressNode && progressNode.dataset
|
||||
? String(progressNode.dataset.conversationId || '')
|
||||
: '';
|
||||
if (progressConversationId && progressConversationId !== expectedConversationId) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const streamScrollWasPinned = typeof window.captureScrollPinState === 'function'
|
||||
? window.captureScrollPinState()
|
||||
: (typeof window.isChatMessagesPinnedToBottom === 'function' ? window.isChatMessagesPinnedToBottom() : true);
|
||||
@@ -2048,7 +2167,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
}
|
||||
|
||||
// 更新当前对话ID
|
||||
currentConversationId = event.data.conversationId;
|
||||
setCurrentConversationIdFromStream(event.data.conversationId);
|
||||
syncAgentLiveStreamConversationId(event.data.conversationId);
|
||||
updateActiveConversation();
|
||||
addAttackChainButton(currentConversationId);
|
||||
@@ -2407,30 +2526,59 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'hitl_interrupt':
|
||||
const hitlItemId = addTimelineItem(timeline, 'warning', {
|
||||
title: '🧑⚖️ HITL',
|
||||
message: event.message,
|
||||
data: event.data
|
||||
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
|
||||
});
|
||||
renderInlineHitlApproval(hitlItemId, event.data || {});
|
||||
break;
|
||||
|
||||
case 'finalization_auto_continue':
|
||||
addTimelineItem(timeline, 'progress', {
|
||||
title: '继续验证',
|
||||
message: event.message,
|
||||
data: event.data,
|
||||
expanded: false
|
||||
});
|
||||
break;
|
||||
|
||||
case 'hitl_interrupt':
|
||||
const hitlTargetItem = findToolCallItemForHitl(timeline, event.data || {});
|
||||
if (hitlTargetItem && hitlTargetItem.id) {
|
||||
renderInlineHitlApproval(hitlTargetItem.id, event.data || {});
|
||||
} else {
|
||||
const hitlItemId = addTimelineItem(timeline, 'hitl_interrupt', {
|
||||
title: '🧑⚖️ HITL',
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
renderInlineHitlApproval(hitlItemId, event.data || {});
|
||||
}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('hitl-interrupt', { detail: event.data || {} }));
|
||||
} catch (e) {}
|
||||
break;
|
||||
case 'hitl_resumed':
|
||||
addTimelineItem(timeline, 'progress', {
|
||||
title: '✅ HITL',
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
if (!resolveInlineHitlDecision(timeline, event.data || {}, 'approve', event.message)) {
|
||||
addTimelineItem(timeline, 'progress', {
|
||||
title: '✅ HITL',
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'hitl_rejected':
|
||||
addTimelineItem(timeline, 'error', {
|
||||
title: '⛔ HITL',
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
if (!resolveInlineHitlDecision(timeline, event.data || {}, 'reject', event.message)) {
|
||||
addTimelineItem(timeline, 'error', {
|
||||
title: '⛔ HITL',
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'user_interrupt_continue': {
|
||||
@@ -2801,7 +2949,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
updateProgressConversation(progressId, responseData.conversationId);
|
||||
break;
|
||||
}
|
||||
currentConversationId = responseData.conversationId;
|
||||
setCurrentConversationIdFromStream(responseData.conversationId);
|
||||
syncAgentLiveStreamConversationId(responseData.conversationId);
|
||||
updateActiveConversation();
|
||||
addAttackChainButton(currentConversationId);
|
||||
@@ -2904,7 +3052,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
break;
|
||||
}
|
||||
|
||||
currentConversationId = responseData.conversationId;
|
||||
setCurrentConversationIdFromStream(responseData.conversationId);
|
||||
syncAgentLiveStreamConversationId(responseData.conversationId);
|
||||
updateActiveConversation();
|
||||
addAttackChainButton(currentConversationId);
|
||||
@@ -2916,7 +3064,12 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
const streamState = responseStreamStateByProgressId.get(progressId);
|
||||
const existingAssistantId = streamState?.assistantId || getAssistantId();
|
||||
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) {
|
||||
assistantIdFinal = addMessage('assistant', bubbleText, mcpIds, progressId);
|
||||
@@ -2925,11 +3078,12 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
setAssistantId(assistantIdFinal);
|
||||
updateAssistantBubbleContent(assistantIdFinal, bubbleText, true);
|
||||
}
|
||||
markAssistantFinalizationState(assistantIdFinal, responseData);
|
||||
|
||||
// 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
|
||||
if (streamState && streamState.itemId) {
|
||||
finalizeMainResponseStreamItem(streamState, event.message, responseData);
|
||||
} else if (timeline && bubbleText && String(bubbleText).trim() && !isEinoEmptyResponsePlaceholder(event.message)) {
|
||||
finalizeMainResponseStreamItem(streamState, responseFinalized ? event.message : '', responseData);
|
||||
} else if (timeline && responseFinalized && bubbleText && String(bubbleText).trim() && !isEinoEmptyResponsePlaceholder(event.message)) {
|
||||
addTimelineItem(timeline, 'planning', {
|
||||
title: typeof einoMainStreamPlanningTitle === 'function'
|
||||
? einoMainStreamPlanningTitle(responseData)
|
||||
@@ -2938,6 +3092,13 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
data: responseData,
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
|
||||
@@ -3064,7 +3225,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
}
|
||||
// 更新对话ID
|
||||
if (event.data && event.data.conversationId) {
|
||||
currentConversationId = event.data.conversationId;
|
||||
setCurrentConversationIdFromStream(event.data.conversationId);
|
||||
syncAgentLiveStreamConversationId(event.data.conversationId);
|
||||
updateActiveConversation();
|
||||
addAttackChainButton(currentConversationId);
|
||||
@@ -3115,6 +3276,22 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
function renderInlineHitlApproval(itemId, data) {
|
||||
const item = document.getElementById(itemId);
|
||||
if (!item || !data || !data.interruptId) return;
|
||||
if (item.classList.contains('timeline-item-tool_call')) {
|
||||
const state = toolCallDetailStateByItemId.get(item.id) || {};
|
||||
state.hitlData = data;
|
||||
state.pending = true;
|
||||
setToolCallDetailState(item, state);
|
||||
if (data.interruptId) {
|
||||
hitlInterruptToolItemMap.set(String(data.interruptId), item.id);
|
||||
}
|
||||
const existingContent = item.querySelector('.timeline-item-content.tool-call-detail-content');
|
||||
if (existingContent) {
|
||||
existingContent.remove();
|
||||
item.classList.remove('tool-call-detail-expanded');
|
||||
}
|
||||
renderToolCallDetailContent(item);
|
||||
return;
|
||||
}
|
||||
let contentEl = item.querySelector('.timeline-item-content');
|
||||
if (!contentEl) {
|
||||
// warning 等类型默认没有内容区域;HITL 内联审批需要可交互容器
|
||||
@@ -3136,31 +3313,165 @@ function renderInlineHitlApproval(itemId, data) {
|
||||
const allowEdit = mode === 'review_edit';
|
||||
const argsObj = payload.argumentsObj && typeof payload.argumentsObj === 'object' ? payload.argumentsObj : {};
|
||||
const argsJSON = JSON.stringify(argsObj, null, 2);
|
||||
const modeLabel = mode === 'review_edit' ? '审查编辑' : '审批模式';
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'hitl-inline-approval';
|
||||
panel.innerHTML = `
|
||||
<div class="hitl-input-help"><strong>${escapeHtml(toolName)}</strong> 待人工审批。模式:${escapeHtml(mode || '-')}。</div>
|
||||
${allowEdit
|
||||
? `<div class="hitl-input-help">审查编辑参数(JSON,可选):留空表示沿用原参数。</div>
|
||||
<textarea class="hitl-edit-args hitl-inline-edit" placeholder='{"command":"ls -la"}'>${escapeHtml(argsJSON === '{}' ? '' : argsJSON)}</textarea>`
|
||||
: '<div class="hitl-input-help">当前模式不支持改参,仅可通过/拒绝。</div>'
|
||||
panel.innerHTML = buildInlineHitlApprovalHtml(data, {
|
||||
toolName: toolName,
|
||||
mode: mode,
|
||||
modeLabel: modeLabel,
|
||||
allowEdit: allowEdit,
|
||||
argsJSON: argsJSON
|
||||
});
|
||||
contentEl.appendChild(panel);
|
||||
bindInlineHitlApproval(panel, data, { allowEdit: allowEdit });
|
||||
}
|
||||
|
||||
function resolveInlineHitlDecision(timeline, data, decision, message) {
|
||||
if (!timeline || !data || !data.interruptId) return false;
|
||||
const interruptId = String(data.interruptId);
|
||||
let item = null;
|
||||
const mappedId = hitlInterruptToolItemMap.get(interruptId);
|
||||
if (mappedId) item = document.getElementById(mappedId);
|
||||
if (!item) item = findToolCallItemForHitl(timeline, data);
|
||||
if (!item) {
|
||||
item = timeline.querySelector('[data-hitl-interrupt-id="' + hitlEscapeAttrSelector(interruptId) + '"]');
|
||||
}
|
||||
if (!item) return false;
|
||||
|
||||
if (item.classList.contains('timeline-item-tool_call')) {
|
||||
const state = toolCallDetailStateByItemId.get(item.id) || {};
|
||||
state.hitlData = Object.assign({}, state.hitlData || data, {
|
||||
resolved: true,
|
||||
decision: decision,
|
||||
decisionMessage: message || '',
|
||||
comment: data.comment || (state.hitlData && state.hitlData.comment) || '',
|
||||
editedArgs: data.editedArgs || data.editedArguments || (state.hitlData && (state.hitlData.editedArgs || state.hitlData.editedArguments)) || null
|
||||
});
|
||||
if (decision === 'approve' && state.hitlData.editedArgs && typeof state.hitlData.editedArgs === 'object') {
|
||||
state.originalArgs = state.originalArgs || state.args || {};
|
||||
state.args = state.hitlData.editedArgs;
|
||||
state.argsEditedByHitl = true;
|
||||
}
|
||||
<div class="hitl-input-help">备注(可选):建议写审批依据。</div>
|
||||
<input class="hitl-config-input hitl-inline-comment" type="text" placeholder="例如:允许只读命令">
|
||||
<div class="hitl-pending-actions">
|
||||
state.pending = decision === 'approve';
|
||||
setToolCallDetailState(item, state);
|
||||
const content = item.querySelector('.timeline-item-content.tool-call-detail-content');
|
||||
if (content) {
|
||||
content.remove();
|
||||
item.classList.remove('tool-call-detail-expanded');
|
||||
}
|
||||
renderToolCallDetailContent(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
const panel = item.querySelector('.hitl-inline-approval');
|
||||
if (panel) {
|
||||
markInlineHitlDecision(panel, decision, message || '');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findToolCallItemForHitl(timeline, data) {
|
||||
if (!timeline || !data) return null;
|
||||
const payload = data.payload && typeof data.payload === 'object' ? data.payload : {};
|
||||
const toolCallId = String(data.toolCallId || payload.toolCallId || '').trim();
|
||||
if (toolCallId) {
|
||||
const byId = timeline.querySelector('[data-tool-call-id="' + hitlEscapeAttrSelector(toolCallId) + '"]');
|
||||
if (byId && byId.classList.contains('timeline-item-tool_call')) return byId;
|
||||
}
|
||||
const toolName = String(data.toolName || payload.toolName || '').trim().toLowerCase();
|
||||
if (!toolName) return null;
|
||||
const shortWant = toolName.indexOf('::') >= 0 ? toolName.split('::').pop() : toolName;
|
||||
const calls = timeline.querySelectorAll('.timeline-item-tool_call');
|
||||
for (let i = calls.length - 1; i >= 0; i--) {
|
||||
const tn = String(calls[i].dataset.toolName || '').trim().toLowerCase();
|
||||
const shortTn = tn.indexOf('::') >= 0 ? tn.split('::').pop() : tn;
|
||||
if (tn === toolName || tn.endsWith('::' + shortWant) || shortTn === shortWant) {
|
||||
return calls[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildInlineHitlApprovalHtml(data, opts) {
|
||||
const hasToolNameOverride = opts && Object.prototype.hasOwnProperty.call(opts, 'toolName');
|
||||
const toolName = hasToolNameOverride ? String(opts.toolName || '') : (data.toolName || '-');
|
||||
const mode = opts && opts.mode ? opts.mode : String(data.mode || 'approval').trim().toLowerCase();
|
||||
const modeLabel = opts && opts.modeLabel ? opts.modeLabel : (mode === 'review_edit' ? '审查编辑' : '审批模式');
|
||||
const allowEdit = opts && opts.allowEdit === true;
|
||||
const argsJSON = opts && opts.argsJSON ? opts.argsJSON : '';
|
||||
const toolBadge = toolName
|
||||
? '<span class="hitl-tool-badge">' + escapeHtml(toolName) + '</span>'
|
||||
: '';
|
||||
if (data && data.resolved) {
|
||||
const ok = data.decision === 'approve';
|
||||
const text = data.decisionMessage || (ok ? '已通过,继续执行' : '已拒绝');
|
||||
const comment = data.comment ? '<span class="hitl-inline-decision-comment">' + escapeHtml(data.comment) + '</span>' : '';
|
||||
return `
|
||||
<div class="hitl-inline-decision hitl-inline-decision--${ok ? 'approve' : 'reject'}">
|
||||
<span class="hitl-inline-decision-dot" aria-hidden="true"></span>
|
||||
<strong>${escapeHtml(ok ? '审批通过' : '审批拒绝')}</strong>
|
||||
<span>${escapeHtml(text)}</span>
|
||||
${comment}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<div class="hitl-inline-header">
|
||||
<div class="hitl-inline-title">
|
||||
<span class="hitl-inline-icon" aria-hidden="true">!</span>
|
||||
<span>待审批</span>
|
||||
</div>
|
||||
<div class="hitl-inline-badges">
|
||||
${toolBadge}
|
||||
<span class="hitl-mode-tag hitl-mode-tag--${escapeHtml(mode || 'approval')}">${escapeHtml(modeLabel)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hitl-inline-body">
|
||||
<div class="hitl-input-help hitl-inline-summary">确认上方参数后决定是否继续执行。</div>
|
||||
${allowEdit
|
||||
? `<label class="hitl-inline-field">
|
||||
<span class="hitl-context-label">参数覆盖(JSON,可选)</span>
|
||||
<textarea class="hitl-edit-args hitl-inline-edit" placeholder='{"command":"ls -la"}'>${escapeHtml(argsJSON === '{}' ? '' : argsJSON)}</textarea>
|
||||
</label>`
|
||||
: ''
|
||||
}
|
||||
<label class="hitl-inline-field">
|
||||
<span class="hitl-context-label">备注(可选)</span>
|
||||
<input class="hitl-config-input hitl-inline-comment" type="text" placeholder="例如:允许只读命令">
|
||||
</label>
|
||||
</div>
|
||||
<div class="hitl-pending-actions hitl-inline-actions">
|
||||
<div class="hitl-input-help hitl-inline-status" aria-live="polite"></div>
|
||||
<button class="btn-secondary hitl-inline-reject">拒绝</button>
|
||||
<button class="btn-primary hitl-inline-approve">通过</button>
|
||||
</div>
|
||||
<div class="hitl-input-help hitl-inline-status"></div>
|
||||
`;
|
||||
contentEl.appendChild(panel);
|
||||
}
|
||||
|
||||
function autoResizeHitlTextarea(textarea) {
|
||||
if (!textarea) return;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.max(textarea.scrollHeight, textarea.offsetHeight || 0) + 'px';
|
||||
}
|
||||
|
||||
function bindInlineHitlApproval(panel, data, opts) {
|
||||
const approveBtn = panel.querySelector('.hitl-inline-approve');
|
||||
const rejectBtn = panel.querySelector('.hitl-inline-reject');
|
||||
const commentInput = panel.querySelector('.hitl-inline-comment');
|
||||
const editInput = panel.querySelector('.hitl-inline-edit');
|
||||
const statusEl = panel.querySelector('.hitl-inline-status');
|
||||
const allowEdit = opts && opts.allowEdit === true;
|
||||
if (!approveBtn || !rejectBtn || !commentInput || !statusEl) return;
|
||||
|
||||
if (editInput) {
|
||||
autoResizeHitlTextarea(editInput);
|
||||
editInput.addEventListener('input', function () {
|
||||
autoResizeHitlTextarea(editInput);
|
||||
});
|
||||
}
|
||||
|
||||
const setBusy = function (busy) {
|
||||
approveBtn.disabled = busy;
|
||||
@@ -3197,7 +3508,27 @@ function renderInlineHitlApproval(itemId, data) {
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = decision === 'approve' ? '已通过,等待执行继续...' : '已拒绝,反馈已交给模型继续迭代...';
|
||||
const msg = decision === 'approve' ? '已通过,等待执行继续...' : '已拒绝,反馈已交给模型继续迭代...';
|
||||
const toolItem = panel.closest('.timeline-item-tool_call');
|
||||
if (toolItem && toolItem.id) {
|
||||
const state = toolCallDetailStateByItemId.get(toolItem.id) || {};
|
||||
state.hitlData = Object.assign({}, state.hitlData || data, {
|
||||
resolved: true,
|
||||
decision: decision,
|
||||
decisionMessage: msg,
|
||||
comment: comment,
|
||||
editedArgs: editedArgs
|
||||
});
|
||||
if (decision === 'approve' && editedArgs && typeof editedArgs === 'object') {
|
||||
state.originalArgs = state.originalArgs || state.args || {};
|
||||
state.args = editedArgs;
|
||||
state.argsEditedByHitl = true;
|
||||
}
|
||||
state.pending = decision === 'approve';
|
||||
setToolCallDetailState(toolItem, state);
|
||||
}
|
||||
statusEl.textContent = msg;
|
||||
markInlineHitlDecision(panel, decision, msg);
|
||||
panel.classList.add('hitl-inline-done');
|
||||
} catch (e) {
|
||||
statusEl.textContent = '提交失败:' + (e && e.message ? e.message : 'unknown error');
|
||||
@@ -3209,6 +3540,19 @@ function renderInlineHitlApproval(itemId, data) {
|
||||
rejectBtn.onclick = function () { submit('reject'); };
|
||||
}
|
||||
|
||||
function markInlineHitlDecision(panel, decision, message) {
|
||||
if (!panel) return;
|
||||
const ok = decision === 'approve';
|
||||
panel.classList.add('hitl-inline-done');
|
||||
panel.innerHTML = `
|
||||
<div class="hitl-inline-decision hitl-inline-decision--${ok ? 'approve' : 'reject'}">
|
||||
<span class="hitl-inline-decision-dot" aria-hidden="true"></span>
|
||||
<strong>${escapeHtml(ok ? '审批通过' : '审批拒绝')}</strong>
|
||||
<span>${escapeHtml(message || (ok ? '已通过,继续执行' : '已拒绝'))}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderInlineWorkflowHitlApproval(itemId, data) {
|
||||
const item = document.getElementById(itemId);
|
||||
if (!item || !data) return;
|
||||
@@ -3228,15 +3572,28 @@ function renderInlineWorkflowHitlApproval(itemId, data) {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'workflow-hitl-inline-approval hitl-inline-approval';
|
||||
panel.innerHTML = `
|
||||
<div class="hitl-input-help"><strong>${escapeHtml(label)}</strong> 等待人工审批。</div>
|
||||
${prompt ? `<div class="hitl-input-help">${escapeHtml(prompt)}</div>` : ''}
|
||||
<div class="hitl-input-help">备注(可选)</div>
|
||||
<input class="hitl-config-input workflow-hitl-inline-comment" type="text" placeholder="审批意见">
|
||||
<div class="hitl-pending-actions">
|
||||
<div class="hitl-inline-header">
|
||||
<div class="hitl-inline-title">
|
||||
<span class="hitl-inline-icon" aria-hidden="true">!</span>
|
||||
<span>工作流审批</span>
|
||||
</div>
|
||||
<div class="hitl-inline-badges">
|
||||
<span class="hitl-tool-badge">${escapeHtml(label)}</span>
|
||||
<span class="hitl-mode-tag hitl-mode-tag--approval">审批模式</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hitl-inline-body">
|
||||
${prompt ? `<div class="hitl-inline-note">${escapeHtml(prompt)}</div>` : '<div class="hitl-inline-note">工作流暂停,等待你确认是否继续。</div>'}
|
||||
<label class="hitl-inline-field">
|
||||
<span class="hitl-context-label">备注(可选)</span>
|
||||
<input class="hitl-config-input workflow-hitl-inline-comment" type="text" placeholder="审批意见">
|
||||
</label>
|
||||
</div>
|
||||
<div class="hitl-pending-actions hitl-inline-actions">
|
||||
<div class="hitl-input-help workflow-hitl-inline-status" aria-live="polite"></div>
|
||||
<button class="btn-secondary workflow-hitl-inline-reject">拒绝</button>
|
||||
<button class="btn-primary workflow-hitl-inline-approve">通过</button>
|
||||
</div>
|
||||
<div class="hitl-input-help workflow-hitl-inline-status"></div>
|
||||
`;
|
||||
contentEl.appendChild(panel);
|
||||
|
||||
@@ -3520,8 +3877,8 @@ async function restoreHitlInlineForConversation(conversationId) {
|
||||
payload: payloadObj,
|
||||
conversationId: item.conversationId || conversationId
|
||||
};
|
||||
let hitlItemEl = detailsContainer.querySelector('[data-hitl-interrupt-id="' + hitlEscapeAttrSelector(String(item.id)) + '"]');
|
||||
if (!hitlItemEl && item.toolCallId) {
|
||||
let hitlItemEl = null;
|
||||
if (item.toolCallId) {
|
||||
hitlItemEl = detailsContainer.querySelector('[data-tool-call-id="' + hitlEscapeAttrSelector(String(item.toolCallId)) + '"]');
|
||||
}
|
||||
if (!hitlItemEl && item.toolName) {
|
||||
@@ -3538,6 +3895,9 @@ async function restoreHitlInlineForConversation(conversationId) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hitlItemEl) {
|
||||
hitlItemEl = detailsContainer.querySelector('[data-hitl-interrupt-id="' + hitlEscapeAttrSelector(String(item.id)) + '"]');
|
||||
}
|
||||
if (!hitlItemEl) continue;
|
||||
renderInlineHitlApproval(hitlItemEl.id, hitlData);
|
||||
}
|
||||
@@ -3683,7 +4043,16 @@ async function attachRunningTaskEventStream(conversationId) {
|
||||
if (eventData && eventData.type === 'done') {
|
||||
replaySawDone = true;
|
||||
}
|
||||
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
|
||||
if (typeof window.currentConversationId === 'string' && window.currentConversationId !== conversationId) {
|
||||
return;
|
||||
}
|
||||
const eventConvId = eventData && eventData.data && eventData.data.conversationId
|
||||
? String(eventData.data.conversationId)
|
||||
: '';
|
||||
if (eventConvId && eventConvId !== conversationId) {
|
||||
return;
|
||||
}
|
||||
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); }, { conversationId: conversationId });
|
||||
};
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
@@ -3842,7 +4211,7 @@ function buildToolResultSectionHtml(data, opts) {
|
||||
};
|
||||
const execResultLabel = _t('timeline.executionResult');
|
||||
const execIdLabel = _t('timeline.executionId');
|
||||
const waitingLabel = _t('timeline.running');
|
||||
const waitingLabel = opts.pendingText || _t('timeline.running');
|
||||
if (opts.pending) {
|
||||
return (
|
||||
'<div class="tool-result-section pending">' +
|
||||
@@ -3982,21 +4351,55 @@ async function renderToolCallDetailContent(item) {
|
||||
buildToolResultSectionHtml(state.resultData, { rawText: state.rawText }) +
|
||||
'</div>';
|
||||
} else if (state.pending !== false) {
|
||||
let pendingOpts = { pending: true };
|
||||
if (state.hitlData && state.hitlData.interruptId) {
|
||||
pendingOpts = {
|
||||
pending: true,
|
||||
pendingText: state.hitlData.resolved ? '已通过,等待执行结果' : '等待审批,通过后执行'
|
||||
};
|
||||
}
|
||||
resultBlock = '<div class="tool-details tool-result-slot">' +
|
||||
buildToolResultSectionHtml({}, { pending: true }) +
|
||||
buildToolResultSectionHtml({}, pendingOpts) +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
const paramsLabel = typeof window.t === 'function' ? window.t('timeline.params') : '参数:';
|
||||
const hitlEditedArgsLabel = state.argsEditedByHitl
|
||||
? '<span class="tool-args-hitl-edited">已按 HITL 改参执行</span>'
|
||||
: '';
|
||||
const argsBlock = state.hideArgs ? '' :
|
||||
'<div class="tool-arg-section">' +
|
||||
'<strong data-i18n="timeline.params">' + escapeHtml(paramsLabel) + '</strong>' +
|
||||
hitlEditedArgsLabel +
|
||||
'<pre class="tool-args">' + escapeHtml(JSON.stringify(args, null, 2)) + '</pre>' +
|
||||
'</div>';
|
||||
content.innerHTML = '<div class="tool-details">' + argsBlock + resultBlock + '</div>';
|
||||
let hitlBlock = '';
|
||||
if (state.hitlData && state.hitlData.interruptId) {
|
||||
const hitlPayload = state.hitlData.payload && typeof state.hitlData.payload === 'object' ? state.hitlData.payload : {};
|
||||
let hitlMode = String(state.hitlData.mode || '').trim().toLowerCase();
|
||||
if (hitlMode === 'feedback' || hitlMode === 'followup') hitlMode = 'approval';
|
||||
const hitlAllowEdit = hitlMode === 'review_edit';
|
||||
const hitlArgsObj = hitlPayload.argumentsObj && typeof hitlPayload.argumentsObj === 'object' ? hitlPayload.argumentsObj : args;
|
||||
hitlBlock = '<div class="hitl-inline-approval hitl-inline-approval--merged">' +
|
||||
buildInlineHitlApprovalHtml(state.hitlData, {
|
||||
toolName: '',
|
||||
mode: hitlMode,
|
||||
modeLabel: hitlMode === 'review_edit' ? '审查编辑' : '审批模式',
|
||||
allowEdit: hitlAllowEdit,
|
||||
argsJSON: JSON.stringify(hitlArgsObj || {}, null, 2)
|
||||
}) +
|
||||
'</div>';
|
||||
}
|
||||
content.innerHTML = '<div class="tool-details">' + argsBlock + resultBlock + hitlBlock + '</div>';
|
||||
item.appendChild(content);
|
||||
item.classList.add('tool-call-detail-expanded');
|
||||
updateToolDetailToggleLabel(item);
|
||||
const hitlPanel = content.querySelector('.hitl-inline-approval');
|
||||
if (hitlPanel && state.hitlData) {
|
||||
let bindMode = String(state.hitlData.mode || '').trim().toLowerCase();
|
||||
if (bindMode === 'feedback' || bindMode === 'followup') bindMode = 'approval';
|
||||
bindInlineHitlApproval(hitlPanel, state.hitlData, { allowEdit: bindMode === 'review_edit' });
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined' && !document.__cyberStrikeToolCallDetailToggleBound) {
|
||||
|
||||
@@ -596,8 +596,12 @@ function prefetchProjectsForChat() {
|
||||
ensureProjectsLoaded().catch(() => {});
|
||||
}
|
||||
|
||||
/** 新对话时默认不绑定项目;用户需主动选择后才写入共享黑板 */
|
||||
/** 新对话沿用用户最近选择的项目;没有选择时才保持未绑定。 */
|
||||
async function ensureDefaultActiveProjectForNewChat() {
|
||||
const id = getActiveProjectId();
|
||||
if (!id) return '';
|
||||
const project = await fetchProjectSummary(id).catch(() => null);
|
||||
if (project && project.id && project.status !== 'archived') return project.id;
|
||||
setActiveProjectId('');
|
||||
return '';
|
||||
}
|
||||
@@ -988,6 +992,7 @@ function updateProjectStats(stats) {
|
||||
|
||||
async function selectProject(id) {
|
||||
currentProjectId = id;
|
||||
if (id) setActiveProjectId(id);
|
||||
projectAssetsPagination.page = 1;
|
||||
const searchEl = document.getElementById('project-facts-search');
|
||||
const catEl = document.getElementById('project-facts-filter-category');
|
||||
|
||||
+892
-72
File diff suppressed because it is too large
Load Diff
+283
-4
@@ -622,7 +622,8 @@ function getWebshellConnections() {
|
||||
if (typeof apiFetch === 'undefined') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return apiFetch('/api/webshell/connections', { method: 'GET' })
|
||||
var url = '/api/webshell/connections';
|
||||
return apiFetch(url, { method: 'GET' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (list) { return Array.isArray(list) ? list : []; })
|
||||
.catch(function (e) {
|
||||
@@ -631,11 +632,205 @@ function getWebshellConnections() {
|
||||
});
|
||||
}
|
||||
|
||||
function webshellConnectionProjectId(conn) {
|
||||
return (conn && (conn.project_id || conn.projectId) || '').trim();
|
||||
}
|
||||
|
||||
function webshellProjectOptionsHtml(selectedId) {
|
||||
var selected = String(selectedId || '').trim();
|
||||
var html = '<option value="">' + escapeHtml(wsT('assets.unboundProject') || '暂不绑定') + '</option>';
|
||||
var entries = [];
|
||||
try {
|
||||
if (typeof projectNameById !== 'undefined') entries = Object.entries(projectNameById);
|
||||
} catch (e) {}
|
||||
entries.sort(function (a, b) {
|
||||
return String(a[1] || '').localeCompare(String(b[1] || ''), undefined, { sensitivity: 'base' });
|
||||
});
|
||||
entries.forEach(function (entry) {
|
||||
var id = entry[0];
|
||||
var name = entry[1] || id;
|
||||
if (!id) return;
|
||||
html += '<option value="' + escapeHtml(id) + '"' + (id === selected ? ' selected' : '') + '>' + escapeHtml(name) + '</option>';
|
||||
});
|
||||
if (selected && !entries.some(function (entry) { return entry[0] === selected; })) {
|
||||
html += '<option value="' + escapeHtml(selected) + '" selected>' + escapeHtml(selected) + '</option>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
var webshellFormSelectMap = {};
|
||||
var webshellFormSelectDocBound = false;
|
||||
var WEBSHELL_FORM_SELECT_CARET = '<span class="webshell-form-select-caret" aria-hidden="true"></span>';
|
||||
|
||||
function closeAllWebshellFormSelects() {
|
||||
Object.keys(webshellFormSelectMap).forEach(function (id) {
|
||||
var reg = webshellFormSelectMap[id];
|
||||
if (!reg || !reg.wrapper) return;
|
||||
reg.wrapper.classList.remove('open');
|
||||
if (reg.trigger) reg.trigger.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function syncWebshellFormSelect(select) {
|
||||
if (!select || !select.id) return;
|
||||
var reg = webshellFormSelectMap[select.id];
|
||||
if (!reg) return;
|
||||
var dropdown = reg.dropdown;
|
||||
var trigger = reg.trigger;
|
||||
var valueSpan = trigger.querySelector('.webshell-form-select-value');
|
||||
dropdown.innerHTML = '';
|
||||
Array.prototype.forEach.call(select.options, function (opt) {
|
||||
var item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'webshell-form-select-option';
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-value', opt.value);
|
||||
item.setAttribute('aria-selected', opt.value === select.value ? 'true' : 'false');
|
||||
if (opt.value === select.value) item.classList.add('is-selected');
|
||||
|
||||
var check = document.createElement('span');
|
||||
check.className = 'webshell-form-select-check';
|
||||
check.textContent = '✓';
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
|
||||
var label = document.createElement('span');
|
||||
label.className = 'webshell-form-select-label';
|
||||
label.textContent = opt.textContent;
|
||||
|
||||
item.appendChild(check);
|
||||
item.appendChild(label);
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
var selectedOpt = select.options[select.selectedIndex];
|
||||
if (valueSpan) valueSpan.textContent = selectedOpt ? selectedOpt.textContent : '';
|
||||
trigger.disabled = !!select.disabled;
|
||||
reg.wrapper.classList.toggle('is-disabled', !!select.disabled);
|
||||
}
|
||||
|
||||
function enhanceWebshellFormSelect(select) {
|
||||
if (!select || !select.id) return;
|
||||
var existing = webshellFormSelectMap[select.id];
|
||||
if (existing && existing.select !== select) delete webshellFormSelectMap[select.id];
|
||||
if (select.dataset.webshellFormCustom === '1') {
|
||||
syncWebshellFormSelect(select);
|
||||
return;
|
||||
}
|
||||
|
||||
select.dataset.webshellFormCustom = '1';
|
||||
select.classList.add('webshell-form-native-select');
|
||||
select.tabIndex = -1;
|
||||
select.setAttribute('aria-hidden', 'true');
|
||||
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'webshell-form-select-ui';
|
||||
|
||||
var trigger = document.createElement('button');
|
||||
trigger.type = 'button';
|
||||
trigger.className = 'webshell-form-select-trigger';
|
||||
trigger.setAttribute('aria-haspopup', 'listbox');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
|
||||
var valueSpan = document.createElement('span');
|
||||
valueSpan.className = 'webshell-form-select-value';
|
||||
trigger.appendChild(valueSpan);
|
||||
trigger.insertAdjacentHTML('beforeend', WEBSHELL_FORM_SELECT_CARET);
|
||||
|
||||
var dropdown = document.createElement('div');
|
||||
dropdown.className = 'webshell-form-select-dropdown';
|
||||
dropdown.setAttribute('role', 'listbox');
|
||||
|
||||
var parent = select.parentNode;
|
||||
parent.insertBefore(wrapper, select);
|
||||
wrapper.appendChild(trigger);
|
||||
wrapper.appendChild(dropdown);
|
||||
wrapper.appendChild(select);
|
||||
|
||||
webshellFormSelectMap[select.id] = { wrapper: wrapper, trigger: trigger, dropdown: dropdown, select: select };
|
||||
|
||||
trigger.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (select.disabled) return;
|
||||
var open = wrapper.classList.contains('open');
|
||||
closeAllWebshellFormSelects();
|
||||
if (!open) {
|
||||
wrapper.classList.add('open');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
dropdown.addEventListener('click', function (e) {
|
||||
var item = e.target.closest('.webshell-form-select-option');
|
||||
if (!item) return;
|
||||
e.stopPropagation();
|
||||
var value = item.getAttribute('data-value');
|
||||
if (value === null) return;
|
||||
if (select.value !== value) {
|
||||
select.value = value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
wrapper.classList.remove('open');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
syncWebshellFormSelect(select);
|
||||
});
|
||||
|
||||
select.addEventListener('change', function () {
|
||||
syncWebshellFormSelect(select);
|
||||
});
|
||||
|
||||
syncWebshellFormSelect(select);
|
||||
}
|
||||
|
||||
function refreshWebshellFormSelects(root) {
|
||||
var container = root || document.getElementById('webshell-modal');
|
||||
if (!container) return;
|
||||
Object.keys(webshellFormSelectMap).forEach(function (id) {
|
||||
if (!document.getElementById(id)) delete webshellFormSelectMap[id];
|
||||
});
|
||||
container.querySelectorAll('select').forEach(enhanceWebshellFormSelect);
|
||||
if (!webshellFormSelectDocBound) {
|
||||
webshellFormSelectDocBound = true;
|
||||
document.addEventListener('click', closeAllWebshellFormSelects);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeAllWebshellFormSelects();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function populateWebshellProjectSelect(selectedId) {
|
||||
var sel = document.getElementById('webshell-project-id');
|
||||
if (!sel) return Promise.resolve();
|
||||
var selected = String(selectedId || '').trim();
|
||||
sel.innerHTML = webshellProjectOptionsHtml(selected);
|
||||
sel.value = selected;
|
||||
syncWebshellFormSelect(sel);
|
||||
var loadPromise = Promise.resolve([]);
|
||||
if (typeof ensureProjectsLoaded === 'function') {
|
||||
loadPromise = ensureProjectsLoaded();
|
||||
} else if (typeof fetchAllProjects === 'function') {
|
||||
loadPromise = fetchAllProjects(false).then(function (list) {
|
||||
if (typeof rebuildProjectNameMap === 'function') rebuildProjectNameMap(list || []);
|
||||
return list || [];
|
||||
});
|
||||
}
|
||||
return loadPromise.then(function () {
|
||||
sel.innerHTML = webshellProjectOptionsHtml(selected);
|
||||
sel.value = selected;
|
||||
syncWebshellFormSelect(sel);
|
||||
}).catch(function (e) {
|
||||
console.warn('加载 WebShell 项目选项失败', e);
|
||||
});
|
||||
}
|
||||
|
||||
// 从服务端刷新连接列表并重绘侧栏
|
||||
function refreshWebshellConnectionsFromServer() {
|
||||
return getWebshellConnections().then(function (list) {
|
||||
webshellConnections = list;
|
||||
renderWebshellList();
|
||||
if (typeof ensureProjectsLoaded === 'function') {
|
||||
ensureProjectsLoaded().then(function () {
|
||||
renderWebshellList();
|
||||
}).catch(function () {});
|
||||
}
|
||||
return list;
|
||||
});
|
||||
}
|
||||
@@ -1143,6 +1338,55 @@ function escapeHtmlAttr(s) {
|
||||
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) {
|
||||
var s = value == null ? '' : String(value);
|
||||
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
@@ -3198,7 +3442,10 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
message: message,
|
||||
webshellConnectionId: conn.id,
|
||||
conversationId: convId,
|
||||
role: wsRole
|
||||
role: wsRole,
|
||||
finalization: {
|
||||
requireExecutionEvidence: true
|
||||
}
|
||||
};
|
||||
if (!convId) {
|
||||
var wsPid = getWebshellAiProjectSelection(conn);
|
||||
@@ -3270,6 +3517,8 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
streamingTarget = '';
|
||||
webshellStreamingTypingId += 1;
|
||||
streamingTypingId = webshellStreamingTypingId;
|
||||
assistantDiv.dataset.finalized = 'false';
|
||||
assistantDiv.classList.add('webshell-ai-candidate-output');
|
||||
assistantDiv.textContent = '…';
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
} else if (_et === 'response_delta') {
|
||||
@@ -3290,6 +3539,23 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
}
|
||||
} else if (_et === 'response') {
|
||||
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) {
|
||||
streamingTarget = String(text);
|
||||
webshellStreamingTypingId += 1;
|
||||
@@ -3298,6 +3564,11 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
}
|
||||
|
||||
// ─── 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) {
|
||||
streamingTypingId += 1;
|
||||
var errLabel = wsTOr('chat.error', '错误');
|
||||
@@ -4636,11 +4907,15 @@ function showAddWebshellModal() {
|
||||
if (osSelEl) osSelEl.value = 'auto';
|
||||
var encSelEl = document.getElementById('webshell-encoding');
|
||||
if (encSelEl) encSelEl.value = 'auto';
|
||||
populateWebshellProjectSelect('');
|
||||
document.getElementById('webshell-remark').value = '';
|
||||
var titleEl = document.getElementById('webshell-modal-title');
|
||||
if (titleEl) titleEl.textContent = wsT('webshell.addConnection');
|
||||
var modal = document.getElementById('webshell-modal');
|
||||
if (modal) openAppModal(modal);
|
||||
if (modal) {
|
||||
openAppModal(modal);
|
||||
refreshWebshellFormSelects(modal);
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑连接弹窗(预填当前连接信息)
|
||||
@@ -4662,7 +4937,9 @@ function showEditWebshellModal(connId) {
|
||||
if (osEditEl) osEditEl.value = normalizeWebshellOS(conn.os);
|
||||
var encEditEl = document.getElementById('webshell-encoding');
|
||||
if (encEditEl) encEditEl.value = normalizeWebshellEncoding(conn.encoding);
|
||||
populateWebshellProjectSelect(webshellConnectionProjectId(conn));
|
||||
document.getElementById('webshell-remark').value = conn.remark || '';
|
||||
refreshWebshellFormSelects(document.getElementById('webshell-modal'));
|
||||
document.getElementById('webshell-url')?.focus();
|
||||
});
|
||||
}
|
||||
@@ -4961,7 +5238,9 @@ function saveWebshellConnection() {
|
||||
|
||||
var editIdEl = document.getElementById('webshell-edit-id');
|
||||
var editId = editIdEl ? editIdEl.value.trim() : '';
|
||||
var body = { url: url, password: password, type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam, encoding: encoding, os: osTag, remark: remark || url };
|
||||
var projectId = (document.getElementById('webshell-project-id') || {}).value || '';
|
||||
if (projectId && typeof projectId.trim === 'function') projectId = projectId.trim(); else projectId = '';
|
||||
var body = { url: url, password: password, type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam, encoding: encoding, os: osTag, remark: remark || url, project_id: projectId };
|
||||
if (typeof apiFetch === 'undefined') return;
|
||||
|
||||
var reqUrl = editId ? ('/api/webshell/connections/' + encodeURIComponent(editId)) : '/api/webshell/connections';
|
||||
|
||||
+295
-164
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CyberStrikeAI</title>
|
||||
<link rel="icon" type="image/png" href="/static/logo.png">
|
||||
<link rel="shortcut icon" type="image/png" href="/static/favicon.ico">
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico?v=20260728">
|
||||
<link rel="shortcut icon" type="image/x-icon" href="/static/favicon.ico?v=20260728">
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
@@ -990,7 +990,7 @@
|
||||
</div>
|
||||
<div id="conversations-pagination" class="sidebar-list-pagination conversation-sidebar-pagination"></div>
|
||||
<div id="chat-reasoning-wrapper" class="chat-reasoning-wrapper conversation-reasoning-card conversation-reasoning-collapsed" style="display: none;">
|
||||
<button type="button" id="conversation-reasoning-toggle" class="conversation-reasoning-card-header" onclick="toggleConversationReasoningCard()" aria-expanded="false" aria-controls="conversation-reasoning-body" data-i18n="chat.reasoningCompactAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="模型推理选项" title="模型推理选项">
|
||||
<button type="button" id="conversation-reasoning-toggle" class="conversation-reasoning-card-header" onclick="toggleConversationReasoningCard()" aria-expanded="false" aria-controls="conversation-reasoning-body" data-i18n="chat.sessionSettingsAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="会话设置" title="会话设置">
|
||||
<div class="conversation-reasoning-heading">
|
||||
<span class="conversation-reasoning-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -999,7 +999,7 @@
|
||||
</svg>
|
||||
</span>
|
||||
<div class="conversation-reasoning-heading-text">
|
||||
<span class="conversation-reasoning-title" data-i18n="chat.reasoningPanelTitle">模型推理</span>
|
||||
<span class="conversation-reasoning-title" data-i18n="chat.sessionSettingsTitle">会话设置</span>
|
||||
<span id="chat-reasoning-summary" class="conversation-reasoning-summary"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1010,89 +1010,75 @@
|
||||
</span>
|
||||
</button>
|
||||
<div id="conversation-reasoning-body" class="conversation-reasoning-body" role="region">
|
||||
<p class="chat-reasoning-panel-hint" data-i18n="chat.reasoningPanelHint">仅 Eino 请求生效,与系统设置中的默认值合并。</p>
|
||||
<p class="chat-reasoning-panel-hint" data-i18n="chat.sessionSettingsHint">AI 通道、推理设置与人机协同只影响后续消息。</p>
|
||||
<div class="chat-reasoning-fields">
|
||||
<div class="chat-reasoning-field">
|
||||
<label class="chat-reasoning-field-label" for="chat-reasoning-mode"><span data-i18n="chat.reasoningModeLabel">模式</span></label>
|
||||
<select id="chat-reasoning-mode" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
|
||||
<option value="default" data-i18n="chat.reasoningModeDefault">跟随系统</option>
|
||||
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
|
||||
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
|
||||
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="chat-reasoning-field">
|
||||
<label class="chat-reasoning-field-label" for="chat-reasoning-effort"><span data-i18n="chat.reasoningEffortLabel">推理强度</span></label>
|
||||
<select id="chat-reasoning-effort" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hitl-sidebar-card hitl-sidebar-collapsed" id="hitl-sidebar-card">
|
||||
<div class="hitl-sidebar-card-header" id="hitl-sidebar-toggle" role="button" tabindex="0" aria-expanded="false" aria-controls="hitl-sidebar-body" onclick="toggleHitlSidebarCard()" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleHitlSidebarCard(); }">
|
||||
<div class="hitl-sidebar-heading">
|
||||
<span class="hitl-sidebar-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3z" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"/>
|
||||
<path d="M9.5 12.5l2 2 3-4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="hitl-sidebar-heading-text">
|
||||
<span class="hitl-sidebar-title" data-i18n="chat.hitlTitle">人机协同</span>
|
||||
<span class="hitl-sidebar-subtitle" data-i18n="chat.hitlCardSubtitle">审批与白名单</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="sidebar-card-chevron hitl-sidebar-chevron" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hitl-sidebar-body" id="hitl-sidebar-body">
|
||||
<div id="hitl-apply-feedback" class="hitl-apply-feedback" role="status" aria-live="polite"></div>
|
||||
<div class="hitl-sidebar-config">
|
||||
<div class="hitl-config-field">
|
||||
<label class="hitl-config-label" for="hitl-mode-select" data-i18n="chat.hitlModeLabel">模式</label>
|
||||
<select id="hitl-mode-select" class="hitl-config-select">
|
||||
<option value="off" data-i18n="chat.hitlModeOff">关闭</option>
|
||||
<option value="approval" data-i18n="chat.hitlModeApproval">审批模式</option>
|
||||
<option value="review_edit" data-i18n="chat.hitlModeReviewEdit">审查编辑</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="hitl-config-field" id="hitl-reviewer-field">
|
||||
<label class="hitl-config-label" data-i18n="chat.hitlReviewerLabel">审批方</label>
|
||||
<div class="hitl-reviewer-toggle" role="group" aria-label="Reviewer">
|
||||
<button type="button" class="hitl-reviewer-toggle-btn is-active" data-reviewer="human" aria-pressed="true">
|
||||
<span data-i18n="chat.hitlReviewerHuman">人工审批</span>
|
||||
</button>
|
||||
<button type="button" class="hitl-reviewer-toggle-btn" data-reviewer="audit_agent" aria-pressed="false">
|
||||
<span data-i18n="chat.hitlReviewerAgent">审计 Agent</span>
|
||||
</button>
|
||||
<div class="session-settings-group session-settings-group-ai">
|
||||
<div class="session-settings-group-title">AI</div>
|
||||
<div class="chat-reasoning-field">
|
||||
<label class="chat-reasoning-field-label" for="chat-ai-channel-select"><span data-i18n="chat.aiChannelLabel">AI 通道</span></label>
|
||||
<select id="chat-ai-channel-select" class="chat-reasoning-select" onchange="persistChatAIChannelPref()">
|
||||
<option value="" data-i18n="chat.aiChannelDefault">跟随默认通道</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="session-settings-inline">
|
||||
<div class="chat-reasoning-field">
|
||||
<label class="chat-reasoning-field-label" for="chat-reasoning-mode"><span data-i18n="chat.reasoningModeLabel">模式</span></label>
|
||||
<select id="chat-reasoning-mode" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
|
||||
<option value="default" data-i18n="chat.reasoningModeDefault">跟随系统</option>
|
||||
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
|
||||
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
|
||||
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="chat-reasoning-field">
|
||||
<label class="chat-reasoning-field-label" for="chat-reasoning-effort"><span data-i18n="chat.reasoningEffortLabel">推理强度</span></label>
|
||||
<select id="chat-reasoning-effort" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
|
||||
<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>
|
||||
<input type="hidden" id="hitl-reviewer-select" value="human" />
|
||||
<p class="hitl-config-hint" data-i18n="chat.hitlReviewerHint">可在人工与审计 Agent 之间随时切换;规则与白名单不变。人机协同为「关闭」时也可预先选择。</p>
|
||||
</div>
|
||||
<div class="hitl-config-field hitl-config-field--tools">
|
||||
<label class="hitl-config-label" for="hitl-sensitive-tools" data-i18n="chat.hitlWhitelistTools">白名单工具(免审批,逗号分隔)</label>
|
||||
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
|
||||
<p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并展示。</p>
|
||||
</div>
|
||||
<div class="hitl-config-actions">
|
||||
<button type="button" class="hitl-apply-btn" data-require-permission="hitl:write" id="hitl-apply-btn" onclick="window.applyHitlSidebarConfig && window.applyHitlSidebarConfig()">
|
||||
<span data-i18n="chat.hitlApply">应用</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="chat-reasoning-field session-hitl-field session-settings-group">
|
||||
<div class="session-settings-group-title" data-i18n="chat.hitlTitle">人机协同</div>
|
||||
<div id="hitl-apply-feedback" class="hitl-apply-feedback" role="status" aria-live="polite"></div>
|
||||
<div class="hitl-sidebar-config session-hitl-config">
|
||||
<div class="hitl-config-field">
|
||||
<label class="hitl-config-label" for="hitl-mode-select" data-i18n="chat.reasoningModeLabel">模式</label>
|
||||
<select id="hitl-mode-select" class="hitl-config-select">
|
||||
<option value="off" data-i18n="chat.hitlModeOff">关闭</option>
|
||||
<option value="approval" data-i18n="chat.hitlModeApproval">审批模式</option>
|
||||
<option value="review_edit" data-i18n="chat.hitlModeReviewEdit">审查编辑</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="hitl-config-field" id="hitl-reviewer-field">
|
||||
<label class="hitl-config-label" data-i18n="chat.hitlReviewerLabel">审批方</label>
|
||||
<div class="hitl-reviewer-toggle" role="group" aria-label="Reviewer">
|
||||
<button type="button" class="hitl-reviewer-toggle-btn is-active" data-reviewer="human" aria-pressed="true">
|
||||
<span data-i18n="chat.hitlReviewerHuman">人工审批</span>
|
||||
</button>
|
||||
<button type="button" class="hitl-reviewer-toggle-btn" data-reviewer="audit_agent" aria-pressed="false">
|
||||
<span data-i18n="chat.hitlReviewerAgent">审计 Agent</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="hitl-reviewer-select" value="human" />
|
||||
<p class="hitl-config-hint" data-i18n="chat.hitlReviewerHint">可在人工与审计 Agent 之间随时切换;规则与白名单不变。人机协同为「关闭」时也可预先选择。</p>
|
||||
</div>
|
||||
<div class="hitl-config-field hitl-config-field--tools">
|
||||
<label class="hitl-config-label" for="hitl-sensitive-tools" data-i18n="chat.hitlWhitelistTools">白名单工具(免审批,逗号分隔)</label>
|
||||
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
|
||||
<p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并展示。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 分组详情页面 -->
|
||||
<div id="group-detail-page" class="group-detail-page" style="display: none;">
|
||||
@@ -2741,6 +2727,7 @@
|
||||
<option value="all" data-i18n="chatFilesPage.sourceAll">全部</option>
|
||||
<option value="upload" data-i18n="chatFilesPage.sourceUpload">对话附件</option>
|
||||
<option value="reduction" data-i18n="chatFilesPage.sourceReduction">工具输出</option>
|
||||
<option value="workspace" data-i18n="chatFilesPage.sourceWorkspace">工作目录</option>
|
||||
<option value="conversation_artifact" data-i18n="chatFilesPage.sourceConversationArtifact">会话产物</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -2827,8 +2814,8 @@
|
||||
<div id="page-c2-sessions" class="page">
|
||||
<div class="page-header">
|
||||
<h2 data-i18n="c2.sessions.title">会话管理</h2>
|
||||
<div class="page-header-actions">
|
||||
<button type="button" class="btn-danger" id="c2-sessions-batch-delete" data-require-permission="c2:delete" disabled onclick="C2.deleteSelectedSessions()"><span data-i18n="c2.sessions.batchDelete">批量删除</span></button>
|
||||
<div class="page-header-actions c2-sessions-page-actions">
|
||||
<button type="button" class="btn-secondary" id="c2-sessions-batch-delete" data-require-permission="c2:delete" disabled onclick="C2.deleteSelectedSessions()"><span data-i18n="c2.sessions.batchDelete">批量删除</span></button>
|
||||
<button type="button" class="btn-secondary" id="c2-sessions-delete-filtered" data-require-permission="c2:delete" disabled onclick="C2.deleteFilteredSessions()"><span data-i18n="c2.sessions.deleteFiltered">删除筛选结果</span></button>
|
||||
<button class="btn-secondary" onclick="C2.loadSessions()"><span data-i18n="common.refresh">刷新</span></button>
|
||||
</div>
|
||||
@@ -2854,14 +2841,76 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content c2-tasks-page-wrap">
|
||||
<div class="c2-tasks-toolbar">
|
||||
<label class="c2-tasks-select-all-label">
|
||||
<input type="checkbox" id="c2-tasks-select-all" onchange="C2.onTasksSelectAll(this.checked)">
|
||||
<span data-i18n="c2.tasks.selectAll">全选本页</span>
|
||||
</label>
|
||||
<div class="c2-tasks-summary" id="c2-tasks-summary" hidden>
|
||||
<span class="c2-tasks-stat">
|
||||
<span class="c2-tasks-stat-label" data-i18n="c2.tasks.statTotal">当前筛选</span>
|
||||
<strong id="c2-tasks-stat-total">0</strong>
|
||||
</span>
|
||||
<span class="c2-tasks-stat c2-tasks-stat--success">
|
||||
<span class="c2-tasks-stat-label" data-i18n="c2.tasks.success">成功</span>
|
||||
<strong id="c2-tasks-stat-success">0</strong>
|
||||
</span>
|
||||
<span class="c2-tasks-stat c2-tasks-stat--failed">
|
||||
<span class="c2-tasks-stat-label" data-i18n="c2.tasks.failed">失败</span>
|
||||
<strong id="c2-tasks-stat-failed">0</strong>
|
||||
</span>
|
||||
<span class="c2-tasks-stat c2-tasks-stat--pending">
|
||||
<span class="c2-tasks-stat-label" data-i18n="c2.tasks.pending">待处理</span>
|
||||
<strong id="c2-tasks-stat-pending">0</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="c2-tasks-filter-card">
|
||||
<div class="c2-tasks-time-presets" id="c2-tasks-time-presets">
|
||||
<span class="c2-tasks-time-presets-label" data-i18n="c2.tasks.timePresets">快捷</span>
|
||||
<button type="button" class="c2-tasks-time-preset-btn" data-preset="15m" data-i18n="c2.tasks.preset15m">最近15分钟</button>
|
||||
<button type="button" class="c2-tasks-time-preset-btn" data-preset="1h" data-i18n="c2.tasks.preset1h">最近1小时</button>
|
||||
<button type="button" class="c2-tasks-time-preset-btn" data-preset="24h" data-i18n="c2.tasks.preset24h">最近24小时</button>
|
||||
<button type="button" class="c2-tasks-time-preset-btn" data-preset="7d" data-i18n="c2.tasks.preset7d">最近7天</button>
|
||||
<button type="button" class="c2-tasks-time-preset-btn" data-preset="all" data-i18n="c2.tasks.presetAll">全部</button>
|
||||
</div>
|
||||
<div class="c2-tasks-filter-fields">
|
||||
<label class="c2-tasks-field">
|
||||
<span data-i18n="c2.tasks.colStatus">状态</span>
|
||||
<select id="c2-tasks-filter-status" class="form-control">
|
||||
<option value="" data-i18n="c2.tasks.filterAllStatuses">全部状态</option>
|
||||
<option value="queued" data-i18n="c2.tasks.queued">队列中</option>
|
||||
<option value="sent" data-i18n="c2.tasks.sent">已发送</option>
|
||||
<option value="running" data-i18n="c2.tasks.running">执行中</option>
|
||||
<option value="success" data-i18n="c2.tasks.success">成功</option>
|
||||
<option value="failed" data-i18n="c2.tasks.failed">失败</option>
|
||||
<option value="cancelled" data-i18n="c2.tasks.cancelled">已取消</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="c2-tasks-field">
|
||||
<span data-i18n="c2.tasks.colType">类型</span>
|
||||
<select id="c2-tasks-filter-type" class="form-control">
|
||||
<option value="" data-i18n="c2.tasks.filterAllTypes">全部类型</option>
|
||||
<option value="shell">shell</option>
|
||||
<option value="exec">exec</option>
|
||||
<option value="ls">ls</option>
|
||||
<option value="pwd">pwd</option>
|
||||
<option value="cd">cd</option>
|
||||
<option value="download">download</option>
|
||||
<option value="upload">upload</option>
|
||||
<option value="ps">ps</option>
|
||||
<option value="sleep">sleep</option>
|
||||
<option value="screenshot">screenshot</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="c2-tasks-field c2-tasks-field--session">
|
||||
<span data-i18n="c2.tasks.filterSession">会话 ID</span>
|
||||
<input type="text" id="c2-tasks-filter-session" class="form-control" data-i18n="c2.tasks.filterSessionPlaceholder" data-i18n-attr="placeholder" placeholder="s_xxxxxxxx" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
<div class="c2-tasks-filter-actions">
|
||||
<button type="button" class="btn-primary btn-small" onclick="C2.applyTasksFilters()" data-i18n="c2.tasks.applyFilters">应用筛选</button>
|
||||
<button type="button" class="btn-secondary btn-small" onclick="C2.resetTasksFilters()" data-i18n="c2.tasks.resetFilters">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c2-tasks-panel">
|
||||
<div id="c2-task-list" class="c2-tasks-list-container"></div>
|
||||
<div id="c2-tasks-pagination" class="pagination-container c2-tasks-pagination"></div>
|
||||
</div>
|
||||
<div id="c2-task-list" class="c2-task-list-container"></div>
|
||||
<div id="c2-tasks-pagination" class="pagination-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3391,78 +3440,154 @@
|
||||
<h3 data-i18n="settingsBasic.basicTitle">基本设置</h3>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI配置 -->
|
||||
<!-- AI 通道配置 -->
|
||||
<div class="settings-subsection">
|
||||
<h4 data-i18n="settingsBasic.openaiConfig">OpenAI 配置</h4>
|
||||
<div class="settings-form">
|
||||
<div class="form-group">
|
||||
<label for="openai-provider" data-i18n="settingsBasic.apiProvider">API 提供商</label>
|
||||
<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;">
|
||||
<option value="openai" data-i18n="settingsBasic.providerOpenAI">OpenAI / 兼容 OpenAI 协议</option>
|
||||
<option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-base-url">Base URL <span style="color: red;">*</span></label>
|
||||
<input type="text" id="openai-base-url" data-i18n="settingsBasic.openaiBaseUrlPlaceholder" data-i18n-attr="placeholder" placeholder="https://api.openai.com/v1" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-api-key">API Key <span style="color: red;">*</span></label>
|
||||
<input type="password" id="openai-api-key" data-i18n="settingsBasic.openaiApiKeyPlaceholder" data-i18n-attr="placeholder" placeholder="输入OpenAI API Key" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-model"><span data-i18n="settingsBasic.model">模型</span> <span style="color: red;">*</span></label>
|
||||
<div class="model-pick-row">
|
||||
<input type="text" id="openai-model" class="model-pick-input" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
|
||||
<select id="openai-model-select" class="model-pick-native" style="display: none;" title="" aria-hidden="true" tabindex="-1">
|
||||
<option value="" disabled data-i18n="settingsBasic.modelsListSelectPlaceholder">请选择模型</option>
|
||||
</select>
|
||||
<a href="javascript:void(0)" id="fetch-openai-models-btn" class="model-pick-fetch-link" onclick="fetchModelList('openai')" data-i18n="settingsBasic.fetchModels">获取列表</a>
|
||||
<div class="ai-channel-manager">
|
||||
<div class="ai-channel-manager-header">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<small id="fetch-openai-models-hint" class="form-hint" style="display: none; font-size: 0.75rem; margin-top: 4px;"></small>
|
||||
<span id="fetch-openai-models-result" style="font-size: 0.75rem; margin-top: 2px; display: block;"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-max-total-tokens"><span data-i18n="settingsBasic.maxTotalTokens">最大上下文 Token 数</span></label>
|
||||
<input type="number" id="openai-max-total-tokens" data-i18n="settingsBasic.maxTotalTokensPlaceholder" data-i18n-attr="placeholder" placeholder="120000" min="1000" step="1000" />
|
||||
<small style="color: var(--text-muted, #718096); font-size: 0.75rem;" data-i18n="settingsBasic.maxTotalTokensHint">内存压缩和攻击链构建共用此配置,默认 120000</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settingsBasic.openaiReasoningTitle">模型推理</label>
|
||||
<small class="form-hint" data-i18n="settingsBasic.openaiReasoningHint">与对话页「模型推理」下拉配合使用。</small>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; align-items: center;">
|
||||
<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);">
|
||||
<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>
|
||||
<label for="openai-reasoning-effort" style="font-size: 0.8125rem;" data-i18n="chat.reasoningEffortLabel">强度</label>
|
||||
<select id="openai-reasoning-effort" style="min-width: 140px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
|
||||
<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>
|
||||
<label for="openai-reasoning-profile" style="font-size: 0.8125rem;" data-i18n="settingsBasic.openaiReasoningProfile">线路</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);">
|
||||
<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 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>
|
||||
<label class="checkbox-label" style="margin-top: 8px;">
|
||||
<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>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 2px;">
|
||||
<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>
|
||||
<span id="test-openai-result" style="font-size: 0.8125rem;"></span>
|
||||
<div class="ai-channel-manager-body">
|
||||
<div class="ai-channel-switcher" aria-label="AI 通道选择" data-i18n="settingsBasic.aiChannelListAria" data-i18n-attr="aria-label">
|
||||
<div class="ai-channel-switcher-field">
|
||||
<label for="ai-channel-select" data-i18n="settingsBasic.aiChannelCurrent">当前通道</label>
|
||||
<select id="ai-channel-select" class="ai-channel-switch-select" onchange="selectAIChannelForEditing(this.value)"></select>
|
||||
</div>
|
||||
<div class="ai-channel-switcher-actions">
|
||||
<button type="button" class="btn-secondary" onclick="probeSelectedAIChannels()" data-i18n="settingsBasic.aiChannelBulkProbe" data-i18n-title="settingsBasic.aiChannelBulkProbeTitle" data-i18n-attr="title">批量探活</button>
|
||||
<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 id="ai-channel-list" class="ai-channel-list" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="ai-channel-editor">
|
||||
<div class="ai-channel-editor-head">
|
||||
<div>
|
||||
<span class="ai-channel-editor-kicker" data-i18n="settingsBasic.aiChannelFormContext">编辑当前选择的通道</span>
|
||||
<p id="ai-channel-editor-title" class="ai-channel-editor-title">-</p>
|
||||
</div>
|
||||
<div id="ai-channel-editor-meta" class="ai-channel-editor-meta" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="settings-form ai-channel-editor-form">
|
||||
<section class="ai-channel-form-section">
|
||||
<div class="ai-channel-form-section-head">
|
||||
<div>
|
||||
<h6 data-i18n="settingsBasic.aiChannelConnectionSection">连接信息</h6>
|
||||
<p data-i18n="settingsBasic.aiChannelConnectionHint">先确认服务商、地址、密钥和模型,测试连接会使用这些信息。</p>
|
||||
</div>
|
||||
<div class="ai-channel-section-actions">
|
||||
<button type="button" id="test-openai-btn" class="btn-secondary" onclick="testOpenAIConnection()" data-i18n="settingsBasic.testConnection">测试连接</button>
|
||||
<span id="test-openai-result" class="form-inline-result connection-test-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-channel-form-grid">
|
||||
<div class="form-group">
|
||||
<label for="ai-channel-name" data-i18n="settingsBasic.aiChannelName">通道名称</label>
|
||||
<input type="text" id="ai-channel-name" placeholder="Qwen Max" maxlength="24" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-provider" data-i18n="settingsBasic.apiProvider">API 提供商</label>
|
||||
<select id="openai-provider">
|
||||
<option value="openai_compatible" data-i18n="settingsBasic.providerOpenAI">OpenAI / 兼容 OpenAI 协议</option>
|
||||
<option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group span-2">
|
||||
<label for="openai-base-url">Base URL <span class="required-mark">*</span></label>
|
||||
<input type="text" id="openai-base-url" data-i18n="settingsBasic.openaiBaseUrlPlaceholder" data-i18n-attr="placeholder" placeholder="https://api.openai.com/v1" required />
|
||||
</div>
|
||||
<div class="form-group span-2">
|
||||
<label for="openai-api-key">API Key <span class="required-mark">*</span></label>
|
||||
<input type="password" id="openai-api-key" data-i18n="settingsBasic.openaiApiKeyPlaceholder" data-i18n-attr="placeholder" placeholder="输入OpenAI API Key" required />
|
||||
</div>
|
||||
<div class="form-group span-2">
|
||||
<label for="openai-model"><span data-i18n="settingsBasic.model">模型</span> <span class="required-mark">*</span></label>
|
||||
<div class="model-pick-row">
|
||||
<input type="text" id="openai-model" class="model-pick-input" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
|
||||
<select id="openai-model-select" class="model-pick-native" style="display: none;" title="" aria-hidden="true" tabindex="-1">
|
||||
<option value="" disabled data-i18n="settingsBasic.modelsListSelectPlaceholder">请选择模型</option>
|
||||
</select>
|
||||
<a href="javascript:void(0)" id="fetch-openai-models-btn" class="model-pick-fetch-link" onclick="fetchModelList('openai')" data-i18n="settingsBasic.fetchModels">获取列表</a>
|
||||
</div>
|
||||
<small id="fetch-openai-models-hint" class="form-hint" style="display: none;"></small>
|
||||
<span id="fetch-openai-models-result" class="form-inline-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ai-channel-form-section">
|
||||
<div class="ai-channel-form-section-head">
|
||||
<div>
|
||||
<h6 data-i18n="settingsBasic.aiChannelLimitsSection">额度设置</h6>
|
||||
<p data-i18n="settingsBasic.aiChannelLimitsHint">Token 上限控制上下文窗口和单次输出。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-channel-form-grid">
|
||||
<div class="form-group">
|
||||
<label for="openai-max-total-tokens"><span data-i18n="settingsBasic.maxTotalTokens">最大上下文 Token 数</span></label>
|
||||
<input type="number" id="openai-max-total-tokens" data-i18n="settingsBasic.maxTotalTokensPlaceholder" data-i18n-attr="placeholder" placeholder="120000" min="1000" step="1000" />
|
||||
<small class="form-hint" data-i18n="settingsBasic.maxTotalTokensHint">内存压缩和攻击链构建共用此配置,默认 120000</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-max-completion-tokens"><span data-i18n="settingsBasic.maxCompletionTokens">最大输出 Token 数</span></label>
|
||||
<input type="number" id="openai-max-completion-tokens" data-i18n="settingsBasic.maxCompletionTokensPlaceholder" data-i18n-attr="placeholder" placeholder="16384" min="1" step="256" />
|
||||
<small class="form-hint" data-i18n="settingsBasic.maxCompletionTokensHint">单次模型回复的输出上限,默认 16384</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="ai-channel-form-section ai-channel-advanced-section">
|
||||
<summary>
|
||||
<span>
|
||||
<strong data-i18n="settingsBasic.openaiReasoningTitle">推理设置</strong>
|
||||
<small data-i18n="settingsBasic.openaiReasoningHint">作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。</small>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="ai-channel-reasoning-grid">
|
||||
<div class="form-group">
|
||||
<label for="openai-reasoning-mode" data-i18n="chat.reasoningModeLabel">模式</label>
|
||||
<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>
|
||||
@@ -3483,7 +3608,7 @@
|
||||
<div class="form-group">
|
||||
<label for="vision-provider" data-i18n="settingsBasic.provider">提供商</label>
|
||||
<select id="vision-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;">
|
||||
<option value="" data-i18n="settingsBasic.visionProviderReuseOpenAI">OpenAI 配置(留空复用)</option>
|
||||
<option value="" data-i18n="settingsBasic.visionProviderReuseOpenAI">默认 AI 通道(留空复用)</option>
|
||||
<option value="openai" data-i18n="settingsBasic.providerOpenAI">OpenAI / 兼容 OpenAI 协议</option>
|
||||
<option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
|
||||
</select>
|
||||
@@ -6116,6 +6241,12 @@
|
||||
</select>
|
||||
<small class="form-hint" data-i18n="webshell.encodingHint">中文 Windows 目标若出现乱码,请切换为 GBK 或 GB18030</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="webshell-project-id" data-i18n="assets.project">所属项目</label>
|
||||
<select id="webshell-project-id">
|
||||
<option value="" data-i18n="assets.unboundProject">暂不绑定</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="webshell-remark" data-i18n="webshell.remark">备注</label>
|
||||
<input type="text" id="webshell-remark" data-i18n="webshell.remarkPlaceholder" data-i18n-attr="placeholder" placeholder="便于识别的备注名" />
|
||||
@@ -6544,8 +6675,8 @@
|
||||
<script src="/static/js/agents.js"></script>
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
<script src="/static/js/chat-scroll.js"></script>
|
||||
<script src="/static/js/monitor.js?v=20260717-1"></script>
|
||||
<script src="/static/js/chat.js?v=20260717-1"></script>
|
||||
<script src="/static/js/monitor.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/settings.js?v=20260717-1"></script>
|
||||
<script src="/static/js/audit-datetime-picker.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user