mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-01 16:38:48 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.
|
**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:**
|
**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.
|
- 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
|
```yaml
|
||||||
openai:
|
ai:
|
||||||
api_key: "${OPENAI_API_KEY}"
|
default_channel: openai-main
|
||||||
base_url: "https://api.openai.com/v1" # or https://api.deepseek.com/v1
|
channels:
|
||||||
model: "gpt-4o" # or deepseek-chat, claude-3-opus, etc.
|
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**
|
2. **Login** - On first startup the console prints an auto-generated initial `admin` password; create accounts from **Platform permissions → User management**
|
||||||
3. **Install security tools (optional)** - Install tools from `tools/` as needed; missing tools are skipped or substituted at runtime. Common examples:
|
3. **Install security tools (optional)** - Install tools from `tools/` as needed; missing tools are skipped or substituted at runtime. Common examples:
|
||||||
|
|
||||||
@@ -281,19 +288,23 @@ Requirements / tips:
|
|||||||
|
|
||||||
## Configuration
|
## 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
|
```yaml
|
||||||
server:
|
server:
|
||||||
host: "127.0.0.1"
|
host: "127.0.0.1"
|
||||||
port: 8080
|
port: 8080
|
||||||
openai:
|
ai:
|
||||||
api_key: "${OPENAI_API_KEY}"
|
default_channel: openai-main
|
||||||
base_url: "https://api.openai.com/v1"
|
channels:
|
||||||
model: "your-model"
|
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
|
## 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` 写错时程序会在终端提示正确写法。
|
**网络默认:** `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://`** 访问。
|
- 启动后在浏览器打开 **`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
|
```yaml
|
||||||
openai:
|
ai:
|
||||||
api_key: "${OPENAI_API_KEY}"
|
default_channel: openai-main
|
||||||
base_url: "https://api.openai.com/v1" # 或 https://api.deepseek.com/v1
|
channels:
|
||||||
model: "gpt-4o" # 或 deepseek-chat, claude-3-opus 等
|
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` 初始密码;也可在「平台权限 → 用户管理」中创建账号
|
2. **登录系统** - 首次启动时控制台会显示自动生成的 `admin` 初始密码;也可在「平台权限 → 用户管理」中创建账号
|
||||||
3. **安装安全工具(可选)** - 按需安装 `tools/` 目录中的工具;未安装的工具在执行时会自动跳过或改用替代方案。常用示例:
|
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
|
```yaml
|
||||||
server:
|
server:
|
||||||
host: "127.0.0.1"
|
host: "127.0.0.1"
|
||||||
port: 8080
|
port: 8080
|
||||||
openai:
|
ai:
|
||||||
api_key: "${OPENAI_API_KEY}"
|
default_channel: openai-main
|
||||||
base_url: "https://api.openai.com/v1"
|
channels:
|
||||||
model: "your-model"
|
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)。
|
||||||
|
|
||||||
## 相关文档
|
## 相关文档
|
||||||
|
|
||||||
|
|||||||
+27
-22
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.7.7"
|
version: "v1.7.8"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
@@ -49,35 +49,40 @@ monitor:
|
|||||||
# 对话相关配置
|
# 对话相关配置
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# AI 模型配置(支持 OpenAI 兼容 API)
|
# AI 通道配置(支持保存多个 OpenAI 兼容 / Claude 通道)
|
||||||
# 必填项:api_key, base_url, model 必须填写才能正常运行
|
# default_channel 指定新对话与未显式选择通道的任务使用哪个通道。
|
||||||
|
# 每个 channels.<id> 必填:api_key, base_url, model。
|
||||||
# 支持的 API 服务商:
|
# 支持的 API 服务商:
|
||||||
# - OpenAI: https://api.openai.com/v1
|
# - OpenAI: https://api.openai.com/v1
|
||||||
# - DeepSeek: https://api.deepseek.com/v1
|
# - DeepSeek: https://api.deepseek.com/v1
|
||||||
# - 其他兼容 OpenAI 协议的 API
|
# - 其他兼容 OpenAI 协议的 API
|
||||||
# 常用模型: gpt-4, gpt-3.5-turbo, deepseek-chat, claude-3-opus 等
|
# 常用模型: gpt-4, gpt-3.5-turbo, deepseek-chat, claude-3-opus 等
|
||||||
# provider: 可选值 openai(默认) | claude(自动桥接到 Anthropic Claude Messages API)
|
# provider: 可选值 openai_compatible(默认) | claude(自动桥接到 Anthropic Claude Messages API)
|
||||||
openai:
|
ai:
|
||||||
provider: openai # API 提供商: openai(默认,兼容OpenAI协议) | claude(自动桥接到Anthropic Claude Messages API)
|
default_channel: qwen-max
|
||||||
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1 # API 基础 URL(必填)
|
channels:
|
||||||
api_key: sk-xxxxxxx # API 密钥(必填)
|
qwen-max:
|
||||||
model: qwen3-max # 模型名称(必填)
|
name: Qwen Max
|
||||||
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
|
provider: openai_compatible
|
||||||
max_completion_tokens: 16384 # 单次生成上限(含 reasoning 与可见输出),防止依赖网关隐式默认值
|
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort;Claude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
|
api_key: sk-xxxxxxx
|
||||||
reasoning:
|
model: qwen3-max
|
||||||
mode: on # auto | on | off;off:OpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考)
|
max_total_tokens: 120000
|
||||||
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
|
max_completion_tokens: 16384
|
||||||
allow_client_reasoning: true # false 时忽略对话请求体 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
|
||||||
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
reasoning:
|
||||||
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
|
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 上下文只保留文字摘要)
|
# 视觉分析(analyze_image MCP 工具;图片仅在单次 VL 调用中出现,Agent 上下文只保留文字摘要)
|
||||||
vision:
|
vision:
|
||||||
enabled: false # true 且 model 非空时注册 analyze_image
|
enabled: false # true 且 model 非空时注册 analyze_image
|
||||||
model: qwen-vl # VL 模型名(enabled 时必填)
|
model: qwen-vl # VL 模型名(enabled 时必填)
|
||||||
api_key: "" # 留空则复用 openai.api_key
|
api_key: "" # 留空则复用默认 AI 通道 api_key
|
||||||
base_url: "" # 留空则复用 openai.base_url
|
base_url: "" # 留空则复用默认 AI 通道 base_url
|
||||||
provider: # 留空则复用 openai.provider(openai | claude)
|
provider: # 留空则复用默认 AI 通道 provider(openai_compatible | claude)
|
||||||
max_image_bytes: 5242880 # 原始文件上限(字节),默认 5MB
|
max_image_bytes: 5242880 # 原始文件上限(字节),默认 5MB
|
||||||
max_dimension: 2048 # 长边缩放像素
|
max_dimension: 2048 # 长边缩放像素
|
||||||
jpeg_quality: 82
|
jpeg_quality: 82
|
||||||
@@ -110,7 +115,7 @@ agent:
|
|||||||
tool_wait_timeout_seconds: 300 # 工具本轮最多等待(秒);到时返回 execution_id,worker 继续后台执行,可用 wait_tool_execution 继续等待;0=等到完成
|
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_per_server: 5 # 单个外部 MCP server 同时运行的工具数;0=默认2;负数=不限制
|
||||||
external_mcp_max_concurrent_total: 16 # 所有外部 MCP 工具全局并发上限;0=默认16;负数=不限制
|
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
|
external_mcp_circuit_cooldown_seconds: 60 # 熔断冷却秒数;0=默认60
|
||||||
shell_no_output_timeout_seconds: 1200 # execute/exec 连续无新输出则终止(秒);通用防挂死;0=默认300;-1=关闭
|
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
|
workspace_root_dir: "" # 会话工作目录根路径(curl/wget 下载、read_file/glob/grep 本地分析);空=tmp/workspace,其下按 projects/{id} 或 conversations/{id} 隔离;勿用系统 /tmp
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ vision:
|
|||||||
timeout_seconds: 60
|
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
|
## Data Handling
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ Multi-agent:
|
|||||||
|
|
||||||
`orchestration` may be `deep`, `plan_execute`, or `supervisor`.
|
`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
|
## SSE Notes
|
||||||
|
|
||||||
Streaming endpoints are long-lived. Clients should:
|
Streaming endpoints are long-lived. Clients should:
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ server:
|
|||||||
# - https://trusted-integration.example
|
# - https://trusted-integration.example
|
||||||
auth:
|
auth:
|
||||||
session_duration_hours: 12
|
session_duration_hours: 12
|
||||||
openai:
|
ai:
|
||||||
provider: openai
|
default_channel: openai-main
|
||||||
base_url: https://api.openai.com/v1
|
channels:
|
||||||
api_key: sk-...
|
openai-main:
|
||||||
model: gpt-4.1
|
name: OpenAI Main
|
||||||
|
provider: openai_compatible
|
||||||
|
base_url: https://api.openai.com/v1
|
||||||
|
api_key: sk-...
|
||||||
|
model: gpt-4.1
|
||||||
agent:
|
agent:
|
||||||
max_iterations: 12000
|
max_iterations: 12000
|
||||||
tool_timeout_minutes: 60
|
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.
|
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
|
## 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.
|
`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 |
|
| 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 |
|
| `agent.max_iterations` | new tasks | existing tasks continue |
|
||||||
| `hitl.tool_whitelist` | new approval checks | pending approvals are not re-decided |
|
| `hitl.tool_whitelist` | new approval checks | pending approvals are not re-decided |
|
||||||
| `knowledge.enabled` | initializes/updates components | scan and index are still required |
|
| `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
|
## Fallback Relationships
|
||||||
|
|
||||||
- `vision.api_key/base_url/provider` can inherit from `openai`.
|
- `vision.api_key/base_url/provider` can inherit from the resolved default AI channel.
|
||||||
- `hitl.audit_model` can inherit from `openai`.
|
- `hitl.audit_model` can inherit from the resolved default AI channel.
|
||||||
- `knowledge.embedding.base_url/api_key` can inherit from model settings.
|
- `knowledge.embedding.base_url/api_key` can inherit from model settings.
|
||||||
- rerank config can inherit from embedding/openai.
|
- rerank config can inherit from embedding/openai.
|
||||||
- `database.knowledge_db_path` can be separate or reuse the main DB.
|
- `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.
|
- Python for some MCP servers and tool scripts.
|
||||||
- SQLite files under `data/`; no external DB is required by default.
|
- SQLite files under `data/`; no external DB is required by default.
|
||||||
- Actual security tools installed in PATH. YAML files under `tools/` only describe commands.
|
- 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:
|
Important persistent paths:
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ hitl:
|
|||||||
provider: ""
|
provider: ""
|
||||||
base_url: ""
|
base_url: ""
|
||||||
api_key: ""
|
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
|
retention_days: 90
|
||||||
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
|
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
|
## Recommended Approval Strategy
|
||||||
|
|
||||||
|
|||||||
@@ -56,10 +56,11 @@ Output `1` means that the row was updated. The command requires `sqlite3` and `h
|
|||||||
|
|
||||||
Model fails:
|
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;
|
- invalid API key;
|
||||||
- model unavailable;
|
- 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:
|
Streaming stalls:
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
vision:
|
vision:
|
||||||
enabled: true
|
enabled: true
|
||||||
model: qwen-vl-max # 必填
|
model: qwen-vl-max # 必填
|
||||||
api_key: # 留空 → openai.api_key
|
api_key: # 留空 → 默认 AI 通道 api_key
|
||||||
base_url: # 留空 → openai.base_url
|
base_url: # 留空 → 默认 AI 通道 base_url
|
||||||
provider: # 留空 → openai.provider
|
provider: # 留空 → 默认 AI 通道 provider
|
||||||
max_image_bytes: 5242880
|
max_image_bytes: 5242880
|
||||||
max_dimension: 2048
|
max_dimension: 2048
|
||||||
jpeg_quality: 82
|
jpeg_quality: 82
|
||||||
@@ -28,7 +28,7 @@ vision:
|
|||||||
|
|
||||||
## Web 设置
|
## Web 设置
|
||||||
|
|
||||||
**系统设置 → 基本设置 → 视觉分析(analyze_image)** 可配置启用开关、视觉模型、API Key/Base URL(留空复用 OpenAI)、预处理参数;**保存并应用** 后写入 `config.yaml` 并重新注册 MCP 工具。
|
**系统设置 → 基本设置 → 视觉分析(analyze_image)** 可配置启用开关、视觉模型、API Key/Base URL(留空复用默认 AI 通道)、预处理参数;**保存并应用** 后写入 `config.yaml` 并重新注册 MCP 工具。
|
||||||
|
|
||||||
## 路径
|
## 路径
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ Content-Type: application/json
|
|||||||
- `plan_execute`
|
- `plan_execute`
|
||||||
- `supervisor`
|
- `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`
|
- `POST /api/conversations`
|
||||||
|
|||||||
+50
-18
@@ -29,26 +29,57 @@ log:
|
|||||||
- `auth.session_duration_hours`:登录会话有效期(小时)。登录密码由 RBAC 用户管理,首次启动时在控制台输出 `admin` 初始密码。
|
- `auth.session_duration_hours`:登录会话有效期(小时)。登录密码由 RBAC 用户管理,首次启动时在控制台输出 `admin` 初始密码。
|
||||||
- `log.output`:可以是 `stdout`、`stderr` 或文件路径。
|
- `log.output`:可以是 `stdout`、`stderr` 或文件路径。
|
||||||
|
|
||||||
## 模型配置
|
## AI 通道与模型配置
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
openai:
|
ai:
|
||||||
provider: openai
|
default_channel: openai-main
|
||||||
base_url: https://api.openai.com/v1
|
channels:
|
||||||
api_key: sk-...
|
openai-main:
|
||||||
model: gpt-4.1
|
name: OpenAI Main
|
||||||
max_total_tokens: 120000
|
provider: openai_compatible
|
||||||
reasoning:
|
base_url: https://api.openai.com/v1
|
||||||
mode: on
|
api_key: sk-...
|
||||||
effort: high
|
model: gpt-4.1
|
||||||
allow_client_reasoning: true
|
max_total_tokens: 120000
|
||||||
profile: openai_compat
|
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。
|
`ai` 是推荐的模型配置入口。系统设置页对应路径是 **系统设置 → 基本设置 → AI 通道配置**,保存后写入 `ai.default_channel` 和 `ai.channels`。旧版 `openai` 字段仍保留为兼容运行时字段;加载配置时会确保至少有一个默认通道,并把 `ai.default_channel` 解析后的配置同步到运行时 `openai`。
|
||||||
- `base_url/api_key/model`:主模型配置。
|
|
||||||
- `max_total_tokens`:上下文压缩、攻击链构建、多代理摘要等共用的总预算。
|
通道字段:
|
||||||
- `reasoning`:控制推理扩展字段。不同网关支持差异较大,异常时先尝试 `mode: off`。
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `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
|
## Agent
|
||||||
|
|
||||||
@@ -213,7 +244,8 @@ project:
|
|||||||
|
|
||||||
| 配置段 | 应用后通常立即生效 | 需要额外动作 |
|
| 配置段 | 应用后通常立即生效 | 需要额外动作 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `openai` | 新请求使用新模型配置 | 旧的流式请求不会被强制切换 |
|
| `ai.default_channel` / `ai.channels` | 新请求使用解析后的默认或选定通道 | 旧的流式请求不会被强制切换;前端通道列表需要重新读取配置 |
|
||||||
|
| `openai` | 兼容字段;通常由默认 AI 通道同步 | 新配置优先维护 `ai.channels` |
|
||||||
| `agent.max_iterations` | 新 Agent 任务生效 | 已运行任务按启动时状态继续 |
|
| `agent.max_iterations` | 新 Agent 任务生效 | 已运行任务按启动时状态继续 |
|
||||||
| `security.tool_description_mode` | 工具重新暴露时生效 | 模型已有上下文不会回滚 |
|
| `security.tool_description_mode` | 工具重新暴露时生效 | 模型已有上下文不会回滚 |
|
||||||
| `hitl.tool_whitelist` | 新工具调用审批判断生效 | 已挂起审批不自动重判 |
|
| `hitl.tool_whitelist` | 新工具调用审批判断生效 | 已挂起审批不自动重判 |
|
||||||
@@ -228,7 +260,7 @@ project:
|
|||||||
几个字段有“留空复用”的关系:
|
几个字段有“留空复用”的关系:
|
||||||
|
|
||||||
- `vision.api_key/base_url/provider` 留空时复用 `openai`。
|
- `vision.api_key/base_url/provider` 留空时复用 `openai`。
|
||||||
- `hitl.audit_model` 留空时复用 `openai`。
|
- `hitl.audit_model` 留空时复用默认 AI 通道解析后的 `openai`。
|
||||||
- `knowledge.embedding.base_url/api_key` 留空时复用主模型或 embedding 默认配置。
|
- `knowledge.embedding.base_url/api_key` 留空时复用主模型或 embedding 默认配置。
|
||||||
- `knowledge.retrieval.rerank.base_url/api_key` 留空时复用 embedding/openai。
|
- `knowledge.retrieval.rerank.base_url/api_key` 留空时复用 embedding/openai。
|
||||||
- `database.knowledge_db_path` 留空时可以复用主会话数据库,但独立文件更利于备份。
|
- `database.knowledge_db_path` 留空时可以复用主会话数据库,但独立文件更利于备份。
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
- Python:部分 MCP 服务或工具脚本需要 Python 运行环境。
|
- Python:部分 MCP 服务或工具脚本需要 Python 运行环境。
|
||||||
- SQLite:默认使用文件型数据库,无需单独服务。
|
- SQLite:默认使用文件型数据库,无需单独服务。
|
||||||
- 安全工具:`tools/` 中的 YAML 只是工具定义,实际命令如 `nmap`、`sqlmap`、`nuclei` 仍需安装到系统 PATH。
|
- 安全工具:`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: ""
|
provider: ""
|
||||||
base_url: ""
|
base_url: ""
|
||||||
api_key: ""
|
api_key: ""
|
||||||
model: "" # 可填小模型;留空复用 openai.model
|
model: "" # 可填小模型;留空复用默认 AI 通道的模型
|
||||||
retention_days: 90
|
retention_days: 90
|
||||||
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
|
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
|
||||||
```
|
```
|
||||||
|
|
||||||
`audit_model` 的字段可以只填一部分。空字段会自动继承主 `openai` 配置,因此常见做法是只填 `model`,让审计 Agent 使用更便宜的小模型。
|
`audit_model` 的字段可以只填一部分。空字段会自动继承默认 AI 通道解析后的模型配置,因此常见做法是只填 `model`,让审计 Agent 使用更便宜的小模型。
|
||||||
|
|
||||||
## 推荐审批策略
|
## 推荐审批策略
|
||||||
|
|
||||||
|
|||||||
@@ -44,17 +44,20 @@ HASH=$(htpasswd -nBC 10 '' | cut -d: -f2 | tr -d '\n') && sqlite3 data/conversat
|
|||||||
|
|
||||||
检查:
|
检查:
|
||||||
|
|
||||||
- `openai.base_url` 是否包含正确路径,如 `/v1`。
|
- 当前对话选择的 AI 通道是否存在;为空时会使用 `ai.default_channel`。
|
||||||
- `openai.api_key` 是否有效。
|
- `ai.channels.<id>.base_url` 是否包含正确路径,如 `/v1`。
|
||||||
- `openai.model` 是否存在。
|
- `ai.channels.<id>.api_key` 是否有效。
|
||||||
- 服务商是否支持当前 `reasoning` 字段。
|
- `ai.channels.<id>.model` 是否存在。
|
||||||
|
- 服务商是否支持当前通道的 `reasoning` 字段。
|
||||||
|
|
||||||
可在系统设置中使用模型测试。若网关报 400,先尝试:
|
可在系统设置中使用模型测试。若网关报 400,先尝试:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
openai:
|
ai:
|
||||||
reasoning:
|
channels:
|
||||||
mode: off
|
your-channel:
|
||||||
|
reasoning:
|
||||||
|
mode: off
|
||||||
```
|
```
|
||||||
|
|
||||||
## 流式输出中断
|
## 流式输出中断
|
||||||
|
|||||||
+83
-9
@@ -1313,6 +1313,7 @@ func setupRoutes(
|
|||||||
c2Routes.GET("/sessions/:id", c2Handler.GetSession)
|
c2Routes.GET("/sessions/:id", c2Handler.GetSession)
|
||||||
c2Routes.DELETE("/sessions/:id", c2Handler.DeleteSession)
|
c2Routes.DELETE("/sessions/:id", c2Handler.DeleteSession)
|
||||||
c2Routes.PUT("/sessions/:id/sleep", c2Handler.SetSessionSleep)
|
c2Routes.PUT("/sessions/:id/sleep", c2Handler.SetSessionSleep)
|
||||||
|
c2Routes.PUT("/sessions/:id/note", c2Handler.SetSessionNote)
|
||||||
c2Routes.GET("/tasks", c2Handler.ListTasks)
|
c2Routes.GET("/tasks", c2Handler.ListTasks)
|
||||||
c2Routes.DELETE("/tasks", c2Handler.DeleteTasks)
|
c2Routes.DELETE("/tasks", c2Handler.DeleteTasks)
|
||||||
c2Routes.GET("/tasks/:id", c2Handler.GetTask)
|
c2Routes.GET("/tasks/:id", c2Handler.GetTask)
|
||||||
@@ -1574,22 +1575,62 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
|||||||
logger.Warn("跳过 WebShell 管理工具注册:db 为空")
|
logger.Warn("跳过 WebShell 管理工具注册:db 为空")
|
||||||
return
|
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 连接
|
// manage_webshell_list - 列出所有 webshell 连接
|
||||||
listTool := mcp.Tool{
|
listTool := mcp.Tool{
|
||||||
Name: builtin.ToolManageWebshellList,
|
Name: builtin.ToolManageWebshellList,
|
||||||
Description: "列出所有已保存的 WebShell 连接,返回连接ID、URL、类型、备注等信息。",
|
Description: "列出已保存的 WebShell 连接,返回连接ID、URL、类型、所属项目、备注等信息。默认按当前对话项目边界过滤:项目对话看本项目,未绑定项目的对话看未绑定连接;显式传 project_id 时按指定项目过滤。",
|
||||||
ShortDescription: "列出所有 WebShell 连接",
|
ShortDescription: "列出所有 WebShell 连接",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{},
|
"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) {
|
listHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||||
connections := []database.WebShellConnection{}
|
connections := []database.WebShellConnection{}
|
||||||
var err error
|
var err error
|
||||||
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
|
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 {
|
} else {
|
||||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "缺少认证身份"}}, IsError: true}, nil
|
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "缺少认证身份"}}, IsError: true}, nil
|
||||||
}
|
}
|
||||||
@@ -1613,6 +1654,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.Type))
|
||||||
sb.WriteString(fmt.Sprintf(" 请求方式: %s\n", conn.Method))
|
sb.WriteString(fmt.Sprintf(" 请求方式: %s\n", conn.Method))
|
||||||
sb.WriteString(fmt.Sprintf(" 命令参数: %s\n", conn.CmdParam))
|
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 != "" {
|
if conn.Remark != "" {
|
||||||
sb.WriteString(fmt.Sprintf(" 备注: %s\n", conn.Remark))
|
sb.WriteString(fmt.Sprintf(" 备注: %s\n", conn.Remark))
|
||||||
}
|
}
|
||||||
@@ -1687,6 +1733,14 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
|||||||
cmdParam = "cmd"
|
cmdParam = "cmd"
|
||||||
}
|
}
|
||||||
remark, _ := args["remark"].(string)
|
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
|
// 生成连接ID
|
||||||
connID := "ws_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12]
|
connID := "ws_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12]
|
||||||
@@ -1698,6 +1752,7 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
|||||||
Method: strings.ToLower(method),
|
Method: strings.ToLower(method),
|
||||||
CmdParam: cmdParam,
|
CmdParam: cmdParam,
|
||||||
Remark: remark,
|
Remark: remark,
|
||||||
|
ProjectID: projectID,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1707,15 +1762,17 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
|||||||
IsError: true,
|
IsError: true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
|
_ = db.SetResourceOwner("webshell", conn.ID, principal.UserID)
|
||||||
_ = db.SetResourceOwner("webshell", conn.ID, principal.UserID)
|
_ = db.AssignResourceToUser(principal.UserID, "webshell", conn.ID)
|
||||||
_ = db.AssignResourceToUser(principal.UserID, "webshell", conn.ID)
|
projectLine := "项目: 未绑定"
|
||||||
|
if conn.ProjectID != "" {
|
||||||
|
projectLine = "项目ID: " + conn.ProjectID
|
||||||
}
|
}
|
||||||
|
|
||||||
return &mcp.ToolResult{
|
return &mcp.ToolResult{
|
||||||
Content: []mcp.Content{{
|
Content: []mcp.Content{{
|
||||||
Type: "text",
|
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,
|
IsError: false,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -1760,6 +1817,10 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "新的备注",
|
"description": "新的备注",
|
||||||
},
|
},
|
||||||
|
"project_id": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "新的所属项目 ID;传空字符串可取消绑定。",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": []string{"connection_id"},
|
"required": []string{"connection_id"},
|
||||||
},
|
},
|
||||||
@@ -1801,6 +1862,19 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
|||||||
if remark, ok := args["remark"].(string); ok {
|
if remark, ok := args["remark"].(string); ok {
|
||||||
existing.Remark = remark
|
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 {
|
if err := db.UpdateWebshellConnection(existing); err != nil {
|
||||||
return &mcp.ToolResult{
|
return &mcp.ToolResult{
|
||||||
@@ -1812,7 +1886,7 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
|||||||
return &mcp.ToolResult{
|
return &mcp.ToolResult{
|
||||||
Content: []mcp.Content{{
|
Content: []mcp.Content{{
|
||||||
Type: "text",
|
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,
|
IsError: false,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登
|
|||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case "list":
|
case "list":
|
||||||
listeners, err := m.DB().ListC2ListenersForAccess(c2ToolAccess(ctx))
|
listeners, err := m.DB().ListC2ListenersForAccess(c2ToolAccess(ctx), mcpEffectiveProjectFilter(ctx, m.DB()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return makeC2Result(nil, err)
|
return makeC2Result(nil, err)
|
||||||
}
|
}
|
||||||
@@ -123,6 +123,7 @@ tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登
|
|||||||
BindPort: int(getFloat64(params, "bind_port")),
|
BindPort: int(getFloat64(params, "bind_port")),
|
||||||
ProfileID: getString(params, "profile_id"),
|
ProfileID: getString(params, "profile_id"),
|
||||||
Remark: getString(params, "remark"),
|
Remark: getString(params, "remark"),
|
||||||
|
ProjectID: strings.TrimSpace(mcp.MCPProjectIDFromContext(ctx)),
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
CallbackHost: getString(params, "callback_host"),
|
CallbackHost: getString(params, "callback_host"),
|
||||||
}
|
}
|
||||||
@@ -260,6 +261,7 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
|||||||
case "list":
|
case "list":
|
||||||
filter := database.ListC2SessionsFilter{
|
filter := database.ListC2SessionsFilter{
|
||||||
ListenerID: getString(params, "listener_id"),
|
ListenerID: getString(params, "listener_id"),
|
||||||
|
ProjectID: mcpEffectiveProjectFilter(ctx, m.DB()),
|
||||||
Status: getString(params, "status"),
|
Status: getString(params, "status"),
|
||||||
OS: getString(params, "os"),
|
OS: getString(params, "os"),
|
||||||
Search: getString(params, "search"),
|
Search: getString(params, "search"),
|
||||||
@@ -495,6 +497,7 @@ func registerC2TaskManageTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
|||||||
case "list":
|
case "list":
|
||||||
filter := database.ListC2TasksFilter{
|
filter := database.ListC2TasksFilter{
|
||||||
SessionID: getString(params, "session_id"),
|
SessionID: getString(params, "session_id"),
|
||||||
|
ProjectID: mcpEffectiveProjectFilter(ctx, m.DB()),
|
||||||
Status: getString(params, "status"),
|
Status: getString(params, "status"),
|
||||||
}
|
}
|
||||||
if limit := int(getFloat64(params, "limit")); limit > 0 {
|
if limit := int(getFloat64(params, "limit")); limit > 0 {
|
||||||
@@ -645,6 +648,7 @@ func registerC2EventTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
|||||||
filter := database.ListC2EventsFilter{
|
filter := database.ListC2EventsFilter{
|
||||||
Level: getString(params, "level"),
|
Level: getString(params, "level"),
|
||||||
Category: getString(params, "category"),
|
Category: getString(params, "category"),
|
||||||
|
ProjectID: mcpEffectiveProjectFilter(ctx, m.DB()),
|
||||||
SessionID: getString(params, "session_id"),
|
SessionID: getString(params, "session_id"),
|
||||||
TaskID: getString(params, "task_id"),
|
TaskID: getString(params, "task_id"),
|
||||||
Limit: int(getFloat64(params, "limit")),
|
Limit: int(getFloat64(params, "limit")),
|
||||||
|
|||||||
@@ -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) {
|
if id == "" || db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), resourceType, id) {
|
||||||
return fmt.Errorf("no access to %s %s", 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
|
return nil
|
||||||
}
|
}
|
||||||
toolExecutionResource := func(permission string) error {
|
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:
|
builtin.ToolBatchTaskScheduleEnabled, builtin.ToolBatchTaskAdd, builtin.ToolBatchTaskUpdate:
|
||||||
return resource("tasks:write", "batch_task", "queue_id")
|
return resource("tasks:write", "batch_task", "queue_id")
|
||||||
case builtin.ToolC2Listener:
|
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:
|
case builtin.ToolC2Session, builtin.ToolC2Task, builtin.ToolC2File:
|
||||||
if toolName == builtin.ToolC2File && mcpAuthorizationString(args, "action") == "get_result" {
|
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:
|
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:
|
case builtin.ToolC2Payload:
|
||||||
return resource("c2:write", "c2_listener", "listener_id")
|
return resource("c2:write", "c2_listener", "listener_id")
|
||||||
case builtin.ToolC2Event:
|
case builtin.ToolC2Event:
|
||||||
if id := mcpAuthorizationString(args, "session_id"); id != "" {
|
if id := mcpAuthorizationString(args, "session_id"); id != "" {
|
||||||
return resource("c2:read", "c2_session", "session_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 {
|
if principal.ScopeFor("c2:read") != database.RBACScopeAll {
|
||||||
return fmt.Errorf("unfiltered C2 event list requires global scope")
|
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")
|
action := mcpAuthorizationString(args, "action")
|
||||||
permission := "c2:write"
|
permission := "c2:write"
|
||||||
if action == "list" || action == "get" || action == "get_result" || action == "wait" {
|
if action == "list" || action == "get" || action == "get_result" || action == "wait" {
|
||||||
@@ -228,6 +237,9 @@ func authorizeC2Action(principal authctx.Principal, db *database.DB, args map[st
|
|||||||
if db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), resourceType, candidate) {
|
if db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), resourceType, candidate) {
|
||||||
return fmt.Errorf("no access to %s %s", 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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -240,9 +252,93 @@ func authorizeC2Action(principal authctx.Principal, db *database.DB, args map[st
|
|||||||
if db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), resourceType, id) {
|
if db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), resourceType, id) {
|
||||||
return fmt.Errorf("no access to %s %s", 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
|
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 {
|
func mcpAuthorizationStrings(args map[string]interface{}, key string) []string {
|
||||||
values := []string{}
|
values := []string{}
|
||||||
switch raw := args[key].(type) {
|
switch raw := args[key].(type) {
|
||||||
|
|||||||
@@ -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) {
|
func TestEveryBuiltinMCPToolHasExplicitAuthorizationPolicy(t *testing.T) {
|
||||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "mcp-policy-inventory.db"), zap.NewNop())
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "mcp-policy-inventory.db"), zap.NewNop())
|
||||||
if err != nil {
|
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 实例
|
runningListeners map[string]Listener // listener_id → 已 Start 的 listener 实例
|
||||||
storageDir string // 大结果(截图/下载)落盘根目录
|
storageDir string // 大结果(截图/下载)落盘根目录
|
||||||
|
|
||||||
hitlBridge HITLBridge // 危险任务在 EnqueueTask 时调它发起审批(nil 表示不接 HITL)
|
hitlBridge HITLBridge // 危险任务在 EnqueueTask 时调它发起审批(nil 表示不接 HITL)
|
||||||
hitlDangerousGate func(conversationID, mcpToolName string) bool // 与人机协同一致:为 nil 或返回 false 时不走桥
|
hitlDangerousGate func(conversationID, mcpToolName string) bool // 与人机协同一致:为 nil 或返回 false 时不走桥
|
||||||
hooks Hooks // 扩展挂钩:会话上线 / 任务完成 时通知漏洞库与攻击链
|
hooks Hooks // 扩展挂钩:会话上线 / 任务完成 时通知漏洞库与攻击链
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCPToolC2Task 与 MCP builtin、c2_task 工具名一致,供 HITL 白名单与 Agent 侧对齐。
|
// MCPToolC2Task 与 MCP builtin、c2_task 工具名一致,供 HITL 白名单与 Agent 侧对齐。
|
||||||
@@ -63,7 +63,7 @@ type HITLApprovalRequest struct {
|
|||||||
|
|
||||||
// Hooks 给上层(漏洞管理 / 攻击链)注入回调
|
// Hooks 给上层(漏洞管理 / 攻击链)注入回调
|
||||||
type Hooks struct {
|
type Hooks struct {
|
||||||
OnSessionFirstSeen func(session *database.C2Session) // 新会话首次上线
|
OnSessionFirstSeen func(session *database.C2Session) // 新会话首次上线
|
||||||
OnTaskCompleted func(task *database.C2Task, sessionID string) // 任务完成(success/failed)
|
OnTaskCompleted func(task *database.C2Task, sessionID string) // 任务完成(success/failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +144,7 @@ func (m *Manager) Close() {
|
|||||||
// CreateListenerInput Web/MCP 创建监听器的入参(已校验 + 已 trim)
|
// CreateListenerInput Web/MCP 创建监听器的入参(已校验 + 已 trim)
|
||||||
type CreateListenerInput struct {
|
type CreateListenerInput struct {
|
||||||
Name string
|
Name string
|
||||||
|
ProjectID string
|
||||||
Type string
|
Type string
|
||||||
BindHost string
|
BindHost string
|
||||||
BindPort int
|
BindPort int
|
||||||
@@ -195,6 +196,7 @@ func (m *Manager) CreateListener(in CreateListenerInput) (*database.C2Listener,
|
|||||||
|
|
||||||
listener := &database.C2Listener{
|
listener := &database.C2Listener{
|
||||||
ID: "l_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14],
|
ID: "l_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14],
|
||||||
|
ProjectID: strings.TrimSpace(in.ProjectID),
|
||||||
Name: strings.TrimSpace(in.Name),
|
Name: strings.TrimSpace(in.Name),
|
||||||
Type: strings.ToLower(strings.TrimSpace(in.Type)),
|
Type: strings.ToLower(strings.TrimSpace(in.Type)),
|
||||||
BindHost: bindHost,
|
BindHost: bindHost,
|
||||||
|
|||||||
+140
-6
@@ -21,7 +21,8 @@ type Config struct {
|
|||||||
Server ServerConfig `yaml:"server"`
|
Server ServerConfig `yaml:"server"`
|
||||||
Log LogConfig `yaml:"log"`
|
Log LogConfig `yaml:"log"`
|
||||||
MCP MCPConfig `yaml:"mcp"`
|
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"`
|
FOFA FofaConfig `yaml:"fofa,omitempty" json:"fofa,omitempty"`
|
||||||
ZoomEye SpaceSearchConfig `yaml:"zoomeye,omitempty" json:"zoomeye,omitempty"`
|
ZoomEye SpaceSearchConfig `yaml:"zoomeye,omitempty" json:"zoomeye,omitempty"`
|
||||||
Quake SpaceSearchConfig `yaml:"quake,omitempty" json:"quake,omitempty"`
|
Quake SpaceSearchConfig `yaml:"quake,omitempty" json:"quake,omitempty"`
|
||||||
@@ -840,6 +841,130 @@ type OpenAIConfig struct {
|
|||||||
Reasoning OpenAIReasoningConfig `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
|
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 {
|
func (c OpenAIConfig) MaxCompletionTokensEffective() int {
|
||||||
if c.MaxCompletionTokens > 0 {
|
if c.MaxCompletionTokens > 0 {
|
||||||
return c.MaxCompletionTokens
|
return c.MaxCompletionTokens
|
||||||
@@ -1224,6 +1349,7 @@ func Load(path string) (*Config, error) {
|
|||||||
if cfg.Audit.MaxDetailBytes <= 0 {
|
if cfg.Audit.MaxDetailBytes <= 0 {
|
||||||
cfg.Audit.MaxDetailBytes = 8192
|
cfg.Audit.MaxDetailBytes = 8192
|
||||||
}
|
}
|
||||||
|
cfg.ApplyDefaultAIChannel()
|
||||||
if err := validateModelOutputLimits(cfg.OpenAI, cfg.MultiAgent.EinoMiddleware); err != nil {
|
if err := validateModelOutputLimits(cfg.OpenAI, cfg.MultiAgent.EinoMiddleware); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1720,12 +1846,20 @@ func Default() *Config {
|
|||||||
Host: "127.0.0.1",
|
Host: "127.0.0.1",
|
||||||
Port: 8081,
|
Port: 8081,
|
||||||
},
|
},
|
||||||
OpenAI: OpenAIConfig{
|
AI: AIConfig{
|
||||||
BaseURL: "https://api.openai.com/v1",
|
DefaultChannel: "default",
|
||||||
Model: "gpt-4",
|
Channels: map[string]AIChannelConfig{
|
||||||
MaxTotalTokens: 120000,
|
"default": {
|
||||||
MaxCompletionTokens: DefaultMaxCompletionTokens,
|
Name: "Default",
|
||||||
|
Provider: "openai_compatible",
|
||||||
|
BaseURL: "https://api.openai.com/v1",
|
||||||
|
Model: "gpt-4",
|
||||||
|
MaxTotalTokens: 120000,
|
||||||
|
MaxCompletionTokens: DefaultMaxCompletionTokens,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
OpenAI: OpenAIConfig{},
|
||||||
Agent: AgentConfig{
|
Agent: AgentConfig{
|
||||||
MaxIterations: 30, // 默认最大迭代次数
|
MaxIterations: 30, // 默认最大迭代次数
|
||||||
ToolTimeoutMinutes: 10, // 单次工具执行默认最多 10 分钟,避免异常长时间占用
|
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) {
|
func TestSummarizationUserIntentLedgerRunesEffective(t *testing.T) {
|
||||||
var zero MultiAgentEinoMiddlewareConfig
|
var zero MultiAgentEinoMiddlewareConfig
|
||||||
if got := zero.SummarizationUserIntentLedgerMaxRunesEffective(); got != DefaultSummarizationUserIntentLedgerMaxRunes {
|
if got := zero.SummarizationUserIntentLedgerMaxRunesEffective(); got != DefaultSummarizationUserIntentLedgerMaxRunes {
|
||||||
|
|||||||
+152
-14
@@ -46,6 +46,7 @@ func validC2TextIDForDelete(id string) bool {
|
|||||||
// C2Listener 监听器实体
|
// C2Listener 监听器实体
|
||||||
type C2Listener struct {
|
type C2Listener struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"` // tcp_reverse|http_beacon|https_beacon|websocket|dns
|
Type string `json:"type"` // tcp_reverse|http_beacon|https_beacon|websocket|dns
|
||||||
BindHost string `json:"bindHost"` // 默认 127.0.0.1
|
BindHost string `json:"bindHost"` // 默认 127.0.0.1
|
||||||
@@ -165,12 +166,12 @@ func (db *DB) CreateC2Listener(l *C2Listener) error {
|
|||||||
l.ConfigJSON = "{}"
|
l.ConfigJSON = "{}"
|
||||||
}
|
}
|
||||||
query := `
|
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)
|
implant_token, status, config_json, remark, owner_user_id, created_at, started_at, last_error)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
_, err := db.Exec(query,
|
_, 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,
|
l.ImplantToken, l.Status, l.ConfigJSON, l.Remark, l.OwnerUserID, l.CreatedAt, l.StartedAt, l.LastError,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -190,12 +191,12 @@ func (db *DB) UpdateC2Listener(l *C2Listener) error {
|
|||||||
}
|
}
|
||||||
query := `
|
query := `
|
||||||
UPDATE c2_listeners SET
|
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 = ?
|
implant_token = ?, status = ?, config_json = ?, remark = ?, owner_user_id = ?, started_at = ?, last_error = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
res, err := db.Exec(query,
|
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,
|
l.ImplantToken, l.Status, l.ConfigJSON, l.Remark, l.OwnerUserID, l.StartedAt, l.LastError, l.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -229,7 +230,7 @@ func (db *DB) SetC2ListenerStatus(id, status, lastError string, startedAt *time.
|
|||||||
// GetC2Listener 单条查询
|
// GetC2Listener 单条查询
|
||||||
func (db *DB) GetC2Listener(id string) (*C2Listener, error) {
|
func (db *DB) GetC2Listener(id string) (*C2Listener, error) {
|
||||||
query := `
|
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(encryption_key, ''), COALESCE(implant_token, ''), status,
|
||||||
COALESCE(config_json, '{}'), COALESCE(remark, ''),
|
COALESCE(config_json, '{}'), COALESCE(remark, ''),
|
||||||
COALESCE(owner_user_id, ''), created_at, started_at, COALESCE(last_error, '')
|
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 l C2Listener
|
||||||
var startedAt sql.NullTime
|
var startedAt sql.NullTime
|
||||||
err := db.QueryRow(query, id).Scan(
|
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.EncryptionKey, &l.ImplantToken, &l.Status,
|
||||||
&l.ConfigJSON, &l.Remark,
|
&l.ConfigJSON, &l.Remark,
|
||||||
&l.OwnerUserID, &l.CreatedAt, &startedAt, &l.LastError,
|
&l.OwnerUserID, &l.CreatedAt, &startedAt, &l.LastError,
|
||||||
@@ -259,7 +260,7 @@ func (db *DB) GetC2Listener(id string) (*C2Listener, error) {
|
|||||||
// ListC2Listeners 全量列表,按创建时间倒序
|
// ListC2Listeners 全量列表,按创建时间倒序
|
||||||
func (db *DB) ListC2Listeners() ([]*C2Listener, error) {
|
func (db *DB) ListC2Listeners() ([]*C2Listener, error) {
|
||||||
query := `
|
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(encryption_key, ''), COALESCE(implant_token, ''), status,
|
||||||
COALESCE(config_json, '{}'), COALESCE(remark, ''),
|
COALESCE(config_json, '{}'), COALESCE(remark, ''),
|
||||||
COALESCE(owner_user_id, ''), created_at, started_at, COALESCE(last_error, '')
|
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 l C2Listener
|
||||||
var startedAt sql.NullTime
|
var startedAt sql.NullTime
|
||||||
if err := rows.Scan(
|
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.EncryptionKey, &l.ImplantToken, &l.Status,
|
||||||
&l.ConfigJSON, &l.Remark,
|
&l.ConfigJSON, &l.Remark,
|
||||||
&l.OwnerUserID, &l.CreatedAt, &startedAt, &l.LastError,
|
&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.
|
// 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"}
|
conditions := []string{"1=1"}
|
||||||
args := []interface{}{}
|
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)
|
appendC2ListenerAccessFilter(&conditions, &args, access)
|
||||||
query := `
|
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(encryption_key, ''), COALESCE(implant_token, ''), status,
|
||||||
COALESCE(config_json, '{}'), COALESCE(remark, ''),
|
COALESCE(config_json, '{}'), COALESCE(remark, ''),
|
||||||
COALESCE(owner_user_id, ''), created_at, started_at, COALESCE(last_error, '')
|
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 l C2Listener
|
||||||
var startedAt sql.NullTime
|
var startedAt sql.NullTime
|
||||||
if err := rows.Scan(
|
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.EncryptionKey, &l.ImplantToken, &l.Status,
|
||||||
&l.ConfigJSON, &l.Remark, &l.OwnerUserID,
|
&l.ConfigJSON, &l.Remark, &l.OwnerUserID,
|
||||||
&l.CreatedAt, &startedAt, &l.LastError,
|
&l.CreatedAt, &startedAt, &l.LastError,
|
||||||
@@ -535,6 +542,7 @@ func (db *DB) queryC2SessionWhere(whereClause string, args ...interface{}) (*C2S
|
|||||||
// ListC2SessionsFilter 列表过滤参数
|
// ListC2SessionsFilter 列表过滤参数
|
||||||
type ListC2SessionsFilter struct {
|
type ListC2SessionsFilter struct {
|
||||||
ListenerID string
|
ListenerID string
|
||||||
|
ProjectID string
|
||||||
Status string // active|sleeping|dead|killed;空表示全部
|
Status string // active|sleeping|dead|killed;空表示全部
|
||||||
OS string
|
OS string
|
||||||
Search string // 模糊匹配 hostname/username/internal_ip
|
Search string // 模糊匹配 hostname/username/internal_ip
|
||||||
@@ -550,6 +558,18 @@ func (db *DB) ListC2Sessions(filter ListC2SessionsFilter) ([]*C2Session, error)
|
|||||||
conditions = append(conditions, "listener_id = ?")
|
conditions = append(conditions, "listener_id = ?")
|
||||||
args = append(args, filter.ListenerID)
|
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 != "" {
|
if filter.Status != "" {
|
||||||
conditions = append(conditions, "status = ?")
|
conditions = append(conditions, "status = ?")
|
||||||
args = append(args, filter.Status)
|
args = append(args, filter.Status)
|
||||||
@@ -645,6 +665,18 @@ func buildC2SessionsWhere(filter ListC2SessionsFilter) ([]string, []interface{})
|
|||||||
conditions = append(conditions, "listener_id = ?")
|
conditions = append(conditions, "listener_id = ?")
|
||||||
args = append(args, filter.ListenerID)
|
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 != "" {
|
if filter.Status != "" {
|
||||||
conditions = append(conditions, "status = ?")
|
conditions = append(conditions, "status = ?")
|
||||||
args = append(args, filter.Status)
|
args = append(args, filter.Status)
|
||||||
@@ -947,7 +979,10 @@ func (db *DB) GetC2Task(id string) (*C2Task, error) {
|
|||||||
// ListC2TasksFilter 任务过滤
|
// ListC2TasksFilter 任务过滤
|
||||||
type ListC2TasksFilter struct {
|
type ListC2TasksFilter struct {
|
||||||
SessionID string
|
SessionID string
|
||||||
|
ProjectID string
|
||||||
Status string
|
Status string
|
||||||
|
TaskType string
|
||||||
|
Since *time.Time
|
||||||
Limit int
|
Limit int
|
||||||
Offset int
|
Offset int
|
||||||
}
|
}
|
||||||
@@ -959,10 +994,32 @@ func buildC2TasksWhere(filter ListC2TasksFilter) (where string, args []interface
|
|||||||
conditions = append(conditions, "session_id = ?")
|
conditions = append(conditions, "session_id = ?")
|
||||||
args = append(args, filter.SessionID)
|
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 != "" {
|
if filter.Status != "" {
|
||||||
conditions = append(conditions, "status = ?")
|
conditions = append(conditions, "status = ?")
|
||||||
args = append(args, filter.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
|
return strings.Join(conditions, " AND "), args
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1016,6 +1073,43 @@ func (db *DB) CountC2TasksForAccess(filter ListC2TasksFilter, access RBACListAcc
|
|||||||
return n, err
|
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 状态任务数(仪表盘「待审任务」)
|
// CountC2TasksQueuedOrPending 统计 queued/pending 状态任务数(仪表盘「待审任务」)
|
||||||
func (db *DB) CountC2TasksQueuedOrPending(sessionID string) (int64, error) {
|
func (db *DB) CountC2TasksQueuedOrPending(sessionID string) (int64, error) {
|
||||||
conditions := []string{"status IN ('queued', 'pending')"}
|
conditions := []string{"status IN ('queued', 'pending')"}
|
||||||
@@ -1030,8 +1124,8 @@ func (db *DB) CountC2TasksQueuedOrPending(sessionID string) (int64, error) {
|
|||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) CountC2TasksQueuedOrPendingForAccess(sessionID string, access RBACListAccess) (int64, error) {
|
func (db *DB) CountC2TasksQueuedOrPendingForAccess(sessionID, projectID string, access RBACListAccess) (int64, error) {
|
||||||
filter := ListC2TasksFilter{SessionID: sessionID}
|
filter := ListC2TasksFilter{SessionID: sessionID, ProjectID: projectID}
|
||||||
where, args := buildC2TasksWhereForAccess(filter, access)
|
where, args := buildC2TasksWhereForAccess(filter, access)
|
||||||
query := `SELECT COUNT(*) FROM c2_tasks WHERE status IN ('queued', 'pending') AND ` + where
|
query := `SELECT COUNT(*) FROM c2_tasks WHERE status IN ('queued', 'pending') AND ` + where
|
||||||
var n int64
|
var n int64
|
||||||
@@ -1412,6 +1506,7 @@ func (db *DB) AppendC2Event(e *C2Event) error {
|
|||||||
type ListC2EventsFilter struct {
|
type ListC2EventsFilter struct {
|
||||||
Level string
|
Level string
|
||||||
Category string
|
Category string
|
||||||
|
ProjectID string
|
||||||
SessionID string
|
SessionID string
|
||||||
TaskID string
|
TaskID string
|
||||||
Since *time.Time
|
Since *time.Time
|
||||||
@@ -1430,6 +1525,49 @@ func buildC2EventsWhere(filter ListC2EventsFilter) (where string, args []interfa
|
|||||||
conditions = append(conditions, "category = ?")
|
conditions = append(conditions, "category = ?")
|
||||||
args = append(args, filter.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 != "" {
|
if filter.SessionID != "" {
|
||||||
conditions = append(conditions, "session_id = ?")
|
conditions = append(conditions, "session_id = ?")
|
||||||
args = append(args, filter.SessionID)
|
args = append(args, filter.SessionID)
|
||||||
|
|||||||
@@ -494,6 +494,7 @@ func (db *DB) initTables() error {
|
|||||||
createWebshellConnectionsTable := `
|
createWebshellConnectionsTable := `
|
||||||
CREATE TABLE IF NOT EXISTS webshell_connections (
|
CREATE TABLE IF NOT EXISTS webshell_connections (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT,
|
||||||
url TEXT NOT NULL,
|
url TEXT NOT NULL,
|
||||||
password TEXT NOT NULL DEFAULT '',
|
password TEXT NOT NULL DEFAULT '',
|
||||||
type TEXT NOT NULL DEFAULT 'php',
|
type TEXT NOT NULL DEFAULT 'php',
|
||||||
@@ -520,6 +521,7 @@ func (db *DB) initTables() error {
|
|||||||
createC2ListenersTable := `
|
createC2ListenersTable := `
|
||||||
CREATE TABLE IF NOT EXISTS c2_listeners (
|
CREATE TABLE IF NOT EXISTS c2_listeners (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
bind_host TEXT NOT NULL DEFAULT '127.0.0.1',
|
bind_host TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||||
@@ -756,8 +758,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_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_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_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_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_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_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_listener ON c2_sessions(listener_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_c2_sessions_status ON c2_sessions(status);
|
CREATE INDEX IF NOT EXISTS idx_c2_sessions_status ON c2_sessions(status);
|
||||||
@@ -956,6 +960,9 @@ func (db *DB) initTables() error {
|
|||||||
db.logger.Warn("迁移webshell_connections表失败", zap.Error(err))
|
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 {
|
if err := db.migrateWorkflowRunsTable(); err != nil {
|
||||||
db.logger.Warn("迁移workflow_runs表失败", zap.Error(err))
|
db.logger.Warn("迁移workflow_runs表失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
@@ -1610,6 +1617,7 @@ func (db *DB) migrateWebshellConnectionsTable() error {
|
|||||||
name string
|
name string
|
||||||
stmt 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: "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 ''"},
|
{name: "os", stmt: "ALTER TABLE webshell_connections ADD COLUMN os TEXT NOT NULL DEFAULT ''"},
|
||||||
}
|
}
|
||||||
@@ -1635,6 +1643,10 @@ func (db *DB) migrateWebshellConnectionsTable() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) migrateC2ListenersTable() error {
|
||||||
|
return db.addColumnIfMissing("c2_listeners", "project_id", "ALTER TABLE c2_listeners ADD COLUMN project_id TEXT")
|
||||||
|
}
|
||||||
|
|
||||||
// NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表)
|
// NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表)
|
||||||
func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
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")
|
sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1&_busy_timeout=5000&_synchronous=NORMAL")
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ func (db *DB) UpdateProject(p *Project) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteProject 删除项目(级联删除事实;对话 project_id 置空由 FK 处理;漏洞 project_id 置空)。
|
// DeleteProject 删除项目(级联删除事实;对话 project_id 置空由 FK 处理;其他资源 project_id 置空)。
|
||||||
func (db *DB) DeleteProject(id string) error {
|
func (db *DB) DeleteProject(id string) error {
|
||||||
if _, err := db.Exec(`UPDATE vulnerabilities SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
if _, err := db.Exec(`UPDATE vulnerabilities SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
||||||
return fmt.Errorf("解除漏洞项目关联失败: %w", err)
|
return fmt.Errorf("解除漏洞项目关联失败: %w", err)
|
||||||
@@ -271,6 +271,12 @@ func (db *DB) DeleteProject(id string) error {
|
|||||||
if _, err := db.Exec(`UPDATE assets SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
if _, err := db.Exec(`UPDATE assets SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
||||||
return fmt.Errorf("解除资产项目关联失败: %w", err)
|
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)
|
_, err := db.Exec(`DELETE FROM projects WHERE id = ?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("删除项目失败: %w", err)
|
return fmt.Errorf("删除项目失败: %w", err)
|
||||||
|
|||||||
@@ -373,22 +373,46 @@ func TestRBACBatchResourceAssignmentValidationAndAtomicity(t *testing.T) {
|
|||||||
|
|
||||||
func TestRBACWebshellAndBatchListAccess(t *testing.T) {
|
func TestRBACWebshellAndBatchListAccess(t *testing.T) {
|
||||||
db := newRBACTestDB(t)
|
db := newRBACTestDB(t)
|
||||||
ws1 := WebShellConnection{ID: "ws_visible", URL: "http://a", 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", URL: "http://b", 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 {
|
if err := db.CreateWebshellConnection(&ws1); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := db.CreateWebshellConnection(&ws2); err != nil {
|
if err := db.CreateWebshellConnection(&ws2); err != nil {
|
||||||
t.Fatal(err)
|
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", ws1.ID, "u1")
|
||||||
_ = db.SetResourceOwner("webshell", ws2.ID, "u2")
|
_ = 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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(webshells) != 1 || webshells[0].ID != ws1.ID {
|
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 {
|
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) {
|
func TestRBACC2AccessInheritsListener(t *testing.T) {
|
||||||
db := newRBACTestDB(t)
|
db := newRBACTestDB(t)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
l1 := &C2Listener{ID: "l_visible", Name: "visible", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9001, OwnerUserID: "u1", 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", Name: "hidden", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9002, OwnerUserID: "u2", 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 {
|
if err := db.CreateC2Listener(l1); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := db.CreateC2Listener(l2); err != nil {
|
if err := db.CreateC2Listener(l2); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
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)
|
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 {
|
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)
|
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 {
|
if err := db.CreateC2Task(&C2Task{ID: "t_visible", SessionID: "s_visible", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := db.CreateC2Task(&C2Task{ID: "t_hidden", SessionID: "s_hidden", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
|
if err := db.CreateC2Task(&C2Task{ID: "t_hidden", SessionID: "s_hidden", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
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)
|
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 {
|
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)
|
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}
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(listeners) != 1 || listeners[0].ID != l1.ID {
|
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)
|
sessions, err := db.ListC2SessionsForAccess(ListC2SessionsFilter{}, access)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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" {
|
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)
|
tasks, err := db.ListC2TasksForAccess(ListC2TasksFilter{}, access)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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" {
|
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)
|
events, err := db.ListC2EventsForAccess(ListC2EventsFilter{}, access)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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" {
|
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") {
|
if !db.UserCanAccessResource("u1", RBACScopeOwn, "c2_task", "t_visible") {
|
||||||
t.Fatalf("expected listener ownership to allow task detail")
|
t.Fatalf("expected listener ownership to allow task detail")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
// WebShellConnection WebShell 连接配置
|
// WebShellConnection WebShell 连接配置
|
||||||
type WebShellConnection struct {
|
type WebShellConnection struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -60,17 +61,24 @@ func (db *DB) UpsertWebshellConnectionState(connectionID, stateJSON string) erro
|
|||||||
|
|
||||||
// ListWebshellConnections 列出所有 WebShell 连接,按创建时间倒序
|
// ListWebshellConnections 列出所有 WebShell 连接,按创建时间倒序
|
||||||
func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
|
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 := `
|
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
|
COALESCE(encoding, '') AS encoding, COALESCE(os, '') AS os, created_at
|
||||||
FROM webshell_connections
|
FROM webshell_connections
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`
|
`
|
||||||
args := []interface{}{}
|
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)
|
userID = strings.TrimSpace(userID)
|
||||||
if userID != "" && scope != RBACScopeAll {
|
if userID != "" && scope != RBACScopeAll {
|
||||||
query += ` AND (
|
query += ` AND (
|
||||||
@@ -93,7 +101,7 @@ func (db *DB) ListWebshellConnectionsForAccess(userID, scope string) ([]WebShell
|
|||||||
var list []WebShellConnection
|
var list []WebShellConnection
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c WebShellConnection
|
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 {
|
if err != nil {
|
||||||
db.logger.Warn("扫描 WebShell 连接行失败", zap.Error(err))
|
db.logger.Warn("扫描 WebShell 连接行失败", zap.Error(err))
|
||||||
continue
|
continue
|
||||||
@@ -106,12 +114,12 @@ func (db *DB) ListWebshellConnectionsForAccess(userID, scope string) ([]WebShell
|
|||||||
// GetWebshellConnection 根据 ID 获取一条连接
|
// GetWebshellConnection 根据 ID 获取一条连接
|
||||||
func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
|
func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
|
||||||
query := `
|
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
|
COALESCE(encoding, '') AS encoding, COALESCE(os, '') AS os, created_at
|
||||||
FROM webshell_connections WHERE id = ?
|
FROM webshell_connections WHERE id = ?
|
||||||
`
|
`
|
||||||
var c WebShellConnection
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -125,10 +133,10 @@ func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
|
|||||||
// CreateWebshellConnection 创建 WebShell 连接
|
// CreateWebshellConnection 创建 WebShell 连接
|
||||||
func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
|
func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO webshell_connections (id, url, password, type, method, cmd_param, remark, encoding, os, created_at)
|
INSERT INTO webshell_connections (id, project_id, url, password, type, method, cmd_param, remark, encoding, os, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
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 {
|
if err != nil {
|
||||||
db.logger.Error("创建 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
|
db.logger.Error("创建 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
|
||||||
return err
|
return err
|
||||||
@@ -140,10 +148,10 @@ func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
|
|||||||
func (db *DB) UpdateWebshellConnection(c *WebShellConnection) error {
|
func (db *DB) UpdateWebshellConnection(c *WebShellConnection) error {
|
||||||
query := `
|
query := `
|
||||||
UPDATE webshell_connections
|
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 = ?
|
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 {
|
if err != nil {
|
||||||
db.logger.Error("更新 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
|
db.logger.Error("更新 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -341,12 +341,26 @@ type ChatRequest struct {
|
|||||||
Role string `json:"role,omitempty"` // 角色名称
|
Role string `json:"role,omitempty"` // 角色名称
|
||||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||||
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
||||||
|
AIChannelID string `json:"aiChannelId,omitempty"` // 会话级 AI 通道;空则使用 ai.default_channel
|
||||||
Hitl *HITLRequest `json:"hitl,omitempty"`
|
Hitl *HITLRequest `json:"hitl,omitempty"`
|
||||||
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
|
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
|
||||||
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
|
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
|
||||||
Orchestration string `json:"orchestration,omitempty"`
|
Orchestration string `json:"orchestration,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func chatReasoningToClientIntent(r *ChatReasoningRequest) *reasoning.ClientIntent {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+85
-2
@@ -60,7 +60,7 @@ func (h *C2Handler) SetManager(m *c2.Manager) {
|
|||||||
|
|
||||||
// ListListeners 获取监听器列表
|
// ListListeners 获取监听器列表
|
||||||
func (h *C2Handler) ListListeners(c *gin.Context) {
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -77,6 +77,7 @@ func (h *C2Handler) ListListeners(c *gin.Context) {
|
|||||||
func (h *C2Handler) CreateListener(c *gin.Context) {
|
func (h *C2Handler) CreateListener(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
BindHost string `json:"bind_host"`
|
BindHost string `json:"bind_host"`
|
||||||
BindPort int `json:"bind_port"`
|
BindPort int `json:"bind_port"`
|
||||||
@@ -92,6 +93,7 @@ func (h *C2Handler) CreateListener(c *gin.Context) {
|
|||||||
|
|
||||||
input := c2.CreateListenerInput{
|
input := c2.CreateListenerInput{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
|
ProjectID: req.ProjectID,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
BindHost: req.BindHost,
|
BindHost: req.BindHost,
|
||||||
BindPort: req.BindPort,
|
BindPort: req.BindPort,
|
||||||
@@ -100,6 +102,10 @@ func (h *C2Handler) CreateListener(c *gin.Context) {
|
|||||||
Config: req.Config,
|
Config: req.Config,
|
||||||
CallbackHost: strings.TrimSpace(req.CallbackHost),
|
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)
|
listener, err := h.mgr().CreateListener(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -158,6 +164,7 @@ func (h *C2Handler) UpdateListener(c *gin.Context) {
|
|||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
BindHost string `json:"bind_host"`
|
BindHost string `json:"bind_host"`
|
||||||
BindPort int `json:"bind_port"`
|
BindPort int `json:"bind_port"`
|
||||||
ProfileID string `json:"profile_id"`
|
ProfileID string `json:"profile_id"`
|
||||||
@@ -179,6 +186,7 @@ func (h *C2Handler) UpdateListener(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listener.Name = req.Name
|
listener.Name = req.Name
|
||||||
|
listener.ProjectID = strings.TrimSpace(req.ProjectID)
|
||||||
listener.BindHost = req.BindHost
|
listener.BindHost = req.BindHost
|
||||||
listener.BindPort = req.BindPort
|
listener.BindPort = req.BindPort
|
||||||
listener.ProfileID = req.ProfileID
|
listener.ProfileID = req.ProfileID
|
||||||
@@ -187,6 +195,10 @@ func (h *C2Handler) UpdateListener(c *gin.Context) {
|
|||||||
cfgJSON, _ := json.Marshal(req.Config)
|
cfgJSON, _ := json.Marshal(req.Config)
|
||||||
listener.ConfigJSON = string(cfgJSON)
|
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 {
|
if req.CallbackHost != nil {
|
||||||
cfg := &c2.ListenerConfig{}
|
cfg := &c2.ListenerConfig{}
|
||||||
raw := strings.TrimSpace(listener.ConfigJSON)
|
raw := strings.TrimSpace(listener.ConfigJSON)
|
||||||
@@ -275,6 +287,7 @@ func (h *C2Handler) StopListener(c *gin.Context) {
|
|||||||
func (h *C2Handler) ListSessions(c *gin.Context) {
|
func (h *C2Handler) ListSessions(c *gin.Context) {
|
||||||
filter := database.ListC2SessionsFilter{
|
filter := database.ListC2SessionsFilter{
|
||||||
ListenerID: c.Query("listener_id"),
|
ListenerID: c.Query("listener_id"),
|
||||||
|
ProjectID: c.Query("project_id"),
|
||||||
Status: c.Query("status"),
|
Status: c.Query("status"),
|
||||||
OS: c.Query("os"),
|
OS: c.Query("os"),
|
||||||
Search: c.Query("search"),
|
Search: c.Query("search"),
|
||||||
@@ -404,6 +417,47 @@ func (h *C2Handler) SetSessionSleep(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, out)
|
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
|
// 任务 API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -412,7 +466,14 @@ func (h *C2Handler) SetSessionSleep(c *gin.Context) {
|
|||||||
func (h *C2Handler) ListTasks(c *gin.Context) {
|
func (h *C2Handler) ListTasks(c *gin.Context) {
|
||||||
filter := database.ListC2TasksFilter{
|
filter := database.ListC2TasksFilter{
|
||||||
SessionID: c.Query("session_id"),
|
SessionID: c.Query("session_id"),
|
||||||
|
ProjectID: c.Query("project_id"),
|
||||||
Status: c.Query("status"),
|
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
|
paginated := false
|
||||||
@@ -447,7 +508,7 @@ func (h *C2Handler) ListTasks(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 仪表盘「待审任务」为全局 queued/pending 数量,与列表 session 过滤无关
|
// 仪表盘「待审任务」为全局 queued/pending 数量,与列表 session 过滤无关
|
||||||
pendingN, _ := h.mgr().DB().CountC2TasksQueuedOrPendingForAccess("", access)
|
pendingN, _ := h.mgr().DB().CountC2TasksQueuedOrPendingForAccess("", filter.ProjectID, access)
|
||||||
|
|
||||||
if !paginated {
|
if !paginated {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"tasks": tasks,
|
"tasks": tasks,
|
||||||
"total": total,
|
"total": total,
|
||||||
|
"status_counts": statusCounts,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": pageSize,
|
"page_size": pageSize,
|
||||||
"pending_queued_count": pendingN,
|
"pending_queued_count": pendingN,
|
||||||
@@ -784,6 +851,7 @@ func (h *C2Handler) ListEvents(c *gin.Context) {
|
|||||||
filter := database.ListC2EventsFilter{
|
filter := database.ListC2EventsFilter{
|
||||||
Level: c.Query("level"),
|
Level: c.Query("level"),
|
||||||
Category: c.Query("category"),
|
Category: c.Query("category"),
|
||||||
|
ProjectID: c.Query("project_id"),
|
||||||
SessionID: c.Query("session_id"),
|
SessionID: c.Query("session_id"),
|
||||||
TaskID: c.Query("task_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}
|
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 {
|
func (h *C2Handler) c2ResourceAllowed(c *gin.Context, resourceType, resourceID string) bool {
|
||||||
session, ok := security.CurrentSession(c)
|
session, ok := security.CurrentSession(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ func (h *ConfigHandler) ApplyWechatRobotBinding(wc config.RobotWechatConfig) err
|
|||||||
|
|
||||||
// GetConfigResponse 获取配置响应
|
// GetConfigResponse 获取配置响应
|
||||||
type GetConfigResponse struct {
|
type GetConfigResponse struct {
|
||||||
|
AI config.AIConfig `json:"ai"`
|
||||||
OpenAI config.OpenAIConfig `json:"openai"`
|
OpenAI config.OpenAIConfig `json:"openai"`
|
||||||
Vision config.VisionConfig `json:"vision"`
|
Vision config.VisionConfig `json:"vision"`
|
||||||
FOFA config.FofaConfig `json:"fofa"`
|
FOFA config.FofaConfig `json:"fofa"`
|
||||||
@@ -363,6 +364,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, GetConfigResponse{
|
c.JSON(http.StatusOK, GetConfigResponse{
|
||||||
|
AI: h.config.AI,
|
||||||
OpenAI: h.config.OpenAI,
|
OpenAI: h.config.OpenAI,
|
||||||
Vision: h.config.Vision,
|
Vision: h.config.Vision,
|
||||||
FOFA: h.config.FOFA,
|
FOFA: h.config.FOFA,
|
||||||
@@ -706,6 +708,7 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
|||||||
|
|
||||||
// UpdateConfigRequest 更新配置请求
|
// UpdateConfigRequest 更新配置请求
|
||||||
type UpdateConfigRequest struct {
|
type UpdateConfigRequest struct {
|
||||||
|
AI *config.AIConfig `json:"ai,omitempty"`
|
||||||
OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
|
OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
|
||||||
Vision *config.VisionConfig `json:"vision,omitempty"`
|
Vision *config.VisionConfig `json:"vision,omitempty"`
|
||||||
FOFA *config.FofaConfig `json:"fofa,omitempty"`
|
FOFA *config.FofaConfig `json:"fofa,omitempty"`
|
||||||
@@ -785,8 +788,20 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
|||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
// 更新OpenAI配置
|
// 更新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 {
|
if req.OpenAI != nil {
|
||||||
h.config.OpenAI = *req.OpenAI
|
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配置",
|
h.logger.Info("更新OpenAI配置",
|
||||||
zap.String("base_url", h.config.OpenAI.BaseURL),
|
zap.String("base_url", h.config.OpenAI.BaseURL),
|
||||||
zap.String("model", h.config.OpenAI.Model),
|
zap.String("model", h.config.OpenAI.Model),
|
||||||
@@ -1683,7 +1698,8 @@ func (h *ConfigHandler) saveConfig() error {
|
|||||||
|
|
||||||
updateAgentConfig(root, h.config.Agent)
|
updateAgentConfig(root, h.config.Agent)
|
||||||
updateMCPConfig(root, h.config.MCP)
|
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)
|
updateVisionConfig(root, h.config.Vision)
|
||||||
updateFOFAConfig(root, h.config.FOFA)
|
updateFOFAConfig(root, h.config.FOFA)
|
||||||
updateSpaceSearchConfig(root, "zoomeye", h.config.ZoomEye)
|
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) {
|
func updateFOFAConfig(doc *yaml.Node, cfg config.FofaConfig) {
|
||||||
root := doc.Content[0]
|
root := doc.Content[0]
|
||||||
fofaNode := ensureMap(root, "fofa")
|
fofaNode := ensureMap(root, "fofa")
|
||||||
|
|||||||
@@ -149,6 +149,14 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
return
|
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 result *multiagent.RunResult
|
||||||
var runErr error
|
var runErr error
|
||||||
@@ -222,8 +230,8 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
||||||
taskCtxLoop,
|
taskCtxLoop,
|
||||||
h.config,
|
runCfg,
|
||||||
&h.config.MultiAgent,
|
&runCfg.MultiAgent,
|
||||||
h.agent,
|
h.agent,
|
||||||
h.db,
|
h.db,
|
||||||
h.logger,
|
h.logger,
|
||||||
@@ -236,6 +244,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
chatReasoningToClientIntent(req.Reasoning),
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
h.agentSessionContextBlock(conversationID),
|
h.agentSessionContextBlock(conversationID),
|
||||||
)
|
)
|
||||||
|
_ = resolvedAIChannelID
|
||||||
|
|
||||||
if result != nil && len(result.MCPExecutionIDs) > 0 {
|
if result != nil && len(result.MCPExecutionIDs) > 0 {
|
||||||
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
|
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
|
||||||
@@ -410,6 +419,11 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器配置未加载"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器配置未加载"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
runCfg, _, err := h.configForAIChannel(req.AIChannelID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
curHist := prep.History
|
curHist := prep.History
|
||||||
curMsg := prep.FinalMessage
|
curMsg := prep.FinalMessage
|
||||||
@@ -418,8 +432,8 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
for {
|
for {
|
||||||
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
||||||
taskCtx,
|
taskCtx,
|
||||||
h.config,
|
runCfg,
|
||||||
&h.config.MultiAgent,
|
&runCfg.MultiAgent,
|
||||||
h.agent,
|
h.agent,
|
||||||
h.db,
|
h.db,
|
||||||
h.logger,
|
h.logger,
|
||||||
|
|||||||
@@ -158,6 +158,12 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
stopKeepalive := runSSEKeepalive(c, &sseWriteMu)
|
stopKeepalive := runSSEKeepalive(c, &sseWriteMu)
|
||||||
defer stopKeepalive()
|
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 result *multiagent.RunResult
|
||||||
var runErr error
|
var runErr error
|
||||||
@@ -232,8 +238,8 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
result, runErr = multiagent.RunDeepAgent(
|
result, runErr = multiagent.RunDeepAgent(
|
||||||
taskCtxLoop,
|
taskCtxLoop,
|
||||||
h.config,
|
runCfg,
|
||||||
&h.config.MultiAgent,
|
&runCfg.MultiAgent,
|
||||||
h.agent,
|
h.agent,
|
||||||
h.db,
|
h.db,
|
||||||
h.logger,
|
h.logger,
|
||||||
@@ -421,6 +427,11 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
|
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)
|
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
|
curHist := prep.History
|
||||||
curMsg := prep.FinalMessage
|
curMsg := prep.FinalMessage
|
||||||
@@ -429,8 +440,8 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
for {
|
for {
|
||||||
result, runErr = multiagent.RunDeepAgent(
|
result, runErr = multiagent.RunDeepAgent(
|
||||||
taskCtx,
|
taskCtx,
|
||||||
h.config,
|
runCfg,
|
||||||
&h.config.MultiAgent,
|
&runCfg.MultiAgent,
|
||||||
h.agent,
|
h.agent,
|
||||||
h.db,
|
h.db,
|
||||||
h.logger,
|
h.logger,
|
||||||
|
|||||||
@@ -352,26 +352,28 @@ func NewWebShellHandler(logger *zap.Logger, db *database.DB) *WebShellHandler {
|
|||||||
|
|
||||||
// CreateConnectionRequest 创建连接请求
|
// CreateConnectionRequest 创建连接请求
|
||||||
type CreateConnectionRequest struct {
|
type CreateConnectionRequest struct {
|
||||||
URL string `json:"url" binding:"required"`
|
ProjectID string `json:"project_id"`
|
||||||
Password string `json:"password"`
|
URL string `json:"url" binding:"required"`
|
||||||
Type string `json:"type"`
|
Password string `json:"password"`
|
||||||
Method string `json:"method"`
|
Type string `json:"type"`
|
||||||
CmdParam string `json:"cmd_param"`
|
Method string `json:"method"`
|
||||||
Remark string `json:"remark"`
|
CmdParam string `json:"cmd_param"`
|
||||||
Encoding string `json:"encoding"`
|
Remark string `json:"remark"`
|
||||||
OS string `json:"os"`
|
Encoding string `json:"encoding"`
|
||||||
|
OS string `json:"os"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateConnectionRequest 更新连接请求
|
// UpdateConnectionRequest 更新连接请求
|
||||||
type UpdateConnectionRequest struct {
|
type UpdateConnectionRequest struct {
|
||||||
URL string `json:"url" binding:"required"`
|
ProjectID string `json:"project_id"`
|
||||||
Password string `json:"password"`
|
URL string `json:"url" binding:"required"`
|
||||||
Type string `json:"type"`
|
Password string `json:"password"`
|
||||||
Method string `json:"method"`
|
Type string `json:"type"`
|
||||||
CmdParam string `json:"cmd_param"`
|
Method string `json:"method"`
|
||||||
Remark string `json:"remark"`
|
CmdParam string `json:"cmd_param"`
|
||||||
Encoding string `json:"encoding"`
|
Remark string `json:"remark"`
|
||||||
OS string `json:"os"`
|
Encoding string `json:"encoding"`
|
||||||
|
OS string `json:"os"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListConnections 列出所有 WebShell 连接(GET /api/webshell/connections)
|
// ListConnections 列出所有 WebShell 连接(GET /api/webshell/connections)
|
||||||
@@ -381,7 +383,7 @@ func (h *WebShellHandler) ListConnections(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
session, _ := security.CurrentSession(c)
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -412,6 +414,11 @@ func (h *WebShellHandler) CreateConnection(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
|
||||||
return
|
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))
|
method := strings.ToLower(strings.TrimSpace(req.Method))
|
||||||
if method != "get" && method != "post" {
|
if method != "get" && method != "post" {
|
||||||
method = "post"
|
method = "post"
|
||||||
@@ -422,6 +429,7 @@ func (h *WebShellHandler) CreateConnection(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
conn := &database.WebShellConnection{
|
conn := &database.WebShellConnection{
|
||||||
ID: "ws_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12],
|
ID: "ws_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12],
|
||||||
|
ProjectID: projectID,
|
||||||
URL: req.URL,
|
URL: req.URL,
|
||||||
Password: strings.TrimSpace(req.Password),
|
Password: strings.TrimSpace(req.Password),
|
||||||
Type: shellType,
|
Type: shellType,
|
||||||
@@ -477,6 +485,11 @@ func (h *WebShellHandler) UpdateConnection(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
|
||||||
return
|
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))
|
method := strings.ToLower(strings.TrimSpace(req.Method))
|
||||||
if method != "get" && method != "post" {
|
if method != "get" && method != "post" {
|
||||||
method = "post"
|
method = "post"
|
||||||
@@ -486,15 +499,16 @@ func (h *WebShellHandler) UpdateConnection(c *gin.Context) {
|
|||||||
shellType = "php"
|
shellType = "php"
|
||||||
}
|
}
|
||||||
conn := &database.WebShellConnection{
|
conn := &database.WebShellConnection{
|
||||||
ID: id,
|
ID: id,
|
||||||
URL: req.URL,
|
ProjectID: projectID,
|
||||||
Password: strings.TrimSpace(req.Password),
|
URL: req.URL,
|
||||||
Type: shellType,
|
Password: strings.TrimSpace(req.Password),
|
||||||
Method: method,
|
Type: shellType,
|
||||||
CmdParam: strings.TrimSpace(req.CmdParam),
|
Method: method,
|
||||||
Remark: strings.TrimSpace(req.Remark),
|
CmdParam: strings.TrimSpace(req.CmdParam),
|
||||||
Encoding: normalizeWebshellEncoding(req.Encoding),
|
Remark: strings.TrimSpace(req.Remark),
|
||||||
OS: normalizeWebshellOS(req.OS),
|
Encoding: normalizeWebshellEncoding(req.Encoding),
|
||||||
|
OS: normalizeWebshellOS(req.OS),
|
||||||
}
|
}
|
||||||
if err := h.db.UpdateWebshellConnection(conn); err != nil {
|
if err := h.db.UpdateWebshellConnection(conn); err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -940,6 +954,21 @@ func (h *WebShellHandler) authorizedWebshellConnection(c *gin.Context, connectio
|
|||||||
return conn, true
|
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 调用)
|
// ExecWithConnection 在指定 WebShell 连接上执行命令(供 MCP/Agent 等非 HTTP 调用)
|
||||||
func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection, command string) (output string, ok bool, errMsg string) {
|
func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection, command string) (output string, ok bool, errMsg string) {
|
||||||
if conn == nil {
|
if conn == nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package multiagent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/cloudwego/eino/adk"
|
"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 {
|
func hitlInvokableToolCallMiddleware() compose.InvokableToolMiddleware {
|
||||||
return func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint {
|
return func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint {
|
||||||
return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
||||||
|
originalArgs := ""
|
||||||
|
editedArgs := ""
|
||||||
if input != nil {
|
if input != nil {
|
||||||
if fn, ok := ctx.Value(hitlInterceptorKey{}).(HITLToolInterceptor); ok && fn != nil {
|
if fn, ok := ctx.Value(hitlInterceptorKey{}).(HITLToolInterceptor); ok && fn != nil {
|
||||||
|
originalArgs = input.Arguments
|
||||||
edited, err := fn(ctx, input.Name, input.Arguments)
|
edited, err := fn(ctx, input.Name, input.Arguments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if IsHumanRejectError(err) {
|
if IsHumanRejectError(err) {
|
||||||
@@ -85,11 +126,17 @@ func hitlInvokableToolCallMiddleware() compose.InvokableToolMiddleware {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if edited != "" {
|
if edited != "" {
|
||||||
|
editedArgs = edited
|
||||||
input.Arguments = 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 {
|
func hitlStreamableToolCallMiddleware() compose.StreamableToolMiddleware {
|
||||||
return func(next compose.StreamableToolEndpoint) compose.StreamableToolEndpoint {
|
return func(next compose.StreamableToolEndpoint) compose.StreamableToolEndpoint {
|
||||||
return func(ctx context.Context, input *compose.ToolInput) (*compose.StreamToolOutput, error) {
|
return func(ctx context.Context, input *compose.ToolInput) (*compose.StreamToolOutput, error) {
|
||||||
|
originalArgs := ""
|
||||||
|
editedArgs := ""
|
||||||
if input != nil {
|
if input != nil {
|
||||||
if fn, ok := ctx.Value(hitlInterceptorKey{}).(HITLToolInterceptor); ok && fn != nil {
|
if fn, ok := ctx.Value(hitlInterceptorKey{}).(HITLToolInterceptor); ok && fn != nil {
|
||||||
|
originalArgs = input.Arguments
|
||||||
edited, err := fn(ctx, input.Name, input.Arguments)
|
edited, err := fn(ctx, input.Name, input.Arguments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if IsHumanRejectError(err) {
|
if IsHumanRejectError(err) {
|
||||||
@@ -111,11 +161,26 @@ func hitlStreamableToolCallMiddleware() compose.StreamableToolMiddleware {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if edited != "" {
|
if edited != "" {
|
||||||
|
editedArgs = edited
|
||||||
input.Arguments = 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1338
-288
File diff suppressed because it is too large
Load Diff
+1352
-42
File diff suppressed because it is too large
Load Diff
@@ -708,6 +708,12 @@
|
|||||||
"reasoningCompactAria": "Open model reasoning options",
|
"reasoningCompactAria": "Open model reasoning options",
|
||||||
"reasoningPanelTitle": "Model reasoning",
|
"reasoningPanelTitle": "Model reasoning",
|
||||||
"reasoningPanelHint": "Only Eino single- and multi-agent requests use these; merged with defaults in Settings.",
|
"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",
|
"reasoningSummaryFollow": "System",
|
||||||
"reasoningSummaryDash": "—",
|
"reasoningSummaryDash": "—",
|
||||||
"agentModeOrchPlanExecute": "Plan-Exec",
|
"agentModeOrchPlanExecute": "Plan-Exec",
|
||||||
@@ -729,7 +735,7 @@
|
|||||||
"hitlWhitelistHint": "Separate with commas or new lines; shown merged with the global allowlist in config.",
|
"hitlWhitelistHint": "Separate with commas or new lines; shown merged with the global allowlist in config.",
|
||||||
"hitlApply": "Apply",
|
"hitlApply": "Apply",
|
||||||
"hitlApplyOkSync": "HITL settings saved and synced to the server.",
|
"hitlApplyOkSync": "HITL settings saved and synced to the server.",
|
||||||
"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.",
|
"hitlApplyOkLocal": "Saved in this browser.",
|
||||||
"hitlApplyFail": "Failed to sync to server",
|
"hitlApplyFail": "Failed to sync to server",
|
||||||
"hitlStatusOff": "Human-in-the-loop: Off"
|
"hitlStatusOff": "Human-in-the-loop: Off"
|
||||||
@@ -2588,11 +2594,34 @@
|
|||||||
},
|
},
|
||||||
"settingsBasic": {
|
"settingsBasic": {
|
||||||
"basicTitle": "Basic settings",
|
"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": "Saved channels appear on the left. Saving writes to ai.channels; the default channel is used by new chats and tasks without an explicit channel.",
|
||||||
|
"aiChannelSavedList": "Saved channels",
|
||||||
|
"aiChannelListAria": "AI channel list",
|
||||||
|
"aiChannelEditing": "Editing",
|
||||||
|
"aiChannelDefaultBadge": "Default",
|
||||||
|
"aiChannelReady": "Ready",
|
||||||
|
"aiChannelDraft": "Incomplete",
|
||||||
|
"aiChannelDefaultMeta": "Default channel",
|
||||||
|
"aiChannelCustomMeta": "Custom channel",
|
||||||
|
"aiChannelOpenAICompat": "OpenAI compatible",
|
||||||
|
"aiChannelModelMissing": "Model missing",
|
||||||
|
"aiChannelName": "Channel name",
|
||||||
|
"aiChannelUntitled": "New Channel",
|
||||||
|
"aiChannelDeleteConfirm": "Delete AI channel \"{name}\"?",
|
||||||
|
"aiChannelSaved": "Channel saved",
|
||||||
|
"aiChannelDefaultSaved": "Default channel saved",
|
||||||
|
"aiChannelCount": "{count} channel(s) saved",
|
||||||
"apiProvider": "API Provider",
|
"apiProvider": "API Provider",
|
||||||
"providerOpenAI": "OpenAI / OpenAI-compatible API",
|
"providerOpenAI": "OpenAI / OpenAI-compatible API",
|
||||||
"providerClaude": "Claude (Anthropic Messages API)",
|
"providerClaude": "Claude (Anthropic Messages API)",
|
||||||
"visionProviderReuseOpenAI": "Reuse OpenAI config (leave empty)",
|
"visionProviderReuseOpenAI": "Reuse default AI channel",
|
||||||
"fofaConfig": "FOFA config",
|
"fofaConfig": "FOFA config",
|
||||||
"fofaConfigHint": "Used for asset discovery and import; only an API key is required.",
|
"fofaConfigHint": "Used for asset discovery and import; only an API key is required.",
|
||||||
"agentConfig": "Agent config",
|
"agentConfig": "Agent config",
|
||||||
@@ -2613,8 +2642,11 @@
|
|||||||
"maxTotalTokens": "Max Context Tokens",
|
"maxTotalTokens": "Max Context Tokens",
|
||||||
"maxTotalTokensPlaceholder": "120000",
|
"maxTotalTokensPlaceholder": "120000",
|
||||||
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
|
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
|
||||||
"openaiReasoningTitle": "Model reasoning",
|
"maxCompletionTokens": "Max Output Tokens",
|
||||||
"openaiReasoningHint": "Works with the reasoning controls on the chat page.",
|
"maxCompletionTokensPlaceholder": "16384",
|
||||||
|
"maxCompletionTokensHint": "Maximum tokens for a single model response. Default: 16384",
|
||||||
|
"openaiReasoningTitle": "Reasoning settings",
|
||||||
|
"openaiReasoningHint": "Default reasoning settings for this AI channel; chat Session settings can override them.",
|
||||||
"openaiReasoningProfile": "Wire profile",
|
"openaiReasoningProfile": "Wire profile",
|
||||||
"openaiReasoningAllowClient": "Allow chat page to override reasoning options",
|
"openaiReasoningAllowClient": "Allow chat page to override reasoning options",
|
||||||
"fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all (optional)",
|
"fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all (optional)",
|
||||||
@@ -3839,6 +3871,11 @@
|
|||||||
"infoLastCheckin": "Last check-in",
|
"infoLastCheckin": "Last check-in",
|
||||||
"infoNote": "Note",
|
"infoNote": "Note",
|
||||||
"infoNoteEmpty": "No notes",
|
"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",
|
"infoSectionIdentity": "Identity",
|
||||||
"infoSectionSystem": "System",
|
"infoSectionSystem": "System",
|
||||||
"infoSectionNetwork": "Network & beacon",
|
"infoSectionNetwork": "Network & beacon",
|
||||||
@@ -3952,7 +3989,20 @@
|
|||||||
"paginationPrev": "Previous",
|
"paginationPrev": "Previous",
|
||||||
"paginationPage": "Page {{current}} / {{total}}",
|
"paginationPage": "Page {{current}} / {{total}}",
|
||||||
"paginationNext": "Next",
|
"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": {
|
"payloads": {
|
||||||
"386": "386",
|
"386": "386",
|
||||||
|
|||||||
@@ -696,6 +696,12 @@
|
|||||||
"reasoningCompactAria": "打开模型推理选项",
|
"reasoningCompactAria": "打开模型推理选项",
|
||||||
"reasoningPanelTitle": "模型推理",
|
"reasoningPanelTitle": "模型推理",
|
||||||
"reasoningPanelHint": "仅 Eino 单代理与多代理请求会带上这些参数;与系统设置中的默认值合并。",
|
"reasoningPanelHint": "仅 Eino 单代理与多代理请求会带上这些参数;与系统设置中的默认值合并。",
|
||||||
|
"sessionSettingsTitle": "会话设置",
|
||||||
|
"sessionSettingsAria": "打开会话设置",
|
||||||
|
"sessionSettingsHint": "AI 通道、推理设置与人机协同只影响后续消息。",
|
||||||
|
"aiChannelLabel": "AI 通道",
|
||||||
|
"aiChannelDefault": "跟随默认通道",
|
||||||
|
"aiChannelDefaultShort": "默认通道",
|
||||||
"reasoningSummaryFollow": "系统",
|
"reasoningSummaryFollow": "系统",
|
||||||
"reasoningSummaryDash": "—",
|
"reasoningSummaryDash": "—",
|
||||||
"agentModeOrchPlanExecute": "Plan-Exec",
|
"agentModeOrchPlanExecute": "Plan-Exec",
|
||||||
@@ -717,7 +723,7 @@
|
|||||||
"hitlWhitelistHint": "白名单内工具免审批;每行一个或逗号分隔,与 config 全局白名单合并。",
|
"hitlWhitelistHint": "白名单内工具免审批;每行一个或逗号分隔,与 config 全局白名单合并。",
|
||||||
"hitlApply": "应用",
|
"hitlApply": "应用",
|
||||||
"hitlApplyOkSync": "人机协同配置已保存并同步到服务器。",
|
"hitlApplyOkSync": "人机协同配置已保存并同步到服务器。",
|
||||||
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。协同模式、超时等仍须选中会话后再点「应用」才会写入服务器。",
|
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。",
|
||||||
"hitlApplyOkLocal": "已保存到本浏览器。",
|
"hitlApplyOkLocal": "已保存到本浏览器。",
|
||||||
"hitlApplyFail": "同步到服务器失败",
|
"hitlApplyFail": "同步到服务器失败",
|
||||||
"hitlStatusOff": "人机协同:关闭"
|
"hitlStatusOff": "人机协同:关闭"
|
||||||
@@ -2576,11 +2582,34 @@
|
|||||||
},
|
},
|
||||||
"settingsBasic": {
|
"settingsBasic": {
|
||||||
"basicTitle": "基本设置",
|
"basicTitle": "基本设置",
|
||||||
"openaiConfig": "OpenAI 配置",
|
"openaiConfig": "AI 通道配置",
|
||||||
|
"aiChannelCurrent": "当前通道",
|
||||||
|
"aiChannelSave": "保存更改",
|
||||||
|
"aiChannelSetDefault": "设为默认",
|
||||||
|
"aiChannelNew": "新增",
|
||||||
|
"aiChannelCopy": "复制",
|
||||||
|
"aiChannelDelete": "删除",
|
||||||
|
"aiChannelHint": "已保存的通道会显示在左侧;保存后写入 ai.channels,默认通道用于新对话和未指定通道的任务。",
|
||||||
|
"aiChannelSavedList": "已保存通道",
|
||||||
|
"aiChannelListAria": "AI 通道列表",
|
||||||
|
"aiChannelEditing": "正在编辑",
|
||||||
|
"aiChannelDefaultBadge": "默认",
|
||||||
|
"aiChannelReady": "可用",
|
||||||
|
"aiChannelDraft": "待完善",
|
||||||
|
"aiChannelDefaultMeta": "默认通道",
|
||||||
|
"aiChannelCustomMeta": "自定义通道",
|
||||||
|
"aiChannelOpenAICompat": "OpenAI 兼容",
|
||||||
|
"aiChannelModelMissing": "未填写模型",
|
||||||
|
"aiChannelName": "通道名称",
|
||||||
|
"aiChannelUntitled": "新通道",
|
||||||
|
"aiChannelDeleteConfirm": "确定删除 AI 通道「{name}」吗?",
|
||||||
|
"aiChannelSaved": "通道已保存",
|
||||||
|
"aiChannelDefaultSaved": "已设为默认通道",
|
||||||
|
"aiChannelCount": "已保存 {count} 个通道",
|
||||||
"apiProvider": "API 提供商",
|
"apiProvider": "API 提供商",
|
||||||
"providerOpenAI": "OpenAI / 兼容 OpenAI 协议",
|
"providerOpenAI": "OpenAI / 兼容 OpenAI 协议",
|
||||||
"providerClaude": "Claude (Anthropic Messages API)",
|
"providerClaude": "Claude (Anthropic Messages API)",
|
||||||
"visionProviderReuseOpenAI": "OpenAI 配置(留空复用)",
|
"visionProviderReuseOpenAI": "默认 AI 通道(留空复用)",
|
||||||
"fofaConfig": "FOFA 配置",
|
"fofaConfig": "FOFA 配置",
|
||||||
"fofaConfigHint": "用于资产发现与导入,仅需配置 API Key。",
|
"fofaConfigHint": "用于资产发现与导入,仅需配置 API Key。",
|
||||||
"agentConfig": "Agent 配置",
|
"agentConfig": "Agent 配置",
|
||||||
@@ -2601,8 +2630,11 @@
|
|||||||
"maxTotalTokens": "最大上下文 Token 数",
|
"maxTotalTokens": "最大上下文 Token 数",
|
||||||
"maxTotalTokensPlaceholder": "120000",
|
"maxTotalTokensPlaceholder": "120000",
|
||||||
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
|
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
|
||||||
"openaiReasoningTitle": "模型推理",
|
"maxCompletionTokens": "最大输出 Token 数",
|
||||||
"openaiReasoningHint": "与对话页「模型推理」下拉配合使用。",
|
"maxCompletionTokensPlaceholder": "16384",
|
||||||
|
"maxCompletionTokensHint": "单次模型回复的输出上限,默认 16384",
|
||||||
|
"openaiReasoningTitle": "推理设置",
|
||||||
|
"openaiReasoningHint": "作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。",
|
||||||
"openaiReasoningProfile": "线路 profile",
|
"openaiReasoningProfile": "线路 profile",
|
||||||
"openaiReasoningAllowClient": "允许对话页覆盖推理选项",
|
"openaiReasoningAllowClient": "允许对话页覆盖推理选项",
|
||||||
"fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all(可选)",
|
"fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all(可选)",
|
||||||
@@ -3827,6 +3859,11 @@
|
|||||||
"infoLastCheckin": "上次心跳",
|
"infoLastCheckin": "上次心跳",
|
||||||
"infoNote": "备注",
|
"infoNote": "备注",
|
||||||
"infoNoteEmpty": "暂无备注",
|
"infoNoteEmpty": "暂无备注",
|
||||||
|
"infoCopy": "复制",
|
||||||
|
"noteEdit": "编辑",
|
||||||
|
"notePlaceholder": "添加备注,便于识别该会话…",
|
||||||
|
"toastNoteSaved": "备注已保存",
|
||||||
|
"toastNoteTooLong": "备注最多 2000 字",
|
||||||
"infoSectionIdentity": "身份信息",
|
"infoSectionIdentity": "身份信息",
|
||||||
"infoSectionSystem": "系统环境",
|
"infoSectionSystem": "系统环境",
|
||||||
"infoSectionNetwork": "网络与信标",
|
"infoSectionNetwork": "网络与信标",
|
||||||
@@ -3940,7 +3977,20 @@
|
|||||||
"paginationPrev": "上一页",
|
"paginationPrev": "上一页",
|
||||||
"paginationPage": "第 {{current}} / {{total}} 页",
|
"paginationPage": "第 {{current}} / {{total}} 页",
|
||||||
"paginationNext": "下一页",
|
"paginationNext": "下一页",
|
||||||
"paginationLast": "末页"
|
"paginationLast": "末页",
|
||||||
|
"statTotal": "当前筛选",
|
||||||
|
"timePresets": "快捷",
|
||||||
|
"preset15m": "最近15分钟",
|
||||||
|
"preset1h": "最近1小时",
|
||||||
|
"preset24h": "最近24小时",
|
||||||
|
"preset7d": "最近7天",
|
||||||
|
"presetAll": "全部",
|
||||||
|
"filterAllStatuses": "全部状态",
|
||||||
|
"filterAllTypes": "全部类型",
|
||||||
|
"filterSession": "会话 ID",
|
||||||
|
"filterSessionPlaceholder": "s_xxxxxxxx",
|
||||||
|
"applyFilters": "应用筛选",
|
||||||
|
"resetFilters": "重置"
|
||||||
},
|
},
|
||||||
"payloads": {
|
"payloads": {
|
||||||
"386": "386",
|
"386": "386",
|
||||||
|
|||||||
@@ -868,7 +868,7 @@ async function openAssetProjectModal() {
|
|||||||
}
|
}
|
||||||
const subtitle = document.getElementById('asset-project-subtitle');
|
const subtitle = document.getElementById('asset-project-subtitle');
|
||||||
if (subtitle) subtitle.textContent = assetT('assets.bindProjectCount', `将更新 ${assets.length} 个资产`, { count: assets.length });
|
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';
|
else document.getElementById('asset-project-modal').style.display = 'flex';
|
||||||
if (select) select.focus();
|
if (select) select.focus();
|
||||||
}
|
}
|
||||||
|
|||||||
+593
-88
@@ -14,6 +14,8 @@
|
|||||||
tasksPageSize: 10,
|
tasksPageSize: 10,
|
||||||
tasksTotal: 0,
|
tasksTotal: 0,
|
||||||
tasksPendingQueuedCount: null,
|
tasksPendingQueuedCount: null,
|
||||||
|
tasksStatusCounts: { success: 0, failed: 0, pending: 0, cancelled: 0 },
|
||||||
|
tasksFilter: { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' },
|
||||||
events: [],
|
events: [],
|
||||||
eventsPage: 1,
|
eventsPage: 1,
|
||||||
eventsPageSize: 10,
|
eventsPageSize: 10,
|
||||||
@@ -47,6 +49,111 @@
|
|||||||
// API 基础路径
|
// API 基础路径
|
||||||
const API_BASE = '/api/c2';
|
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) {
|
window.__c2DownloadPayload = function(filename) {
|
||||||
const url = `${API_BASE}/payloads/${filename}/download`;
|
const url = `${API_BASE}/payloads/${filename}/download`;
|
||||||
const fetchFn = (typeof apiFetch === 'function') ? apiFetch : fetch;
|
const fetchFn = (typeof apiFetch === 'function') ? apiFetch : fetch;
|
||||||
@@ -339,56 +446,116 @@
|
|||||||
return String(os || '?').substring(0, 3).toLowerCase();
|
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) {
|
function sessionInfoRow(label, value, opts) {
|
||||||
|
const empty = isEmptyInfoValue(value);
|
||||||
|
const display = empty ? '—' : String(value);
|
||||||
const valueClasses = ['c2-session-info-dl__value'];
|
const valueClasses = ['c2-session-info-dl__value'];
|
||||||
if (opts && opts.mono) valueClasses.push('is-mono');
|
if (opts && opts.mono) valueClasses.push('is-mono');
|
||||||
if (opts && opts.accent) valueClasses.push('is-accent');
|
if (opts && opts.accent && !empty) valueClasses.push('is-accent');
|
||||||
if (opts && opts.warn) valueClasses.push('is-warn');
|
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 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 `
|
return `
|
||||||
<div class="c2-session-info-dl__row${rowCls}">
|
<div class="c2-session-info-dl__row${rowCls}">
|
||||||
<dt class="c2-session-info-dl__label">${escapeHtml(label)}</dt>
|
<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>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSessionInfoPanel(s, adminVal, sleepLine) {
|
function renderSessionInfoPanel(s, adminVal, sleepLine) {
|
||||||
const lastCheckin = formatTime(s.lastCheckIn);
|
const lastCheckin = formatTime(s.lastCheckIn);
|
||||||
const lastRel = formatRelativeTime(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 `
|
return `
|
||||||
<div class="c2-session-info-panel">
|
<div class="c2-session-info-panel">
|
||||||
<section class="c2-session-info-block">
|
<div class="c2-session-info-grid">
|
||||||
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionIdentity'))}</div>
|
<section class="c2-session-info-block">
|
||||||
<dl class="c2-session-info-dl">
|
<div class="c2-session-info-block__head">
|
||||||
${sessionInfoRow(c2t('c2.sessions.infoSessionId'), s.id, { mono: true, accent: true, full: true })}
|
<span class="c2-session-info-block__icon c2-session-info-block__icon--id" aria-hidden="true"></span>
|
||||||
${sessionInfoRow(c2t('c2.sessions.infoImplantUuid'), s.implantUuid, { mono: true, full: true })}
|
<span>${escapeHtml(c2t('c2.sessions.infoSectionIdentity'))}</span>
|
||||||
${sessionInfoRow(c2t('c2.sessions.infoHostname'), s.hostname)}
|
</div>
|
||||||
${sessionInfoRow(c2t('c2.sessions.infoUsername'), s.username)}
|
<dl class="c2-session-info-dl">
|
||||||
</dl>
|
${sessionInfoRow(c2t('c2.sessions.infoSessionId'), s.id, { mono: true, accent: true, copy: true })}
|
||||||
</section>
|
${sessionInfoRow(c2t('c2.sessions.infoImplantUuid'), s.implantUuid, { mono: true, copy: true })}
|
||||||
<section class="c2-session-info-block">
|
${sessionInfoRow(c2t('c2.sessions.infoHostname'), s.hostname)}
|
||||||
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionSystem'))}</div>
|
${sessionInfoRow(c2t('c2.sessions.infoUsername'), s.username)}
|
||||||
<dl class="c2-session-info-dl">
|
</dl>
|
||||||
${sessionInfoRow(c2t('c2.sessions.infoOs'), s.os)}
|
</section>
|
||||||
${sessionInfoRow(c2t('c2.sessions.infoArch'), s.arch, { mono: true })}
|
<section class="c2-session-info-block">
|
||||||
${sessionInfoRow(c2t('c2.sessions.infoPid'), s.pid, { mono: true })}
|
<div class="c2-session-info-block__head">
|
||||||
${sessionInfoRow(c2t('c2.sessions.infoProcess'), s.processName || '-', { mono: true })}
|
<span class="c2-session-info-block__icon c2-session-info-block__icon--sys" aria-hidden="true"></span>
|
||||||
${sessionInfoRow(c2t('c2.sessions.infoAdmin'), adminVal, { warn: s.isAdmin, full: true })}
|
<span>${escapeHtml(c2t('c2.sessions.infoSectionSystem'))}</span>
|
||||||
</dl>
|
</div>
|
||||||
</section>
|
<dl class="c2-session-info-dl">
|
||||||
<section class="c2-session-info-block">
|
${sessionInfoRow(c2t('c2.sessions.infoOs'), s.os)}
|
||||||
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionNetwork'))}</div>
|
${sessionInfoRow(c2t('c2.sessions.infoArch'), s.arch, { mono: true })}
|
||||||
<dl class="c2-session-info-dl">
|
${sessionInfoRow(c2t('c2.sessions.infoPid'), s.pid, { mono: true })}
|
||||||
${sessionInfoRow(c2t('c2.sessions.infoInternalIp'), s.internalIp || '-', { mono: true, accent: true })}
|
${sessionInfoRow(c2t('c2.sessions.infoProcess'), s.processName || '-', { mono: true })}
|
||||||
${sessionInfoRow(c2t('c2.sessions.infoSleep'), sleepLine)}
|
${sessionInfoRow(c2t('c2.sessions.infoAdmin'), adminVal, { warn: !!s.isAdmin })}
|
||||||
${sessionInfoRow(c2t('c2.sessions.infoFirstSeen'), formatTime(s.firstSeenAt), { mono: true })}
|
</dl>
|
||||||
${sessionInfoRow(c2t('c2.sessions.infoLastCheckin'), checkinDisplay, { mono: true, accent: true })}
|
</section>
|
||||||
</dl>
|
<section class="c2-session-info-block">
|
||||||
</section>
|
<div class="c2-session-info-block__head">
|
||||||
<section class="c2-session-info-block c2-session-info-block--note">
|
<span class="c2-session-info-block__icon c2-session-info-block__icon--net" aria-hidden="true"></span>
|
||||||
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionNote'))}</div>
|
<span>${escapeHtml(c2t('c2.sessions.infoSectionNetwork'))}</span>
|
||||||
<div class="c2-session-info-note">${escapeHtml(s.note || c2t('c2.sessions.infoNoteEmpty'))}</div>
|
</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>
|
</section>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -398,6 +565,7 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function apiRequest(method, url, data) {
|
function apiRequest(method, url, data) {
|
||||||
|
if (method === 'GET') url = withC2ProjectQuery(url);
|
||||||
const options = {
|
const options = {
|
||||||
method: method,
|
method: method,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
@@ -448,6 +616,21 @@
|
|||||||
return c2t('c2.fmt.durationMin', { n: (ms / 60000).toFixed(1) });
|
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) {
|
function escapeHtml(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
const div = document.createElement('div');
|
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() {
|
C2.loadListeners = function() {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
apiRequest('GET', `${API_BASE}/listeners`),
|
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) {
|
]).then(function(results) {
|
||||||
var ldata = results[0];
|
var ldata = results[0];
|
||||||
var pdata = results[1];
|
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>'
|
? '<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 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 startedHtml = formatListenerStartedHtml(l.startedAt);
|
||||||
const pillLabel = escapeHtml(listenerCardStatusPillLabel(st));
|
const pillLabel = escapeHtml(listenerCardStatusPillLabel(st));
|
||||||
const typeMark = escapeHtml(listenerTypeShortLabel(l.type));
|
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>
|
<span class="c2-listener-kv-val c2-listener-mono"><span class="c2-status-dot ${escapeHtml(st)}"></span>${bindVal}</span>
|
||||||
</div>
|
</div>
|
||||||
${cbRow}
|
${cbRow}
|
||||||
|
${projectRow}
|
||||||
${profileBadge}
|
${profileBadge}
|
||||||
${remarkRow}
|
${remarkRow}
|
||||||
${startedHtml}
|
${startedHtml}
|
||||||
@@ -751,8 +942,15 @@
|
|||||||
</div>
|
</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 profileOpts = listenerProfileSelectHtml('');
|
||||||
|
const projectOpts = c2ProjectOptionsHtml(createSelectedProjectId, projects);
|
||||||
const emptyProfHintCreate = (C2.profiles && C2.profiles.length > 0)
|
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>`;
|
: `<div class="form-hint form-hint--warning" style="margin-bottom:6px;">${escapeHtml(c2t('c2.listeners.malleableProfileEmptyListHint'))}</div>`;
|
||||||
@@ -777,6 +975,10 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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-row">
|
||||||
<div class="c2-form-group">
|
<div class="c2-form-group">
|
||||||
<label>${escapeHtml(c2t('c2.listeners.bindHost'))}</label>
|
<label>${escapeHtml(c2t('c2.listeners.bindHost'))}</label>
|
||||||
@@ -864,6 +1066,7 @@
|
|||||||
const body = {
|
const body = {
|
||||||
name, type, bind_host: bindHost, bind_port: bindPort, remark,
|
name, type, bind_host: bindHost, bind_port: bindPort, remark,
|
||||||
callback_host: callbackHost,
|
callback_host: callbackHost,
|
||||||
|
project_id: (document.getElementById('c2-listener-project-id')?.value || '').trim(),
|
||||||
profile_id: profileId
|
profile_id: profileId
|
||||||
};
|
};
|
||||||
if (type === 'tcp_reverse' && legacyShell) {
|
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) {
|
C2.editListener = function(id) {
|
||||||
const l = C2.listeners.find(x => x.id === id);
|
const l = C2.listeners.find(x => x.id === id);
|
||||||
if (!l) return;
|
if (!l) return;
|
||||||
@@ -931,9 +1166,16 @@
|
|||||||
</div>
|
</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 resolvedPid = listenerResolvedProfileId(l);
|
||||||
const profileOpts = listenerProfileSelectHtml(resolvedPid);
|
const profileOpts = listenerProfileSelectHtml(resolvedPid);
|
||||||
|
const projectOpts = c2ProjectOptionsHtml(editSelectedProjectId, projects);
|
||||||
const lt = String(l.type || '').toLowerCase();
|
const lt = String(l.type || '').toLowerCase();
|
||||||
const httpHint = (lt === 'http_beacon' || lt === 'https_beacon')
|
const httpHint = (lt === 'http_beacon' || lt === 'https_beacon')
|
||||||
? ''
|
? ''
|
||||||
@@ -951,6 +1193,10 @@
|
|||||||
<label>${escapeHtml(c2t('c2.listeners.name'))}</label>
|
<label>${escapeHtml(c2t('c2.listeners.name'))}</label>
|
||||||
<input type="text" id="c2-listener-name" class="form-control" value="${escapeHtml(l.name)}">
|
<input type="text" id="c2-listener-name" class="form-control" value="${escapeHtml(l.name)}">
|
||||||
</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-row">
|
||||||
<div class="c2-form-group">
|
<div class="c2-form-group">
|
||||||
<label>${escapeHtml(c2t('c2.listeners.bindHost'))}</label>
|
<label>${escapeHtml(c2t('c2.listeners.bindHost'))}</label>
|
||||||
@@ -1009,6 +1255,7 @@
|
|||||||
const body = {
|
const body = {
|
||||||
name, bind_host: bindHost, bind_port: bindPort, remark,
|
name, bind_host: bindHost, bind_port: bindPort, remark,
|
||||||
callback_host: callbackHost,
|
callback_host: callbackHost,
|
||||||
|
project_id: (document.getElementById('c2-listener-project-id')?.value || '').trim(),
|
||||||
profile_id: profileId
|
profile_id: profileId
|
||||||
};
|
};
|
||||||
if (legacyEl) {
|
if (legacyEl) {
|
||||||
@@ -1119,9 +1366,16 @@
|
|||||||
const batchBtn = document.getElementById('c2-sessions-batch-delete');
|
const batchBtn = document.getElementById('c2-sessions-batch-delete');
|
||||||
const filteredBtn = document.getElementById('c2-sessions-delete-filtered');
|
const filteredBtn = document.getElementById('c2-sessions-delete-filtered');
|
||||||
const ids = C2.collectCheckedSessionIds();
|
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;
|
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');
|
const countEl = document.getElementById('c2-sessions-count');
|
||||||
if (countEl) {
|
if (countEl) {
|
||||||
countEl.textContent = c2t('c2.sessions.filterCount', { n: total, selected: ids.length });
|
countEl.textContent = c2t('c2.sessions.filterCount', { n: total, selected: ids.length });
|
||||||
@@ -1191,7 +1445,12 @@
|
|||||||
return;
|
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' : ''}"
|
<div class="c2-session-item ${s.id === C2.selectedSessionId ? 'active' : ''}"
|
||||||
data-status="${escapeHtml(s.status || '')}"
|
data-status="${escapeHtml(s.status || '')}"
|
||||||
onclick="C2.selectSession('${s.id}')">
|
onclick="C2.selectSession('${s.id}')">
|
||||||
@@ -1206,21 +1465,21 @@
|
|||||||
<span class="c2-session-status ${s.status}">${escapeHtml(sessionStatusLabel(s.status))}</span>
|
<span class="c2-session-status ${s.status}">${escapeHtml(sessionStatusLabel(s.status))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="c2-session-chips">
|
<div class="c2-session-chips">
|
||||||
<span class="c2-session-chip">${escapeHtml(s.username)}</span>
|
<span class="c2-session-chip${userEmpty ? ' c2-session-chip--empty' : ''}">${escapeHtml(userLabel)}</span>
|
||||||
<span class="c2-session-chip c2-session-chip--mono">${escapeHtml(s.os)}/${escapeHtml(s.arch)}</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>' : ''}
|
${s.isAdmin ? '<span class="c2-session-chip c2-session-chip--warn">' + escapeHtml(c2t('c2.sessions.rootBadge')) + '</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="c2-session-chips c2-session-chips--sub">
|
<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">${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">PID ${escapeHtml(String(s.pid != null ? s.pid : '—'))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="c2-session-item-footer">
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
`).join('');
|
}).join('');
|
||||||
|
|
||||||
if (C2.selectedSessionId && !C2.sessions.find(s => s.id === C2.selectedSessionId)) {
|
if (C2.selectedSessionId && !C2.sessions.find(s => s.id === C2.selectedSessionId)) {
|
||||||
C2.selectedSessionId = null;
|
C2.selectedSessionId = null;
|
||||||
@@ -1270,11 +1529,11 @@
|
|||||||
<span class="c2-session-badge ${escapeHtml(s.status || '')}">${escapeHtml(sessionStatusLabel(s.status))}</span>
|
<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>' : ''}
|
${s.isAdmin ? '<span class="c2-session-hero-root">' + escapeHtml(c2t('c2.sessions.rootBadge')) + '</span>' : ''}
|
||||||
</div>
|
</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">
|
<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 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">${escapeHtml(s.internalIp || '—')}</span>
|
||||||
<span class="c2-session-hero-chip">PID ${s.pid}</span>
|
<span class="c2-session-hero-chip">PID ${escapeHtml(String(s.pid != null ? s.pid : '—'))}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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) {
|
C2.killSession = function(id) {
|
||||||
if (!confirm(c2t('c2.sessions.confirmExitSession'))) return;
|
if (!confirm(c2t('c2.sessions.confirmExitSession'))) return;
|
||||||
apiRequest('POST', `${API_BASE}/tasks`, {
|
apiRequest('POST', `${API_BASE}/tasks`, {
|
||||||
@@ -2072,6 +2413,8 @@
|
|||||||
});
|
});
|
||||||
container.setAttribute('tabindex', '0');
|
container.setAttribute('tabindex', '0');
|
||||||
|
|
||||||
|
C2.bindTerminalScrollbarAutoHide(container);
|
||||||
|
|
||||||
C2.terminalInstance = term;
|
C2.terminalInstance = term;
|
||||||
|
|
||||||
if (C2.terminalResizeObserver) {
|
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() {
|
C2.fitTerminal = function() {
|
||||||
const container = document.getElementById('c2-terminal-container');
|
const container = document.getElementById('c2-terminal-container');
|
||||||
if (!container || !C2.terminalFitAddon || !C2.terminalInstance) return;
|
if (!container || !C2.terminalFitAddon || !C2.terminalInstance) return;
|
||||||
@@ -2392,18 +2771,22 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${entries.map(function(entry) {
|
${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 `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="c2-file-name">
|
<td>
|
||||||
<span class="c2-file-icon">${entry.isDir ? '📁' : '📄'}</span>
|
<div class="c2-file-name">
|
||||||
${escapeHtml(entry.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>
|
||||||
<td>${escapeHtml(entry.size)}</td>
|
<td title="${escapeHtml(String(entry.size || ''))}">${escapeHtml(sizeLabel)}</td>
|
||||||
<td>${escapeHtml(entry.mode)}</td>
|
<td>${escapeHtml(entry.mode)}</td>
|
||||||
<td>
|
<td>
|
||||||
${entry.isDir
|
${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 c2-file-action-btn" 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.downloadFile(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.download'))}</button>`
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -2717,16 +3100,20 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
C2.loadTasks = function(page) {
|
C2.loadTasks = function(page) {
|
||||||
|
bindTasksFilterUiOnce();
|
||||||
|
readTasksFilterFromDom();
|
||||||
const p = page != null ? page : (C2.tasksPage || 1);
|
const p = page != null ? page : (C2.tasksPage || 1);
|
||||||
C2.tasksPage = p;
|
C2.tasksPage = p;
|
||||||
const ps = C2.tasksPageSize || 10;
|
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) {
|
if (data.error) {
|
||||||
showToast(String(data.error), 'error');
|
showToast(String(data.error), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
C2.tasks = data.tasks || [];
|
C2.tasks = data.tasks || [];
|
||||||
C2.tasksTotal = typeof data.total === 'number' ? data.total : (C2.tasks.length || 0);
|
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') {
|
if (typeof data.pending_queued_count === 'number') {
|
||||||
C2.tasksPendingQueuedCount = data.pending_queued_count;
|
C2.tasksPendingQueuedCount = data.pending_queued_count;
|
||||||
}
|
}
|
||||||
@@ -2736,8 +3123,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
C2.renderTasks();
|
C2.renderTasks();
|
||||||
|
C2.renderTasksSummary();
|
||||||
C2.renderTasksPagination();
|
C2.renderTasksPagination();
|
||||||
C2.syncTasksToolbar();
|
C2.syncTasksToolbar();
|
||||||
|
syncTasksTimePresetButtons();
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
showToast(err.message || String(err), 'error');
|
showToast(err.message || String(err), 'error');
|
||||||
});
|
});
|
||||||
@@ -2857,6 +3246,105 @@
|
|||||||
}).catch(err => showToast(err.message || String(err), 'error'));
|
}).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) {
|
C2.loadSessionTasks = function(sessionId) {
|
||||||
apiRequest('GET', `${API_BASE}/tasks?session_id=${encodeURIComponent(sessionId)}&limit=50`).then(data => {
|
apiRequest('GET', `${API_BASE}/tasks?session_id=${encodeURIComponent(sessionId)}&limit=50`).then(data => {
|
||||||
const container = document.getElementById('c2-session-tasks-list');
|
const container = document.getElementById('c2-session-tasks-list');
|
||||||
@@ -2941,7 +3429,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (C2.tasks.length === 0) {
|
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;
|
if (selAll) selAll.disabled = true;
|
||||||
C2.syncTasksToolbar();
|
C2.syncTasksToolbar();
|
||||||
return;
|
return;
|
||||||
@@ -2949,59 +3437,76 @@
|
|||||||
if (selAll) selAll.disabled = false;
|
if (selAll) selAll.disabled = false;
|
||||||
|
|
||||||
const delTitle = escapeHtml(c2t('c2.tasks.deleteOne'));
|
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 = `
|
container.innerHTML = `
|
||||||
<table class="c2-task-table">
|
<table class="c2-tasks-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="c2-task-table-col-check"></th>
|
<th class="c2-tasks-table-col-check">
|
||||||
<th>${escapeHtml(c2t('c2.tasks.colTask'))}</th>
|
<label class="c2-task-check-label" title="${escapeHtml(c2t('c2.tasks.selectAll'))}">
|
||||||
<th>${escapeHtml(c2t('c2.tasks.colSession'))}</th>
|
<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.colType'))}</th>
|
||||||
<th>${escapeHtml(c2t('c2.tasks.colCommand'))}</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.colDuration'))}</th>
|
||||||
<th>${escapeHtml(c2t('c2.tasks.colCreated'))}</th>
|
<th class="c2-tasks-table-col-actions">${escapeHtml(c2t('c2.tasks.colActions'))}</th>
|
||||||
<th class="c2-task-table-col-actions">${escapeHtml(c2t('c2.tasks.colActions'))}</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${C2.tasks.map(t => {
|
${C2.tasks.map(t => {
|
||||||
const rawId = t.id || '';
|
const rawId = t.id || '';
|
||||||
const shortTaskId = rawId.length > 14 ? escapeHtml(rawId.substring(0, 12)) + '\u2026' : escapeHtml(rawId);
|
const tid = escapeHtml(rawId);
|
||||||
const sid = t.sessionId ? escapeHtml(String(t.sessionId).substring(0, 8)) + '\u2026' : '-';
|
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 cmd = formatTaskCommand(t);
|
||||||
const cmdShort = truncateCommand(cmd, 48);
|
const cmdEsc = escapeHtml(cmd);
|
||||||
|
const canCancel = status === 'queued' || status === 'sent';
|
||||||
return `
|
return `
|
||||||
<tr>
|
<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-task-table-col-check">
|
<td class="c2-tasks-table-col-check" onclick="event.stopPropagation();">
|
||||||
<label class="c2-task-check-label" onclick="event.stopPropagation();">
|
<label class="c2-task-check-label">
|
||||||
<input type="checkbox" class="c2-task-row-check" data-id="${escapeHtml(rawId)}" onchange="C2.syncTasksToolbar()">
|
<input type="checkbox" class="c2-task-row-check" data-id="${tid}" onchange="C2.syncTasksToolbar()">
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td>${shortTaskId}</td>
|
<td class="c2-tasks-col-time">${escapeHtml(formatTime(t.createdAt))}</td>
|
||||||
<td>${sid}</td>
|
<td><span class="c2-status-badge ${escapeHtml(status)}">${escapeHtml(taskStatusLabel(status))}</span></td>
|
||||||
<td>${escapeHtml(t.taskType || '')}</td>
|
<td><span class="c2-task-type-badge c2-task-type-badge--${typeCat}">${escapeHtml(t.taskType || '-')}</span></td>
|
||||||
<td class="c2-task-command-cell" title="${escapeHtml(cmd)}">${cmdShort ? escapeHtml(cmdShort) : '<span class="c2-muted">-</span>'}</td>
|
<td class="c2-tasks-col-command" title="${cmdEsc}">${cmdEsc || dash}</td>
|
||||||
<td><span class="c2-status-badge ${escapeHtml(t.status || '')}">${escapeHtml(taskStatusLabel(t.status))}</span></td>
|
<td class="c2-tasks-col-mono" title="${escapeHtml(sessionFull)}">${sessionShort || dash}</td>
|
||||||
<td>${formatDuration(t.durationMs)}</td>
|
<td class="c2-tasks-col-mono" title="${tid}">${taskShort || dash}</td>
|
||||||
<td>${formatTime(t.createdAt)}</td>
|
<td class="c2-tasks-col-duration">${formatDuration(t.durationMs)}</td>
|
||||||
<td class="c2-task-table-col-actions">
|
<td class="c2-tasks-table-col-actions" onclick="event.stopPropagation();">
|
||||||
<div class="c2-task-table-actions">
|
<div class="c2-tasks-row-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>
|
${canCancel
|
||||||
${t.status === 'queued' || t.status === 'sent'
|
? `<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="btn-danger btn-small" data-c2-task-action="cancel" data-task-id="${escapeHtml(rawId)}">${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>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>`;
|
||||||
`;
|
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
`;
|
`;
|
||||||
C2.syncTasksToolbar();
|
C2.syncTasksToolbar();
|
||||||
if (typeof applyTranslations === 'function') applyTranslations(container);
|
if (typeof applyTranslations === 'function') applyTranslations(container);
|
||||||
|
if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(container);
|
||||||
};
|
};
|
||||||
|
|
||||||
C2.viewTask = function(id) {
|
C2.viewTask = function(id) {
|
||||||
|
|||||||
+354
-10
@@ -80,11 +80,16 @@ let chatAttachmentSeq = 0;
|
|||||||
|
|
||||||
// 对话模式:eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration)
|
// 对话模式:eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration)
|
||||||
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
|
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
|
||||||
|
const AI_CHANNEL_STORAGE_KEY = 'cyberstrike-chat-ai-channel';
|
||||||
const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
|
const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
|
||||||
const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort';
|
const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort';
|
||||||
|
const CHAT_AI_CHANNEL_SUMMARY_NAME_MAX = 10;
|
||||||
const CHAT_AGENT_MODE_EINO_SINGLE = 'eino_single';
|
const CHAT_AGENT_MODE_EINO_SINGLE = 'eino_single';
|
||||||
const CHAT_AGENT_EINO_MODES = ['deep', 'plan_execute', 'supervisor'];
|
const CHAT_AGENT_EINO_MODES = ['deep', 'plan_execute', 'supervisor'];
|
||||||
let multiAgentAPIEnabled = false;
|
let multiAgentAPIEnabled = false;
|
||||||
|
let chatAIChannels = {};
|
||||||
|
let chatDefaultAIChannel = '';
|
||||||
|
let chatAIChannelIdByNormalizedId = {};
|
||||||
|
|
||||||
// 人机协同(HITL)会话级配置
|
// 人机协同(HITL)会话级配置
|
||||||
const HITL_STORAGE_PREFIX = 'cyberstrike-chat-hitl';
|
const HITL_STORAGE_PREFIX = 'cyberstrike-chat-hitl';
|
||||||
@@ -96,6 +101,190 @@ const HITL_MODE_APPROVAL = 'approval';
|
|||||||
const HITL_MODE_REVIEW_EDIT = 'review_edit';
|
const HITL_MODE_REVIEW_EDIT = 'review_edit';
|
||||||
const HITL_MODE_OPTIONS = [HITL_MODE_OFF, HITL_MODE_APPROVAL, HITL_MODE_REVIEW_EDIT];
|
const HITL_MODE_OPTIONS = [HITL_MODE_OFF, HITL_MODE_APPROVAL, HITL_MODE_REVIEW_EDIT];
|
||||||
let hitlApplyFeedbackTimer = null;
|
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 样式共用) */
|
/** 非阻塞提示(与 chat-files-toast 样式共用) */
|
||||||
function showChatToast(message, type) {
|
function showChatToast(message, type) {
|
||||||
@@ -391,7 +580,7 @@ function readHitlConfigFromForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateHitlStatusUI(_cfg) {
|
function updateHitlStatusUI(_cfg) {
|
||||||
/* 侧栏已改为「应用」按钮生效,不再用角标展示模式 */
|
/* 侧栏已改为自动保存,不再用角标展示模式 */
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyHitlConfigToUI(cfg) {
|
function applyHitlConfigToUI(cfg) {
|
||||||
@@ -409,6 +598,7 @@ function applyHitlConfigToUI(cfg) {
|
|||||||
}
|
}
|
||||||
if (toolsEl) toolsEl.value = toolsVal;
|
if (toolsEl) toolsEl.value = toolsVal;
|
||||||
updateHitlStatusUI(conf);
|
updateHitlStatusUI(conf);
|
||||||
|
refreshSessionSettingsSelects();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindHitlSidebarModeListener() {
|
function bindHitlSidebarModeListener() {
|
||||||
@@ -417,6 +607,9 @@ function bindHitlSidebarModeListener() {
|
|||||||
modeEl.dataset.hitlModeBound = '1';
|
modeEl.dataset.hitlModeBound = '1';
|
||||||
modeEl.addEventListener('change', function () {
|
modeEl.addEventListener('change', function () {
|
||||||
applyHitlConfigToUI(readHitlConfigFromForm());
|
applyHitlConfigToUI(readHitlConfigFromForm());
|
||||||
|
refreshSessionSettingsSelects();
|
||||||
|
scheduleHitlSidebarAutosave(0);
|
||||||
|
updateChatReasoningSummary();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +651,7 @@ function showHitlApplyFeedback(text, isError, partial) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 侧栏人机协同:修改模式/白名单后点此写入本地、合并展示并同步服务端 */
|
/** 侧栏人机协同:自动写入本地、合并展示并尽量同步服务端 */
|
||||||
async function applyHitlSidebarConfig() {
|
async function applyHitlSidebarConfig() {
|
||||||
const btn = document.getElementById('hitl-apply-btn');
|
const btn = document.getElementById('hitl-apply-btn');
|
||||||
showHitlApplyFeedback('', false);
|
showHitlApplyFeedback('', false);
|
||||||
@@ -486,7 +679,7 @@ async function applyHitlSidebarConfig() {
|
|||||||
const ok = typeof window.t === 'function' ? window.t('chat.hitlApplyOkSync') : '人机协同配置已保存并同步到服务器。';
|
const ok = typeof window.t === 'function' ? window.t('chat.hitlApplyOkSync') : '人机协同配置已保存并同步到服务器。';
|
||||||
showHitlApplyFeedback(ok, false);
|
showHitlApplyFeedback(ok, false);
|
||||||
} else if (yamlMerged) {
|
} else if (yamlMerged) {
|
||||||
const okYaml = typeof window.t === 'function' ? window.t('chat.hitlApplyOkWhitelistYaml') : '免审批工具已合并进 config.yaml 并生效。协同模式、超时等仍须选中会话后再点「应用」才会写入服务器。';
|
const okYaml = typeof window.t === 'function' ? window.t('chat.hitlApplyOkWhitelistYaml') : '免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。';
|
||||||
showHitlApplyFeedback(okYaml, false);
|
showHitlApplyFeedback(okYaml, false);
|
||||||
} else {
|
} else {
|
||||||
const localOnly = typeof window.t === 'function' ? window.t('chat.hitlApplyOkLocal') : '已保存到本浏览器。';
|
const localOnly = typeof window.t === 'function' ? window.t('chat.hitlApplyOkLocal') : '已保存到本浏览器。';
|
||||||
@@ -505,6 +698,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 */
|
/** 将 localStorage 规范为 eino_single | deep | plan_execute | supervisor */
|
||||||
function chatAgentModeNormalizeStored(stored, cfg) {
|
function chatAgentModeNormalizeStored(stored, cfg) {
|
||||||
const pub = cfg && cfg.multi_agent ? cfg.multi_agent : null;
|
const pub = cfg && cfg.multi_agent ? cfg.multi_agent : null;
|
||||||
@@ -536,6 +752,7 @@ if (typeof window !== 'undefined') {
|
|||||||
window.getHitlConfigForConversation = getHitlConfigForConversation;
|
window.getHitlConfigForConversation = getHitlConfigForConversation;
|
||||||
bindHitlSidebarModeListener();
|
bindHitlSidebarModeListener();
|
||||||
bindHitlReviewerToggleListeners();
|
bindHitlReviewerToggleListeners();
|
||||||
|
bindHitlSensitiveToolsAutosaveListener();
|
||||||
window.setHitlReviewerUI = setHitlReviewerUI;
|
window.setHitlReviewerUI = setHitlReviewerUI;
|
||||||
window.onHitlReviewerChanged = onHitlReviewerChanged;
|
window.onHitlReviewerChanged = onHitlReviewerChanged;
|
||||||
window.bindHitlReviewerToggleListeners = bindHitlReviewerToggleListeners;
|
window.bindHitlReviewerToggleListeners = bindHitlReviewerToggleListeners;
|
||||||
@@ -638,10 +855,85 @@ function syncReasoningRowVisibility(modeVal) {
|
|||||||
if (!show) {
|
if (!show) {
|
||||||
closeChatReasoningPanel();
|
closeChatReasoningPanel();
|
||||||
} else {
|
} else {
|
||||||
|
syncChatReasoningBarHeight();
|
||||||
updateChatReasoningSummary();
|
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) {
|
function reasoningSummaryModeLabel(mode) {
|
||||||
const m = (mode || 'default').trim();
|
const m = (mode || 'default').trim();
|
||||||
const t = (typeof window.t === 'function') ? window.t : function (k) { return k; };
|
const t = (typeof window.t === 'function') ? window.t : function (k) { return k; };
|
||||||
@@ -662,8 +954,18 @@ function updateChatReasoningSummary() {
|
|||||||
const effort = effEl && effEl.value ? String(effEl.value).trim() : '';
|
const effort = effEl && effEl.value ? String(effEl.value).trim() : '';
|
||||||
const t = (typeof window.t === 'function') ? window.t : function (k) { return k; };
|
const t = (typeof window.t === 'function') ? window.t : function (k) { return k; };
|
||||||
const modePart = reasoningSummaryModeLabel(mode);
|
const modePart = reasoningSummaryModeLabel(mode);
|
||||||
const effPart = effort || t('chat.reasoningSummaryDash');
|
const reasoningPart = effort || modePart || t('chat.reasoningSummaryDash');
|
||||||
el.textContent = modePart + ' / ' + effPart;
|
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() {
|
function closeChatReasoningPanel() {
|
||||||
@@ -677,6 +979,7 @@ function toggleConversationReasoningCard() {
|
|||||||
const wrap = document.getElementById('chat-reasoning-wrapper');
|
const wrap = document.getElementById('chat-reasoning-wrapper');
|
||||||
const toggle = document.getElementById('conversation-reasoning-toggle');
|
const toggle = document.getElementById('conversation-reasoning-toggle');
|
||||||
if (!wrap || !toggle) return;
|
if (!wrap || !toggle) return;
|
||||||
|
syncChatReasoningBarHeight();
|
||||||
wrap.classList.toggle('conversation-reasoning-collapsed');
|
wrap.classList.toggle('conversation-reasoning-collapsed');
|
||||||
const collapsed = wrap.classList.contains('conversation-reasoning-collapsed');
|
const collapsed = wrap.classList.contains('conversation-reasoning-collapsed');
|
||||||
toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||||
@@ -711,6 +1014,7 @@ function restoreChatReasoningControlsFromStorage() {
|
|||||||
e.value = v;
|
e.value = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
refreshSessionSettingsSelects();
|
||||||
updateChatReasoningSummary();
|
updateChatReasoningSummary();
|
||||||
} catch (err) { /* ignore */ }
|
} catch (err) { /* ignore */ }
|
||||||
}
|
}
|
||||||
@@ -721,6 +1025,7 @@ function persistChatReasoningPrefs() {
|
|||||||
const elEff = document.getElementById('chat-reasoning-effort');
|
const elEff = document.getElementById('chat-reasoning-effort');
|
||||||
if (m) localStorage.setItem(REASONING_MODE_LS, m.value || 'default');
|
if (m) localStorage.setItem(REASONING_MODE_LS, m.value || 'default');
|
||||||
if (elEff) localStorage.setItem(REASONING_EFFORT_LS, elEff.value || '');
|
if (elEff) localStorage.setItem(REASONING_EFFORT_LS, elEff.value || '');
|
||||||
|
refreshSessionSettingsSelects();
|
||||||
updateChatReasoningSummary();
|
updateChatReasoningSummary();
|
||||||
} catch (err) { /* ignore */ }
|
} catch (err) { /* ignore */ }
|
||||||
}
|
}
|
||||||
@@ -746,12 +1051,15 @@ function buildReasoningRequestPayload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
window.persistChatAIChannelPref = persistChatAIChannelPref;
|
||||||
|
window.populateChatAIChannelSelect = populateChatAIChannelSelect;
|
||||||
window.persistChatReasoningPrefs = persistChatReasoningPrefs;
|
window.persistChatReasoningPrefs = persistChatReasoningPrefs;
|
||||||
window.buildReasoningRequestPayload = buildReasoningRequestPayload;
|
window.buildReasoningRequestPayload = buildReasoningRequestPayload;
|
||||||
window.closeChatReasoningPanel = closeChatReasoningPanel;
|
window.closeChatReasoningPanel = closeChatReasoningPanel;
|
||||||
window.toggleChatReasoningPanel = toggleChatReasoningPanel;
|
window.toggleChatReasoningPanel = toggleChatReasoningPanel;
|
||||||
window.toggleConversationReasoningCard = toggleConversationReasoningCard;
|
window.toggleConversationReasoningCard = toggleConversationReasoningCard;
|
||||||
window.updateChatReasoningSummary = updateChatReasoningSummary;
|
window.updateChatReasoningSummary = updateChatReasoningSummary;
|
||||||
|
window.refreshSessionSettingsSelects = refreshSessionSettingsSelects;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAgentModePanel() {
|
function closeAgentModePanel() {
|
||||||
@@ -826,6 +1134,7 @@ async function initChatAgentModeFromConfig() {
|
|||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
const cfg = await r.json();
|
const cfg = await r.json();
|
||||||
multiAgentAPIEnabled = !!(cfg.multi_agent && cfg.multi_agent.enabled);
|
multiAgentAPIEnabled = !!(cfg.multi_agent && cfg.multi_agent.enabled);
|
||||||
|
populateChatAIChannelSelect(cfg.ai || {});
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.__csaiMultiAgentPublic = cfg.multi_agent || null;
|
window.__csaiMultiAgentPublic = cfg.multi_agent || null;
|
||||||
const tw = cfg.hitl && cfg.hitl.tool_whitelist;
|
const tw = cfg.hitl && cfg.hitl.tool_whitelist;
|
||||||
@@ -1042,10 +1351,20 @@ async function sendMessage() {
|
|||||||
conversationId: currentConversationId,
|
conversationId: currentConversationId,
|
||||||
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
|
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
|
||||||
};
|
};
|
||||||
|
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') {
|
if (!currentConversationId && typeof getActiveProjectId === 'function') {
|
||||||
const pid = getActiveProjectId();
|
const pid = getActiveProjectId();
|
||||||
if (pid) body.projectId = pid;
|
if (pid) body.projectId = pid;
|
||||||
}
|
}
|
||||||
|
const aiChannelId = selectedChatAIChannelId();
|
||||||
|
if (aiChannelId) {
|
||||||
|
body.aiChannelId = aiChannelId;
|
||||||
|
}
|
||||||
const hitlCfg = readHitlConfigFromForm();
|
const hitlCfg = readHitlConfigFromForm();
|
||||||
if (normalizeHitlMode(hitlCfg.mode) !== HITL_MODE_OFF) {
|
if (normalizeHitlMode(hitlCfg.mode) !== HITL_MODE_OFF) {
|
||||||
const sensitiveTools = hitlToolsSplitToArray(hitlCfg.sensitiveTools || '');
|
const sensitiveTools = hitlToolsSplitToArray(hitlCfg.sensitiveTools || '');
|
||||||
@@ -1078,7 +1397,7 @@ async function sendMessage() {
|
|||||||
window.CyberStrikeChatScroll.onUserSendMessage();
|
window.CyberStrikeChatScroll.onUserSendMessage();
|
||||||
}
|
}
|
||||||
const progressElement = document.getElementById(progressId);
|
const progressElement = document.getElementById(progressId);
|
||||||
registerProgressTask(progressId, currentConversationId);
|
registerProgressTask(progressId, streamConversationId);
|
||||||
loadActiveTasks();
|
loadActiveTasks();
|
||||||
let assistantMessageId = null;
|
let assistantMessageId = null;
|
||||||
let mcpExecutionIds = [];
|
let mcpExecutionIds = [];
|
||||||
@@ -1105,7 +1424,7 @@ async function sendMessage() {
|
|||||||
|
|
||||||
window.__csAgentLiveStream = {
|
window.__csAgentLiveStream = {
|
||||||
active: true,
|
active: true,
|
||||||
conversationId: currentConversationId || null,
|
conversationId: streamConversationId || null,
|
||||||
progressId: progressId
|
progressId: progressId
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
@@ -1117,9 +1436,26 @@ async function sendMessage() {
|
|||||||
if (eventData && eventData.type === 'done') {
|
if (eventData && eventData.type === 'done') {
|
||||||
streamSawDone = true;
|
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,
|
handleStreamEvent(eventData, progressElement, progressId,
|
||||||
() => assistantMessageId, (id) => { assistantMessageId = id; },
|
() => assistantMessageId, (id) => { assistantMessageId = id; },
|
||||||
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
|
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; },
|
||||||
|
{ conversationId: streamConversationId });
|
||||||
};
|
};
|
||||||
const processSseLines = typeof processSseDataLinesYielding === 'function'
|
const processSseLines = typeof processSseDataLinesYielding === 'function'
|
||||||
? processSseDataLinesYielding
|
? processSseDataLinesYielding
|
||||||
@@ -1157,13 +1493,13 @@ async function sendMessage() {
|
|||||||
if (typeof loadActiveTasks === 'function') {
|
if (typeof loadActiveTasks === 'function') {
|
||||||
loadActiveTasks();
|
loadActiveTasks();
|
||||||
}
|
}
|
||||||
const convId = currentConversationId || (body && body.conversationId) || null;
|
const convId = streamConversationId || (body && body.conversationId) || null;
|
||||||
let attached = false;
|
let attached = false;
|
||||||
if (convId && typeof window.attachRunningTaskEventStream === 'function') {
|
if (convId && typeof window.attachRunningTaskEventStream === 'function') {
|
||||||
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
|
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
|
||||||
attached = await window.attachRunningTaskEventStream(convId).catch(() => false);
|
attached = await window.attachRunningTaskEventStream(convId).catch(() => false);
|
||||||
}
|
}
|
||||||
if (!attached) {
|
if (!attached && isStreamStillVisibleForRequest()) {
|
||||||
const hint = typeof window.t === 'function'
|
const hint = typeof window.t === 'function'
|
||||||
? window.t('chat.streamEndedWithoutDone')
|
? window.t('chat.streamEndedWithoutDone')
|
||||||
: '连接提前结束,未收到任务完成信号。任务可能仍在后端执行,请查看顶部运行中任务或刷新当前对话。';
|
: '连接提前结束,未收到任务完成信号。任务可能仍在后端执行,请查看顶部运行中任务或刷新当前对话。';
|
||||||
@@ -1186,6 +1522,12 @@ async function sendMessage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!isStreamStillVisibleForRequest()) {
|
||||||
|
if (typeof loadActiveTasks === 'function') {
|
||||||
|
loadActiveTasks();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
removeMessage(progressId);
|
removeMessage(progressId);
|
||||||
const msg = error && error.message != null ? String(error.message) : String(error);
|
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);
|
const isNetwork = /network|fetch|Failed to fetch|aborted|AbortError|load failed|NetworkError/i.test(msg);
|
||||||
@@ -10201,6 +10543,8 @@ function applyCustomIcon() {
|
|||||||
|
|
||||||
// 自定义图标输入框回车键处理
|
// 自定义图标输入框回车键处理
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
initSessionSettingsSelects();
|
||||||
|
initChatReasoningBarHeightSync();
|
||||||
const customInput = document.getElementById('custom-icon-input');
|
const customInput = document.getElementById('custom-icon-input');
|
||||||
if (customInput) {
|
if (customInput) {
|
||||||
customInput.addEventListener('keydown', function(e) {
|
customInput.addEventListener('keydown', function(e) {
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ var dashboardState = {
|
|||||||
lastProjectSummary: null, // 最近一次项目仪表盘摘要(供 Tab 切换时重绘)
|
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() {
|
async function refreshDashboard() {
|
||||||
const runningEl = document.getElementById('dashboard-running-tasks');
|
const runningEl = document.getElementById('dashboard-running-tasks');
|
||||||
const vulnTotalEl = document.getElementById('dashboard-vuln-total');
|
const vulnTotalEl = document.getElementById('dashboard-vuln-total');
|
||||||
@@ -143,11 +153,11 @@ async function refreshDashboard() {
|
|||||||
// External MCP 健康度
|
// External MCP 健康度
|
||||||
fetchJson('/api/external-mcp/stats'),
|
fetchJson('/api/external-mcp/stats'),
|
||||||
// WebShell 已建立的连接(pentest 落地后的 foothold,对运营场景非常关键)
|
// WebShell 已建立的连接(pentest 落地后的 foothold,对运营场景非常关键)
|
||||||
fetchJson('/api/webshell/connections'),
|
fetchJson(dashboardProjectScopedUrl('/api/webshell/connections')),
|
||||||
// C2 仪表盘条:监听器 / 会话 / 待处理任务(任务接口含 pending_queued_count)
|
// C2 仪表盘条:监听器 / 会话 / 待处理任务(任务接口含 pending_queued_count)
|
||||||
fetchJson('/api/c2/listeners'),
|
fetchJson(dashboardProjectScopedUrl('/api/c2/listeners')),
|
||||||
fetchJson('/api/c2/sessions?limit=500'),
|
fetchJson(dashboardProjectScopedUrl('/api/c2/sessions?limit=500')),
|
||||||
fetchJson('/api/c2/tasks?page=1&page_size=1'),
|
fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')),
|
||||||
fetchJson('/api/projects/dashboard-summary?fact_limit=10'),
|
fetchJson('/api/projects/dashboard-summary?fact_limit=10'),
|
||||||
selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null)
|
selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null)
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -615,6 +615,28 @@ function reconcileHitlUiState() {
|
|||||||
|
|
||||||
let hitlFollowRunSeq = 0;
|
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 已断开:轮询任务列表,运行中则拉取过程详情;任务结束后再整页加载会话以对齐终态。
|
* 审批提交后原 SSE 已断开:轮询任务列表,运行中则拉取过程详情;任务结束后再整页加载会话以对齐终态。
|
||||||
*/
|
*/
|
||||||
@@ -855,6 +877,7 @@ async function refreshHitlPending() {
|
|||||||
} else {
|
} else {
|
||||||
container.innerHTML = workflowHtml + (workflowHtml && toolHtml ? '<div class="hitl-pending-section-divider"></div>' : '') + (toolHtml || '');
|
container.innerHTML = workflowHtml + (workflowHtml && toolHtml ? '<div class="hitl-pending-section-divider"></div>' : '') + (toolHtml || '');
|
||||||
}
|
}
|
||||||
|
bindHitlAutoResizeTextareas(container);
|
||||||
renderHitlPendingPagination();
|
renderHitlPendingPagination();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
hitlPendingLoaded = false;
|
hitlPendingLoaded = false;
|
||||||
|
|||||||
+329
-45
@@ -4,6 +4,7 @@ let userInterruptModalPending = null;
|
|||||||
let activeTaskInterval = null;
|
let activeTaskInterval = null;
|
||||||
const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次
|
const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次
|
||||||
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
|
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
|
||||||
|
const hitlInterruptToolItemMap = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主对话 POST 流仍在读取时,禁止再挂 task-events 补流,否则同一事件会画两遍(与 HITL 是否开启无关)。
|
* 主对话 POST 流仍在读取时,禁止再挂 task-events 补流,否则同一事件会画两遍(与 HITL 是否开启无关)。
|
||||||
@@ -19,6 +20,13 @@ function syncAgentLiveStreamConversationId(cid) {
|
|||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setCurrentConversationIdFromStream(cid) {
|
||||||
|
currentConversationId = cid;
|
||||||
|
try {
|
||||||
|
window.currentConversationId = cid;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
function shouldSkipTaskEventReplayAttach(conversationId) {
|
function shouldSkipTaskEventReplayAttach(conversationId) {
|
||||||
try {
|
try {
|
||||||
const live = window.__csAgentLiveStream;
|
const live = window.__csAgentLiveStream;
|
||||||
@@ -1984,7 +1992,32 @@ function formatEinoRunRetryTitle(data) {
|
|||||||
|
|
||||||
// 处理流式事件
|
// 处理流式事件
|
||||||
function handleStreamEvent(event, progressElement, progressId,
|
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'
|
const streamScrollWasPinned = typeof window.captureScrollPinState === 'function'
|
||||||
? window.captureScrollPinState()
|
? window.captureScrollPinState()
|
||||||
: (typeof window.isChatMessagesPinnedToBottom === 'function' ? window.isChatMessagesPinnedToBottom() : true);
|
: (typeof window.isChatMessagesPinnedToBottom === 'function' ? window.isChatMessagesPinnedToBottom() : true);
|
||||||
@@ -2048,7 +2081,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 更新当前对话ID
|
// 更新当前对话ID
|
||||||
currentConversationId = event.data.conversationId;
|
setCurrentConversationIdFromStream(event.data.conversationId);
|
||||||
syncAgentLiveStreamConversationId(event.data.conversationId);
|
syncAgentLiveStreamConversationId(event.data.conversationId);
|
||||||
updateActiveConversation();
|
updateActiveConversation();
|
||||||
addAttackChainButton(currentConversationId);
|
addAttackChainButton(currentConversationId);
|
||||||
@@ -2408,29 +2441,38 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'hitl_interrupt':
|
case 'hitl_interrupt':
|
||||||
const hitlItemId = addTimelineItem(timeline, 'warning', {
|
const hitlTargetItem = findToolCallItemForHitl(timeline, event.data || {});
|
||||||
title: '🧑⚖️ HITL',
|
if (hitlTargetItem && hitlTargetItem.id) {
|
||||||
message: event.message,
|
renderInlineHitlApproval(hitlTargetItem.id, event.data || {});
|
||||||
data: event.data
|
} else {
|
||||||
});
|
const hitlItemId = addTimelineItem(timeline, 'hitl_interrupt', {
|
||||||
renderInlineHitlApproval(hitlItemId, event.data || {});
|
title: '🧑⚖️ HITL',
|
||||||
|
message: event.message,
|
||||||
|
data: event.data
|
||||||
|
});
|
||||||
|
renderInlineHitlApproval(hitlItemId, event.data || {});
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
window.dispatchEvent(new CustomEvent('hitl-interrupt', { detail: event.data || {} }));
|
window.dispatchEvent(new CustomEvent('hitl-interrupt', { detail: event.data || {} }));
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
break;
|
break;
|
||||||
case 'hitl_resumed':
|
case 'hitl_resumed':
|
||||||
addTimelineItem(timeline, 'progress', {
|
if (!resolveInlineHitlDecision(timeline, event.data || {}, 'approve', event.message)) {
|
||||||
title: '✅ HITL',
|
addTimelineItem(timeline, 'progress', {
|
||||||
message: event.message,
|
title: '✅ HITL',
|
||||||
data: event.data
|
message: event.message,
|
||||||
});
|
data: event.data
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'hitl_rejected':
|
case 'hitl_rejected':
|
||||||
addTimelineItem(timeline, 'error', {
|
if (!resolveInlineHitlDecision(timeline, event.data || {}, 'reject', event.message)) {
|
||||||
title: '⛔ HITL',
|
addTimelineItem(timeline, 'error', {
|
||||||
message: event.message,
|
title: '⛔ HITL',
|
||||||
data: event.data
|
message: event.message,
|
||||||
});
|
data: event.data
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'user_interrupt_continue': {
|
case 'user_interrupt_continue': {
|
||||||
@@ -2801,7 +2843,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
updateProgressConversation(progressId, responseData.conversationId);
|
updateProgressConversation(progressId, responseData.conversationId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
currentConversationId = responseData.conversationId;
|
setCurrentConversationIdFromStream(responseData.conversationId);
|
||||||
syncAgentLiveStreamConversationId(responseData.conversationId);
|
syncAgentLiveStreamConversationId(responseData.conversationId);
|
||||||
updateActiveConversation();
|
updateActiveConversation();
|
||||||
addAttackChainButton(currentConversationId);
|
addAttackChainButton(currentConversationId);
|
||||||
@@ -2904,7 +2946,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentConversationId = responseData.conversationId;
|
setCurrentConversationIdFromStream(responseData.conversationId);
|
||||||
syncAgentLiveStreamConversationId(responseData.conversationId);
|
syncAgentLiveStreamConversationId(responseData.conversationId);
|
||||||
updateActiveConversation();
|
updateActiveConversation();
|
||||||
addAttackChainButton(currentConversationId);
|
addAttackChainButton(currentConversationId);
|
||||||
@@ -3064,7 +3106,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
}
|
}
|
||||||
// 更新对话ID
|
// 更新对话ID
|
||||||
if (event.data && event.data.conversationId) {
|
if (event.data && event.data.conversationId) {
|
||||||
currentConversationId = event.data.conversationId;
|
setCurrentConversationIdFromStream(event.data.conversationId);
|
||||||
syncAgentLiveStreamConversationId(event.data.conversationId);
|
syncAgentLiveStreamConversationId(event.data.conversationId);
|
||||||
updateActiveConversation();
|
updateActiveConversation();
|
||||||
addAttackChainButton(currentConversationId);
|
addAttackChainButton(currentConversationId);
|
||||||
@@ -3115,6 +3157,22 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
function renderInlineHitlApproval(itemId, data) {
|
function renderInlineHitlApproval(itemId, data) {
|
||||||
const item = document.getElementById(itemId);
|
const item = document.getElementById(itemId);
|
||||||
if (!item || !data || !data.interruptId) return;
|
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');
|
let contentEl = item.querySelector('.timeline-item-content');
|
||||||
if (!contentEl) {
|
if (!contentEl) {
|
||||||
// warning 等类型默认没有内容区域;HITL 内联审批需要可交互容器
|
// warning 等类型默认没有内容区域;HITL 内联审批需要可交互容器
|
||||||
@@ -3136,31 +3194,165 @@ function renderInlineHitlApproval(itemId, data) {
|
|||||||
const allowEdit = mode === 'review_edit';
|
const allowEdit = mode === 'review_edit';
|
||||||
const argsObj = payload.argumentsObj && typeof payload.argumentsObj === 'object' ? payload.argumentsObj : {};
|
const argsObj = payload.argumentsObj && typeof payload.argumentsObj === 'object' ? payload.argumentsObj : {};
|
||||||
const argsJSON = JSON.stringify(argsObj, null, 2);
|
const argsJSON = JSON.stringify(argsObj, null, 2);
|
||||||
|
const modeLabel = mode === 'review_edit' ? '审查编辑' : '审批模式';
|
||||||
|
|
||||||
const panel = document.createElement('div');
|
const panel = document.createElement('div');
|
||||||
panel.className = 'hitl-inline-approval';
|
panel.className = 'hitl-inline-approval';
|
||||||
panel.innerHTML = `
|
panel.innerHTML = buildInlineHitlApprovalHtml(data, {
|
||||||
<div class="hitl-input-help"><strong>${escapeHtml(toolName)}</strong> 待人工审批。模式:${escapeHtml(mode || '-')}。</div>
|
toolName: toolName,
|
||||||
${allowEdit
|
mode: mode,
|
||||||
? `<div class="hitl-input-help">审查编辑参数(JSON,可选):留空表示沿用原参数。</div>
|
modeLabel: modeLabel,
|
||||||
<textarea class="hitl-edit-args hitl-inline-edit" placeholder='{"command":"ls -la"}'>${escapeHtml(argsJSON === '{}' ? '' : argsJSON)}</textarea>`
|
allowEdit: allowEdit,
|
||||||
: '<div class="hitl-input-help">当前模式不支持改参,仅可通过/拒绝。</div>'
|
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>
|
state.pending = decision === 'approve';
|
||||||
<input class="hitl-config-input hitl-inline-comment" type="text" placeholder="例如:允许只读命令">
|
setToolCallDetailState(item, state);
|
||||||
<div class="hitl-pending-actions">
|
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-secondary hitl-inline-reject">拒绝</button>
|
||||||
<button class="btn-primary hitl-inline-approve">通过</button>
|
<button class="btn-primary hitl-inline-approve">通过</button>
|
||||||
</div>
|
</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 approveBtn = panel.querySelector('.hitl-inline-approve');
|
||||||
const rejectBtn = panel.querySelector('.hitl-inline-reject');
|
const rejectBtn = panel.querySelector('.hitl-inline-reject');
|
||||||
const commentInput = panel.querySelector('.hitl-inline-comment');
|
const commentInput = panel.querySelector('.hitl-inline-comment');
|
||||||
const editInput = panel.querySelector('.hitl-inline-edit');
|
const editInput = panel.querySelector('.hitl-inline-edit');
|
||||||
const statusEl = panel.querySelector('.hitl-inline-status');
|
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) {
|
const setBusy = function (busy) {
|
||||||
approveBtn.disabled = busy;
|
approveBtn.disabled = busy;
|
||||||
@@ -3197,7 +3389,27 @@ function renderInlineHitlApproval(itemId, data) {
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
return;
|
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');
|
panel.classList.add('hitl-inline-done');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
statusEl.textContent = '提交失败:' + (e && e.message ? e.message : 'unknown error');
|
statusEl.textContent = '提交失败:' + (e && e.message ? e.message : 'unknown error');
|
||||||
@@ -3209,6 +3421,19 @@ function renderInlineHitlApproval(itemId, data) {
|
|||||||
rejectBtn.onclick = function () { submit('reject'); };
|
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) {
|
function renderInlineWorkflowHitlApproval(itemId, data) {
|
||||||
const item = document.getElementById(itemId);
|
const item = document.getElementById(itemId);
|
||||||
if (!item || !data) return;
|
if (!item || !data) return;
|
||||||
@@ -3228,15 +3453,28 @@ function renderInlineWorkflowHitlApproval(itemId, data) {
|
|||||||
const panel = document.createElement('div');
|
const panel = document.createElement('div');
|
||||||
panel.className = 'workflow-hitl-inline-approval hitl-inline-approval';
|
panel.className = 'workflow-hitl-inline-approval hitl-inline-approval';
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
<div class="hitl-input-help"><strong>${escapeHtml(label)}</strong> 等待人工审批。</div>
|
<div class="hitl-inline-header">
|
||||||
${prompt ? `<div class="hitl-input-help">${escapeHtml(prompt)}</div>` : ''}
|
<div class="hitl-inline-title">
|
||||||
<div class="hitl-input-help">备注(可选)</div>
|
<span class="hitl-inline-icon" aria-hidden="true">!</span>
|
||||||
<input class="hitl-config-input workflow-hitl-inline-comment" type="text" placeholder="审批意见">
|
<span>工作流审批</span>
|
||||||
<div class="hitl-pending-actions">
|
</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-secondary workflow-hitl-inline-reject">拒绝</button>
|
||||||
<button class="btn-primary workflow-hitl-inline-approve">通过</button>
|
<button class="btn-primary workflow-hitl-inline-approve">通过</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="hitl-input-help workflow-hitl-inline-status"></div>
|
|
||||||
`;
|
`;
|
||||||
contentEl.appendChild(panel);
|
contentEl.appendChild(panel);
|
||||||
|
|
||||||
@@ -3520,8 +3758,8 @@ async function restoreHitlInlineForConversation(conversationId) {
|
|||||||
payload: payloadObj,
|
payload: payloadObj,
|
||||||
conversationId: item.conversationId || conversationId
|
conversationId: item.conversationId || conversationId
|
||||||
};
|
};
|
||||||
let hitlItemEl = detailsContainer.querySelector('[data-hitl-interrupt-id="' + hitlEscapeAttrSelector(String(item.id)) + '"]');
|
let hitlItemEl = null;
|
||||||
if (!hitlItemEl && item.toolCallId) {
|
if (item.toolCallId) {
|
||||||
hitlItemEl = detailsContainer.querySelector('[data-tool-call-id="' + hitlEscapeAttrSelector(String(item.toolCallId)) + '"]');
|
hitlItemEl = detailsContainer.querySelector('[data-tool-call-id="' + hitlEscapeAttrSelector(String(item.toolCallId)) + '"]');
|
||||||
}
|
}
|
||||||
if (!hitlItemEl && item.toolName) {
|
if (!hitlItemEl && item.toolName) {
|
||||||
@@ -3538,6 +3776,9 @@ async function restoreHitlInlineForConversation(conversationId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!hitlItemEl) {
|
||||||
|
hitlItemEl = detailsContainer.querySelector('[data-hitl-interrupt-id="' + hitlEscapeAttrSelector(String(item.id)) + '"]');
|
||||||
|
}
|
||||||
if (!hitlItemEl) continue;
|
if (!hitlItemEl) continue;
|
||||||
renderInlineHitlApproval(hitlItemEl.id, hitlData);
|
renderInlineHitlApproval(hitlItemEl.id, hitlData);
|
||||||
}
|
}
|
||||||
@@ -3683,7 +3924,16 @@ async function attachRunningTaskEventStream(conversationId) {
|
|||||||
if (eventData && eventData.type === 'done') {
|
if (eventData && eventData.type === 'done') {
|
||||||
replaySawDone = true;
|
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) {
|
while (true) {
|
||||||
const chunk = await reader.read();
|
const chunk = await reader.read();
|
||||||
@@ -3842,7 +4092,7 @@ function buildToolResultSectionHtml(data, opts) {
|
|||||||
};
|
};
|
||||||
const execResultLabel = _t('timeline.executionResult');
|
const execResultLabel = _t('timeline.executionResult');
|
||||||
const execIdLabel = _t('timeline.executionId');
|
const execIdLabel = _t('timeline.executionId');
|
||||||
const waitingLabel = _t('timeline.running');
|
const waitingLabel = opts.pendingText || _t('timeline.running');
|
||||||
if (opts.pending) {
|
if (opts.pending) {
|
||||||
return (
|
return (
|
||||||
'<div class="tool-result-section pending">' +
|
'<div class="tool-result-section pending">' +
|
||||||
@@ -3982,21 +4232,55 @@ async function renderToolCallDetailContent(item) {
|
|||||||
buildToolResultSectionHtml(state.resultData, { rawText: state.rawText }) +
|
buildToolResultSectionHtml(state.resultData, { rawText: state.rawText }) +
|
||||||
'</div>';
|
'</div>';
|
||||||
} else if (state.pending !== false) {
|
} 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">' +
|
resultBlock = '<div class="tool-details tool-result-slot">' +
|
||||||
buildToolResultSectionHtml({}, { pending: true }) +
|
buildToolResultSectionHtml({}, pendingOpts) +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
const paramsLabel = typeof window.t === 'function' ? window.t('timeline.params') : '参数:';
|
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 ? '' :
|
const argsBlock = state.hideArgs ? '' :
|
||||||
'<div class="tool-arg-section">' +
|
'<div class="tool-arg-section">' +
|
||||||
'<strong data-i18n="timeline.params">' + escapeHtml(paramsLabel) + '</strong>' +
|
'<strong data-i18n="timeline.params">' + escapeHtml(paramsLabel) + '</strong>' +
|
||||||
|
hitlEditedArgsLabel +
|
||||||
'<pre class="tool-args">' + escapeHtml(JSON.stringify(args, null, 2)) + '</pre>' +
|
'<pre class="tool-args">' + escapeHtml(JSON.stringify(args, null, 2)) + '</pre>' +
|
||||||
'</div>';
|
'</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.appendChild(content);
|
||||||
item.classList.add('tool-call-detail-expanded');
|
item.classList.add('tool-call-detail-expanded');
|
||||||
updateToolDetailToggleLabel(item);
|
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) {
|
if (typeof document !== 'undefined' && !document.__cyberStrikeToolCallDetailToggleBound) {
|
||||||
|
|||||||
@@ -596,8 +596,12 @@ function prefetchProjectsForChat() {
|
|||||||
ensureProjectsLoaded().catch(() => {});
|
ensureProjectsLoaded().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新对话时默认不绑定项目;用户需主动选择后才写入共享黑板 */
|
/** 新对话沿用用户最近选择的项目;没有选择时才保持未绑定。 */
|
||||||
async function ensureDefaultActiveProjectForNewChat() {
|
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('');
|
setActiveProjectId('');
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -988,6 +992,7 @@ function updateProjectStats(stats) {
|
|||||||
|
|
||||||
async function selectProject(id) {
|
async function selectProject(id) {
|
||||||
currentProjectId = id;
|
currentProjectId = id;
|
||||||
|
if (id) setActiveProjectId(id);
|
||||||
projectAssetsPagination.page = 1;
|
projectAssetsPagination.page = 1;
|
||||||
const searchEl = document.getElementById('project-facts-search');
|
const searchEl = document.getElementById('project-facts-search');
|
||||||
const catEl = document.getElementById('project-facts-filter-category');
|
const catEl = document.getElementById('project-facts-filter-category');
|
||||||
|
|||||||
+546
-50
@@ -1,5 +1,9 @@
|
|||||||
// 设置相关功能
|
// 设置相关功能
|
||||||
let currentConfig = null;
|
let currentConfig = null;
|
||||||
|
let selectedAIChannelId = '';
|
||||||
|
const AI_CHANNEL_PROBE_CONCURRENCY = 2;
|
||||||
|
const selectedAIChannelBulkIds = new Set();
|
||||||
|
const aiChannelProbeResults = {};
|
||||||
let allTools = [];
|
let allTools = [];
|
||||||
let alwaysVisibleToolNames = new Set();
|
let alwaysVisibleToolNames = new Set();
|
||||||
let alwaysVisibleBuiltinToolNames = new Set();
|
let alwaysVisibleBuiltinToolNames = new Set();
|
||||||
@@ -668,39 +672,10 @@ async function loadConfig(loadTools = true, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 填充OpenAI配置
|
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||||
const providerEl = document.getElementById('openai-provider');
|
selectedAIChannelId = currentConfig.ai.default_channel;
|
||||||
if (providerEl) {
|
renderAIChannelSelect();
|
||||||
providerEl.value = currentConfig.openai.provider || 'openai';
|
writeAIChannelToMainForm(selectedAIChannelId);
|
||||||
}
|
|
||||||
document.getElementById('openai-api-key').value = currentConfig.openai.api_key || '';
|
|
||||||
document.getElementById('openai-base-url').value = currentConfig.openai.base_url || '';
|
|
||||||
document.getElementById('openai-model').value = currentConfig.openai.model || '';
|
|
||||||
const maxTokensEl = document.getElementById('openai-max-total-tokens');
|
|
||||||
if (maxTokensEl) {
|
|
||||||
maxTokensEl.value = currentConfig.openai.max_total_tokens || 120000;
|
|
||||||
}
|
|
||||||
const orm = currentConfig.openai && currentConfig.openai.reasoning ? currentConfig.openai.reasoning : {};
|
|
||||||
const orModeEl = document.getElementById('openai-reasoning-mode');
|
|
||||||
if (orModeEl) {
|
|
||||||
const mv = (orm.mode || 'auto').toString().trim().toLowerCase();
|
|
||||||
orModeEl.value = ['auto', 'on', 'off'].includes(mv) ? mv : 'auto';
|
|
||||||
}
|
|
||||||
const orEffEl = document.getElementById('openai-reasoning-effort');
|
|
||||||
if (orEffEl) {
|
|
||||||
const ev = (orm.effort || '').toString().trim().toLowerCase();
|
|
||||||
orEffEl.value = ['', 'low', 'medium', 'high', 'max', 'xhigh'].includes(ev) ? ev : '';
|
|
||||||
}
|
|
||||||
const orProfEl = document.getElementById('openai-reasoning-profile');
|
|
||||||
if (orProfEl) {
|
|
||||||
const pv = (orm.profile || 'auto').toString().trim().toLowerCase();
|
|
||||||
const ok = ['auto', 'deepseek_compat', 'openai_compat', 'output_config_effort'];
|
|
||||||
orProfEl.value = ok.includes(pv) ? pv : 'auto';
|
|
||||||
}
|
|
||||||
const orAllowEl = document.getElementById('openai-reasoning-allow-client');
|
|
||||||
if (orAllowEl) {
|
|
||||||
orAllowEl.checked = orm.allow_client_reasoning !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
fillVisionConfigFromCurrent(currentConfig.vision || {});
|
fillVisionConfigFromCurrent(currentConfig.vision || {});
|
||||||
initModelListControls();
|
initModelListControls();
|
||||||
@@ -1897,7 +1872,14 @@ async function applySettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim();
|
const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim();
|
||||||
const prevOpenai = (currentConfig && currentConfig.openai) ? currentConfig.openai : {};
|
if (!currentConfig) currentConfig = {};
|
||||||
|
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||||
|
const activeChannelId = normalizeAIChannelId(selectedAIChannelId || currentConfig.ai.default_channel || 'default');
|
||||||
|
currentConfig.ai.channels[activeChannelId] = readAIChannelFromMainForm(activeChannelId);
|
||||||
|
currentConfig.ai.default_channel = activeChannelId;
|
||||||
|
renderAIChannelSelect();
|
||||||
|
const activeChannel = currentConfig.ai.channels[activeChannelId] || {};
|
||||||
|
const prevOpenai = activeChannel;
|
||||||
const prevRobots = (currentConfig && currentConfig.robots) ? currentConfig.robots : {};
|
const prevRobots = (currentConfig && currentConfig.robots) ? currentConfig.robots : {};
|
||||||
const prevHitl = (currentConfig && currentConfig.hitl) ? currentConfig.hitl : {};
|
const prevHitl = (currentConfig && currentConfig.hitl) ? currentConfig.hitl : {};
|
||||||
const hitlRetentionRaw = document.getElementById('hitl-retention-days')?.value;
|
const hitlRetentionRaw = document.getElementById('hitl-retention-days')?.value;
|
||||||
@@ -1909,21 +1891,7 @@ async function applySettings() {
|
|||||||
return String(s || '').split(/[\n,,]/).map(v => v.trim()).filter(Boolean);
|
return String(s || '').split(/[\n,,]/).map(v => v.trim()).filter(Boolean);
|
||||||
};
|
};
|
||||||
const config = {
|
const config = {
|
||||||
openai: {
|
ai: currentConfig.ai,
|
||||||
...prevOpenai,
|
|
||||||
provider: provider,
|
|
||||||
api_key: apiKey,
|
|
||||||
base_url: baseUrl,
|
|
||||||
model: model,
|
|
||||||
max_total_tokens: parseInt(document.getElementById('openai-max-total-tokens')?.value) || 120000,
|
|
||||||
reasoning: {
|
|
||||||
...(prevOpenai.reasoning || {}),
|
|
||||||
mode: document.getElementById('openai-reasoning-mode')?.value || 'auto',
|
|
||||||
effort: (document.getElementById('openai-reasoning-effort')?.value || '').trim(),
|
|
||||||
profile: document.getElementById('openai-reasoning-profile')?.value || 'auto',
|
|
||||||
allow_client_reasoning: document.getElementById('openai-reasoning-allow-client')?.checked !== false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
vision: visionPayload,
|
vision: visionPayload,
|
||||||
fofa: {
|
fofa: {
|
||||||
api_key: document.getElementById('fofa-api-key')?.value.trim() || '',
|
api_key: document.getElementById('fofa-api-key')?.value.trim() || '',
|
||||||
@@ -2438,6 +2406,534 @@ function enhanceModelPickSelect(selectId) {
|
|||||||
syncModelPickDropdown(selectId);
|
syncModelPickDropdown(selectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAIChannelId(name) {
|
||||||
|
const raw = String(name || '').trim().toLowerCase().replace(/_/g, '-');
|
||||||
|
const id = raw.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
return id || 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAIChannelHtml(value) {
|
||||||
|
return String(value == null ? '' : value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAIConfigShape(cfg) {
|
||||||
|
const ai = cfg && cfg.ai && typeof cfg.ai === 'object' ? cfg.ai : {};
|
||||||
|
const channels = ai.channels && typeof ai.channels === 'object' ? { ...ai.channels } : {};
|
||||||
|
let def = normalizeAIChannelId(ai.default_channel || '');
|
||||||
|
if (!channels[def]) {
|
||||||
|
const oa = (cfg && cfg.openai) ? cfg.openai : {};
|
||||||
|
channels[def] = {
|
||||||
|
name: def === 'default' ? 'Default' : def,
|
||||||
|
provider: oa.provider || 'openai',
|
||||||
|
api_key: oa.api_key || '',
|
||||||
|
base_url: oa.base_url || '',
|
||||||
|
model: oa.model || '',
|
||||||
|
max_total_tokens: oa.max_total_tokens || 120000,
|
||||||
|
max_completion_tokens: oa.max_completion_tokens || 0,
|
||||||
|
reasoning: oa.reasoning || {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { default_channel: def, channels };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAIChannelFromMainForm(id) {
|
||||||
|
const prev = currentConfig?.ai?.channels?.[id] || {};
|
||||||
|
const maxCompletionTokens = parseInt(document.getElementById('openai-max-completion-tokens')?.value, 10) || 16384;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
name: (document.getElementById('ai-channel-name')?.value || '').trim() || prev.name || id,
|
||||||
|
provider: document.getElementById('openai-provider')?.value || 'openai',
|
||||||
|
api_key: document.getElementById('openai-api-key')?.value.trim() || '',
|
||||||
|
base_url: document.getElementById('openai-base-url')?.value.trim() || '',
|
||||||
|
model: document.getElementById('openai-model')?.value.trim() || '',
|
||||||
|
max_total_tokens: parseInt(document.getElementById('openai-max-total-tokens')?.value, 10) || 120000,
|
||||||
|
max_completion_tokens: maxCompletionTokens,
|
||||||
|
reasoning: {
|
||||||
|
...(prev.reasoning || {}),
|
||||||
|
mode: document.getElementById('openai-reasoning-mode')?.value || 'auto',
|
||||||
|
effort: (document.getElementById('openai-reasoning-effort')?.value || '').trim(),
|
||||||
|
profile: document.getElementById('openai-reasoning-profile')?.value || 'auto',
|
||||||
|
allow_client_reasoning: document.getElementById('openai-reasoning-allow-client')?.checked !== false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAIChannelToMainForm(id) {
|
||||||
|
const ai = ensureAIConfigShape(currentConfig || {});
|
||||||
|
const ch = ai.channels[id] || ai.channels[ai.default_channel] || {};
|
||||||
|
selectedAIChannelId = id || ai.default_channel;
|
||||||
|
const nameEl = document.getElementById('ai-channel-name');
|
||||||
|
if (nameEl) nameEl.value = ch.name || selectedAIChannelId;
|
||||||
|
const providerEl = document.getElementById('openai-provider');
|
||||||
|
if (providerEl) {
|
||||||
|
const provider = (ch.provider === 'openai' || !ch.provider) ? 'openai_compatible' : ch.provider;
|
||||||
|
providerEl.value = provider;
|
||||||
|
}
|
||||||
|
const keyEl = document.getElementById('openai-api-key');
|
||||||
|
if (keyEl) keyEl.value = ch.api_key || '';
|
||||||
|
const baseEl = document.getElementById('openai-base-url');
|
||||||
|
if (baseEl) baseEl.value = ch.base_url || '';
|
||||||
|
const modelEl = document.getElementById('openai-model');
|
||||||
|
if (modelEl) modelEl.value = ch.model || '';
|
||||||
|
const maxTokensEl = document.getElementById('openai-max-total-tokens');
|
||||||
|
if (maxTokensEl) maxTokensEl.value = ch.max_total_tokens || 120000;
|
||||||
|
const maxCompletionTokensEl = document.getElementById('openai-max-completion-tokens');
|
||||||
|
if (maxCompletionTokensEl) maxCompletionTokensEl.value = ch.max_completion_tokens || 16384;
|
||||||
|
const r = ch.reasoning || {};
|
||||||
|
const modeEl = document.getElementById('openai-reasoning-mode');
|
||||||
|
if (modeEl) modeEl.value = ['auto', 'on', 'off'].includes(String(r.mode || '').toLowerCase()) ? String(r.mode).toLowerCase() : 'auto';
|
||||||
|
const effEl = document.getElementById('openai-reasoning-effort');
|
||||||
|
if (effEl) effEl.value = ['', 'low', 'medium', 'high', 'max', 'xhigh'].includes(String(r.effort || '').toLowerCase()) ? String(r.effort || '').toLowerCase() : '';
|
||||||
|
const profileEl = document.getElementById('openai-reasoning-profile');
|
||||||
|
if (profileEl) profileEl.value = ['auto', 'deepseek_compat', 'openai_compat', 'output_config_effort'].includes(String(r.profile || '').toLowerCase()) ? String(r.profile || '').toLowerCase() : 'auto';
|
||||||
|
const allowEl = document.getElementById('openai-reasoning-allow-client');
|
||||||
|
if (allowEl) allowEl.checked = r.allow_client_reasoning !== false;
|
||||||
|
syncModelListFetchButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAIChannelSelect() {
|
||||||
|
if (!currentConfig) return;
|
||||||
|
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||||
|
const select = document.getElementById('ai-channel-select');
|
||||||
|
if (!select) return;
|
||||||
|
select.innerHTML = '';
|
||||||
|
const ids = Object.keys(currentConfig.ai.channels || {}).sort();
|
||||||
|
ids.forEach((id) => {
|
||||||
|
const ch = currentConfig.ai.channels[id] || {};
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = id;
|
||||||
|
const marker = id === currentConfig.ai.default_channel ? ' *' : '';
|
||||||
|
opt.textContent = `${ch.name || id}${marker} · ${ch.model || '-'}`;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
selectedAIChannelId = selectedAIChannelId && currentConfig.ai.channels[selectedAIChannelId]
|
||||||
|
? selectedAIChannelId
|
||||||
|
: currentConfig.ai.default_channel;
|
||||||
|
select.value = selectedAIChannelId;
|
||||||
|
renderAIChannelList(ids);
|
||||||
|
updateAIChannelEditorChrome(selectedAIChannelId);
|
||||||
|
const countLabel = typeof window.t === 'function'
|
||||||
|
? window.t('settingsBasic.aiChannelCount').replace('{count}', String(ids.length))
|
||||||
|
: `已保存 ${ids.length} 个通道`;
|
||||||
|
showAIChannelSaveHint(countLabel, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelHostLabel(baseUrl) {
|
||||||
|
const raw = String(baseUrl || '').trim();
|
||||||
|
if (!raw) return '-';
|
||||||
|
try {
|
||||||
|
return new URL(raw).host || raw;
|
||||||
|
} catch (e) {
|
||||||
|
return raw.replace(/^https?:\/\//, '').split('/')[0] || raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAIChannelList(ids) {
|
||||||
|
const list = document.getElementById('ai-channel-list');
|
||||||
|
if (!list || !currentConfig?.ai?.channels) return;
|
||||||
|
list.innerHTML = '';
|
||||||
|
(ids || Object.keys(currentConfig.ai.channels).sort()).forEach((id) => {
|
||||||
|
const ch = currentConfig.ai.channels[id] || {};
|
||||||
|
const isDefault = id === currentConfig.ai.default_channel;
|
||||||
|
const isComplete = !validateSelectedAIChannelPayload(ch);
|
||||||
|
const probe = aiChannelProbeResults[id] || null;
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'ai-channel-list-item' + (id === selectedAIChannelId ? ' active' : '') + (selectedAIChannelBulkIds.has(id) ? ' checked' : '');
|
||||||
|
item.setAttribute('role', 'button');
|
||||||
|
item.setAttribute('tabindex', '0');
|
||||||
|
item.setAttribute('aria-current', id === selectedAIChannelId ? 'true' : 'false');
|
||||||
|
item.onclick = () => selectAIChannelForEditing(id);
|
||||||
|
item.onkeydown = (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
selectAIChannelForEditing(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const checkbox = document.createElement('input');
|
||||||
|
checkbox.type = 'checkbox';
|
||||||
|
checkbox.className = 'ai-channel-bulk-check';
|
||||||
|
checkbox.checked = selectedAIChannelBulkIds.has(id);
|
||||||
|
checkbox.setAttribute('aria-label', `选择 ${ch.name || id}`);
|
||||||
|
checkbox.onclick = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (checkbox.checked) {
|
||||||
|
selectedAIChannelBulkIds.add(id);
|
||||||
|
} else {
|
||||||
|
selectedAIChannelBulkIds.delete(id);
|
||||||
|
}
|
||||||
|
item.classList.toggle('checked', checkbox.checked);
|
||||||
|
};
|
||||||
|
const defaultBadge = isDefault ? `<span class="ai-channel-badge">${escapeAIChannelHtml(settingsT('settingsBasic.aiChannelDefaultBadge', '默认'))}</span>` : '';
|
||||||
|
let statusText = isComplete
|
||||||
|
? settingsT('settingsBasic.aiChannelReady', '可用')
|
||||||
|
: settingsT('settingsBasic.aiChannelDraft', '待完善');
|
||||||
|
let statusClass = isComplete ? 'ready' : 'draft';
|
||||||
|
if (probe) {
|
||||||
|
statusText = probe.message || statusText;
|
||||||
|
statusClass = probe.status || statusClass;
|
||||||
|
}
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'ai-channel-card-body';
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="ai-channel-list-main">
|
||||||
|
<span class="ai-channel-status-dot ${statusClass}" aria-hidden="true"></span>
|
||||||
|
<strong title="${escapeAIChannelHtml(ch.name || id)}">${escapeAIChannelHtml(ch.name || id)}</strong>
|
||||||
|
${defaultBadge}
|
||||||
|
</div>
|
||||||
|
<div class="ai-channel-list-meta" title="${escapeAIChannelHtml(ch.model || '-')} · ${escapeAIChannelHtml(channelHostLabel(ch.base_url))}">${escapeAIChannelHtml(ch.model || '-')} · ${escapeAIChannelHtml(channelHostLabel(ch.base_url))}</div>
|
||||||
|
<div class="ai-channel-list-foot">
|
||||||
|
<span class="ai-channel-status-label ${statusClass}" title="${escapeAIChannelHtml(statusText)}">${escapeAIChannelHtml(statusText)}</span>
|
||||||
|
<span title="${escapeAIChannelHtml(id)}">${escapeAIChannelHtml(id)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
item.appendChild(checkbox);
|
||||||
|
item.appendChild(body);
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAIChannelSaveHint(message, ok) {
|
||||||
|
const el = document.getElementById('ai-channel-save-hint');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = message;
|
||||||
|
el.classList.toggle('is-error', ok === false);
|
||||||
|
el.classList.toggle('is-success', ok !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAIChannelEditorChrome(id) {
|
||||||
|
const ai = ensureAIConfigShape(currentConfig || {});
|
||||||
|
const channelId = normalizeAIChannelId(id || ai.default_channel || 'default');
|
||||||
|
const ch = ai.channels[channelId] || {};
|
||||||
|
const title = document.getElementById('ai-channel-editor-title');
|
||||||
|
const meta = document.getElementById('ai-channel-editor-meta');
|
||||||
|
if (title) title.textContent = ch.name || channelId;
|
||||||
|
if (meta) {
|
||||||
|
const parts = [
|
||||||
|
channelId === ai.default_channel
|
||||||
|
? settingsT('settingsBasic.aiChannelDefaultMeta', '默认通道')
|
||||||
|
: settingsT('settingsBasic.aiChannelCustomMeta', '自定义通道'),
|
||||||
|
ch.provider === 'claude' ? 'Claude' : settingsT('settingsBasic.aiChannelOpenAICompat', 'OpenAI 兼容'),
|
||||||
|
ch.model || settingsT('settingsBasic.aiChannelModelMissing', '未填写模型'),
|
||||||
|
channelHostLabel(ch.base_url)
|
||||||
|
].filter(Boolean);
|
||||||
|
meta.textContent = parts.join(' / ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSelectedAIChannelPayload(ch) {
|
||||||
|
const missing = [];
|
||||||
|
if (!String(ch.base_url || '').trim()) missing.push('Base URL');
|
||||||
|
if (!String(ch.api_key || '').trim()) missing.push('API Key');
|
||||||
|
if (!String(ch.model || '').trim()) missing.push('模型');
|
||||||
|
if (missing.length) {
|
||||||
|
return missing.join(', ');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistAIChannelsToServer(successMessage, options = {}) {
|
||||||
|
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return false;
|
||||||
|
if (!currentConfig) currentConfig = {};
|
||||||
|
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||||
|
const id = normalizeAIChannelId(selectedAIChannelId || currentConfig.ai.default_channel || 'default');
|
||||||
|
const channelPayload = readAIChannelFromMainForm(id);
|
||||||
|
currentConfig.ai.channels[id] = channelPayload;
|
||||||
|
selectedAIChannelId = id;
|
||||||
|
const missing = validateSelectedAIChannelPayload(channelPayload);
|
||||||
|
if (missing) {
|
||||||
|
showAIChannelSaveHint(`请填写:${missing}`, false);
|
||||||
|
alert(`请填写:${missing}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
renderAIChannelSelect();
|
||||||
|
showAIChannelSaveHint('正在保存通道...', true);
|
||||||
|
try {
|
||||||
|
const shouldMergeLatest = options.mergeLatest !== false;
|
||||||
|
const latestResponse = shouldMergeLatest ? await apiFetch('/api/config') : null;
|
||||||
|
if (latestResponse && latestResponse.ok) {
|
||||||
|
const latest = await latestResponse.json();
|
||||||
|
const latestAI = ensureAIConfigShape(latest || {});
|
||||||
|
currentConfig.ai.channels = {
|
||||||
|
...(latestAI.channels || {}),
|
||||||
|
...(currentConfig.ai.channels || {}),
|
||||||
|
[id]: channelPayload
|
||||||
|
};
|
||||||
|
if (!currentConfig.ai.default_channel) {
|
||||||
|
currentConfig.ai.default_channel = latestAI.default_channel || id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updateResponse = await apiFetch('/api/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ai: currentConfig.ai })
|
||||||
|
});
|
||||||
|
if (!updateResponse.ok) {
|
||||||
|
const error = await updateResponse.json().catch(() => ({}));
|
||||||
|
throw new Error(error.error || '保存通道失败');
|
||||||
|
}
|
||||||
|
const applyResponse = await apiFetch('/api/config/apply', { method: 'POST' });
|
||||||
|
if (!applyResponse.ok) {
|
||||||
|
const error = await applyResponse.json().catch(() => ({}));
|
||||||
|
throw new Error(error.error || '应用通道失败');
|
||||||
|
}
|
||||||
|
const response = await apiFetch('/api/config');
|
||||||
|
if (response.ok) {
|
||||||
|
currentConfig = await response.json();
|
||||||
|
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||||
|
selectedAIChannelId = currentConfig.ai.channels[id] ? id : currentConfig.ai.default_channel;
|
||||||
|
renderAIChannelSelect();
|
||||||
|
writeAIChannelToMainForm(selectedAIChannelId);
|
||||||
|
if (typeof populateChatAIChannelSelect === 'function') {
|
||||||
|
populateChatAIChannelSelect(currentConfig.ai);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showAIChannelSaveHint(successMessage || '通道已保存', true);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
showAIChannelSaveHint(error.message || '保存通道失败', false);
|
||||||
|
alert(error.message || '保存通道失败');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistAIConfigOnlyToServer(successMessage) {
|
||||||
|
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return false;
|
||||||
|
if (!currentConfig) return false;
|
||||||
|
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||||
|
showAIChannelSaveHint('正在保存通道...', true);
|
||||||
|
try {
|
||||||
|
const updateResponse = await apiFetch('/api/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ai: currentConfig.ai })
|
||||||
|
});
|
||||||
|
if (!updateResponse.ok) {
|
||||||
|
const error = await updateResponse.json().catch(() => ({}));
|
||||||
|
throw new Error(error.error || '保存通道失败');
|
||||||
|
}
|
||||||
|
const applyResponse = await apiFetch('/api/config/apply', { method: 'POST' });
|
||||||
|
if (!applyResponse.ok) {
|
||||||
|
const error = await applyResponse.json().catch(() => ({}));
|
||||||
|
throw new Error(error.error || '应用通道失败');
|
||||||
|
}
|
||||||
|
if (typeof populateChatAIChannelSelect === 'function') {
|
||||||
|
populateChatAIChannelSelect(currentConfig.ai);
|
||||||
|
}
|
||||||
|
showAIChannelSaveHint(successMessage || '通道已保存', true);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
showAIChannelSaveHint(error.message || '保存通道失败', false);
|
||||||
|
alert(error.message || '保存通道失败');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSelectedAIChannel() {
|
||||||
|
await persistAIChannelsToServer(typeof window.t === 'function' ? window.t('settingsBasic.aiChannelSaved') : '通道已保存');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setSelectedAIChannelDefault() {
|
||||||
|
if (!currentConfig) return;
|
||||||
|
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||||
|
const id = normalizeAIChannelId(selectedAIChannelId || currentConfig.ai.default_channel || 'default');
|
||||||
|
currentConfig.ai.channels[id] = readAIChannelFromMainForm(id);
|
||||||
|
currentConfig.ai.default_channel = id;
|
||||||
|
await persistAIChannelsToServer(typeof window.t === 'function' ? window.t('settingsBasic.aiChannelDefaultSaved') : '已设为默认通道');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAIChannelForEditing(id) {
|
||||||
|
if (!currentConfig) return;
|
||||||
|
if (selectedAIChannelId && currentConfig.ai?.channels?.[selectedAIChannelId]) {
|
||||||
|
currentConfig.ai.channels[selectedAIChannelId] = readAIChannelFromMainForm(selectedAIChannelId);
|
||||||
|
}
|
||||||
|
const next = normalizeAIChannelId(id || currentConfig.ai?.default_channel || 'default');
|
||||||
|
selectedAIChannelId = next;
|
||||||
|
writeAIChannelToMainForm(next);
|
||||||
|
renderAIChannelSelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueAIChannelId(base) {
|
||||||
|
const ai = ensureAIConfigShape(currentConfig || {});
|
||||||
|
let id = normalizeAIChannelId(base);
|
||||||
|
if (!ai.channels[id]) return id;
|
||||||
|
let i = 2;
|
||||||
|
while (ai.channels[`${id}-${i}`]) i++;
|
||||||
|
return `${id}-${i}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAIChannelFromForm() {
|
||||||
|
if (!currentConfig) currentConfig = {};
|
||||||
|
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||||
|
if (selectedAIChannelId && currentConfig.ai.channels[selectedAIChannelId]) {
|
||||||
|
currentConfig.ai.channels[selectedAIChannelId] = readAIChannelFromMainForm(selectedAIChannelId);
|
||||||
|
}
|
||||||
|
const baseName = (typeof window.t === 'function' ? window.t('settingsBasic.aiChannelUntitled') : 'New Channel');
|
||||||
|
const id = uniqueAIChannelId(baseName);
|
||||||
|
currentConfig.ai.channels[id] = {
|
||||||
|
name: baseName,
|
||||||
|
provider: 'openai_compatible',
|
||||||
|
api_key: '',
|
||||||
|
base_url: '',
|
||||||
|
model: '',
|
||||||
|
max_total_tokens: 120000,
|
||||||
|
max_completion_tokens: 16384,
|
||||||
|
reasoning: { mode: 'auto', effort: '', profile: 'auto', allow_client_reasoning: true }
|
||||||
|
};
|
||||||
|
selectedAIChannelId = id;
|
||||||
|
renderAIChannelSelect();
|
||||||
|
writeAIChannelToMainForm(id);
|
||||||
|
showAIChannelSaveHint('新通道尚未保存,填写后点击「保存更改」。', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyAIChannelFromForm() {
|
||||||
|
if (!currentConfig) return;
|
||||||
|
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||||
|
const source = readAIChannelFromMainForm(selectedAIChannelId || currentConfig.ai.default_channel);
|
||||||
|
const id = uniqueAIChannelId((source.name || selectedAIChannelId || 'channel') + '-copy');
|
||||||
|
currentConfig.ai.channels[id] = { ...source, name: (source.name || id) + ' Copy' };
|
||||||
|
selectedAIChannelId = id;
|
||||||
|
renderAIChannelSelect();
|
||||||
|
writeAIChannelToMainForm(id);
|
||||||
|
showAIChannelSaveHint('复制的通道尚未保存,确认后点击「保存更改」。', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSelectedAIChannel() {
|
||||||
|
if (!currentConfig) return;
|
||||||
|
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||||
|
const ids = Object.keys(currentConfig.ai.channels || {});
|
||||||
|
if (ids.length <= 1) {
|
||||||
|
alert('至少保留一个 AI 通道');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = selectedAIChannelId || currentConfig.ai.default_channel;
|
||||||
|
const ch = currentConfig.ai.channels[id] || {};
|
||||||
|
const name = ch.name || id;
|
||||||
|
const msg = typeof window.t === 'function'
|
||||||
|
? window.t('settingsBasic.aiChannelDeleteConfirm').replace('{name}', name)
|
||||||
|
: `确定删除 AI 通道「${name}」吗?`;
|
||||||
|
if (!confirm(msg)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete currentConfig.ai.channels[id];
|
||||||
|
currentConfig.ai.default_channel = Object.keys(currentConfig.ai.channels).sort()[0];
|
||||||
|
renderAIChannelSelect();
|
||||||
|
writeAIChannelToMainForm(currentConfig.ai.default_channel);
|
||||||
|
persistAIChannelsToServer('通道已删除', { mergeLatest: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedOrAllAIChannelIdsForProbe() {
|
||||||
|
if (!currentConfig) return [];
|
||||||
|
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||||
|
if (selectedAIChannelId && currentConfig.ai.channels[selectedAIChannelId]) {
|
||||||
|
currentConfig.ai.channels[selectedAIChannelId] = readAIChannelFromMainForm(selectedAIChannelId);
|
||||||
|
}
|
||||||
|
const checked = Array.from(selectedAIChannelBulkIds).filter((id) => currentConfig.ai.channels[id]);
|
||||||
|
const ids = checked.length ? checked : Object.keys(currentConfig.ai.channels || {}).sort();
|
||||||
|
return ids.filter((id) => !validateSelectedAIChannelPayload(currentConfig.ai.channels[id] || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeSelectedAIChannels() {
|
||||||
|
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return;
|
||||||
|
const ids = selectedOrAllAIChannelIdsForProbe();
|
||||||
|
if (!ids.length) {
|
||||||
|
alert('没有可探活的完整通道,请先填写 Base URL、API Key 和模型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showAIChannelSaveHint(`正在探活 ${ids.length} 个通道...`, true);
|
||||||
|
ids.forEach((id) => {
|
||||||
|
aiChannelProbeResults[id] = { status: 'testing', message: '测试中...' };
|
||||||
|
});
|
||||||
|
renderAIChannelList();
|
||||||
|
let okCount = 0;
|
||||||
|
let nextIndex = 0;
|
||||||
|
async function probeNextAIChannel() {
|
||||||
|
const id = ids[nextIndex++];
|
||||||
|
if (!id) return;
|
||||||
|
const ch = currentConfig.ai.channels[id] || {};
|
||||||
|
try {
|
||||||
|
const response = await apiFetch('/api/config/test-openai', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: ch.provider || 'openai_compatible',
|
||||||
|
base_url: ch.base_url || '',
|
||||||
|
api_key: ch.api_key || '',
|
||||||
|
model: ch.model || ''
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const result = await response.json().catch(() => ({}));
|
||||||
|
if (response.ok && result.success) {
|
||||||
|
okCount += 1;
|
||||||
|
const latency = result.latency_ms ? ` ${result.latency_ms}ms` : '';
|
||||||
|
aiChannelProbeResults[id] = { status: 'ready', message: `可用${latency}` };
|
||||||
|
} else {
|
||||||
|
aiChannelProbeResults[id] = { status: 'failed', message: (result.error || '连接失败') };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
aiChannelProbeResults[id] = { status: 'failed', message: error.message || '测试出错' };
|
||||||
|
}
|
||||||
|
renderAIChannelList();
|
||||||
|
}
|
||||||
|
const workers = Array.from({ length: Math.min(AI_CHANNEL_PROBE_CONCURRENCY, ids.length) }, async function () {
|
||||||
|
while (nextIndex < ids.length) {
|
||||||
|
await probeNextAIChannel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
showAIChannelSaveHint(`探活完成:${okCount}/${ids.length} 可用`, okCount === ids.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCheckedAIChannels() {
|
||||||
|
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return;
|
||||||
|
if (!currentConfig) return;
|
||||||
|
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||||
|
const ids = Array.from(selectedAIChannelBulkIds).filter((id) => currentConfig.ai.channels[id]);
|
||||||
|
if (!ids.length) {
|
||||||
|
alert('请先勾选要删除的通道');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deletable = ids.filter((id) => id !== currentConfig.ai.default_channel);
|
||||||
|
if (!deletable.length) {
|
||||||
|
alert('默认通道不能批量删除,请先切换默认通道');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Object.keys(currentConfig.ai.channels || {}).length - deletable.length < 1) {
|
||||||
|
alert('至少保留一个 AI 通道');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const names = deletable.map((id) => currentConfig.ai.channels[id]?.name || id).join('、');
|
||||||
|
if (!confirm(`确定删除 ${deletable.length} 个 AI 通道吗?\n${names}`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deletable.forEach((id) => {
|
||||||
|
delete currentConfig.ai.channels[id];
|
||||||
|
selectedAIChannelBulkIds.delete(id);
|
||||||
|
delete aiChannelProbeResults[id];
|
||||||
|
});
|
||||||
|
if (!currentConfig.ai.channels[selectedAIChannelId]) {
|
||||||
|
selectedAIChannelId = currentConfig.ai.default_channel;
|
||||||
|
}
|
||||||
|
renderAIChannelSelect();
|
||||||
|
writeAIChannelToMainForm(selectedAIChannelId);
|
||||||
|
await persistAIConfigOnlyToServer(`已删除 ${deletable.length} 个通道`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.selectAIChannelForEditing = selectAIChannelForEditing;
|
||||||
|
window.saveSelectedAIChannel = saveSelectedAIChannel;
|
||||||
|
window.setSelectedAIChannelDefault = setSelectedAIChannelDefault;
|
||||||
|
window.createAIChannelFromForm = createAIChannelFromForm;
|
||||||
|
window.copyAIChannelFromForm = copyAIChannelFromForm;
|
||||||
|
window.deleteSelectedAIChannel = deleteSelectedAIChannel;
|
||||||
|
window.probeSelectedAIChannels = probeSelectedAIChannels;
|
||||||
|
window.deleteCheckedAIChannels = deleteCheckedAIChannels;
|
||||||
|
}
|
||||||
|
|
||||||
function initModelListControls() {
|
function initModelListControls() {
|
||||||
const providerEl = document.getElementById('openai-provider');
|
const providerEl = document.getElementById('openai-provider');
|
||||||
if (providerEl && !providerEl.dataset.modelListBound) {
|
if (providerEl && !providerEl.dataset.modelListBound) {
|
||||||
@@ -2941,7 +3437,7 @@ async function saveToolsConfig() {
|
|||||||
|
|
||||||
// 构建只包含工具配置的配置对象
|
// 构建只包含工具配置的配置对象
|
||||||
const config = {
|
const config = {
|
||||||
openai: currentConfig.openai || {},
|
ai: ensureAIConfigShape(currentConfig || {}),
|
||||||
agent: currentConfig.agent || {},
|
agent: currentConfig.agent || {},
|
||||||
multi_agent: {
|
multi_agent: {
|
||||||
enabled: currentConfig?.multi_agent?.enabled === true,
|
enabled: currentConfig?.multi_agent?.enabled === true,
|
||||||
|
|||||||
+210
-3
@@ -622,7 +622,8 @@ function getWebshellConnections() {
|
|||||||
if (typeof apiFetch === 'undefined') {
|
if (typeof apiFetch === 'undefined') {
|
||||||
return Promise.resolve([]);
|
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 (r) { return r.json(); })
|
||||||
.then(function (list) { return Array.isArray(list) ? list : []; })
|
.then(function (list) { return Array.isArray(list) ? list : []; })
|
||||||
.catch(function (e) {
|
.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() {
|
function refreshWebshellConnectionsFromServer() {
|
||||||
return getWebshellConnections().then(function (list) {
|
return getWebshellConnections().then(function (list) {
|
||||||
webshellConnections = list;
|
webshellConnections = list;
|
||||||
renderWebshellList();
|
renderWebshellList();
|
||||||
|
if (typeof ensureProjectsLoaded === 'function') {
|
||||||
|
ensureProjectsLoaded().then(function () {
|
||||||
|
renderWebshellList();
|
||||||
|
}).catch(function () {});
|
||||||
|
}
|
||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4636,11 +4831,19 @@ function showAddWebshellModal() {
|
|||||||
if (osSelEl) osSelEl.value = 'auto';
|
if (osSelEl) osSelEl.value = 'auto';
|
||||||
var encSelEl = document.getElementById('webshell-encoding');
|
var encSelEl = document.getElementById('webshell-encoding');
|
||||||
if (encSelEl) encSelEl.value = 'auto';
|
if (encSelEl) encSelEl.value = 'auto';
|
||||||
|
var defaultProjectId = '';
|
||||||
|
try {
|
||||||
|
defaultProjectId = typeof getActiveProjectId === 'function' ? (getActiveProjectId() || '') : '';
|
||||||
|
} catch (e) {}
|
||||||
|
populateWebshellProjectSelect(defaultProjectId);
|
||||||
document.getElementById('webshell-remark').value = '';
|
document.getElementById('webshell-remark').value = '';
|
||||||
var titleEl = document.getElementById('webshell-modal-title');
|
var titleEl = document.getElementById('webshell-modal-title');
|
||||||
if (titleEl) titleEl.textContent = wsT('webshell.addConnection');
|
if (titleEl) titleEl.textContent = wsT('webshell.addConnection');
|
||||||
var modal = document.getElementById('webshell-modal');
|
var modal = document.getElementById('webshell-modal');
|
||||||
if (modal) openAppModal(modal);
|
if (modal) {
|
||||||
|
openAppModal(modal);
|
||||||
|
refreshWebshellFormSelects(modal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开编辑连接弹窗(预填当前连接信息)
|
// 打开编辑连接弹窗(预填当前连接信息)
|
||||||
@@ -4662,7 +4865,9 @@ function showEditWebshellModal(connId) {
|
|||||||
if (osEditEl) osEditEl.value = normalizeWebshellOS(conn.os);
|
if (osEditEl) osEditEl.value = normalizeWebshellOS(conn.os);
|
||||||
var encEditEl = document.getElementById('webshell-encoding');
|
var encEditEl = document.getElementById('webshell-encoding');
|
||||||
if (encEditEl) encEditEl.value = normalizeWebshellEncoding(conn.encoding);
|
if (encEditEl) encEditEl.value = normalizeWebshellEncoding(conn.encoding);
|
||||||
|
populateWebshellProjectSelect(webshellConnectionProjectId(conn));
|
||||||
document.getElementById('webshell-remark').value = conn.remark || '';
|
document.getElementById('webshell-remark').value = conn.remark || '';
|
||||||
|
refreshWebshellFormSelects(document.getElementById('webshell-modal'));
|
||||||
document.getElementById('webshell-url')?.focus();
|
document.getElementById('webshell-url')?.focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4961,7 +5166,9 @@ function saveWebshellConnection() {
|
|||||||
|
|
||||||
var editIdEl = document.getElementById('webshell-edit-id');
|
var editIdEl = document.getElementById('webshell-edit-id');
|
||||||
var editId = editIdEl ? editIdEl.value.trim() : '';
|
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;
|
if (typeof apiFetch === 'undefined') return;
|
||||||
|
|
||||||
var reqUrl = editId ? ('/api/webshell/connections/' + encodeURIComponent(editId)) : '/api/webshell/connections';
|
var reqUrl = editId ? ('/api/webshell/connections/' + encodeURIComponent(editId)) : '/api/webshell/connections';
|
||||||
|
|||||||
+202
-103
@@ -990,7 +990,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="conversations-pagination" class="sidebar-list-pagination conversation-sidebar-pagination"></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;">
|
<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">
|
<div class="conversation-reasoning-heading">
|
||||||
<span class="conversation-reasoning-icon" aria-hidden="true">
|
<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">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -999,7 +999,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<div class="conversation-reasoning-heading-text">
|
<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>
|
<span id="chat-reasoning-summary" class="conversation-reasoning-summary"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1010,89 +1010,75 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div id="conversation-reasoning-body" class="conversation-reasoning-body" role="region">
|
<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-fields">
|
||||||
<div class="chat-reasoning-field">
|
<div class="session-settings-group session-settings-group-ai">
|
||||||
<label class="chat-reasoning-field-label" for="chat-reasoning-mode"><span data-i18n="chat.reasoningModeLabel">模式</span></label>
|
<div class="session-settings-group-title">AI</div>
|
||||||
<select id="chat-reasoning-mode" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
|
<div class="chat-reasoning-field">
|
||||||
<option value="default" data-i18n="chat.reasoningModeDefault">跟随系统</option>
|
<label class="chat-reasoning-field-label" for="chat-ai-channel-select"><span data-i18n="chat.aiChannelLabel">AI 通道</span></label>
|
||||||
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
|
<select id="chat-ai-channel-select" class="chat-reasoning-select" onchange="persistChatAIChannelPref()">
|
||||||
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
|
<option value="" data-i18n="chat.aiChannelDefault">跟随默认通道</option>
|
||||||
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
|
</select>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
<div class="session-settings-inline">
|
||||||
<div class="chat-reasoning-field">
|
<div class="chat-reasoning-field">
|
||||||
<label class="chat-reasoning-field-label" for="chat-reasoning-effort"><span data-i18n="chat.reasoningEffortLabel">推理强度</span></label>
|
<label class="chat-reasoning-field-label" for="chat-reasoning-mode"><span data-i18n="chat.reasoningModeLabel">模式</span></label>
|
||||||
<select id="chat-reasoning-effort" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
|
<select id="chat-reasoning-mode" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
|
||||||
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
|
<option value="default" data-i18n="chat.reasoningModeDefault">跟随系统</option>
|
||||||
<option value="low">low</option>
|
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
|
||||||
<option value="medium">medium</option>
|
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
|
||||||
<option value="high">high</option>
|
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
|
||||||
<option value="xhigh">xhigh</option>
|
</select>
|
||||||
<option value="max">max</option>
|
</div>
|
||||||
</select>
|
<div class="chat-reasoning-field">
|
||||||
</div>
|
<label class="chat-reasoning-field-label" for="chat-reasoning-effort"><span data-i18n="chat.reasoningEffortLabel">推理强度</span></label>
|
||||||
</div>
|
<select id="chat-reasoning-effort" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
|
||||||
</div>
|
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
|
||||||
</div>
|
<option value="low">low</option>
|
||||||
<div class="hitl-sidebar-card hitl-sidebar-collapsed" id="hitl-sidebar-card">
|
<option value="medium">medium</option>
|
||||||
<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(); }">
|
<option value="high">high</option>
|
||||||
<div class="hitl-sidebar-heading">
|
<option value="xhigh">xhigh</option>
|
||||||
<span class="hitl-sidebar-icon" aria-hidden="true">
|
<option value="max">max</option>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
</select>
|
||||||
<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"/>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
<input type="hidden" id="hitl-reviewer-select" value="human" />
|
|
||||||
<p class="hitl-config-hint" data-i18n="chat.hitlReviewerHint">可在人工与审计 Agent 之间随时切换;规则与白名单不变。人机协同为「关闭」时也可预先选择。</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hitl-config-field hitl-config-field--tools">
|
<div class="chat-reasoning-field session-hitl-field session-settings-group">
|
||||||
<label class="hitl-config-label" for="hitl-sensitive-tools" data-i18n="chat.hitlWhitelistTools">白名单工具(免审批,逗号分隔)</label>
|
<div class="session-settings-group-title" data-i18n="chat.hitlTitle">人机协同</div>
|
||||||
<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>
|
<div id="hitl-apply-feedback" class="hitl-apply-feedback" role="status" aria-live="polite"></div>
|
||||||
<p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并展示。</p>
|
<div class="hitl-sidebar-config session-hitl-config">
|
||||||
</div>
|
<div class="hitl-config-field">
|
||||||
<div class="hitl-config-actions">
|
<label class="hitl-config-label" for="hitl-mode-select" data-i18n="chat.reasoningModeLabel">模式</label>
|
||||||
<button type="button" class="hitl-apply-btn" data-require-permission="hitl:write" id="hitl-apply-btn" onclick="window.applyHitlSidebarConfig && window.applyHitlSidebarConfig()">
|
<select id="hitl-mode-select" class="hitl-config-select">
|
||||||
<span data-i18n="chat.hitlApply">应用</span>
|
<option value="off" data-i18n="chat.hitlModeOff">关闭</option>
|
||||||
</button>
|
<option value="approval" data-i18n="chat.hitlModeApproval">审批模式</option>
|
||||||
</div>
|
<option value="review_edit" data-i18n="chat.hitlModeReviewEdit">审查编辑</option>
|
||||||
</div>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="hitl-config-field" id="hitl-reviewer-field">
|
||||||
</aside>
|
<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;">
|
<div id="group-detail-page" class="group-detail-page" style="display: none;">
|
||||||
@@ -2827,8 +2813,8 @@
|
|||||||
<div id="page-c2-sessions" class="page">
|
<div id="page-c2-sessions" class="page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2 data-i18n="c2.sessions.title">会话管理</h2>
|
<h2 data-i18n="c2.sessions.title">会话管理</h2>
|
||||||
<div class="page-header-actions">
|
<div class="page-header-actions c2-sessions-page-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>
|
<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 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>
|
<button class="btn-secondary" onclick="C2.loadSessions()"><span data-i18n="common.refresh">刷新</span></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2854,14 +2840,76 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-content c2-tasks-page-wrap">
|
<div class="page-content c2-tasks-page-wrap">
|
||||||
<div class="c2-tasks-toolbar">
|
<div class="c2-tasks-summary" id="c2-tasks-summary" hidden>
|
||||||
<label class="c2-tasks-select-all-label">
|
<span class="c2-tasks-stat">
|
||||||
<input type="checkbox" id="c2-tasks-select-all" onchange="C2.onTasksSelectAll(this.checked)">
|
<span class="c2-tasks-stat-label" data-i18n="c2.tasks.statTotal">当前筛选</span>
|
||||||
<span data-i18n="c2.tasks.selectAll">全选本页</span>
|
<strong id="c2-tasks-stat-total">0</strong>
|
||||||
</label>
|
</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>
|
||||||
<div id="c2-task-list" class="c2-task-list-container"></div>
|
|
||||||
<div id="c2-tasks-pagination" class="pagination-container"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3391,16 +3439,53 @@
|
|||||||
<h3 data-i18n="settingsBasic.basicTitle">基本设置</h3>
|
<h3 data-i18n="settingsBasic.basicTitle">基本设置</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OpenAI配置 -->
|
<!-- AI 通道配置 -->
|
||||||
<div class="settings-subsection">
|
<div class="settings-subsection">
|
||||||
<h4 data-i18n="settingsBasic.openaiConfig">OpenAI 配置</h4>
|
<div class="ai-channel-manager">
|
||||||
<div class="settings-form">
|
<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>
|
||||||
|
<button type="button" class="btn-primary ai-channel-save-btn" onclick="saveSelectedAIChannel()" data-i18n="settingsBasic.aiChannelSave">保存更改</button>
|
||||||
|
</div>
|
||||||
|
<div class="ai-channel-manager-body">
|
||||||
|
<aside class="ai-channel-sidebar" aria-label="AI 通道列表" data-i18n="settingsBasic.aiChannelListAria" data-i18n-attr="aria-label">
|
||||||
|
<div class="ai-channel-sidebar-head">
|
||||||
|
<span data-i18n="settingsBasic.aiChannelSavedList">已保存通道</span>
|
||||||
|
<div class="ai-channel-bulk-actions">
|
||||||
|
<button type="button" class="ai-channel-bulk-btn" onclick="probeSelectedAIChannels()" title="检测所选或全部完整通道是否可用">批量探活</button>
|
||||||
|
<button type="button" class="ai-channel-bulk-btn danger" onclick="deleteCheckedAIChannels()" title="删除已勾选的非默认通道">删除所选</button>
|
||||||
|
<button type="button" class="ai-channel-icon-btn" onclick="createAIChannelFromForm()" title="新增通道" aria-label="新增通道">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<select id="ai-channel-select" class="ai-channel-native-select" onchange="selectAIChannelForEditing(this.value)" aria-hidden="true" tabindex="-1"></select>
|
||||||
|
<div id="ai-channel-list" class="ai-channel-list" aria-live="polite"></div>
|
||||||
|
</aside>
|
||||||
|
<div class="ai-channel-editor">
|
||||||
|
<div class="ai-channel-editor-head">
|
||||||
|
<div>
|
||||||
|
<span class="ai-channel-editor-kicker" data-i18n="settingsBasic.aiChannelEditing">正在编辑</span>
|
||||||
|
<h5 id="ai-channel-editor-title">-</h5>
|
||||||
|
<p id="ai-channel-editor-meta">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="ai-channel-editor-actions">
|
||||||
|
<button type="button" class="btn-secondary" onclick="setSelectedAIChannelDefault()" data-i18n="settingsBasic.aiChannelSetDefault">设为默认</button>
|
||||||
|
<button type="button" class="btn-secondary" onclick="copyAIChannelFromForm()" data-i18n="settingsBasic.aiChannelCopy">复制</button>
|
||||||
|
<button type="button" class="btn-secondary danger" onclick="deleteSelectedAIChannel()" data-i18n="settingsBasic.aiChannelDelete">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-form ai-channel-editor-form">
|
||||||
|
<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">
|
<div class="form-group">
|
||||||
<label for="openai-provider" data-i18n="settingsBasic.apiProvider">API 提供商</label>
|
<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;">
|
<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="openai_compatible" data-i18n="settingsBasic.providerOpenAI">OpenAI / 兼容 OpenAI 协议</option>
|
||||||
<option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
|
<option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="openai-base-url">Base URL <span style="color: red;">*</span></label>
|
<label for="openai-base-url">Base URL <span style="color: red;">*</span></label>
|
||||||
@@ -3428,8 +3513,13 @@
|
|||||||
<small style="color: var(--text-muted, #718096); font-size: 0.75rem;" data-i18n="settingsBasic.maxTotalTokensHint">内存压缩和攻击链构建共用此配置,默认 120000</small>
|
<small style="color: var(--text-muted, #718096); font-size: 0.75rem;" data-i18n="settingsBasic.maxTotalTokensHint">内存压缩和攻击链构建共用此配置,默认 120000</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label data-i18n="settingsBasic.openaiReasoningTitle">模型推理</label>
|
<label for="openai-max-completion-tokens"><span data-i18n="settingsBasic.maxCompletionTokens">最大输出 Token 数</span></label>
|
||||||
<small class="form-hint" data-i18n="settingsBasic.openaiReasoningHint">与对话页「模型推理」下拉配合使用。</small>
|
<input type="number" id="openai-max-completion-tokens" data-i18n="settingsBasic.maxCompletionTokensPlaceholder" data-i18n-attr="placeholder" placeholder="16384" min="1" step="256" />
|
||||||
|
<small style="color: var(--text-muted, #718096); font-size: 0.75rem;" data-i18n="settingsBasic.maxCompletionTokensHint">单次模型回复的输出上限,默认 16384</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settingsBasic.openaiReasoningTitle">推理设置</label>
|
||||||
|
<small class="form-hint" data-i18n="settingsBasic.openaiReasoningHint">作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。</small>
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; align-items: center;">
|
<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>
|
<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);">
|
<select id="openai-reasoning-mode" style="min-width: 140px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
|
||||||
@@ -3463,6 +3553,9 @@
|
|||||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 2px;">
|
<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>
|
<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>
|
<span id="test-openai-result" style="font-size: 0.8125rem;"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3483,7 +3576,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="vision-provider" data-i18n="settingsBasic.provider">提供商</label>
|
<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;">
|
<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="openai" data-i18n="settingsBasic.providerOpenAI">OpenAI / 兼容 OpenAI 协议</option>
|
||||||
<option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
|
<option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -6116,6 +6209,12 @@
|
|||||||
</select>
|
</select>
|
||||||
<small class="form-hint" data-i18n="webshell.encodingHint">中文 Windows 目标若出现乱码,请切换为 GBK 或 GB18030</small>
|
<small class="form-hint" data-i18n="webshell.encodingHint">中文 Windows 目标若出现乱码,请切换为 GBK 或 GB18030</small>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="webshell-remark" data-i18n="webshell.remark">备注</label>
|
<label for="webshell-remark" data-i18n="webshell.remark">备注</label>
|
||||||
<input type="text" id="webshell-remark" data-i18n="webshell.remarkPlaceholder" data-i18n-attr="placeholder" placeholder="便于识别的备注名" />
|
<input type="text" id="webshell-remark" data-i18n="webshell.remarkPlaceholder" data-i18n-attr="placeholder" placeholder="便于识别的备注名" />
|
||||||
@@ -6544,8 +6643,8 @@
|
|||||||
<script src="/static/js/agents.js"></script>
|
<script src="/static/js/agents.js"></script>
|
||||||
<script src="/static/js/dashboard.js"></script>
|
<script src="/static/js/dashboard.js"></script>
|
||||||
<script src="/static/js/chat-scroll.js"></script>
|
<script src="/static/js/chat-scroll.js"></script>
|
||||||
<script src="/static/js/monitor.js?v=20260717-1"></script>
|
<script src="/static/js/monitor.js?v=20260723-1"></script>
|
||||||
<script src="/static/js/chat.js?v=20260717-1"></script>
|
<script src="/static/js/chat.js?v=20260723-1"></script>
|
||||||
<script src="/static/js/hitl.js"></script>
|
<script src="/static/js/hitl.js"></script>
|
||||||
<script src="/static/js/settings.js?v=20260717-1"></script>
|
<script src="/static/js/settings.js?v=20260717-1"></script>
|
||||||
<script src="/static/js/audit-datetime-picker.js"></script>
|
<script src="/static/js/audit-datetime-picker.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user